Skip to content
Merged
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ Content lives as Markdown in your repo and is served **at request time** — par
- **Instant production content** — GitHub-sourced content pinned to a commit SHA, ISR-cached HTML, revalidated on push by a GitHub webhook (`/api/revalidate`).
- **Versioned previews** — any branch (`/tree/:branch`) or commit (`/blob/:sha`) can be previewed through versioned URLs.
- Docs UI built with [Nuxt UI](https://ui.nuxt.com): sidebar navigation, search (`⌘K`), TOC, prev/next links, version history panel.
- SEO & AEO out of the box: sitemap, robots, canonical URLs, OG images (Satori), JSON-LD, `llms.txt` / `llms-full.txt`, raw markdown mirrors (`/raw/**`), RSS, MCP server (`/mcp`), Agent Skills discovery (`/.well-known/skills/`).
- SEO out of the box: sitemap, robots, canonical URLs, OG images (Satori), JSON-LD, RSS.
- Markdown for agents through [nuxt-agent-discovery](https://github.com/benjamincanac/nuxt-agent-discovery): content negotiation on every documentation page, raw markdown mirrors (`/raw/**`), `llms.txt` / `llms-full.txt`, `sitemap.md`, `/openapi.json`, `/.well-known/api-catalog`, an MCP server (`/mcp`) with its server card, Agent Skills discovery (`/.well-known/skills/`).

## Quick start

Expand Down Expand Up @@ -44,13 +45,13 @@ From there, the docs cover everything:

## Agent Skills

Drop skills into a `skills/` directory at the app root and the layer serves them at `/.well-known/skills/`, following the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) (v0.1). Users install them with:
Drop skills into a `skills/` directory at the app root and [nuxt-agent-discovery](https://github.com/benjamincanac/nuxt-agent-discovery) serves them at `/.well-known/skills/`, following the [Agent Skills Discovery RFC](https://github.com/cloudflare/agent-skills-discovery-rfc) (v0.1). Users install them with:

```bash
npx skills add https://your-docs-domain.com
```

Each skill is a directory with a `SKILL.md` whose frontmatter includes a `description`; `name` defaults to the directory name. Skills are scanned at build time from the filesystem (they ship with the app, not with GitHub-sourced content), so a skill change needs a redeploy. Override the directory with `comarkDocs.skills.dir`.
Each skill is a directory with a `SKILL.md` whose frontmatter includes a `description`; `name` defaults to the directory name. Skills are scanned at build time from the filesystem (they ship with the app, not with GitHub-sourced content), so a skill change needs a redeploy. Override the directory with `agentDiscovery.skills.dir`.

## Keyboard shortcuts

Expand Down
3 changes: 2 additions & 1 deletion app/components/docs/DocsPageAsideLinks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const { copy: copyLink } = useClipboard()
const copying = ref(false)
const site = useSiteConfig()

const mdPath = computed(() => `/raw${route.path}.md`)
const { rawPrefix } = useRuntimeConfig().public.agentDiscovery
const mdPath = computed(() => `${rawPrefix}${route.path}.md`)
const mdUrl = computed(() => `${site.url}${mdPath.value}`)

const { github, docs } = useAppConfig()
Expand Down
23 changes: 6 additions & 17 deletions app/components/landing/LandingFaq.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,12 @@ const props = defineProps<{
items: FaqItem[]
}>()

// FAQPage structured data mirrors the visible accordion.
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: jsonLd({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: props.items.map((item) => ({
'@type': 'Question',
name: item.label,
acceptedAnswer: { '@type': 'Answer', text: item.content },
})),
}),
},
],
})
// FAQPage structured data mirrors the visible accordion. The page node is retyped rather than a second
// one emitted, so the questions hang off the `WebPage` nuxt-schema-org already has, through `mainEntity`.
useSchemaOrg([
defineWebPage({ '@type': 'FAQPage' }),
...props.items.map((item) => defineQuestion({ name: item.label, acceptedAnswer: item.content })),
])
</script>

<template>
Expand Down
74 changes: 37 additions & 37 deletions app/pages/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
Browser,
}

const { toc, seo } = useAppConfig()
const { toc } = useAppConfig()
const navigation = inject<Ref<NavigationItem[]>>('navigation')
const content = useDocsContent()
const layout = inject<Ref<NavigationLayout>>('layout')
Expand All @@ -139,8 +139,8 @@

const surroundLinks = computed(() => findSurroundLinks(navigation?.value, selfPath.value))

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

Check warning on line 142 in app/pages/[...slug].vue

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type
const tocLinks = computed<any[]>(() => (page.value?.meta as any)?.toc?.links ?? [])

Check warning on line 143 in app/pages/[...slug].vue

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

Check warning on line 143 in app/pages/[...slug].vue

View workflow job for this annotation

GitHub Actions / ci

Unexpected any. Specify a different type

const title = computed(() => fm.value.seo?.title || fm.value.title)
const description = computed(() => fm.value.seo?.description || fm.value.description)
Expand All @@ -148,6 +148,9 @@
const site = useSiteConfig()
// Previews (/tree, /blob) canonicalize to the production URL.
const canonicalUrl = computed(() => joinURL(site.url, content.value.path))
// The page's markdown twin, so an agent reading the HTML finds it without negotiating again.
// Previews have no twin of their own, so this follows the canonical to production.
const markdownUrl = computed(() => `${canonicalUrl.value}.md`)

