diff --git a/README.md b/README.md index f2f767b..49f54db 100644 --- a/README.md +++ b/README.md @@ -261,10 +261,14 @@ The home page hits target. The doc-page performance gap is dominated by Nextra v - **`color-contrast` audit on the landing's ambient code spans** (`gofasta-code-breath`). The spans are decorative atmosphere set inside an `aria-hidden="true"` parent, with each span individually marked `aria-hidden="true"` as well. Lighthouse / axe-core still flags them because contrast checks run on visually-rendered text regardless of `aria-hidden` (the rule is about low-vision sighted users, not screen readers). The contrast is design-intentional. Score impact: −4 on accessibility for the home page. - **`unused-javascript`** opportunities (~450ms on home, ~600ms on docs). Most of this is Next.js / Nextra runtime code that is genuinely loaded; some is React Compiler emit. Aggressively code-splitting beyond what Next does by default usually trades one Lighthouse number for another. - +## Maintenance and sustainability Gofasta is currently maintained by one person; sustainability planning — release cadence, security SLOs, the solo-to-team transition, and the automation arc that retires manual steps as the project matures — is documented in the [release coordination repo](https://github.com/gofastadev/release), specifically in [`CADENCE.md`](https://github.com/gofastadev/release/blob/main/CADENCE.md), [`RELEASING.md`](https://github.com/gofastadev/release/blob/main/RELEASING.md), and [`COMMUNITY.md`](https://github.com/gofastadev/release/blob/main/COMMUNITY.md). Read those three together for the full picture. +## Contributing + +Contributions are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). + ## License MIT diff --git a/data/blog/hello-gofasta.mdx b/data/blog/hello-gofasta.mdx new file mode 100644 index 0000000..3c91b80 --- /dev/null +++ b/data/blog/hello-gofasta.mdx @@ -0,0 +1,90 @@ +--- +title: Hello, Gofasta! +description: >- + What the Gofasta toolkit ships, what a freshly scaffolded project actually + contains, and why every line of generated code is plain Go you own. +publishedAt: 2026-05-10T10:00:00.000Z +author: Gofasta Team +authorUrl: https://gofasta.dev +tags: + - golang + - toolkit + - agent-native +cover: /blog/covers/hello-gofasta/cover.png +--- +# Hello, Gofasta! + +Starting a production Go backend is mostly assembly work. You pick a router, wire structured logging, choose a database client, hook up migrations, write CRUD layers for every new resource, set up authentication, prepare a Dockerfile, decide where models and handlers live. None of it is business logic. All of it has to exist before the first feature ships. Most teams pay that cost from scratch on every project. + +Gofasta is what we built so that cost gets paid once. + +## What gofasta is + +Gofasta is a Go backend toolkit. Two pieces, both open source under the MIT license: + +* **A CLI** at [`github.com/gofastadev/cli`](https://github.com/gofastadev/cli). Install it with `go install github.com/gofastadev/cli/cmd/gofasta@latest`. It scaffolds new projects, generates full CRUD layers from a single command, runs migrations, regenerates dependency injection, and reports drift in your tree. It never imports the library at runtime — it only writes files to disk. +* **A library** at [`github.com/gofastadev/gofasta`](https://github.com/gofastadev/gofasta). Packages under `pkg/*` covering the concerns every backend ends up needing: configuration loading, JWT authentication, caching, structured errors, HTTP middleware, mailer, multi-channel notifications, WebSockets, scheduled jobs, async task queues, observability, feature flags, file storage, and a few more. Each package is independently importable; there is no umbrella that pulls everything in. The few cross-package dependencies are functional, not structural — `pkg/health` pings the database and `pkg/cache` because that is the job of a health probe. + +A project produced by `gofasta new` is plain, idiomatic Go that you own. The HTTP server in `cmd/serve.go` is a standard `net/http` listener with [`chi`](https://github.com/go-chi/chi) for routing. Handlers return errors. Models are GORM structs. Dependency injection is resolved at compile time by [Google Wire](https://github.com/google/wire). You can read every line top to bottom and trace exactly what runs. + +## What gofasta isn't + +It isn't a framework. There is no central application object you register handlers into, no inversion-of-control runtime, no lifecycle that owns your code. The CLI emits standard Go files that compile and run on their own — `go build`, `go test`, `golangci-lint`, Delve, and your IDE all work the same way they do on any other Go project, because nothing is custom. + +It isn't lock-in. The scaffold imports `pkg/*` packages as **opt-out defaults** — the same way any Go service imports `chi`, `go-redis`, or `gorm.io/gorm`. Prefer a different JWT library? Delete the `pkg/auth` import and `go get` your alternative. Nothing else in the project has to change. Every package is independently importable; every default is swappable. + +It isn't a clone of anything. The shape of the library is informed by what real Go services need in production — config loading, structured logging, RBAC, request middleware, async tasks, observability — not by mapping conventions from elsewhere onto Go. + +## How it actually works + +Run `gofasta new myapp` and you get a complete project: HTTP server, database layer, migration tooling, JWT auth with [Casbin](https://casbin.org) RBAC, structured logging via `slog`, Swagger generation, Docker setup, GitHub Actions workflow. Roughly 80 files, all readable, all yours to modify. + +Run `gofasta g scaffold User name:string email:string` and you get a model, migration, repository, service, DTOs, controller, route registration, and a Wire provider — all for one resource, in one command. The output is the same code a senior Go engineer would write by hand. The CLI doesn't watch it, doesn't reconcile it, doesn't sync it. After generation, it is your code. + +Run `gofasta dev` and the full local stack comes up: Docker services, migrations applied, [Air](https://github.com/air-verse/air) hot reload, optional debug dashboard. One terminal, no orchestration scripts. + +The CLI runs once per generation and then gets out of the way. No daemon, no continuous reconciliation, no hidden state. + +## Agent-native by default + +Most backend tooling treats AI coding agents as an afterthought. Gofasta treats them as a first-class user. + +Every CLI command emits structured JSON output via `--json`. Every error carries a stable machine-readable code, a one-line message, a remediation hint, and a documentation URL. Every resource is inspectable as structured data via `gofasta inspect`. Every scaffolded project ships with an [`AGENTS.md`](https://gofasta.dev/docs/cli-reference/ai) briefing — the cross-tool file format that Claude Code, OpenAI Codex, Cursor, Aider, and Windsurf all read at startup. The `gofasta ai ` command installs per-agent configuration (permission allowlists, hooks, slash commands) without cluttering projects that don't use that particular agent. + +The documentation site also publishes [`/llms.txt`](https://gofasta.dev/llms.txt) and [`/llms-full.txt`](https://gofasta.dev/llms-full.txt) so agents reason about current features instead of stale training data. + +The goal is straightforward: an AI agent working in a Gofasta project should be measurably faster and more accurate than one working on a hand-rolled Go project. That is a design constraint on every feature, not a separate product layered on top. + +## What you get on day one + +A single `gofasta new` gives you: + +* HTTP routing via [chi](https://github.com/go-chi/chi) +* Database support for PostgreSQL, MySQL, SQLite, SQL Server, and ClickHouse — driver chosen from `config.yaml` +* Migrations via [`golang-migrate`](https://github.com/golang-migrate/migrate) +* JWT authentication plus Casbin RBAC +* Structured logging via `slog` +* Caching abstraction (in-memory or Redis) +* File storage abstraction (local or S3-compatible) +* Mailer with SMTP, SendGrid, and Brevo drivers +* WebSocket hub for real-time channels +* Scheduled jobs (`pkg/scheduler`) and async tasks (`pkg/queue`, backed by [asynq](https://github.com/hibiken/asynq)) +* Resilience patterns: circuit breaker and retry with exponential backoff +* Prometheus metrics and OpenTelemetry tracing +* Feature flags via the [OpenFeature](https://openfeature.dev) Go SDK +* Swagger/OpenAPI generation from code comments +* Docker setup plus a `gofasta deploy` command for VPS-via-SSH deployment +* A debug dashboard, build-tag-gated so it costs nothing in production +* AGENTS.md, `llms.txt` awareness, and structured CLI output across the board + +Pick what you use; delete what you don't. + +## Where this is going + +The roadmap splits across three surfaces: + +1. **CLI ergonomics.** Faster scaffolding, broader generator coverage, more `gofasta do` workflows that compose existing commands. +2. **Library depth.** More production-tested defaults in the packages that already exist — especially around observability, resilience, and authorization. +3. **Documentation.** Guides that go beyond "here's the API" into "here's the trade-off you should think about before you wire this up." This blog is part of that effort: when a `pkg/*` package changes in a non-trivial way, the why often deserves more than a CHANGELOG line. + +Start with the [Quick Start](https://gofasta.dev/docs/getting-started/quick-start) for a five-minute walkthrough, or read the full [white paper](https://gofasta.dev/docs/white-paper) for the design rationale behind every choice. Issues, ideas, and contributions are welcome on [GitHub](https://github.com/gofastadev) — we read every one, and many turn into posts here. diff --git a/keystatic.config.ts b/keystatic.config.ts new file mode 100644 index 0000000..c5888ed --- /dev/null +++ b/keystatic.config.ts @@ -0,0 +1,116 @@ +import { config, fields, collection } from "@keystatic/core"; + +// Keystatic editor config for the Gofasta blog. +// +// Storage mode is capability-conditional, not environment-conditional: +// if every GitHub OAuth env var is present, we use `github` (PR mode +// with a `keystatic/` branch prefix); otherwise we fall back to +// `local` so builds without secrets (CI, dev, preview deploys +// without the OAuth app linked) still succeed. +// +// Required env vars for github mode (Vercel Production scope): +// KEYSTATIC_GITHUB_CLIENT_ID +// KEYSTATIC_GITHUB_CLIENT_SECRET +// KEYSTATIC_SECRET +// NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG +// +// All Keystatic-flavored MDX disallows `import` and raw HTML tags; +// custom components must be passed at render time (see +// `src/lib/blog-mdx-components.tsx`). + +const hasGithubCredentials = + Boolean(process.env.KEYSTATIC_GITHUB_CLIENT_ID) && + Boolean(process.env.KEYSTATIC_GITHUB_CLIENT_SECRET) && + Boolean(process.env.KEYSTATIC_SECRET); + +export default config({ + storage: hasGithubCredentials + ? { + kind: "github", + repo: { owner: "gofastadev", name: "website" }, + // PR mode — saves open a Pull Request on a dedicated branch + // instead of committing to main. Editorial review + preview + // deploy per post. + branchPrefix: "keystatic/", + } + : { kind: "local" }, + ui: { + brand: { name: "Gofasta" }, + }, + collections: { + posts: collection({ + label: "Blog posts", + slugField: "title", + path: "data/blog/*", + format: { contentField: "body" }, + entryLayout: "content", + schema: { + draft: fields.checkbox({ + label: "Draft", + description: + "Drafts are excluded from the public blog, sitemap, RSS, JSON Feed, tag pages, and on-site search. Pair with a future Published date to schedule — the post will stay hidden until both Draft is unchecked AND the scheduled date passes.", + defaultValue: false, + }), + title: fields.slug({ + name: { label: "Title" }, + slug: { + label: "URL slug", + description: + "Auto-derived from the title — only edit if the URL needs to differ.", + }, + }), + description: fields.text({ + label: "Description", + description: + "1–2 sentences shown under the title and used as the meta description.", + multiline: true, + validation: { length: { min: 50, max: 200 } }, + }), + publishedAt: fields.datetime({ + label: "Published at", + description: + "Posts dated in the future are hidden until the next deploy after that timestamp.", + }), + updatedAt: fields.datetime({ + label: "Updated at (optional)", + }), + author: fields.text({ + label: "Author name", + defaultValue: "Gofasta Team", + }), + authorUrl: fields.url({ + label: "Author URL (optional)", + }), + tags: fields.array( + fields.text({ label: "Tag" }), + { + label: "Tags", + itemLabel: (props) => props.value || "Tag", + }, + ), + cover: fields.image({ + label: "Cover image", + description: "1200×630 recommended. Saved under public/blog/covers/.", + directory: "public/blog/covers", + publicPath: "/blog/covers/", + validation: { isRequired: true }, + }), + body: fields.mdx({ + label: "Body", + description: + "Start at H2 — the post title (H1) is rendered above the body by the article header. Medium and Hashnode follow the same convention.", + options: { + // H1 is reserved for the post title, which the article header + // renders from frontmatter. Allowing H1 in the body produces + // a duplicate title on the rendered page. + heading: [2, 3, 4, 5, 6], + image: { + directory: "public/blog/inline", + publicPath: "/blog/inline/", + }, + }, + }), + }, + }), + }, +}); diff --git a/next.config.ts b/next.config.ts index ecfffba..6f90b97 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,6 +13,22 @@ export default withNextra({ output: "standalone", turbopack: { root: __dirname, + // ── react-aria duplicate-context workaround ────────────────────── + // Keystatic 0.5.50 imports `@react-aria/interactions` while its + // button components transitively load the `react-aria` umbrella. + // Each ships its own bundled PressResponderContext, so toolbar + // PressResponders never find their pressable children → toolbar + // buttons render but do not fire. The shim below re-exports the + // subpackage's surface from the umbrella's private paths so both + // worlds share one set of context instances. See + // shims/react-aria-interactions-shim.mjs for the full rationale, + // and adobe/react-spectrum#5647 for the upstream bug. + // + // Remove this once Keystatic ships a version that uses the + // `react-aria` monopackage directly. + resolveAlias: { + "@react-aria/interactions": "./shims/react-aria-interactions-shim.mjs", + }, }, // Image optimization: serve modern formats by default. Next.js will // negotiate AVIF first (best compression), then WebP, then fall @@ -24,5 +40,15 @@ export default withNextra({ images: { formats: ["image/avif", "image/webp"], minimumCacheTTL: 60 * 60 * 24 * 30, + // SVG covers are allowed for blog posts. Authoring goes through + // Keystatic (an authenticated admin UI gated behind GitHub OAuth) + // OR via direct PRs we review — never via untrusted user upload. + // The strict CSP below sandboxes the rendered SVG so scripts and + // foreign-origin assets inside the file are blocked, eliminating + // the XSS vector that makes user-uploaded SVGs risky on hosts + // that mirror unmodified user input. + dangerouslyAllowSVG: true, + contentSecurityPolicy: + "default-src 'self'; script-src 'none'; sandbox;", }, }); diff --git a/package.json b/package.json index 5439e53..aa95982 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "prebuild": "node scripts/generate-llms-txt.mjs", + "prebuild": "node scripts/generate-llms-txt.mjs && node scripts/check-blog-images.mjs", "build": "next build", "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind", "start": "next start", @@ -15,17 +15,27 @@ "generate:llms": "node scripts/generate-llms-txt.mjs" }, "dependencies": { + "@giscus/react": "^3.1.0", + "@keystatic/core": "^0.5.50", + "@keystatic/next": "^5.0.4", + "@mdx-js/mdx": "^3.1.1", "clsx": "^2.1.1", + "image-size": "^2.0.2", "next": "16.2.2", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "nextra": "^4.6.1", "nextra-theme-docs": "^4.6.1", "react": "19.2.4", "react-dom": "19.2.4", - "tailwind-merge": "^3.5.0" + "reading-time": "^1.5.0", + "rehype-pretty-code": "^0.14.3", + "tailwind-merge": "^3.5.0", + "yaml": "^2.8.4" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/public/blog/covers/hello-gofasta/cover.png b/public/blog/covers/hello-gofasta/cover.png new file mode 100644 index 0000000..008f67d Binary files /dev/null and b/public/blog/covers/hello-gofasta/cover.png differ diff --git a/scripts/check-blog-images.mjs b/scripts/check-blog-images.mjs new file mode 100755 index 0000000..16a0caf --- /dev/null +++ b/scripts/check-blog-images.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// +// Image-budget guard for `public/blog/`. +// +// Walks every file under `public/blog/` and fails the build if any +// raster image (.jpg, .jpeg, .png, .webp, .avif, .gif) is larger than +// the configured ceiling. SVGs are exempt because they're vector and +// typically a few kilobytes — the budget applies to bitmap formats +// only. +// +// The threshold is intentionally modest. A 1200×630 JPEG at ~80% +// quality lands in the 100–200 KB range; an editor who commits a +// 4 MB straight-from-camera screenshot triggers the budget and gets +// a clear failure with the file path + actual size before the PR +// lands in main. +// +// Adjust MAX_BYTES if real cover art legitimately needs more room. + +import { readdir, stat } from "node:fs/promises"; +import { join, resolve, extname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const ROOT = resolve(__dirname, "..", "public", "blog"); +const MAX_BYTES = 250_000; +const RASTER_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".avif", + ".gif", +]); + +async function walk(dir) { + const out = []; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err && err.code === "ENOENT") return out; + throw err; + } + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...(await walk(full))); + } else if (entry.isFile()) { + out.push(full); + } + } + return out; +} + +async function main() { + const files = await walk(ROOT); + const offenders = []; + for (const file of files) { + const ext = extname(file).toLowerCase(); + if (!RASTER_EXTENSIONS.has(ext)) continue; + const info = await stat(file); + if (info.size > MAX_BYTES) { + offenders.push({ file, size: info.size }); + } + } + + if (offenders.length === 0) { + console.log( + `✓ image-budget check passed (${files.length} files under public/blog/, limit ${MAX_BYTES.toLocaleString()} bytes per raster image).`, + ); + return; + } + + console.error( + `✗ image-budget check failed — ${offenders.length} file(s) exceed the ${MAX_BYTES.toLocaleString()}-byte limit:`, + ); + for (const { file, size } of offenders) { + const rel = file.slice(ROOT.length + 1); + console.error( + ` - public/blog/${rel} (${size.toLocaleString()} bytes, +${( + size - MAX_BYTES + ).toLocaleString()} over)`, + ); + } + console.error( + "\nCompress the file(s) or raise MAX_BYTES in scripts/check-blog-images.mjs if the larger size is intentional.", + ); + process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/generate-llms-txt.mjs b/scripts/generate-llms-txt.mjs index 5b25f71..afb535f 100644 --- a/scripts/generate-llms-txt.mjs +++ b/scripts/generate-llms-txt.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // -// Walks src/content/ and emits two files into public/: +// Walks src/content/ (docs) and data/blog/ (blog posts) and emits two files +// into public/: // // - llms.txt : structured markdown index (title + one-line summary + URL // per page) following the /llms.txt spec at @@ -8,7 +9,11 @@ // site contains and which URL to read for a given topic. // - llms-full.txt : every MDX body concatenated into a single markdown file. // Agents that want the entire site as context load this -// one file instead of crawling dozens of URLs. +// one file instead of crawling dozens of URLs. MDX JSX +// components are stripped (they are presentation widgets, +// not content) and relative `/docs/...` and `/blog/...` +// links are rewritten to absolute URLs so the file is +// useful offline. // // Runs automatically via `yarn prebuild` so both files ship on every deploy. // No external dependencies — pure Node stdlib (fs/promises, path, url). @@ -19,9 +24,11 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CONTENT_DIR = resolve(__dirname, '..', 'src', 'content'); +const BLOG_DIR = resolve(__dirname, '..', 'data', 'blog'); const PUBLIC_DIR = resolve(__dirname, '..', 'public'); const SITE_URL = 'https://gofasta.dev'; const DOCS_BASE = '/docs'; +const BLOG_BASE = '/blog'; // Human-readable names for each section directory. Matches the top-level // _meta.js. If a section isn't listed, the directory name is used verbatim. @@ -29,14 +36,56 @@ const SECTION_TITLES = { '': 'Overview', 'getting-started': 'Getting Started', 'guides': 'Guides', + 'guides/debugging': 'Debugging', 'cli-reference': 'CLI Reference', 'cli-reference/generate': 'Code Generators', 'api-reference': 'Package Library', + 'blog': 'Blog', }; +// Doc paths to surface under the spec's `## Optional` section — "less-important +// resources that can be skipped if context window space is limited" per +// https://llmstxt.org/#format. Add the canonical `path` (e.g. `/docs/foo/bar`) +// for any page that should drop out of a budget-constrained crawl. Currently +// empty: the docs are tight enough that everything is worth indexing. Curate +// as the surface grows. +const OPTIONAL_PAGES = new Set([]); + +// Top-of-file context for LLM consumers. Hardcoded prelude — kept near the +// rest of the config so a positioning change is one place, not buried. +const REPO_STATEMENT = + 'Gofasta is split across two repositories: `github.com/gofastadev/cli` (the CLI) and `github.com/gofastadev/gofasta` (the library). A scaffolded project is plain Go code the developer owns — no runtime framework, no custom compiler.'; + +// Recommended starting points surfaced above the docs sections — agents that +// only read the first section of llms.txt still hit the highest-signal links. +const STARTING_POINTS = [ + { + title: 'CLI source code', + url: 'https://github.com/gofastadev/cli', + description: 'The Gofasta CLI — project scaffolding and code generation.', + }, + { + title: 'Library source code', + url: 'https://github.com/gofastadev/gofasta', + description: 'The pkg/* library of independent backend packages.', + }, + { + title: 'White paper', + url: 'https://gofasta.dev/docs/white-paper', + description: + 'Design principles, architecture, and the full positioning of the toolkit.', + }, + { + title: 'Blog', + url: 'https://gofasta.dev/blog', + description: 'Release notes, technical deep-dives, and positioning posts.', + }, +]; + // Minimal YAML frontmatter parser — only handles `key: value` pairs on a -// single line, which is all our MDX frontmatter uses. Returns { frontmatter, -// body }. No external YAML dep needed. +// single line, which is all our MDX frontmatter (docs + blog) uses for the +// fields llms.txt cares about (title, description, publishedAt). Returns +// { frontmatter, body }. No external YAML dep needed. function parseFrontmatter(source) { const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!match) return { frontmatter: {}, body: source }; @@ -142,25 +191,130 @@ async function collect(dir, urlPrefix = '', sectionPath = '') { return pages; } +// Walks BLOG_DIR (flat — one .mdx per post, no subdirs) and emits one record +// per published post. Mirrors the future-dated filter from src/lib/blog.ts: +// posts whose `publishedAt` is in the future are excluded so the LLM-facing +// index doesn't expose them ahead of schedule. +async function collectBlog(now = new Date()) { + let entries; + try { + entries = await readdir(BLOG_DIR, { withFileTypes: true }); + } catch { + return []; + } + const posts = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.mdx')) continue; + if (entry.name.startsWith('_')) continue; + const full = join(BLOG_DIR, entry.name); + const source = await readFile(full, 'utf8'); + const { frontmatter, body } = parseFrontmatter(source); + if (!frontmatter.title) continue; + if (frontmatter.publishedAt) { + const published = new Date(frontmatter.publishedAt); + if (Number.isFinite(published.getTime()) && published > now) continue; + } + const slug = entry.name.replace(/\.mdx$/, ''); + posts.push({ + url: `${SITE_URL}${BLOG_BASE}/${slug}`, + path: `${BLOG_BASE}/${slug}`, + title: frontmatter.title, + description: frontmatter.description || '', + body: body.trim(), + section: 'blog', + publishedAt: frontmatter.publishedAt || '', + }); + } + // Newest first — same ordering as /blog and the RSS feed. + return posts.sort( + (a, b) => Date.parse(b.publishedAt || 0) - Date.parse(a.publishedAt || 0), + ); +} + +// Strip MDX JSX components from a markdown body. Removes , +// multi-line self-closing tags, and matching ... blocks. JSX +// components in this codebase are presentation widgets (RelatedPages, +// Callout, etc.) — leaving them in produces noise for LLM consumers. +// +// Regex limitations (acceptable for this codebase): +// - Doesn't support nested same-named components. Not used here. +// - Doesn't distinguish JSX inside fenced code blocks. Our code blocks +// show Go / bash / SQL, not JSX, so the false-positive rate is zero +// in practice. If that changes, switch to a state-machine scanner. +function stripMdxComponents(body) { + let out = body; + // Self-closing single-line: + out = out.replace(/<[A-Z][a-zA-Z0-9]*\b[^<>]*?\/>/g, ''); + // Self-closing multi-line: + out = out.replace(/<[A-Z][a-zA-Z0-9]*\b[^<]*?\/>/g, ''); + // Block: ... (non-greedy, no nesting). + out = out.replace(/<([A-Z][a-zA-Z0-9]*)\b[^>]*>[\s\S]*?<\/\1>/g, ''); + // Collapse runs of blank lines left behind by stripped components so + // the output stays readable. + out = out.replace(/\n{3,}/g, '\n\n'); + return out; +} + +// Rewrite relative `/docs/...` and `/blog/...` markdown links to absolute +// URLs. LLMs reading llms-full.txt offline can't resolve a bare path; an +// absolute URL is unambiguous and points back to the canonical page. +function absolutizeLinks(body) { + return body.replace(/\]\((\/(?:docs|blog)(?:\/[^)]*)?)\)/g, `](${SITE_URL}$1)`); +} + +// Drop the body's leading `# Title` heading when it matches the page's +// frontmatter title (or near-matches via case-insensitive comparison). The +// concatenated llms-full.txt already names every page in its H2 separator +// (`## /docs/path — Title`); a body H1 right after that is redundant and +// noisy. If the leading H1 differs from the title, leave it — that's a +// content authoring quirk worth preserving. +function stripDuplicateH1(body, title) { + const match = body.match(/^#\s+(.+?)\s*\n+/); + if (!match) return body; + const headingText = match[1].trim().toLowerCase(); + const expected = title.trim().toLowerCase(); + if (headingText !== expected) return body; + return body.slice(match[0].length); +} + +// Apply all body cleanups in the correct order: strip components first +// (so their innards don't break link rewriting), absolutize links, then +// drop the duplicate H1. +function cleanBody(body, title) { + return stripDuplicateH1(absolutizeLinks(stripMdxComponents(body)), title); +} + // Emit the structured llms.txt index. -function renderLLMsTxt(pages) { +function renderLLMsTxt(pages, blogPosts) { const root = pages.find((p) => p.section === '' && p.path === DOCS_BASE); const summary = root?.description || 'Gofasta is a Go backend toolkit — a CLI for scaffolding projects and generating code, backed by a library of composable pkg/* packages.'; - // Group pages by section path, preserving collect() order within each group. + let out = '# Gofasta\n\n'; + out += `> ${summary}\n\n`; + out += `${REPO_STATEMENT}\n\n`; + + out += '## Recommended starting points\n\n'; + for (const link of STARTING_POINTS) { + out += `- [${link.title}](${link.url}): ${link.description}\n`; + } + out += '\n'; + + // Split docs pages into the regular section flow + an Optional bucket + // for any path listed in OPTIONAL_PAGES. const grouped = new Map(); + const optional = []; for (const page of pages) { + if (OPTIONAL_PAGES.has(page.path)) { + optional.push(page); + continue; + } if (!grouped.has(page.section)) grouped.set(page.section, []); grouped.get(page.section).push(page); } - let out = '# Gofasta\n\n'; - out += `> ${summary}\n\n`; - out += - 'Gofasta is split across two repositories: `github.com/gofastadev/cli` (the CLI) and `github.com/gofastadev/gofasta` (the library). A scaffolded project is plain Go code the developer owns — no runtime framework, no custom compiler.\n\n'; - for (const [section, sectionPages] of grouped) { const heading = SECTION_TITLES[section] ?? section; out += `## ${heading}\n\n`; @@ -171,11 +325,29 @@ function renderLLMsTxt(pages) { out += '\n'; } + if (blogPosts.length > 0) { + out += `## ${SECTION_TITLES.blog}\n\n`; + for (const post of blogPosts) { + const suffix = post.description ? `: ${post.description}` : ''; + out += `- [${post.title}](${post.url})${suffix}\n`; + } + out += '\n'; + } + + if (optional.length > 0) { + out += '## Optional\n\n'; + for (const page of optional) { + const suffix = page.description ? `: ${page.description}` : ''; + out += `- [${page.title}](${page.url})${suffix}\n`; + } + out += '\n'; + } + return out.trimEnd() + '\n'; } // Emit the full-text dump — every MDX body concatenated. -function renderLLMsFullTxt(pages) { +function renderLLMsFullTxt(pages, blogPosts) { const today = new Date().toISOString().slice(0, 10); let out = '# Gofasta Documentation — Full Text\n\n'; out += @@ -186,7 +358,15 @@ function renderLLMsFullTxt(pages) { for (const page of pages) { out += `## ${page.path} — ${page.title}\n\n`; if (page.description) out += `> ${page.description}\n\n`; - out += `${page.body}\n\n`; + out += `${cleanBody(page.body, page.title)}\n\n`; + out += '---\n\n'; + } + + for (const post of blogPosts) { + out += `## ${post.path} — ${post.title}\n\n`; + if (post.description) out += `> ${post.description}\n\n`; + if (post.publishedAt) out += `Published: ${post.publishedAt}\n\n`; + out += `${cleanBody(post.body, post.title)}\n\n`; out += '---\n\n'; } @@ -194,15 +374,18 @@ function renderLLMsFullTxt(pages) { } async function main() { - const pages = await collect(CONTENT_DIR); + const [pages, blogPosts] = await Promise.all([ + collect(CONTENT_DIR), + collectBlog(), + ]); await mkdir(PUBLIC_DIR, { recursive: true }); - const llmsTxt = renderLLMsTxt(pages); - const llmsFullTxt = renderLLMsFullTxt(pages); + const llmsTxt = renderLLMsTxt(pages, blogPosts); + const llmsFullTxt = renderLLMsFullTxt(pages, blogPosts); await writeFile(join(PUBLIC_DIR, 'llms.txt'), llmsTxt); await writeFile(join(PUBLIC_DIR, 'llms-full.txt'), llmsFullTxt); const bytes = Buffer.byteLength(llmsFullTxt, 'utf8'); console.log( - `Generated llms.txt (${pages.length} pages) and llms-full.txt (${bytes.toLocaleString()} bytes) in public/.` + `Generated llms.txt (${pages.length} docs + ${blogPosts.length} blog posts) and llms-full.txt (${bytes.toLocaleString()} bytes) in public/.`, ); } diff --git a/shims/react-aria-interactions-shim.mjs b/shims/react-aria-interactions-shim.mjs new file mode 100644 index 0000000..741a739 --- /dev/null +++ b/shims/react-aria-interactions-shim.mjs @@ -0,0 +1,52 @@ +// Workaround for the react-aria duplicate-context bug. See +// next.config.ts for the resolveAlias that routes +// `@react-aria/interactions` here. +// +// Why: the `@react-aria/interactions` subpackage and the `react-aria` +// umbrella each ship a bundled copy of PressResponderContext and +// FocusableContext. When Keystatic's button (which uses the umbrella +// via @react-aria/button → react-aria@3.48.0) renders a PressResponder +// and its toolbar (which imports `@react-aria/interactions` directly) +// reads usePress / useHover, the two see different React contexts. The +// responder never finds a pressable child → "PressResponder was +// rendered without a pressable child" → toolbar buttons render but do +// not fire. +// +// This shim re-exports the same surface that `@react-aria/interactions` +// exposes, but sources every binding from the `react-aria` umbrella. +// Anywhere that imports `@react-aria/interactions` now shares the +// umbrella's contexts, so PressResponder + child consumers agree. +// +// Drop this shim + the alias in next.config.ts once Keystatic publishes +// a release that uses the `react-aria` monopackage directly. + +// Most names live on the umbrella's top-level entry. +export * from "react-aria"; + +// Names that the umbrella keeps in its private subpath exports. +// These are the consumers of the *contexts* that this whole workaround +// exists to dedupe — pulling them from the umbrella's private paths +// guarantees a single context instance shared with @react-aria/button. +export { + PressResponder, + ClearPressResponder, +} from "react-aria/private/interactions/PressResponder"; + +export { + FocusableContext, + FocusableProvider, +} from "react-aria/private/interactions/useFocusable"; + +export { + addWindowFocusTracking, + getInteractionModality, + getPointerType, + isFocusVisible, + setInteractionModality, + useFocusVisibleListener, + useInteractionModality, +} from "react-aria/private/interactions/useFocusVisible"; + +export { useScrollWheel } from "react-aria/private/interactions/useScrollWheel"; + +export { focusSafely } from "react-aria/private/interactions/focusSafely"; diff --git a/src/app/(docs)/docs/[[...mdxPath]]/page.tsx b/src/app/(docs)/docs/[[...mdxPath]]/page.tsx index 98ee33f..3d29296 100644 --- a/src/app/(docs)/docs/[[...mdxPath]]/page.tsx +++ b/src/app/(docs)/docs/[[...mdxPath]]/page.tsx @@ -1,17 +1,24 @@ import { generateStaticParamsFor, importPage } from "nextra/pages"; import { useMDXComponents as getMDXComponents } from "../../../../../mdx-components"; -import { getKeywordsForPath } from "@/lib/seo-keywords"; +import { SITE_URL, withBaseKeywords } from "@/lib/seo"; +import { buildTechArticleJsonLd } from "@/lib/structured-data"; export const generateStaticParams = generateStaticParamsFor("mdxPath"); +interface DocMeta { + title?: string; + description?: string; + keywords?: string[]; +} + export async function generateMetadata(props: { params: Promise<{ mdxPath?: string[] }>; }) { const params = await props.params; const { metadata } = await importPage(params.mdxPath); - const mdxMeta = metadata as unknown as Record; + const mdxMeta = metadata as DocMeta; const urlPath = `/docs${params.mdxPath ? `/${params.mdxPath.join("/")}` : ""}`; - const fullUrl = `https://gofasta.dev${urlPath}`; + const fullUrl = `${SITE_URL}${urlPath}`; const title = mdxMeta?.title ?? "Documentation"; const description = mdxMeta?.description ?? @@ -23,7 +30,7 @@ export async function generateMetadata(props: { return { ...metadata, - keywords: getKeywordsForPath(urlPath), + keywords: withBaseKeywords(...(mdxMeta.keywords ?? [])), openGraph: { type: "article", locale: "en_US", @@ -57,81 +64,14 @@ const { wrapper: Wrapper } = getMDXComponents() as Record< React.ComponentType<{ toc: unknown; metadata: unknown; children: React.ReactNode }> >; -function buildStructuredData( - mdxPath: string[] | undefined, - meta: Record, -) { - const segments = mdxPath ?? []; - const urlPath = `/docs${segments.length > 0 ? `/${segments.join("/")}` : ""}`; - const fullUrl = `https://gofasta.dev${urlPath}`; - const title = meta?.title ?? "Documentation"; - const description = - meta?.description ?? - "Gofasta documentation for the Go backend toolkit."; - - // Section name derived from the first path segment (e.g. "cli-reference" → "Cli Reference"), - // mirroring the OG-image `section` so JSON-LD and OG share a single source of truth. - const articleSection = segments[0] - ? segments[0] - .replace(/-/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) - : "Docs"; - - // Mirror the OG image URL on the schema so Google can use it as the - // article hero image in rich results. Same query-string contract as - // generateMetadata above. - const ogImageUrl = `https://gofasta.dev/api/og?title=${encodeURIComponent(title)}§ion=${encodeURIComponent(articleSection)}`; - - // Per-page keywords come from the same SEO map as the metadata side - // — keep them in sync so structured data and meta tags don't diverge. - const keywords = getKeywordsForPath(urlPath); - - const breadcrumbItems = [ - { name: "Home", url: "https://gofasta.dev" }, - { name: "Docs", url: "https://gofasta.dev/docs" }, - ...segments.map((seg, i) => ({ - name: seg - .replace(/-/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()), - url: `https://gofasta.dev/docs/${segments.slice(0, i + 1).join("/")}`, - })), - ]; - - return { - "@context": "https://schema.org", - "@graph": [ - { - "@type": "BreadcrumbList", - // The breadcrumb's parent: Google groups sitelinks better when - // BreadcrumbList nests under a WebPage with the current URL. - mainEntity: { "@type": "WebPage", "@id": fullUrl }, - itemListElement: breadcrumbItems.map((item, i) => ({ - "@type": "ListItem", - position: i + 1, - name: item.name, - item: item.url, - })), - }, - { - "@type": "TechArticle", - headline: title, - description, - url: fullUrl, - inLanguage: "en", - articleSection, - keywords: keywords.length > 0 ? keywords.join(", ") : undefined, - image: ogImageUrl, - author: { "@type": "Organization", name: "Gofasta" }, - publisher: { - "@type": "Organization", - name: "Gofasta", - url: "https://gofasta.dev", - logo: "https://gofasta.dev/logo.png", - }, - mainEntityOfPage: fullUrl, - }, - ], - }; +function buildStructuredData(mdxPath: string[] | undefined, meta: DocMeta) { + return buildTechArticleJsonLd({ + segments: mdxPath ?? [], + title: meta?.title ?? "Documentation", + description: + meta?.description ?? "Gofasta documentation for the Go backend toolkit.", + keywords: withBaseKeywords(...(meta.keywords ?? [])), + }); } export default async function Page(props: { @@ -141,10 +81,7 @@ export default async function Page(props: { const { default: MDXContent, toc, metadata } = await importPage( params.mdxPath ); - const jsonLd = buildStructuredData( - params.mdxPath, - metadata as unknown as Record, - ); + const jsonLd = buildStructuredData(params.mdxPath, metadata as DocMeta); return ( diff --git a/src/app/(home)/blog/[slug]/page.tsx b/src/app/(home)/blog/[slug]/page.tsx new file mode 100644 index 0000000..7dd95ae --- /dev/null +++ b/src/app/(home)/blog/[slug]/page.tsx @@ -0,0 +1,195 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { MDXRemote } from "next-mdx-remote/rsc"; +import rehypePrettyCode from "rehype-pretty-code"; +import { LandingTemplate } from "@/components/templates"; +import { ReadingProgressBar } from "@/components/atoms/reading-progress-bar"; +import { BlogArticleHeader } from "@/components/molecules/blog-article-header"; +import { BlogPrevNext } from "@/components/molecules/blog-prev-next"; +import { ShareButtons } from "@/components/molecules/share-buttons"; +import { BlogRelatedPosts } from "@/components/molecules/blog-related-posts"; +import { Comments } from "@/components/molecules/comments"; +import { blogMdxComponents } from "@/lib/blog-mdx-components"; +import { + getAllPosts, + getAdjacentPosts, + getPost, + type BlogPost, +} from "@/lib/blog"; +import { SITE_URL, withBaseKeywords } from "@/lib/seo"; +import { buildBlogPostingJsonLd, humanize } from "@/lib/structured-data"; + +// `force-static` + `generateStaticParams` + `dynamicParams = false` +// guarantees each post is prerendered to flat HTML at build time — +// required for Pagefind to find them via the postbuild step, and +// good for LCP since the first byte comes from the edge CDN. +// Without `force-static`, `next-mdx-remote/rsc`'s async render path +// gets the route classified as dynamic and the HTML never lands on +// disk for Pagefind to crawl. +export const dynamic = "force-static"; +export const dynamicParams = false; + +export async function generateStaticParams() { + return getAllPosts().map((post) => ({ slug: post.slug })); +} + +function postUrl(slug: string): string { + return `${SITE_URL}/blog/${slug}`; +} + +// Absolutize a cover URL the same way the JSON-LD builder does so that +// social-card consumers (Facebook / LinkedIn / Slack / Twitter) get an +// origin-qualified URL regardless of whether the author uploaded the +// cover through Keystatic (relative `/blog/covers/...`) or pointed at a +// remote CDN (`https://...`). Falls back to the generated /api/og card +// only when no cover exists — Keystatic enforces `cover` as required, +// so the fallback is defensive (e.g. legacy posts authored before the +// requirement was added, or a malformed frontmatter that still managed +// to parse). +function postOgImage(post: BlogPost): string { + if (post.coverUrl) { + return post.coverUrl.startsWith("http") + ? post.coverUrl + : `${SITE_URL}${post.coverUrl}`; + } + return `${SITE_URL}/api/og?title=${encodeURIComponent(post.title)}§ion=Blog`; +} + +// Medium and Hashnode both render the post title exactly once — in the +// article header — and start the body at H2. If an author duplicates +// the title as a leading `# Title` heading in the body (Keystatic +// allows it on legacy posts), strip it so the page doesn't show two +// identical titles. Match is case-insensitive on text only. +function stripTitleH1(body: string, title: string): string { + const match = body.match(/^\s*#\s+(.+?)\s*\n+/); + if (!match) return body; + if (match[1].trim().toLowerCase() !== title.trim().toLowerCase()) return body; + return body.slice(match[0].length); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await params; + const post = getPost(slug); + if (!post) return { title: "Post not found — Gofasta Blog" }; + + const url = postUrl(slug); + const ogImage = postOgImage(post); + + return { + title: `${post.title} — Gofasta Blog`, + description: post.description, + keywords: withBaseKeywords("blog", ...post.tags), + authors: post.authorUrl + ? [{ name: post.author, url: post.authorUrl }] + : [{ name: post.author }], + alternates: { + canonical: url, + types: { + "application/rss+xml": "/blog/rss.xml", + "application/feed+json": "/blog/feed.json", + }, + }, + openGraph: { + type: "article", + url, + siteName: "Gofasta", + title: post.title, + description: post.description, + publishedTime: post.publishedAt, + modifiedTime: post.updatedAt ?? post.publishedAt, + authors: post.authorUrl ? [post.authorUrl] : [post.author], + tags: post.tags, + images: [ + { + url: ogImage, + width: 1200, + height: 630, + alt: post.title, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.description, + images: [ogImage], + }, + }; +} + +function buildPostJsonLd(post: BlogPost) { + const coverImage = post.coverUrl.startsWith("http") + ? post.coverUrl + : `${SITE_URL}${post.coverUrl}`; + // The builder already emits a `@graph` containing BlogPosting + + // BreadcrumbList, so this thin wrapper just forwards. `wordCount` / + // `timeRequired` / `articleSection` come from data we already compute + // for the page (reading-time + first slugged tag) so they cost nothing + // extra to surface as Article-eligibility signals. + const minutes = Math.max(1, Math.round(post.readingTime.minutes)); + return buildBlogPostingJsonLd({ + slug: post.slug, + title: post.title, + description: post.description, + authorName: post.author, + authorUrl: post.authorUrl, + publishedAt: post.publishedAt, + updatedAt: post.updatedAt, + coverImageUrl: coverImage, + keywords: withBaseKeywords("blog", ...post.tags), + wordCount: post.readingTime.words, + timeRequired: `PT${minutes}M`, + articleSection: post.tags[0] ? humanize(post.tags[0]) : "Blog", + }); +} + +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const post = getPost(slug); + if (!post) notFound(); + + const { prev, next } = getAdjacentPosts(slug); + const allPosts = getAllPosts(); + const jsonLd = buildPostJsonLd(post); + + return ( + + +