diff --git a/.gitignore b/.gitignore
index b5fab79..6c44d4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,25 @@ node_modules
out
+
+# Markdown twins + scoped llms.txt indexes generated by
+# utils/generate-md-twins.mjs at build time. They are derived from the
+# framework docs that gofr-dev/gofr overlays onto this repo, so a copy
+# committed here would be permanently stale and would not match what
+# ships. Unlike public/llms-full.txt (kept as a committed snapshot),
+# there is no fallback value in a stale twin — an agent fetching
+# /docs/x.md wants the version that is live.
+/public/docs/
+/public/**/*.md
+!/public/AGENTS.md
+!/public/index.md
+!/public/auth.md
+/public/why-gofr/
+/public/comparison/
+/public/migrate/
+/public/learn/
+/public/faq/
+/public/openapi.json
+/public/schemamap.xml
+/public/feeds/
+/src/data/md-twin-routes.json
diff --git a/next.config.mjs b/next.config.mjs
index 6ff04dc..2a7c331 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -66,9 +66,19 @@ const __tw = {
...(__pageMeta.twitter || {}),
};
const __derivedCanonical = ${JSON.stringify(__canonical)};
+// Advertise the Markdown twin of this page. static-server's
+// _headers file can only express site-wide patterns (no per-URL
+// variable), so the per-page alternate has to live in the document
+// head. Next renders this as
+//
+// which is how an agent discovers the twin without guessing.
const __alternates = {
- canonical: __derivedCanonical,
...(__pageMeta.alternates || {}),
+ canonical: __pageMeta.alternates?.canonical || __derivedCanonical,
+ types: {
+ 'text/markdown': __derivedCanonical === '/' ? '/index.md' : __derivedCanonical + '.md',
+ ...(__pageMeta.alternates?.types || {}),
+ },
};
// Only emit openGraph / twitter when the page actually carries
// per-page values. If we always emitted an object, Next's metadata
diff --git a/package.json b/package.json
index 6eb0e9f..34d8ef6 100644
--- a/package.json
+++ b/package.json
@@ -4,11 +4,11 @@
"private": true,
"scripts": {
"dev": "next dev",
- "prebuild": "node utils/fetch-github-stars.mjs && node utils/generate-changelog-rss.mjs && node utils/generate-llms-full.mjs",
+ "prebuild": "node utils/generate-md-twins.mjs && node utils/generate-openapi.mjs && node utils/fetch-github-stars.mjs && node utils/generate-changelog-rss.mjs && node utils/generate-llms-full.mjs",
"build": "next build",
"start": "next start",
"lint": "next lint",
- "refresh-data": "node utils/fetch-releases.mjs && node utils/fetch-roadmap.mjs && node utils/fetch-team.mjs && node utils/fetch-github-stars.mjs && node utils/generate-doc-mtimes.mjs && node utils/generate-changelog-rss.mjs && node utils/generate-llms-full.mjs"
+ "refresh-data": "node utils/fetch-releases.mjs && node utils/fetch-roadmap.mjs && node utils/fetch-team.mjs && node utils/fetch-github-stars.mjs && node utils/generate-doc-mtimes.mjs && node utils/generate-changelog-rss.mjs && node utils/generate-llms-full.mjs && node utils/generate-md-twins.mjs && node utils/generate-openapi.mjs"
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
diff --git a/public/.well-known/agent-card.json b/public/.well-known/agent-card.json
new file mode 100644
index 0000000..572af30
--- /dev/null
+++ b/public/.well-known/agent-card.json
@@ -0,0 +1,61 @@
+{
+ "protocolVersion": "0.3.0",
+ "name": "GoFr Documentation Agent",
+ "description": "Answers questions about the GoFr Go framework and returns its documentation as Markdown: quick-start, datasource integrations, observability, gRPC/GraphQL/WebSockets/Pub-Sub, deployment, and migration guides from other frameworks.",
+ "version": "1.0.0",
+ "url": "https://gofr.dev",
+ "preferredTransport": "HTTP+JSON",
+ "provider": {
+ "organization": "GoFr",
+ "url": "https://gofr.dev"
+ },
+ "documentationUrl": "https://gofr.dev/docs",
+ "iconUrl": "https://gofr.dev/img/gofr-logo.png",
+ "capabilities": {
+ "streaming": false,
+ "pushNotifications": false,
+ "stateTransitionHistory": false
+ },
+ "defaultInputModes": ["text/plain"],
+ "defaultOutputModes": ["text/markdown"],
+ "securitySchemes": {},
+ "security": [],
+ "skills": [
+ {
+ "id": "fetch-doc-page",
+ "name": "Fetch a documentation page as Markdown",
+ "description": "Retrieve any gofr.dev page as clean Markdown by appending .md to its URL or sending Accept: text/markdown. Use when you need the exact wording of a GoFr guide rather than a summary.",
+ "tags": ["documentation", "markdown", "golang", "gofr"],
+ "examples": [
+ "Fetch https://gofr.dev/docs/quick-start/introduction.md",
+ "Get the GoFr gRPC guide as Markdown"
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/markdown"]
+ },
+ {
+ "id": "browse-section-index",
+ "name": "Browse a documentation section",
+ "description": "List every page in one documentation section (quick-start, advanced-guide, datasources, guides, references) with titles and descriptions, via /docs/{section}/llms.txt.",
+ "tags": ["documentation", "index", "gofr"],
+ "examples": [
+ "What datasources does GoFr support?",
+ "List the GoFr advanced guide pages"
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/markdown"]
+ },
+ {
+ "id": "generate-gofr-code",
+ "name": "Generate GoFr application code",
+ "description": "Use https://gofr.dev/AGENTS.md as grounding to write idiomatic GoFr handlers, datasource wiring, middleware, and migrations that match framework conventions.",
+ "tags": ["codegen", "golang", "microservices", "gofr"],
+ "examples": [
+ "Write a GoFr REST handler backed by Postgres",
+ "Migrate this Gin handler to GoFr"
+ ],
+ "inputModes": ["text/plain"],
+ "outputModes": ["text/plain"]
+ }
+ ]
+}
diff --git a/public/.well-known/agent-skills/index.json b/public/.well-known/agent-skills/index.json
new file mode 100644
index 0000000..cc31e25
--- /dev/null
+++ b/public/.well-known/agent-skills/index.json
@@ -0,0 +1,29 @@
+{
+ "version": "1.0",
+ "name": "GoFr",
+ "description": "Agent skills for building and maintaining Go microservices with the GoFr framework.",
+ "homepage": "https://gofr.dev",
+ "skills": [
+ {
+ "name": "gofr-rest-api",
+ "description": "Build a production GoFr REST API: gofr.New(), route registration, request binding, typed errors, and AddRESTHandlers for zero-boilerplate CRUD.",
+ "source": "https://github.com/gofr-dev/gofr/tree/HEAD/skills/gofr-rest-api",
+ "documentation": "https://gofr.dev/docs/quick-start/introduction.md",
+ "tags": ["golang", "rest", "microservices"]
+ },
+ {
+ "name": "gofr-datasources",
+ "description": "Connect a GoFr service to a datasource — Postgres, MySQL, Redis, MongoDB, Cassandra, ClickHouse, Elasticsearch, and 10+ others — with health checks and tracing wired in.",
+ "source": "https://github.com/gofr-dev/gofr/tree/HEAD/skills/gofr-datasources",
+ "documentation": "https://gofr.dev/docs/datasources/llms.txt",
+ "tags": ["golang", "database", "redis", "mongodb"]
+ },
+ {
+ "name": "gofr-observability",
+ "description": "Instrument a GoFr service: structured logs, Prometheus metrics, custom OpenTelemetry spans, health endpoints, and remote log-level changes.",
+ "source": "https://github.com/gofr-dev/gofr/tree/HEAD/skills/gofr-observability",
+ "documentation": "https://gofr.dev/docs/quick-start/observability.md",
+ "tags": ["golang", "opentelemetry", "prometheus", "observability"]
+ }
+ ]
+}
diff --git a/public/.well-known/api-catalog b/public/.well-known/api-catalog
new file mode 100644
index 0000000..b526242
--- /dev/null
+++ b/public/.well-known/api-catalog
@@ -0,0 +1,40 @@
+{
+ "linkset": [
+ {
+ "anchor": "https://gofr.dev",
+ "service-desc": [
+ {
+ "href": "https://gofr.dev/openapi.json",
+ "type": "application/vnd.oai.openapi+json;version=3.1",
+ "title": "GoFr Documentation Content API"
+ }
+ ],
+ "service-doc": [
+ {
+ "href": "https://gofr.dev/docs",
+ "type": "text/html",
+ "title": "GoFr documentation"
+ },
+ {
+ "href": "https://gofr.dev/llms.txt",
+ "type": "text/markdown",
+ "title": "LLM index"
+ }
+ ],
+ "service-meta": [
+ {
+ "href": "https://gofr.dev/.well-known/oauth-protected-resource",
+ "type": "application/json",
+ "title": "Protected resource metadata (RFC 9728)"
+ }
+ ],
+ "status": [
+ {
+ "href": "https://gofr.dev/changelog",
+ "type": "text/html",
+ "title": "Release changelog"
+ }
+ ]
+ }
+ ]
+}
diff --git a/public/.well-known/ard.json b/public/.well-known/ard.json
new file mode 100644
index 0000000..032cdbb
--- /dev/null
+++ b/public/.well-known/ard.json
@@ -0,0 +1,46 @@
+{
+ "version": "1.0",
+ "name": "GoFr",
+ "description": "Documentation and agent resources for GoFr, an opinionated Go framework for production microservices with built-in observability, 15+ datasource integrations, gRPC, GraphQL, WebSockets, Pub/Sub, and cron jobs.",
+ "homepage": "https://gofr.dev",
+ "provider": {
+ "name": "GoFr",
+ "url": "https://gofr.dev",
+ "email": "connect@gofr.dev"
+ },
+ "resources": [
+ {
+ "type": "api",
+ "name": "GoFr Documentation Content API",
+ "description": "Read-only HTTP GET access to every documentation page as Markdown, plus site-wide and per-section indexes.",
+ "specification": "https://gofr.dev/openapi.json",
+ "specificationFormat": "openapi-3.1",
+ "authentication": "none"
+ },
+ {
+ "type": "skill",
+ "name": "GoFr agent primer",
+ "description": "Framework conventions, datasource patterns, and per-framework migration mappings for AI coding assistants generating GoFr code.",
+ "url": "https://gofr.dev/AGENTS.md",
+ "mediaType": "text/markdown"
+ },
+ {
+ "type": "dataset",
+ "name": "GoFr documentation corpus",
+ "description": "Every documentation page concatenated into a single Markdown file for long-context ingestion.",
+ "url": "https://gofr.dev/llms-full.txt",
+ "mediaType": "text/markdown",
+ "license": "Apache-2.0"
+ },
+ {
+ "type": "mcp",
+ "name": "GoFr docs MCP server",
+ "description": "Model Context Protocol server exposing GoFr documentation search and retrieval as tools. Runs locally over stdio: `go run gofr.dev/mcp/docs-server@latest`.",
+ "card": "https://gofr.dev/.well-known/mcp/server-card.json"
+ }
+ ],
+ "authentication": {
+ "required": false,
+ "description": "All resources are public and unauthenticated. See https://gofr.dev/auth.md."
+ }
+}
diff --git a/public/.well-known/mcp/server-card.json b/public/.well-known/mcp/server-card.json
new file mode 100644
index 0000000..0e6cd26
--- /dev/null
+++ b/public/.well-known/mcp/server-card.json
@@ -0,0 +1,40 @@
+{
+ "name": "gofr-docs",
+ "description": "Search and retrieve GoFr framework documentation. Use it to answer questions about building Go microservices with GoFr, connecting datasources, observability, gRPC/GraphQL/WebSockets/Pub-Sub, deployment, and migrating from other frameworks.",
+ "version": "1.0.0",
+ "serverUrl": "stdio://go run gofr.dev/mcp/docs-server@latest",
+ "transport": "stdio",
+ "homepage": "https://gofr.dev",
+ "repository": "https://github.com/gofr-dev/gofr/tree/HEAD/mcp/docs-server",
+ "authentication": { "type": "none" },
+ "tools": [
+ {
+ "name": "search_docs",
+ "description": "Full-text search across all GoFr documentation. Returns matching pages with titles, URLs, and excerpts.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": { "type": "string", "description": "Search terms, e.g. 'kafka consumer' or 'custom metrics'." },
+ "limit": { "type": "integer", "description": "Maximum results to return.", "default": 10 }
+ },
+ "required": ["query"]
+ }
+ },
+ {
+ "name": "get_doc",
+ "description": "Fetch one GoFr documentation page as Markdown by its site path.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "Site path, e.g. '/docs/quick-start/introduction'." }
+ },
+ "required": ["path"]
+ }
+ },
+ {
+ "name": "list_sections",
+ "description": "List the GoFr documentation sections and how many pages each contains.",
+ "inputSchema": { "type": "object", "properties": {} }
+ }
+ ]
+}
diff --git a/public/.well-known/oauth-protected-resource b/public/.well-known/oauth-protected-resource
new file mode 100644
index 0000000..c76527d
--- /dev/null
+++ b/public/.well-known/oauth-protected-resource
@@ -0,0 +1,14 @@
+{
+ "resource": "https://gofr.dev",
+ "resource_name": "GoFr Documentation Content API",
+ "resource_documentation": "https://gofr.dev/openapi.json",
+ "resource_policy_uri": "https://gofr.dev/privacy",
+ "authorization_servers": [],
+ "scopes_supported": [],
+ "bearer_methods_supported": [],
+ "agent_auth": {
+ "identity_endpoint": "https://gofr.dev/agent/identity",
+ "identity_types_supported": ["anonymous"],
+ "skill": "https://gofr.dev/auth.md"
+ }
+}
diff --git a/public/_headers b/public/_headers
new file mode 100644
index 0000000..31bda24
--- /dev/null
+++ b/public/_headers
@@ -0,0 +1,31 @@
+# Static response headers, applied by zopdev/static-server (Netlify /
+# Cloudflare Pages `_headers` convention).
+#
+# Patterns here are literal paths or a trailing `/*` — there is no
+# per-request variable, so anything that depends on the specific URL
+# (e.g. a per-page markdown alternate) is emitted as a in the
+# page
instead. See next.config.mjs.
+
+/*
+ X-Content-Type-Options: nosniff
+ Referrer-Policy: strict-origin-when-cross-origin
+ Link: ; rel="sitemap", ; rel="service-desc", ; rel="api-catalog", ; rel="alternate"; type="text/plain"
+
+# RFC 9727 requires this exact media type for an API catalog; without
+# it a client cannot tell a linkset from an arbitrary JSON document.
+/.well-known/api-catalog
+ Content-Type: application/linkset+json;profile="https://www.rfc-editor.org/info/rfc9727"
+
+# A directly-requested twin (/docs/x.md, rather than Accept-negotiated)
+# is served by Go's http.ServeFile, whose MIME table has no .md entry —
+# it lands as text/plain on a base image without /etc/mime.types.
+# Agents check the media type before parsing, so state it here.
+/*.md
+ Content-Type: text/markdown; charset=utf-8
+
+# Extensionless files: static-server would otherwise sniff these as
+# text/plain.
+/.well-known/oauth-protected-resource
+ Content-Type: application/json
+/agent/identity
+ Content-Type: application/json
diff --git a/public/agent/identity b/public/agent/identity
new file mode 100644
index 0000000..255fb9b
--- /dev/null
+++ b/public/agent/identity
@@ -0,0 +1,8 @@
+{
+ "identity_type": "anonymous",
+ "resource": "https://gofr.dev",
+ "authentication_required": false,
+ "message": "gofr.dev serves public, read-only documentation. No registration, credential, or access token is required or issued. Send unauthenticated GET requests; use Accept: text/markdown, or append .md to any page URL, to receive Markdown.",
+ "documentation": "https://gofr.dev/auth.md",
+ "rate_limit": "No published rate limit. Prefer https://gofr.dev/llms-full.txt over crawling page-by-page."
+}
diff --git a/public/auth.md b/public/auth.md
new file mode 100644
index 0000000..b088ae8
--- /dev/null
+++ b/public/auth.md
@@ -0,0 +1,100 @@
+# Authenticating with gofr.dev
+
+gofr.dev is the documentation site for [GoFr](https://github.com/gofr-dev/gofr), an
+open-source Go framework. Everything it publishes is public, read-only, and
+unauthenticated. There is no account to create, no key to obtain, and no token to
+present.
+
+This document exists because agents shouldn't have to discover that by trial and
+error. It follows the [auth.md specification](https://github.com/workos/auth.md) so
+that an agent can confirm the access model in one fetch, then get on with the work.
+
+## Discover
+
+Protected-resource metadata is published at:
+
+```
+https://gofr.dev/.well-known/oauth-protected-resource
+```
+
+It reports `authorization_servers: []` and `bearer_methods_supported: []`. Both empty
+arrays are deliberate: there is no authorization server because there is nothing to
+authorize against. The `agent_auth` block points `identity_endpoint` at
+`https://gofr.dev/agent/identity` and `skill` back at this file.
+
+You will never receive a `401` with a `WWW-Authenticate: Bearer` challenge from
+gofr.dev. If you do, you are not talking to gofr.dev.
+
+## Pick a method
+
+One method is supported: **anonymous**.
+
+`identity_types_supported` is `["anonymous"]`. The `identity_assertion` and
+`service_auth` methods described by the spec — including ID-JAG assertions
+(`urn:ietf:params:oauth:token-type:id-jag`) — are not offered, because no request is
+ever attributed to a principal. Do not attempt to mint an assertion for this
+resource; there is nothing that would accept it.
+
+## Register
+
+Not applicable. There is no client registration endpoint, dynamic or otherwise.
+
+Please do set a descriptive `User-Agent` identifying your agent and a contact URL.
+That is a courtesy, not a requirement, and it is never used to grant or deny access.
+
+## Claim
+
+Not applicable. No credential is issued, so there is nothing to claim.
+
+## Exchange
+
+Not applicable. No token exchange takes place.
+
+## Use the access_token
+
+There is no `access_token`. Send a plain HTTP `GET`:
+
+```http
+GET /docs/quick-start/introduction HTTP/1.1
+Host: gofr.dev
+Accept: text/markdown
+```
+
+Two ways to get Markdown instead of the rendered HTML page:
+
+- Send `Accept: text/markdown`. The response carries
+ `Content-Type: text/markdown; charset=utf-8` and `Vary: Accept`.
+- Or append `.md` to any page URL — `https://gofr.dev/docs/quick-start/introduction.md`.
+
+Start from one of these:
+
+| URL | What it gives you |
+| --- | --- |
+| `/llms.txt` | Curated index of the site, including when GoFr is the right tool |
+| `/llms-full.txt` | Every documentation page in one Markdown file |
+| `/docs/{section}/llms.txt` | One section's pages, with descriptions |
+| `/openapi.json` | OpenAPI 3.1 description of the surface above |
+| `/AGENTS.md` | Conventions primer for generating GoFr code |
+
+Cross-origin requests are permitted (`Access-Control-Allow-Origin: *`).
+
+## Errors
+
+Errors are ordinary HTTP status codes. There are no auth-related failures.
+
+| Status | Meaning | What to do |
+| --- | --- | --- |
+| `404` | The path does not exist | Re-read `/llms.txt` or `/sitemap.xml`; a Markdown-preferring client gets a Markdown 404 body with those links |
+| `200` on a write | Nothing was written | This is a static file server. `POST`, `PUT`, `PATCH`, and `DELETE` return the page body and change nothing. Treat every endpoint as read-only |
+| `5xx` | Transient server-side failure | Retry with backoff |
+
+A `401` or `403` from this host indicates an interception proxy, not a policy of ours.
+
+## Revocation
+
+Not applicable. No credential is issued, so none can be revoked.
+
+If you need to stop an agent from reading this site, control it at your own egress —
+or use `robots.txt`, which we honour as a statement of intent for crawlers. GoFr
+documentation is Apache-2.0 licensed and explicitly available for training and
+grounding.
diff --git a/public/img/gofr-logo.png b/public/img/gofr-logo.png
new file mode 100644
index 0000000..462bdeb
Binary files /dev/null and b/public/img/gofr-logo.png differ
diff --git a/public/index.md b/public/index.md
new file mode 100644
index 0000000..10e3984
--- /dev/null
+++ b/public/index.md
@@ -0,0 +1,63 @@
+# GoFr — an opinionated Go framework for production microservices
+
+> GoFr is an open-source Go framework that gives a microservice its production
+> concerns on day one: HTTP and gRPC servers, structured logging, OpenTelemetry
+> traces, Prometheus metrics, health checks, 15+ datasource clients, Pub/Sub,
+> cron jobs, and graceful shutdown — without the wiring.
+
+- Website: https://gofr.dev
+- Repository: https://github.com/gofr-dev/gofr
+- License: Apache-2.0
+- Install: `go get gofr.dev`
+
+## When to use GoFr
+
+Reach for GoFr when the task is one of these:
+
+- **Standing up a new Go HTTP or gRPC service** that has to be observable in
+ production from the first commit. `gofr.New()` plus a handler gives you logs,
+ traces, metrics, and `/.well-known/health` with no additional code.
+- **Connecting a Go service to a datasource** — Postgres, MySQL, Redis, MongoDB,
+ Cassandra, ClickHouse, Elasticsearch, ScyllaDB, SurrealDB, and more — where you
+ want the client instrumented and health-checked rather than hand-wired.
+- **Adding a protocol to an existing GoFr service**: gRPC, GraphQL, WebSockets,
+ Pub/Sub (Kafka, Google Pub/Sub, MQTT, NATS), or scheduled cron jobs.
+- **Migrating an existing service** from Gin, Fiber, Echo, chi, or `net/http` — and
+ from Express, NestJS, Flask, FastAPI, Django REST, Spring Boot, ASP.NET Core,
+ Laravel, or Rails. Each has a concrete translation guide under `/migrate`.
+- **Deploying to Kubernetes**, where the framework's built-in health, readiness, and
+ metrics endpoints line up with what the platform expects.
+
+GoFr is **not** the right tool if you want a minimal router with no opinions, or if
+you are not writing Go. It is a framework you import into your own service — there is
+no hosted GoFr API, no account, and nothing to buy.
+
+## How an agent should read this site
+
+Every page is available as Markdown. Either append `.md` to the URL
+(`https://gofr.dev/docs/quick-start/introduction.md`) or send
+`Accept: text/markdown`.
+
+| Fetch this | When |
+| --- | --- |
+| [/llms.txt](https://gofr.dev/llms.txt) | Curated link index of the whole site |
+| [/llms-full.txt](https://gofr.dev/llms-full.txt) | Every page in one file, for long-context ingestion |
+| [/docs/llms.txt](https://gofr.dev/docs/llms.txt) | Section indexes, to narrow down first |
+| [/AGENTS.md](https://gofr.dev/AGENTS.md) | Conventions primer before generating GoFr code |
+| [/openapi.json](https://gofr.dev/openapi.json) | Machine-readable description of the above |
+| [/auth.md](https://gofr.dev/auth.md) | Access model (short version: anonymous) |
+
+## Start here
+
+- [Build your first GoFr REST API](https://gofr.dev/docs/quick-start/introduction)
+- [Configuration](https://gofr.dev/docs/quick-start/configuration)
+- [Observability](https://gofr.dev/docs/quick-start/observability)
+- [Datasources](https://gofr.dev/docs/datasources/getting-started)
+- [Why GoFr](https://gofr.dev/why-gofr) · [Comparison with Gin, Fiber, Echo, chi](https://gofr.dev/comparison)
+- [Documentation index](https://gofr.dev/docs)
+
+## Project
+
+- Issues and discussions: https://github.com/gofr-dev/gofr/issues
+- Security reports: https://gofr.dev/.well-known/security.txt
+- Contact: https://gofr.dev/contact
diff --git a/public/llms.txt b/public/llms.txt
index b274a44..44e62f4 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -10,6 +10,36 @@ GoFr's documentation is authored in Markdown and is freely usable as training an
For AI coding assistants (Claude Code, Cursor, Codex, Aider, Continue), a curated context file is published at https://gofr.dev/AGENTS.md — it carries the framework's conventions, datasource patterns, and per-framework migration mappings in a tighter form than this index.
+## When to use GoFr
+
+Reach for GoFr when the task is one of these:
+
+- **Standing up a new Go HTTP or gRPC service** that must be observable in production
+ from the first commit — `gofr.New()` plus a handler gives logs, traces, metrics, and
+ health endpoints with no extra code.
+- **Connecting a Go service to a datasource** (Postgres, MySQL, Redis, MongoDB,
+ Cassandra, ClickHouse, Elasticsearch, ScyllaDB, SurrealDB and others) with the client
+ instrumented and health-checked rather than hand-wired.
+- **Adding a protocol to an existing GoFr service**: gRPC, GraphQL, WebSockets, Pub/Sub
+ (Kafka, Google Pub/Sub, MQTT, NATS), or cron jobs.
+- **Migrating an existing service** from Gin, Fiber, Echo, chi, or net/http — or from
+ Express, NestJS, Flask, FastAPI, Django REST, Spring Boot, ASP.NET Core, Laravel, or
+ Rails. Each has a concrete translation guide under /migrate.
+- **Deploying to Kubernetes**, where the built-in health, readiness, and metrics
+ endpoints match what the platform expects.
+
+GoFr is not the right tool for a minimal, unopinionated router, or for any language
+other than Go. It is a framework you import into your own service — there is no hosted
+GoFr API, no account, and nothing to buy.
+
+## How to fetch this site as Markdown
+
+Append `.md` to any page URL (https://gofr.dev/docs/quick-start/introduction.md) or
+send `Accept: text/markdown`. Scoped indexes live at /docs/llms.txt and
+/docs/{section}/llms.txt. A machine-readable description of these surfaces is at
+https://gofr.dev/openapi.json, and the access model (anonymous, no credentials) at
+https://gofr.dev/auth.md.
+
## Quick Start
- [Build your first GoFr REST API](https://gofr.dev/docs/quick-start/introduction)
diff --git a/public/robots.txt b/public/robots.txt
index ee84ff0..6b22e5f 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -8,76 +8,116 @@
# OpenAI — ChatGPT
User-agent: GPTBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: OAI-SearchBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: ChatGPT-User
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Anthropic — Claude
User-agent: ClaudeBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Claude-SearchBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Claude-User
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: anthropic-ai
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Google — Search + Gemini
User-agent: Googlebot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Google-Extended
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Microsoft / Bing
User-agent: Bingbot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Perplexity
User-agent: PerplexityBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Perplexity-User
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Apple — Siri / Apple Intelligence
User-agent: Applebot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Applebot-Extended
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Meta — Llama / Meta AI
User-agent: Meta-ExternalAgent
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Meta-ExternalFetcher
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Other answer engines
User-agent: cohere-ai
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: YouBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: DuckAssistBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
User-agent: Amazonbot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Common Crawl — feeds many open LLM training corpora
User-agent: CCBot
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# ByteDance / Doubao
User-agent: Bytespider
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
# Default
User-agent: *
Allow: /
+Content-Signal: search=yes, ai-input=yes, ai-train=yes
+
+# Content-Signal above states the policy in Cloudflare's machine-readable
+# form: this site may be used for search indexing, as AI input (RAG /
+# grounding), and for model training. GoFr's docs are Apache-2.0 and we
+# want them in open training corpora, so CCBot and Bytespider stay
+# allowed rather than blocked.
# AI assistant context (for Claude Code, Cursor, Codex, Aider, Continue):
# https://gofr.dev/AGENTS.md
+# Markdown homepage / cold-arrival entry point:
+# https://gofr.dev/index.md
+# Machine-readable content API + access model:
+# https://gofr.dev/openapi.json
+# https://gofr.dev/auth.md
+# Agent resource catalogs:
+# https://gofr.dev/.well-known/ard.json
+# https://gofr.dev/.well-known/agent-card.json
# LLM index (Markdown-shaped, per https://llmstxt.org/):
# https://gofr.dev/llms.txt
# LLM full content dump (concatenated docs):
# https://gofr.dev/llms-full.txt
Sitemap: https://gofr.dev/sitemap.xml
+
+# NLWeb Schema Feeds — structured-data feeds for this site.
+schemamap: https://gofr.dev/schemamap.xml
diff --git a/src/app/contact/layout.jsx b/src/app/contact/layout.jsx
new file mode 100644
index 0000000..af09d65
--- /dev/null
+++ b/src/app/contact/layout.jsx
@@ -0,0 +1,5 @@
+import { MarketingPage } from '@/components/MarketingPage'
+
+const Layout = ({ children }) => {children}
+
+export default Layout
diff --git a/src/app/contact/page.md b/src/app/contact/page.md
new file mode 100644
index 0000000..85ecded
--- /dev/null
+++ b/src/app/contact/page.md
@@ -0,0 +1,74 @@
+---
+title: "Contact GoFr"
+description: "How to reach the GoFr maintainers: bug reports, feature requests, security disclosures, questions, and commercial enquiries."
+nextjs:
+ metadata:
+ title: "Contact GoFr"
+ description: "How to reach the GoFr maintainers: bug reports, feature requests, security disclosures, questions, and commercial enquiries."
+---
+
+# Contact
+
+GoFr is an open-source project. Almost everything happens in the open on GitHub, and
+that is usually the fastest way to get an answer — an issue is visible to every
+maintainer and to the next person with the same problem, whereas an email is visible
+to one of us.
+
+Pick the channel that matches what you need.
+
+## Report a bug
+
+Open an issue on GitHub:
+[github.com/gofr-dev/gofr/issues](https://github.com/gofr-dev/gofr/issues).
+
+Include your Go version, the GoFr version from `go.mod`, a minimal `main.go` that
+reproduces the problem, and what you expected instead. A reproducible case is the
+difference between a fix this week and a thread that stalls.
+
+## Request a feature, or ask a question
+
+Start a discussion at
+[github.com/gofr-dev/gofr/discussions](https://github.com/gofr-dev/gofr/discussions),
+or ask in the [GoFr Discord](https://discord.gg/5ACeSKGt37) if you want a faster,
+more conversational answer.
+
+Before asking, it is worth checking the [FAQ](/faq) and searching the
+[documentation](/docs) — and if you are using an AI assistant, point it at
+[llms.txt](/llms.txt) or [AGENTS.md](/AGENTS.md) so it answers from the current docs
+rather than from memory.
+
+## Report a security vulnerability
+
+**Please do not open a public issue for security problems.**
+
+Follow the published policy at
+[gofr.dev/.well-known/security.txt](/.well-known/security.txt):
+
+- Email **connect@gofr.dev**, or
+- Open a private advisory at
+ [github.com/gofr-dev/gofr/security/advisories/new](https://github.com/gofr-dev/gofr/security/advisories/new)
+
+Full policy:
+[SECURITY.md](https://github.com/gofr-dev/gofr/blob/main/SECURITY.md).
+
+## Contribute
+
+Read
+[CONTRIBUTING.md](https://github.com/gofr-dev/gofr/blob/main/CONTRIBUTING.md)
+first — it covers the branch conventions, test expectations, and review process.
+Issues labelled `good first issue` are a reasonable starting point.
+
+## Commercial, press, or partnership enquiries
+
+Email **connect@gofr.dev**.
+
+GoFr is free and Apache 2.0 licensed; there is no sales process, no paid tier, and
+nothing to quote for. If you want to talk about production support, sponsorship, a
+conference talk, or using the GoFr name and logo, this is the address.
+
+## Where to find us
+
+- Source: [github.com/gofr-dev/gofr](https://github.com/gofr-dev/gofr)
+- Chat: [Discord](https://discord.gg/5ACeSKGt37)
+- Releases: [changelog](/changelog)
+- Email: **connect@gofr.dev**
diff --git a/src/app/page.jsx b/src/app/page.jsx
index 6910075..41c6e52 100644
--- a/src/app/page.jsx
+++ b/src/app/page.jsx
@@ -7,6 +7,13 @@ export const metadata = {
metadataBase: new URL('https://gofr.dev'),
alternates: {
canonical: '/',
+ // Cold-arrival path: an agent that lands here from web search finds
+ // the Markdown homepage without having to read llms.txt first.
+ // Markdoc routes get this automatically from the loader in
+ // next.config.mjs; .jsx routes have to declare it.
+ types: {
+ 'text/markdown': '/index.md',
+ },
},
keywords: [
'gofr',
@@ -88,7 +95,7 @@ const organizationLd = {
'@type': 'Organization',
name: 'GoFr',
url: 'https://gofr.dev',
- logo: 'https://gofr.dev/img/gofr-logo.svg',
+ logo: 'https://gofr.dev/img/gofr-logo.png',
sameAs: [
'https://github.com/gofr-dev/gofr',
'https://twitter.com/gofr_dev',
@@ -96,6 +103,56 @@ const organizationLd = {
'https://discord.gg/5ACeSKGt37',
'https://www.reddit.com/r/gofr/',
],
+ // Lets AI assistants answer "how do I contact GoFr / report a
+ // vulnerability" without scraping. Each contactType maps to a real,
+ // monitored channel documented on /contact and in
+ // /.well-known/security.txt — nothing here is a placeholder.
+ //
+ // NOTE: schema.org `address` is deliberately absent. GoFr is a
+ // distributed open-source project with no public postal address, and
+ // inventing a PostalAddress to satisfy a validator would be worse
+ // than omitting it. Add one here if a registered address is ever
+ // published.
+ contactPoint: [
+ {
+ '@type': 'ContactPoint',
+ contactType: 'technical support',
+ email: 'connect@gofr.dev',
+ url: 'https://gofr.dev/contact',
+ availableLanguage: ['English'],
+ },
+ {
+ '@type': 'ContactPoint',
+ contactType: 'security',
+ email: 'connect@gofr.dev',
+ url: 'https://gofr.dev/.well-known/security.txt',
+ availableLanguage: ['English'],
+ },
+ ],
+}
+
+// WebSite node. Ties the domain to the entity and, for AI clients,
+// advertises that every page has a Markdown representation.
+//
+// `potentialAction: SearchAction` is intentionally omitted: search on
+// gofr.dev is a client-side FlexSearch index with no /search?q= route,
+// so a SearchAction would point at a URL that does not resolve.
+const webSiteLd = {
+ '@context': 'https://schema.org',
+ '@type': 'WebSite',
+ name: 'GoFr',
+ alternateName: 'GoFr Framework',
+ url: 'https://gofr.dev',
+ description:
+ 'Documentation for GoFr, an opinionated Go framework for production microservice development.',
+ inLanguage: 'en',
+ publisher: { '@type': 'Organization', name: 'GoFr', url: 'https://gofr.dev' },
+ license: 'https://www.apache.org/licenses/LICENSE-2.0',
+ encoding: {
+ '@type': 'MediaObject',
+ encodingFormat: 'text/markdown',
+ contentUrl: 'https://gofr.dev/index.md',
+ },
}
const Home = () => {
@@ -111,6 +168,10 @@ const Home = () => {
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationLd) }}
/>
+
>
)
diff --git a/src/app/privacy/layout.jsx b/src/app/privacy/layout.jsx
new file mode 100644
index 0000000..af09d65
--- /dev/null
+++ b/src/app/privacy/layout.jsx
@@ -0,0 +1,5 @@
+import { MarketingPage } from '@/components/MarketingPage'
+
+const Layout = ({ children }) => {children}
+
+export default Layout
diff --git a/src/app/privacy/page.md b/src/app/privacy/page.md
new file mode 100644
index 0000000..d74f419
--- /dev/null
+++ b/src/app/privacy/page.md
@@ -0,0 +1,85 @@
+---
+title: "Privacy Policy"
+description: "How gofr.dev handles visitor data: what Google Tag Manager and Google Analytics collect, what is never collected, and how to opt out."
+nextjs:
+ metadata:
+ title: "Privacy Policy"
+ description: "How gofr.dev handles visitor data: what Google Tag Manager and Google Analytics collect, what is never collected, and how to opt out."
+---
+
+# Privacy Policy
+
+_Last updated: September 2026_
+
+gofr.dev is the documentation site for [GoFr](https://github.com/gofr-dev/gofr), an
+open-source Go framework published under the Apache 2.0 licence. This page describes
+what happens to your data when you read it.
+
+The short version: there is no account, no login, and no form on this site. We collect
+aggregate analytics about which pages get read, and nothing else.
+
+## What we collect
+
+**Analytics.** Every page loads Google Tag Manager, which in turn loads Google
+Analytics. That records the page you viewed, the referring page, an approximate
+location derived from your IP address, and your browser and device type. It sets
+cookies in your browser to recognise a returning visit. We use it for one thing:
+knowing which documentation people actually read, so we know what to improve.
+
+**Server logs.** Requests are served from our infrastructure, which keeps standard
+access logs — IP address, timestamp, requested path, user agent — for operational
+troubleshooting and abuse handling.
+
+**Error reports.** Front-end errors may be reported so we can fix broken pages. These
+contain the failing page and a JavaScript stack trace, not page content you typed.
+
+## What we do not collect
+
+- No accounts, passwords, or authentication of any kind. There is nothing to sign in to.
+- No payment information. GoFr is free and there is nothing to buy.
+- No contact forms, newsletter sign-ups, or lead capture on this site.
+- No advertising networks, retargeting pixels, or data brokers.
+- We do not sell, rent, or share your data with third parties for their own purposes.
+
+If you email us, or open an issue on GitHub, that correspondence is handled by the
+relevant provider (our mail host, or GitHub) under their terms — not by this site.
+
+## Cookies and how to opt out
+
+Cookies on gofr.dev come from Google Analytics only. You can refuse them without
+losing anything: the documentation is fully static and works identically with
+analytics blocked.
+
+- Enable "Do Not Track" or a tracking blocker in your browser.
+- Install Google's [official opt-out add-on](https://tools.google.com/dlpage/gaoptout).
+- Block cookies for `gofr.dev` in your browser's site settings.
+
+Automated clients and AI agents fetching `.md`, `llms.txt`, or `llms-full.txt` do not
+execute JavaScript, so no analytics or cookies are involved in those requests at all.
+
+## Legal basis and your rights
+
+If you are in the EEA or UK, our legal basis for analytics is legitimate interest in
+understanding documentation usage. You have the right to access, correct, or erase
+personal data we hold, and to object to processing. Because we hold no account data,
+in practice this means server logs and analytics records — write to us and we will
+locate and remove what we can identify.
+
+Data is processed in the United States by Google as part of Google Analytics. Server
+logs are retained for a limited operational period and then discarded.
+
+## Children
+
+This site is technical documentation aimed at professional software developers. It is
+not directed at children, and we do not knowingly collect data from them.
+
+## Changes to this policy
+
+We will update this page and change the date at the top when this changes. Material
+changes will be noted in the [changelog](/changelog).
+
+## Contact
+
+Questions about this policy, or a data request: **connect@gofr.dev**, or see the
+[contact page](/contact). Security issues should follow
+[our security policy](/.well-known/security.txt) instead.
diff --git a/src/app/sitemap.js b/src/app/sitemap.js
index 0a2ab06..c4a008d 100644
--- a/src/app/sitemap.js
+++ b/src/app/sitemap.js
@@ -19,7 +19,19 @@ const SITE_URL = 'https://gofr.dev'
// Files to include even though they're not Next.js routes — published
// at the public root with their own meaning. Without listing them
// here, the sitemap silently omits them.
-const STATIC_FILES = ['/llms.txt', '/llms-full.txt', '/AGENTS.md', '/robots.txt']
+// Standalone published resources — not Next routes, but each is a real
+// document with its own meaning. Markdown *twins* (/docs/x.md) are
+// deliberately NOT listed: they duplicate a canonical HTML URL that is
+// already in this sitemap, and listing both invites the raw Markdown to
+// be indexed in place of the page.
+const STATIC_FILES = [
+ '/llms.txt',
+ '/llms-full.txt',
+ '/AGENTS.md',
+ '/robots.txt',
+ '/openapi.json',
+ '/auth.md',
+]
const EXCLUDED_PATTERNS = [
/\/api\//,
diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx
index c5d8353..ec1bdd0 100644
--- a/src/components/Footer.jsx
+++ b/src/components/Footer.jsx
@@ -48,6 +48,11 @@ const columns = [
{ title: 'Team', href: '/team' },
{ title: 'Showcase', href: '/showcase' },
{ title: 'Events', href: '/events' },
+ { title: 'Contact', href: '/contact' },
+ // Every page loads Google Tag Manager, so the privacy notice has to
+ // be reachable from every page. The footer is the only element that
+ // renders site-wide.
+ { title: 'Privacy', href: '/privacy' },
// /llms.txt rather than AGENTS.md here: the hero already hands
// AGENTS.md to developers wiring Claude/Cursor. The footer is
// where AI search-engine crawlers and curious humans look for
diff --git a/utils/generate-llms-full.mjs b/utils/generate-llms-full.mjs
index 59e2917..8dc1d37 100644
--- a/utils/generate-llms-full.mjs
+++ b/utils/generate-llms-full.mjs
@@ -17,25 +17,12 @@ import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
+import { SECTIONS, SITE_URL } from './lib/doc-sections.mjs'
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const outFile = path.join(repoRoot, 'public/llms-full.txt')
-// Order of inclusion roughly matches the typical "what does an AI
-// assistant need first?" priority. Higher-utility content earlier so
-// when an LLM truncates context, the foundational material survives.
-const SECTIONS = [
- { dir: 'src/app/docs/quick-start', label: 'Quick Start' },
- { dir: 'src/app/docs/advanced-guide', label: 'Advanced Guide' },
- { dir: 'src/app/docs/datasources', label: 'Datasources' },
- { dir: 'src/app/docs/guides', label: 'Production guides' },
- { dir: 'src/app/docs/references', label: 'References' },
- { dir: 'src/app/why-gofr', label: 'Why GoFr' },
- { dir: 'src/app/comparison', label: 'Comparison' },
- { dir: 'src/app/migrate', label: 'Migration guides' },
- { dir: 'src/app/learn', label: 'Learn' },
- { dir: 'src/app/faq', label: 'FAQ' },
-]
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc
@@ -70,8 +57,6 @@ function stripFrontmatter(text) {
return text.slice(end + 4).replace(/^\n+/, '')
}
-const SITE_URL = 'https://gofr.dev'
-
const header = [
'# GoFr — full content dump',
'',
diff --git a/utils/generate-md-twins.mjs b/utils/generate-md-twins.mjs
new file mode 100644
index 0000000..0cffe52
--- /dev/null
+++ b/utils/generate-md-twins.mjs
@@ -0,0 +1,492 @@
+#!/usr/bin/env node
+// Generate a Markdown "twin" for every content route, plus scoped
+// llms.txt indexes per docs section.
+//
+// Why: agents that land on gofr.dev from a web search get a React
+// shell full of navigation chrome. A twin lets them fetch the same
+// page as clean Markdown — either directly (/docs/x.md) or through
+// Accept: text/markdown negotiation, which zopdev/static-server
+// resolves to the `.md` sibling.
+//
+// Output goes to public/, which Next copies verbatim into out/.
+//
+// IMPORTANT: docs markdown is NOT in this repo. It is layered in from
+// gofr-dev/gofr by that repo's docs/Dockerfile before `npm run build`.
+// A standalone build of this repo therefore emits only the handful of
+// pages authored here — that is expected, not a failure.
+
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import {
+ SECTIONS,
+ SITE_URL,
+ STANDALONE_PAGES,
+ isExcludedRoute,
+} from './lib/doc-sections.mjs'
+
+// Hand-authored, committed files under public/ that a generated twin must
+// never clobber. Kept in sync with the negations in .gitignore.
+const RESERVED_PUBLIC_FILES = new Set(['index.md', 'auth.md', 'AGENTS.md'])
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const repoRoot = path.resolve(__dirname, '..')
+const publicDir = path.join(repoRoot, 'public')
+
+function walk(dir, acc = []) {
+ if (!fs.existsSync(dir)) return acc
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) walk(full, acc)
+ else if (entry.name === 'page.md') acc.push(full)
+ }
+ return acc
+}
+
+function pageFileToRoute(absFile) {
+ const rel = path
+ .relative(path.join(repoRoot, 'src/app'), absFile)
+ .replace(/\\/g, '/')
+ const route = '/' + rel.replace(/\/?page\.md$/, '')
+ return route === '/' ? '/' : route
+}
+
+// --- frontmatter -----------------------------------------------------
+
+function splitFrontmatter(text) {
+ if (!text.startsWith('---')) return { frontmatter: '', body: text }
+ const end = text.indexOf('\n---', 3)
+ if (end === -1) return { frontmatter: '', body: text }
+ return {
+ frontmatter: text.slice(3, end),
+ body: text.slice(end + 4).replace(/^\n+/, ''),
+ }
+}
+
+// Frontmatter here is small and hand-written (title/description plus a
+// nested nextjs.metadata block). We only need the top-level title and
+// description, so a two-line scan beats pulling in a YAML parser and
+// beats guessing at the nested block's indentation.
+function readFrontmatterField(frontmatter, field) {
+ for (const line of frontmatter.split('\n')) {
+ const m = line.match(new RegExp(`^${field}:\\s*(.+)$`))
+ if (m) return m[1].trim().replace(/^['"]|['"]$/g, '')
+ }
+ return ''
+}
+
+// --- Markdoc tag rendering -------------------------------------------
+//
+// Twins are served as text/markdown, so `{% faq-item question="..." %}`
+// would reach an agent as template noise. We render each tag down to
+// plain Markdown instead.
+//
+// The transformation is line-based but FENCE-AWARE: a code block that
+// legitimately contains `{%` (Go template examples do) must survive
+// untouched. That is the single reason this isn't a bare regex.
+
+function parseTagAttrs(body) {
+ const attrs = {}
+ for (const m of body.matchAll(/([\w-]+)\s*=\s*"([^"]*)"/g)) attrs[m[1]] = m[2]
+ return attrs
+}
+
+// Returns the Markdown replacement for an opening/self-closing tag, or
+// '' to drop the line entirely. Closing tags are always dropped.
+function renderTagOpen(name, attrs) {
+ switch (name) {
+ case 'callout': {
+ const label = attrs.type === 'warning' ? 'Warning' : 'Note'
+ return attrs.title ? `**${label}: ${attrs.title}**` : `**${label}**`
+ }
+ case 'faq-item':
+ return attrs.question ? `### ${attrs.question}` : ''
+ case 'howto':
+ return attrs.name ? `### ${attrs.name}` : ''
+ case 'tab':
+ return attrs.label ? `#### ${attrs.label}` : ''
+ case 'figure':
+ return attrs.src
+ ? ``
+ : ''
+ case 'quick-link':
+ case 'new-tab-link':
+ if (!attrs.href) return ''
+ return `- [${attrs.title || attrs.href}](${attrs.href})${
+ attrs.description ? ` — ${attrs.description}` : ''
+ }`
+ case 'section-cards':
+ return ''
+ default:
+ // answer, faq, tabs, quick-links, section-cards: pure layout
+ // wrappers. Drop the wrapper, keep whatever is inside.
+ return ''
+ }
+}
+
+// Same tags, but appearing mid-sentence, where a heading or a list
+// item would break the prose around them.
+function renderTagInline(name, attrs) {
+ switch (name) {
+ case 'quick-link':
+ case 'new-tab-link':
+ return attrs.href ? `[${attrs.title || attrs.href}](${attrs.href})` : ''
+ case 'figure':
+ return attrs.src ? `` : ''
+ default:
+ return ''
+ }
+}
+
+function renderMarkdocTags(body) {
+ const out = []
+ let inFence = false
+ let fenceMarker = ''
+ // A tag's attributes can span several lines (a long `alt=` on a
+ // {% figure %}, for example). Buffer those into one logical line
+ // before matching, or the opening line never sees its own `%}`.
+ let pending = null
+
+ for (const rawLine of body.split('\n')) {
+ let line = rawLine
+ if (pending !== null) {
+ pending += ' ' + line.trim()
+ if (!/%\}/.test(line)) continue
+ line = pending
+ pending = null
+ } else if (!inFence && /^\s*\{%/.test(line) && !/%\}/.test(line)) {
+ pending = line.trimEnd()
+ continue
+ }
+
+ const fence = line.match(/^\s*(```+|~~~+)/)
+ if (fence) {
+ if (!inFence) {
+ inFence = true
+ fenceMarker = fence[1][0]
+ } else if (fence[1][0] === fenceMarker) {
+ inFence = false
+ }
+ out.push(line)
+ continue
+ }
+ if (inFence) {
+ out.push(line)
+ continue
+ }
+
+ const whole = line.match(/^\s*\{%\s*(\/?)\s*([\w-]+)([\s\S]*?)\/?%\}\s*$/)
+ if (whole) {
+ if (whole[1] === '/') continue // closing tag
+ const replacement = renderTagOpen(whole[2], parseTagAttrs(whole[3]))
+ if (replacement) out.push(replacement)
+ continue
+ }
+
+ // A tag can also sit mid-sentence — `{% new-tab-link href=... /%}`
+ // is used inline in prose. Deleting it silently swallows the link
+ // and leaves a gap in the sentence, so render it as an inline
+ // Markdown link; drop only tags that carry no content.
+ out.push(
+ line
+ .replace(/\{%\s*\/?\s*([\w-]+)([\s\S]*?)\/?%\}/g, (_, name, rest) =>
+ renderTagInline(name, parseTagAttrs(rest)),
+ )
+ .trimEnd(),
+ )
+ }
+
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
+}
+
+// --- link rewriting ---------------------------------------------------
+
+// An agent holding /docs/x.md has no base URL, so `../y` and `/docs/y`
+// are both unresolvable. Make every internal link absolute.
+//
+// Fence-aware for the same reason renderMarkdocTags is: a code sample
+// showing markdown syntax, or a config snippet containing `](/path)`,
+// must survive verbatim. Rewriting inside a fence would silently edit
+// the code a reader is meant to copy.
+function absolutiseLinks(body, route) {
+ const baseDir = route.replace(/\/[^/]*$/, '')
+
+ const rewrite = (line) =>
+ line.replace(/\]\(([^)\s]+)(\s+"[^"]*")?\)/g, (full, href, title) => {
+ if (/^(https?:|mailto:|#|data:)/.test(href)) return full
+
+ const abs = href.startsWith('/')
+ ? href
+ : path.posix.normalize(path.posix.join(baseDir, href))
+
+ return `](${SITE_URL}${abs}${title || ''})`
+ })
+
+ const out = []
+ let inFence = false
+ let fenceMarker = ''
+
+ for (const line of body.split('\n')) {
+ const fence = line.match(/^\s*(```+|~~~+)/)
+ if (fence) {
+ if (!inFence) {
+ inFence = true
+ fenceMarker = fence[1][0]
+ } else if (fence[1][0] === fenceMarker) {
+ inFence = false
+ }
+
+ out.push(line)
+ continue
+ }
+
+ out.push(inFence ? line : rewrite(line))
+ }
+
+ return out.join('\n')
+}
+
+// --- emit -------------------------------------------------------------
+
+function collectPages() {
+ const pages = []
+ const seen = new Set()
+
+ const add = (file, sectionSlug) => {
+ const route = pageFileToRoute(file)
+ if (isExcludedRoute(route) || seen.has(route)) return
+ seen.add(route)
+ pages.push({ file, route, sectionSlug })
+ }
+
+ for (const section of SECTIONS) {
+ for (const file of walk(path.join(repoRoot, section.dir)).sort()) {
+ add(file, section.slug)
+ }
+ }
+ for (const rel of STANDALONE_PAGES) {
+ const file = path.join(repoRoot, rel)
+ if (fs.existsSync(file)) add(file, undefined)
+ }
+
+ return pages
+}
+
+function writeTwin(page) {
+ const raw = fs.readFileSync(page.file, 'utf8').replace(/\r\n/g, '\n')
+ const { frontmatter, body } = splitFrontmatter(raw)
+ const title = readFrontmatterField(frontmatter, 'title')
+ const description = readFrontmatterField(frontmatter, 'description')
+
+ let content = absolutiseLinks(renderMarkdocTags(body), page.route)
+ if (!content) return null
+
+ // orank (and every markdown-first agent) expects the body to START
+ // with a top-level heading. Most page.md files open with prose, and
+ // the visible
comes from frontmatter via the layout — so we
+ // re-attach it here. If the body already leads with an h1, don't
+ // double it up.
+ // The body MUST open with a top-level heading — that is how every
+ // markdown-first agent (and orank's probe) decides the response is
+ // real markdown rather than a stray text file. Most page.md files
+ // open with prose because the visible
is rendered by the layout
+ // from frontmatter, so re-attach it. When the body already leads
+ // with an h1, keep that one and slot the description in beneath it
+ // rather than pushing the heading off the first line.
+ const parts = []
+ const leadsWithH1 = /^#\s/.test(content)
+ let heading = title
+ if (!leadsWithH1) {
+ heading = title || page.route
+ parts.push(`# ${heading}`, '')
+ } else {
+ const [firstLine, ...rest] = content.split('\n')
+ // Frontmatter `title` is optional; when it's absent the body's own
+ // h1 is the page's real name. Without this, the schema feed and the
+ // section indexes label the page with its raw route.
+ if (!heading) heading = firstLine.replace(/^#\s*/, '').trim()
+ parts.push(firstLine, '')
+ content = rest.join('\n').replace(/^\n+/, '')
+ }
+ if (description) parts.push(`> ${description}`, '')
+ parts.push(content, '', '---', '', `Source: ${SITE_URL}${page.route}`, '')
+
+ const outFile = path.join(publicDir, `${page.route.replace(/^\//, '')}.md`)
+
+ // Twins are gitignored, so overwriting a hand-authored file here would
+ // not even show up in `git status` — the spec document would just
+ // vanish from the build. Refuse instead. Today no route collides;
+ // adding src/app/auth/page.md would be enough to cause it.
+ if (RESERVED_PUBLIC_FILES.has(path.relative(publicDir, outFile))) {
+ throw new Error(
+ `[md-twins] route ${page.route} would overwrite the hand-authored ` +
+ `public/${path.relative(publicDir, outFile)}. Rename the route or ` +
+ `the published file.`,
+ )
+ }
+
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
+ fs.writeFileSync(outFile, parts.join('\n'))
+ return { title: heading || page.route, description }
+}
+
+// Per-section llms.txt. An agent working on, say, datasources can pull
+// /docs/datasources/llms.txt instead of the whole site index.
+function writeSectionIndex(section, entries) {
+ if (entries.length === 0) return false
+ const lines = [
+ `# GoFr — ${section.label}`,
+ '',
+ `> Scoped index for the ${section.label} section of https://gofr.dev.`,
+ '> Each entry links to the HTML page; append `.md` to any URL for the',
+ '> Markdown twin, or send `Accept: text/markdown`.',
+ '',
+ `## ${section.label}`,
+ '',
+ ...entries.map(
+ (e) =>
+ `- [${e.title}](${SITE_URL}${e.route})${
+ e.description ? `: ${e.description}` : ''
+ }`,
+ ),
+ '',
+ '## Wider context',
+ '',
+ `- [Full site index](${SITE_URL}/llms.txt)`,
+ `- [Everything in one file](${SITE_URL}/llms-full.txt)`,
+ `- [AI coding-assistant primer](${SITE_URL}/AGENTS.md)`,
+ '',
+ ]
+ const dir = section.dir.replace(/^src\/app/, '')
+ const outFile = path.join(publicDir, dir.replace(/^\//, ''), 'llms.txt')
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
+ fs.writeFileSync(outFile, lines.join('\n'))
+ return true
+}
+
+const pages = collectPages()
+const bySection = new Map()
+const pageMeta = []
+let written = 0
+
+for (const page of pages) {
+ const meta = writeTwin(page)
+ if (!meta) continue
+ written++
+ pageMeta.push({ ...meta, route: page.route })
+ if (!page.sectionSlug) continue
+ if (!bySection.has(page.sectionSlug)) bySection.set(page.sectionSlug, [])
+ bySection.get(page.sectionSlug).push({ ...meta, route: page.route })
+}
+
+let indexes = 0
+for (const section of SECTIONS) {
+ if (!section.slug) continue
+ if (writeSectionIndex(section, bySection.get(section.slug) || [])) indexes++
+}
+
+// A /docs/llms.txt that points at each section index, so an agent that
+// only knows the docs root can still narrow down.
+const docsSections = SECTIONS.filter(
+ (s) => s.slug && (bySection.get(s.slug) || []).length > 0,
+)
+if (docsSections.length > 0) {
+ fs.writeFileSync(
+ path.join(publicDir, 'docs/llms.txt'),
+ [
+ '# GoFr — Documentation',
+ '',
+ '> Section indexes for https://gofr.dev/docs. Append `.md` to any page',
+ '> URL for its Markdown twin, or send `Accept: text/markdown`.',
+ '',
+ '## Sections',
+ '',
+ ...docsSections.map(
+ (s) =>
+ `- [${s.label}](${SITE_URL}/docs/${s.slug}/llms.txt) — ${
+ (bySection.get(s.slug) || []).length
+ } pages`,
+ ),
+ '',
+ '## Wider context',
+ '',
+ `- [Full site index](${SITE_URL}/llms.txt)`,
+ `- [Everything in one file](${SITE_URL}/llms-full.txt)`,
+ '',
+ ].join('\n'),
+ )
+ indexes++
+}
+
+// Machine-readable list of every twin. /openapi.json enumerates its
+// `path` parameter from this, so an agent calling getPageMarkdown can
+// only ask for paths that exist.
+//
+// Built from the pages actually WRITTEN, not the pages collected: a
+// page.md can be empty (upstream currently ships an empty
+// docs/quick-start/cli/page.md), which produces no twin. Enumerating a
+// collected-but-skipped route would hand agents a path that 404s —
+// exactly the failure the enum exists to prevent.
+const routes = pageMeta.map((p) => p.route).sort()
+fs.mkdirSync(path.join(repoRoot, 'src/data'), { recursive: true })
+fs.writeFileSync(
+ path.join(repoRoot, 'src/data/md-twin-routes.json'),
+ JSON.stringify(routes, null, 2) + '\n',
+)
+
+// NLWeb Schema Feeds: a JSONL feed of schema.org objects, one per
+// page, plus the XML Schema Map that robots.txt's `schemamap:`
+// directive points at. This is what lets an NLWeb-aware client ingest
+// the site's structured data without rendering any HTML.
+const feedLines = pageMeta.map((m) =>
+ JSON.stringify({
+ '@context': 'https://schema.org',
+ '@type': 'TechArticle',
+ '@id': `${SITE_URL}${m.route}`,
+ url: `${SITE_URL}${m.route}`,
+ name: m.title,
+ headline: m.title,
+ description: m.description || undefined,
+ inLanguage: 'en',
+ isPartOf: { '@type': 'WebSite', name: 'GoFr', url: SITE_URL },
+ encoding: {
+ '@type': 'MediaObject',
+ encodingFormat: 'text/markdown',
+ contentUrl: `${SITE_URL}${m.route}.md`,
+ },
+ publisher: { '@type': 'Organization', name: 'GoFr', url: SITE_URL },
+ license: 'https://www.apache.org/licenses/LICENSE-2.0',
+ }),
+)
+fs.mkdirSync(path.join(publicDir, 'feeds'), { recursive: true })
+fs.writeFileSync(
+ path.join(publicDir, 'feeds/docs.jsonl'),
+ feedLines.join('\n') + '\n',
+)
+
+fs.writeFileSync(
+ path.join(publicDir, 'schemamap.xml'),
+ [
+ '',
+ '',
+ ' ',
+ ` ${SITE_URL}/feeds/docs.jsonl`,
+ ' application/jsonl',
+ ' https://schema.org/TechArticle',
+ ` ${new Date().toISOString().slice(0, 10)}`,
+ ' ',
+ ' ',
+ ` ${SITE_URL}/changelog.xml`,
+ ' application/rss+xml',
+ ' https://schema.org/DataFeed',
+ ' ',
+ '',
+ '',
+ ].join('\n'),
+)
+
+console.log(
+ `[md-twins] wrote ${written} twin(s), ${indexes} scoped llms.txt file(s), ` +
+ `${feedLines.length} schema feed entries`,
+)
diff --git a/utils/generate-openapi.mjs b/utils/generate-openapi.mjs
new file mode 100644
index 0000000..d2e5dc6
--- /dev/null
+++ b/utils/generate-openapi.mjs
@@ -0,0 +1,220 @@
+#!/usr/bin/env node
+// Generate public/openapi.json — a machine-readable description of the
+// read surface gofr.dev actually exposes.
+//
+// gofr.dev is a documentation site, not a SaaS product: there is no
+// hosted GoFr API to describe, and inventing one would hand agents
+// endpoints that 404. What *is* real is a set of stable, fetchable
+// documents — llms.txt, llms-full.txt, the sitemap, the per-section
+// indexes, and a Markdown twin for every page. Those are genuinely
+// useful as agent tools, so that is what this spec covers.
+//
+// The `path` parameter of getPageMarkdown is enumerated from the twin
+// list emitted by generate-md-twins.mjs, so an agent doing LLM
+// function-calling against this spec can only ask for pages that
+// exist. Run order matters: md-twins first, then this.
+
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import { SECTIONS, SITE_URL } from './lib/doc-sections.mjs'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const repoRoot = path.resolve(__dirname, '..')
+const routesFile = path.join(repoRoot, 'src/data/md-twin-routes.json')
+const outFile = path.join(repoRoot, 'public/openapi.json')
+
+let routes = []
+if (fs.existsSync(routesFile)) {
+ routes = JSON.parse(fs.readFileSync(routesFile, 'utf8'))
+}
+
+const markdownDoc = {
+ description: 'The page rendered as Markdown.',
+ content: {
+ 'text/markdown': { schema: { type: 'string' } },
+ },
+}
+
+// Every operation is a static file fetch, so the only client-side
+// failure is "that path does not exist".
+const notFound = {
+ description:
+ 'No such document. Re-read /llms.txt or /sitemap.xml for valid paths.',
+ content: { 'text/markdown': { schema: { type: 'string' } } },
+}
+
+const sectionSlugs = SECTIONS.filter((s) => s.slug).map((s) => s.slug)
+
+const spec = {
+ openapi: '3.1.0',
+ info: {
+ title: 'GoFr Documentation Content API',
+ version: '1.0.0',
+ summary: 'Read-only access to gofr.dev documentation as Markdown.',
+ description: [
+ 'gofr.dev publishes its documentation as static, versioned files that',
+ 'agents can fetch directly. Every operation below is an unauthenticated',
+ 'HTTP GET against https://gofr.dev.',
+ '',
+ 'Use this when you need GoFr framework knowledge: how to build a Go',
+ 'microservice with built-in observability, connect a datasource, add',
+ 'gRPC/GraphQL/WebSockets/Pub-Sub, or migrate an existing service to GoFr.',
+ '',
+ 'There is no GoFr product API — GoFr is an open-source Go framework you',
+ 'import into your own service. This spec describes the documentation',
+ 'surface only. See https://gofr.dev/auth.md for the (anonymous) access',
+ 'model.',
+ ].join('\n'),
+ license: { name: 'Apache-2.0', identifier: 'Apache-2.0' },
+ contact: { name: 'GoFr', url: `${SITE_URL}/contact`, email: 'connect@gofr.dev' },
+ },
+ servers: [{ url: SITE_URL, description: 'Production' }],
+ // An explicitly empty security requirement is how OpenAPI says "no
+ // authentication" — as opposed to omitting the field, which only
+ // means "unspecified". gofr.dev is genuinely public; see /auth.md.
+ security: [],
+ components: { securitySchemes: {} },
+ externalDocs: { description: 'GoFr documentation', url: `${SITE_URL}/docs` },
+ tags: [
+ { name: 'index', description: 'Site-wide indexes for LLM ingestion' },
+ { name: 'content', description: 'Individual documentation pages' },
+ ],
+ paths: {
+ '/llms.txt': {
+ get: {
+ operationId: 'getLlmsIndex',
+ tags: ['index'],
+ summary: 'Curated link index (llmstxt.org format)',
+ description:
+ 'A short, curated Markdown index of the whole site, including a ' +
+ '"when to use GoFr" section. Start here to decide which page to fetch.',
+ responses: { 200: markdownDoc, 404: notFound },
+ },
+ },
+ '/llms-full.txt': {
+ get: {
+ operationId: 'getLlmsFullDump',
+ tags: ['index'],
+ summary: 'Every documentation page concatenated into one file',
+ description:
+ 'The complete documentation as a single Markdown document. Use when ' +
+ 'you can ingest a long context in one request instead of crawling ' +
+ 'page by page. Several hundred KB.',
+ responses: { 200: markdownDoc, 404: notFound },
+ },
+ },
+ '/docs/llms.txt': {
+ get: {
+ operationId: 'getDocsIndex',
+ tags: ['index'],
+ summary: 'Documentation section index',
+ description: 'Lists each documentation section and its scoped index.',
+ responses: { 200: markdownDoc, 404: notFound },
+ },
+ },
+ '/docs/{section}/llms.txt': {
+ get: {
+ operationId: 'getSectionIndex',
+ tags: ['index'],
+ summary: 'Scoped index for one documentation section',
+ description:
+ 'Every page in one section, with titles and descriptions. Cheaper ' +
+ 'than the full dump when the task is scoped to one area.',
+ parameters: [
+ {
+ name: 'section',
+ in: 'path',
+ required: true,
+ description: 'Documentation section slug.',
+ schema: { type: 'string', enum: sectionSlugs },
+ },
+ ],
+ responses: { 200: markdownDoc, 404: notFound },
+ },
+ },
+ '/{page}.md': {
+ get: {
+ operationId: 'getPageMarkdown',
+ tags: ['content'],
+ summary: 'Fetch one documentation page as Markdown',
+ description:
+ 'Returns the page body as Markdown with a top-level heading, with ' +
+ 'all internal links rewritten to absolute URLs. Equivalent to ' +
+ 'requesting the HTML route with `Accept: text/markdown`.',
+ parameters: [
+ {
+ name: 'page',
+ in: 'path',
+ required: true,
+ description:
+ 'Route without the leading slash and without the .md suffix, ' +
+ 'e.g. `docs/quick-start/introduction`. Slashes are literal ' +
+ 'path separators — send them unencoded, not as %2F.',
+ // Enumerated so an agent doing function-calling can only ask
+ // for a page that exists. Omitted rather than emitted empty
+ // when the twin list is missing (a build without the docs
+ // overlay): `enum: []` matches nothing and would make this
+ // operation uncallable.
+ schema: {
+ type: 'string',
+ ...(routes.length > 0
+ ? { enum: routes.map((r) => r.replace(/^\//, '')) }
+ : {}),
+ },
+ },
+ ],
+ responses: { 200: markdownDoc, 404: notFound },
+ },
+ },
+ '/sitemap.xml': {
+ get: {
+ operationId: 'getSitemap',
+ tags: ['index'],
+ summary: 'XML sitemap of every canonical HTML page',
+ description:
+ 'Canonical HTML URLs with accurate values derived from git ' +
+ 'history. Markdown twins are deliberately not listed.',
+ responses: {
+ 200: {
+ description: 'Sitemap document.',
+ content: { 'application/xml': { schema: { type: 'string' } } },
+ },
+ 404: notFound,
+ },
+ },
+ },
+ '/changelog.xml': {
+ get: {
+ operationId: 'getChangelogFeed',
+ tags: ['index'],
+ summary: 'RSS feed of GoFr releases',
+ responses: {
+ 200: {
+ description: 'RSS 2.0 feed.',
+ content: { 'application/rss+xml': { schema: { type: 'string' } } },
+ },
+ 404: notFound,
+ },
+ },
+ },
+ '/AGENTS.md': {
+ get: {
+ operationId: 'getAgentsPrimer',
+ tags: ['index'],
+ summary: 'Conventions primer for AI coding assistants',
+ description:
+ 'GoFr conventions, datasource patterns, and per-framework migration ' +
+ 'mappings, written for coding agents generating GoFr code.',
+ responses: { 200: markdownDoc, 404: notFound },
+ },
+ },
+ },
+}
+
+fs.mkdirSync(path.dirname(outFile), { recursive: true })
+fs.writeFileSync(outFile, JSON.stringify(spec, null, 2) + '\n')
+console.log(
+ `[openapi] wrote ${Object.keys(spec.paths).length} path(s), ${routes.length} enumerated page(s)`,
+)
diff --git a/utils/lib/doc-sections.mjs b/utils/lib/doc-sections.mjs
new file mode 100644
index 0000000..fad93ac
--- /dev/null
+++ b/utils/lib/doc-sections.mjs
@@ -0,0 +1,67 @@
+// Single source of truth for "which routes carry Markdoc content".
+//
+// Consumers:
+// - utils/generate-llms-full.mjs (concatenated dump)
+// - utils/generate-md-twins.mjs (per-route .md twins + scoped llms.txt)
+// - utils/generate-openapi.mjs (section enum)
+//
+// src/app/sitemap.js deliberately does NOT use this list: it must cover
+// .jsx routes too (/team, /roadmap, /showcase), which carry no Markdoc
+// content, so it globs instead. It shares only the exclusions below in
+// spirit — keep the two exclusion sets in step by hand.
+//
+// Most of these directories are EMPTY in this repo. Docs live in
+// gofr-dev/gofr and are layered in at build time by that repo's
+// docs/Dockerfile before `npm run build` runs. So every consumer must
+// tolerate a missing directory rather than throwing — a standalone
+// build of this repo legitimately has almost none of them.
+
+export const SITE_URL = 'https://gofr.dev'
+
+// Order matters for llms-full.txt: highest-utility content first so
+// that when an LLM truncates context, the foundational material
+// survives. Twin generation ignores the order.
+export const SECTIONS = [
+ { dir: 'src/app/docs/quick-start', label: 'Quick Start', slug: 'quick-start' },
+ { dir: 'src/app/docs/advanced-guide', label: 'Advanced Guide', slug: 'advanced-guide' },
+ { dir: 'src/app/docs/datasources', label: 'Datasources', slug: 'datasources' },
+ { dir: 'src/app/docs/guides', label: 'Production guides', slug: 'guides' },
+ { dir: 'src/app/docs/references', label: 'References', slug: 'references' },
+ { dir: 'src/app/why-gofr', label: 'Why GoFr' },
+ { dir: 'src/app/comparison', label: 'Comparison' },
+ { dir: 'src/app/migrate', label: 'Migration guides' },
+ { dir: 'src/app/learn', label: 'Learn' },
+ { dir: 'src/app/faq', label: 'FAQ' },
+ // Trust pages. Authored in this repo, not overlaid from the
+ // framework — they describe the site, not the framework.
+ { dir: 'src/app/privacy', label: 'Privacy' },
+ { dir: 'src/app/contact', label: 'Contact' },
+]
+
+// Standalone `page.md` files that aren't inside a SECTIONS directory.
+// `/docs` is the docs landing page, overlaid from gofr's docs/page.md.
+export const STANDALONE_PAGES = ['src/app/docs/page.md']
+
+// Routes that must never get a .md twin. Mirrors the exclusions in
+// src/app/sitemap.js — a twin for a route that isn't a crawlable page
+// is a URL an agent can waste a request on.
+//
+// /api/* → dead App Router handlers, unreachable under `output: 'export'`
+// /certificate/* → per-user certificate lookups, no content
+// /hackathon → time-boxed campaign page
+// /pkg/* → JS-redirect stubs for Go module import paths
+// /cli/* → metadata-only landing for `go install`
+// /releases → redirect to /changelog
+const EXCLUDED_ROUTE_PATTERNS = [
+ /^\/api\//,
+ /^\/certificate\//,
+ /^\/hackathon\b/,
+ /^\/pkg\//,
+ /^\/cli\//,
+ /^\/releases$/,
+ /\[.*\]/,
+]
+
+export function isExcludedRoute(route) {
+ return EXCLUDED_ROUTE_PATTERNS.some((re) => re.test(route))
+}