useRobotsRule(computed(() => (content.value.mode === 'prod' ? 'index, follow' : 'noindex, nofollow')))

Expand All @@ -159,9 +162,19 @@
ogUrl: canonicalUrl,
})

useHead({
link: [{ rel: 'canonical', href: canonicalUrl }],
})
// Previews are noindex and canonicalize to production, which `useCanonical` cannot express since it
// builds the canonical from the current route. Same split as nuxt.com, which skips the composable on
// the unversioned `/docs/*` stubs.
if (content.value.mode === 'prod') {
useCanonical(() => `${content.value.path}.md`)
Comment thread
benjamincanac marked this conversation as resolved.
} else {
useHead({
link: [
{ rel: 'canonical', href: canonicalUrl },
{ rel: 'alternate', type: 'text/markdown', href: markdownUrl },
],
})
}

const headline = computed(() => findPageHeadline(navigation?.value, selfPath.value))

Expand All @@ -174,39 +187,26 @@

const breadcrumb = computed(() => findBreadcrumb(navigation?.value, selfPath.value))

useHead({
script: [
{
type: 'application/ld+json',
innerHTML: computed(() =>
jsonLd([
{
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: fm.value.title,
description: fm.value.description,
url: canonicalUrl.value,
inLanguage: 'en',
isPartOf: {
'@type': 'WebSite',
name: seo?.siteName,
url: site.url,
},
author: { '@type': 'Organization', name: seo?.siteName, url: site.url },
},
// Google requires `item` on every ListItem. Non-page section nodes
// (page: false) only have a title — omit them from structured data.
breadcrumbListLd([
{ name: 'Docs', item: site.url },
...breadcrumb.value
.filter((item): item is { title: string, path: string } => Boolean(item.path))
.map((item) => ({ name: item.title, item: joinURL(site.url, item.path) })),
]),
])
),
},
],
})
// `WebSite`, `WebPage` and the publisher come from nuxt-schema-org, which also owns the `@id` links
// between them. Only what is specific to this page is declared here.
useSchemaOrg([
defineArticle({
'@type': 'TechArticle',
headline: () => fm.value.title,
description: () => fm.value.description,
inLanguage: 'en',
}),
defineBreadcrumb({
// Google requires `item` on every ListItem. Non-page section nodes
// (page: false) only have a title — omit them from structured data.
itemListElement: [
{ name: 'Docs', item: site.url },
...breadcrumb.value
.filter((item): item is { title: string, path: string } => Boolean(item.path))
.map((item) => ({ name: item.title, item: joinURL(site.url, item.path) })),
],
}),
])
}
</script>

Expand Down
32 changes: 15 additions & 17 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 @@ -45,9 +45,10 @@
ogUrl: site.url,
})

useHead({
link: [{ rel: 'canonical', href: site.url }],
})
// The raw prefix, not `/index.md`: the homepage document has no `.md` twin of its own, so this is the
// URL the module routes at the edge. Same as nuxt.com and ui.nuxt.com.
const rawPrefix = useRuntimeConfig().public.agentDiscovery?.rawPrefix || '/raw'
useCanonical(`${rawPrefix}/index.md`)

if (content.value.mode === 'prod') {
defineOgImage('DocsSatori', {
Expand All @@ -62,21 +63,18 @@
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 })
}
// `WebSite` and `WebPage` come from nuxt-schema-org, which also owns the `@id` links to these.
// Both inputs are cast: the app config carries them untyped, as whatever schema.org accepts.
const nodes = [
...(Object.keys(softwareApp).length
? [defineSoftwareApp({ name: seo?.siteName, ...softwareApp } as Parameters<typeof defineSoftwareApp>[0])]
: []),
...(organization && Object.keys(organization).length
? [defineOrganization({ name: seo?.siteName, ...organization } as Parameters<typeof defineOrganization>[0])]
: []),
]
if (nodes.length) {
useHead({
script: nodes.map((node) => ({
type: 'application/ld+json',
innerHTML: jsonLd({ '@context': 'https://schema.org', ...node }),
})),
})
useSchemaOrg(nodes)
}
}
</script>
Expand Down
5 changes: 2 additions & 3 deletions app/pages/logos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,8 @@ useSeoMeta({
ogUrl: canonicalUrl,
})

useHead({
link: [{ rel: 'canonical', href: canonicalUrl }],
})
// No markdown alternate: `/logos` is a Vue page, excluded from negotiation.
useCanonical()
Comment thread
benjamincanac marked this conversation as resolved.

defineOgImage('DocsSatori', {
title,
Expand Down
32 changes: 0 additions & 32 deletions app/utils/json-ld.ts

This file was deleted.

1 change: 0 additions & 1 deletion app/utils/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ function walk(items: NavigationItem[], path: string): boolean {
return false
}

// Shared with the server-side `/raw/**` mirror (server/routes/raw/[...slug].md.get.ts).
export { findFirstLeaf } from '../../utils/first-leaf'

export interface BreadcrumbItem {
Expand Down
Loading
Loading