Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/components/docs/DocsPageAsideLinks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ const items = [
:ui="{ list: 'gap-2.5' }"
/>
<UButton
:disabled="content.mode !== 'prod'"
:loading="copying"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:color="copied ? 'success' : 'neutral'"
Expand Down
37 changes: 36 additions & 1 deletion app/error.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,48 @@ const { data: files } = useLazyAsyncData('search-sections', () => prodContent.se
})

provide('navigation', navigation)

// First real docs page, so a dead link still offers a way into the content.
const docsLink = computed(() => findFirstPagePath(navigation.value ?? []) ?? '/')

interface NavigationNode {
path?: string
page?: boolean
children?: NavigationNode[]
}

function findFirstPagePath(items: NavigationNode[]): string | undefined {
for (const item of items) {
if (item.page !== false && item.path && item.path !== '/') return item.path
if (item.children?.length) {
const child = findFirstPagePath(item.children)
if (child) return child
}
}
}
</script>

<template>
<UApp>
<AppHeader />

<UError :error="error" />
<UError :error="error">
<template #links>
<UButton
size="lg"
color="primary"
label="Back to home"
to="/"
/>
<UButton
size="lg"
color="neutral"
variant="outline"
label="Documentation"
:to="docsLink"
/>
</template>
</UError>

<AppFooter />

Expand Down
35 changes: 19 additions & 16 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
return p ? { ...p, nodes: prefixTreeLinks(p.nodes, content.value.base) } : p
})

const fm = computed<Record<string, any>>(() => page.value?.data ?? {})

Check warning on line 34 in app/pages/index.vue

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

// Keep branch/commit previews out of search, same as DocsPage.
useRobotsRule(computed(() => (content.value.mode === 'prod' ? 'index, follow' : 'noindex, nofollow')))
Expand All @@ -55,24 +55,27 @@
description: fm.value.seo?.description || fm.value.description,
})

// Optional schema.org SoftwareApplication identity, configured through
// `docs.schemaOrg` in app.config; nothing is emitted when unset.
// Optional schema.org identity, configured through `docs.schemaOrg` in app.config; nothing is
// emitted when unset. `organization` becomes its own top-level node (with contactPoint/address it
// is what agents check to verify the business); everything else describes the SoftwareApplication.
const { seo, docs } = useAppConfig()
const schemaOrg = docs?.schemaOrg as Record<string, unknown> | undefined
if (schemaOrg && Object.keys(schemaOrg).length) {
const { organization, ...softwareApp } = (docs?.schemaOrg ?? {}) as Record<string, unknown> & {
organization?: Record<string, unknown>
}
const identity = { name: seo?.siteName, url: site.url }
const nodes: Record<string, unknown>[] = []
if (Object.keys(softwareApp).length) {
nodes.push({ '@type': 'SoftwareApplication', ...identity, ...softwareApp })
}
if (organization && Object.keys(organization).length) {
nodes.push({ '@type': 'Organization', ...identity, ...organization })
}
if (nodes.length) {
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: jsonLd({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: seo?.siteName,
url: site.url,
...schemaOrg,
}),
},
],
script: nodes.map((node) => ({
type: 'application/ld+json',
innerHTML: jsonLd({ '@context': 'https://schema.org', ...node }),
})),
})
}
}
Expand Down
35 changes: 35 additions & 0 deletions modules/markdown-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFile, writeFile } from 'node:fs/promises'
import { defineNuxtModule, useLogger } from '@nuxt/kit'
import { resolve } from 'pathe'
import { buildMarkdownRewriteRoutes } from '../utils/markdown-rewrite'

const logger = useLogger('comark-docs')

/**
* Serve raw markdown to agents on the *page* URLs: `Accept: text/markdown` (or a curl user-agent) on
* `/getting-started/installation` returns `/raw/getting-started/installation.md`, and `/` returns
* `/llms.txt`. Implemented as Vercel routing-layer rewrites written into `.vercel/output/config.json`
* after Nitro compiles — rewriting at the edge keeps the negotiation out of the ISR cache, which is
* keyed per dest path and so can't serve the HTML variant to a markdown request (or vice versa).
*/
export default defineNuxtModule({
meta: {
name: 'comark-docs/markdown-rewrite',
},
setup(_options, nuxt) {
nuxt.hooks.hook('nitro:init', (nitro) => {
if (nitro.options.dev || !nitro.options.preset.includes('vercel')) return

nitro.hooks.hook('compiled', async () => {
const configPath = resolve(nitro.options.output.dir, 'config.json')
const config = JSON.parse(await readFile(configPath, 'utf8'))

const routes = buildMarkdownRewriteRoutes()
config.routes.unshift(...routes)

await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
logger.info(`Injected ${routes.length} markdown content-negotiation routes into ${configPath}`)
})
})
},
})
12 changes: 11 additions & 1 deletion nuxt.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,20 @@ export default defineNuxtSchema({
llms: {
/** Description emitted under the llms.txt heading. */
description: '',
/**
* "When to use" guidance for agents, emitted as the first llms.txt section (markdown).
* Name the jobs the product is right for and how an agent should call it. Empty = no section.
*/
whenToUse: '',
/** Extra links appended to llms.txt. */
links: [],
},
/** schema.org SoftwareApplication identity, emitted as JSON-LD on the landing page. Empty = none. */
/**
* schema.org SoftwareApplication identity, emitted as JSON-LD on the landing page. Empty = none.
* The `organization` sub-key is emitted as a separate top-level Organization node — give it
* `contactPoint` (with `contactType` and an email or phone) and `address` (a `PostalAddress`)
* so agents can verify the business behind the site.
*/
schemaOrg: {},
/** Extra links appended to the docs page aside. */
asideLinks: [],
Expand Down
2 changes: 1 addition & 1 deletion playground/content/1.getting-started/1.introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A GitHub webhook notifies the site on push, which purges the cached pages. Becau
- **Versioned previews** — browse any branch or commit of your docs through versioned URLs.
- **Docs UI** built with [Nuxt UI](https://ui.nuxt.com): sidebar navigation, search (`⌘K`), table of contents, prev/next links, and a version history panel.
- **SEO out of the box** — sitemap, robots, canonical URLs, OG images, and JSON-LD structured data.
- **AI-native** — `llms.txt`, raw Markdown mirrors (`/raw/**`), an MCP server (`/mcp`), an optional "Ask AI" assistant, and [Agent Skills](https://agentskills.io) discovery.
- **AI-native** — `llms.txt`, raw Markdown mirrors (`/raw/**`), Markdown [content negotiation](/concepts/architecture#markdown-for-agents) on every page URL, an MCP server (`/mcp`), an optional "Ask AI" assistant, and [Agent Skills](https://agentskills.io) discovery.

## Keyboard shortcuts

Expand Down
43 changes: 41 additions & 2 deletions playground/content/1.getting-started/3.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,53 @@ export default defineAppConfig({
| `github.owner` / `github.name` | inferred | Repository owner and name (inferred from git when unset). |
| `docs.rss.title` | `''` | RSS feed title; empty renders `${siteName} Documentation`. |
| `docs.ogImage` | — | `{ accent, tagline, mark }` for the generated OG images. |
| `docs.llms` | — | `{ description, links }` emitted in `llms.txt`. |
| `docs.schemaOrg` | `{}` | schema.org `SoftwareApplication` identity, emitted as JSON-LD on the landing page. |
| `docs.llms` | — | `{ description, whenToUse, links }` emitted in `llms.txt`. `whenToUse` becomes the first section, telling agents when to reach for your product. |
| `docs.schemaOrg` | `{}` | schema.org `SoftwareApplication` identity, emitted as JSON-LD on the landing page. The `organization` sub-key is emitted as a separate `Organization` node — see below. |
| `docs.asideLinks` | `[]` | Extra links appended to the docs page aside. |

::note
Nuxt merges `app.config.ts` across layers with [defu](https://github.com/unjs/defu), which **concatenates arrays**. Your list is appended to the layer's, not substituted for it — which is why every array default in the layer is empty.
::

### Agent metadata

Two optional keys help AI agents understand and verify your site:

- `docs.llms.whenToUse` is emitted as the first `llms.txt` section. Name the jobs your product is right for and how an agent calls it — specific guidance, not marketing copy.
- `docs.schemaOrg.organization` is emitted as a top-level `Organization` JSON-LD node on the landing page. Give it `contactPoint` and `address` so agents can verify the business behind the site.

```ts [app.config.ts]
export default defineAppConfig({
docs: {
llms: {
whenToUse:
'Use My Project to build documentation sites where Markdown is served at request time. ' +
'Fetch any page as raw markdown at `/raw/<path>.md`, or request any page URL with `Accept: text/markdown`.',
},
schemaOrg: {
applicationCategory: 'DeveloperApplication',
operatingSystem: 'Any',
organization: {
contactPoint: {
'@type': 'ContactPoint',
contactType: 'customer support',
email: 'support@example.com',
},
address: {
'@type': 'PostalAddress',
streetAddress: '100 Main St',
addressLocality: 'San Francisco',
addressRegion: 'CA',
postalCode: '94105',
addressCountry: 'US',
},
sameAs: ['https://github.com/my-org'],
},
},
},
})
```

## Environment variables

| Variable | Purpose |
Expand Down
13 changes: 13 additions & 0 deletions playground/content/3.concepts/1.architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ The handler verifies the webhook signature with `WEBHOOK_SECRET`, resolves the n

Without the webhook, the site still updates: ISR entries expire on their own after the `isr` window. The webhook just makes it immediate.

## Markdown for agents

Every documentation page is mirrored as raw Markdown at `/raw/<path>.md` (previews included, at `/raw/tree/<branch>/<path>.md` and `/raw/blob/<sha>/<path>.md`). The mirrors carry the same ISR caching as the HTML pages.

On Vercel, agents don't need to know the mirror URLs. The layer injects rewrites into the build output, ahead of the ISR cache:

- A request for any page URL with `Accept: text/markdown` (or a curl user-agent) is rewritten to its `/raw/**` mirror.
- A request for `/` is rewritten to `/llms.txt`.

Because the rewrite happens at the routing layer, the HTML and Markdown variants are cached under different paths and can't poison each other's cache entries.

A request for a page that doesn't exist returns a real HTTP 404 with a short Markdown body pointing at `/llms.txt`, `/llms-full.txt`, and the sitemap, so an agent that guesses a URL wrong can recover.

## No redeploys for content

Since content never ships in the build, a content-only push doesn't need a deployment at all. On Vercel, an [Ignored Build Step](/deployment/vercel#setup-skip-builds-for-content-pushes) cancels builds for pushes that only touch `content/` — the webhook handles those. Code pushes build and deploy as usual.
2 changes: 1 addition & 1 deletion playground/content/3.concepts/2.versioned-previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ Branch previews resolve the branch to its latest content commit on request, so r
## Good to know

- Previews are public, like the rest of the site, but send `noindex` robots headers and canonicalize to the production URL — they won't compete with your real pages in search.
- The raw Markdown mirrors work in previews too: `/tree/my-branch/raw/<page>.md`.
- The raw Markdown mirrors work in previews too: `/raw/tree/my-branch/<page>.md` and `/raw/blob/<sha>/<page>.md`. On Vercel, requesting a preview page with `Accept: text/markdown` serves the mirror directly — see [Markdown for agents](/concepts/architecture#markdown-for-agents).
- "Edit this page on GitHub" targets the previewed branch on `/tree/` pages, and is disabled on `/blob/` pages since a commit can't be edited.
- Preview instances are kept in a small LRU pool per server instance; evicted versions rebuild on demand, and their parsed pages survive in the per-SHA cache.
10 changes: 10 additions & 0 deletions server/plugins/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export default defineNitroPlugin((nitroApp: NitroApp) => {
links: introLinks,
})

// Agent guidance first: tells an agent *when* to reach for this site before it reads the index.
const whenToUse = appConfig.docs?.llms?.whenToUse
if (whenToUse) {
options.sections.unshift({
title: `When to use ${siteName}`,
description: whenToUse,
links: [],
})
}

for (const item of navigation) {
if (!item.children?.length) continue

Expand Down
11 changes: 4 additions & 7 deletions server/routes/raw/[...slug].md.get.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import { withLeadingSlash } from 'ufo'

export default defineEventHandler(async (event) => {
const slug = getRouterParams(event)['slug.md']
if (!slug?.endsWith('.md')) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
return notFoundMarkdown(event, event.path)
}

const content = await getProdContent()

const stripped = slug.replace(/\.md$/, '')
const path = stripped === 'index' ? '/' : withLeadingSlash(stripped)

const path = pagePathFromRawSlug(slug)
const markdown = await renderPageMarkdown(content, path)
if (!markdown) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
return notFoundMarkdown(event, path)
}

setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Vary', 'Accept')
return markdown
})
28 changes: 28 additions & 0 deletions server/routes/raw/blob/[sha]/[...slug].md.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** Raw markdown mirror of `/blob/:sha/**` preview pages. */
export default defineEventHandler(async (event) => {
const rawSha = getRouterParam(event, 'sha')
const slug = getRouterParams(event)['slug.md']
if (!rawSha || !slug?.endsWith('.md')) {
return notFoundMarkdown(event, event.path)
}

// Public endpoint: reject anything that isn't a commit SHA before it becomes a
// preview-content registry entry (see `getPreviewContent`) or a GitHub ref.
const sha = parseCommitSha(rawSha)
if (!sha) {
throw createError({ statusCode: 400, statusMessage: 'Invalid commit SHA' })
}

// Same basePath key as the `/api/content/blob` route, so both share one preview instance per SHA.
const content = await getPreviewContent(sha, `/api/content/blob/${sha}`)

const path = pagePathFromRawSlug(slug)
const markdown = await renderPageMarkdown(content, path)
if (!markdown) {
return notFoundMarkdown(event, path)
}

setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Vary', 'Accept')
return markdown
})
29 changes: 29 additions & 0 deletions server/routes/raw/tree/[branch]/[...slug].md.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** Raw markdown mirror of `/tree/:branch/**` preview pages. */
export default defineEventHandler(async (event) => {
const rawBranch = getRouterParam(event, 'branch')
const slug = getRouterParams(event)['slug.md']
if (!rawBranch || !slug?.endsWith('.md')) {
return notFoundMarkdown(event, event.path)
}

// Public endpoint: every distinct ref costs a GitHub API call and a preview-content instance — validate first.
const branch = parseBranchName(decodeURIComponent(rawBranch))
if (!branch) {
throw createError({ statusCode: 400, statusMessage: 'Invalid branch name' })
}

// `cacheMisses`: the ref comes from the URL, so a miss must not re-cost a GitHub call each time.
const sha = await resolveContentSha(branch, useRuntimeConfig(event).docs.contentDir, { cacheMisses: true })
// Same basePath key as the `/api/content/tree` route, so both share one preview instance per ref.
const content = await getPreviewContent(sha, `/api/content/tree/${encodeURIComponent(branch)}`)

const path = pagePathFromRawSlug(slug)
const markdown = await renderPageMarkdown(content, path)
if (!markdown) {
return notFoundMarkdown(event, path)
}

setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Vary', 'Accept')
return markdown
})
34 changes: 34 additions & 0 deletions server/utils/not-found.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { H3Event } from 'h3'
import { setHeader, setResponseStatus } from 'h3'

/**
* A 404 with a short markdown body instead of the app shell or a JSON error: agents that land on a
* missing page get pointers to the machine-readable indexes so they can recover instead of guessing.
*/
export function notFoundMarkdown(event: H3Event, path?: string): string {
setResponseStatus(event, 404, 'Page not found')
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Vary', 'Accept')

return [
'# Page not found',
'',
path ? `\`${path}\` does not exist on this site.` : 'This page does not exist on this site.',
'',
'Where to look next:',
'',
'- [/llms.txt](/llms.txt) — index of every documentation page, with raw markdown links',
'- [/llms-full.txt](/llms-full.txt) — the full documentation as a single markdown file',
'- [/raw/index.md](/raw/index.md) — the landing page as markdown',
'- [/sitemap.xml](/sitemap.xml) — sitemap of the rendered pages',
'',
'Every documentation page is mirrored as raw markdown at `/raw/<path>.md`.',
'',
].join('\n')
}

/** `/raw/**` slug (`getting-started/installation.md`) → content path (`/getting-started/installation`). */
export function pagePathFromRawSlug(slug: string): string {
const stripped = slug.replace(/\.md$/, '')
return stripped === 'index' ? '/' : `/${stripped}`
}
Loading
Loading