From 98b83bf148b0333a9c103ce3db2db88cbb560dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Tue, 6 Oct 2026 10:30:25 +0200 Subject: [PATCH 1/3] feat(seo): FAQPage and dateModified on docs pages, sitemap lastmod, one page list for every index - Emit FAQPage JSON-LD from an accordion under a "FAQ" heading on docs pages - Add dateModified to the TechArticle and lastmod to sitemap.xml, from the last commit per file - Build sitemap.xml, sitemap.md, llms.txt and RSS from one page list: a directory index is listed once, and a section index hidden with navigation: false is listed first in its section - Drop the of the decorative header slash icon --- app/components/AppHeaderBrand.vue | 1 - app/pages/[...slug].vue | 18 +++ app/utils/faq.ts | 51 +++++++ modules/config/runtime/server/plugins/llms.ts | 37 ++--- playground/content/2.writing/3.components.md | 2 + playground/content/2.writing/5.navigation.md | 4 +- server/api/__sitemap__/urls.get.ts | 22 ++- server/api/last-modified.get.ts | 13 ++ server/routes/rss.xml.get.ts | 101 +------------- server/utils/agent-source.ts | 20 ++- server/utils/docs-pages.ts | 131 ++++++++++++++++++ server/utils/local.ts | 25 ++++ test/faq.test.ts | 66 +++++++++ test/pages.test.ts | 88 ++++++++++++ utils/pages.ts | 65 +++++++++ 15 files changed, 507 insertions(+), 137 deletions(-) create mode 100644 app/utils/faq.ts create mode 100644 server/api/last-modified.get.ts create mode 100644 server/utils/docs-pages.ts create mode 100644 test/faq.test.ts create mode 100644 test/pages.test.ts create mode 100644 utils/pages.ts diff --git a/app/components/AppHeaderBrand.vue b/app/components/AppHeaderBrand.vue index 3b5e602..81e5f26 100644 --- a/app/components/AppHeaderBrand.vue +++ b/app/components/AppHeaderBrand.vue @@ -61,7 +61,6 @@ onMounted(() => { viewBox="0 0 16 16" width="16" > - <title>Slash forward icon findBreadcrumb(navigation?.value, selfPath.value)) + // Last commit touching the page's source file, from the per-commit map the sitemap also reads. + const { data: modified } = await useFetch('/api/last-modified', { + key: `last-modified:${content.value.path}`, + query: { path: content.value.path }, + default: () => ({ date: null }), + }) + + // A "FAQ" section built from an accordion doubles as FAQPage data. The page node is retyped rather + // than a second one emitted, the same as LandingFaq, so the questions hang off the existing `WebPage`. + const faq = extractFaq(page.value?.nodes) + // `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([ @@ -195,7 +206,14 @@ if (content.value.mode === 'prod') { headline: () => fm.value.title, description: () => fm.value.description, inLanguage: 'en', + ...(modified.value?.date ? { dateModified: modified.value.date } : {}), }), + ...(faq.length + ? [ + defineWebPage({ '@type': 'FAQPage' }), + ...faq.map((entry) => defineQuestion({ name: entry.question, acceptedAnswer: entry.answer })), + ] + : []), defineBreadcrumb({ // Google requires `item` on every ListItem. Non-page section nodes // (page: false) only have a title — omit them from structured data. diff --git a/app/utils/faq.ts b/app/utils/faq.ts new file mode 100644 index 0000000..7a43230 --- /dev/null +++ b/app/utils/faq.ts @@ -0,0 +1,51 @@ +import type { Node } from 'comark' + +export interface FaqEntry { + question: string + answer: string +} + +const FAQ_HEADING = /^(?:faqs?|frequently asked questions)$/i + +/** + * The questions of a page's FAQ, for FAQPage structured data: every `accordion-item` of an `accordion` + * placed under a top-level heading named "FAQ" (or "Frequently asked questions"), up to the next heading + * of the same level. The item `label` is the question and its text content the answer. + */ +export function extractFaq(nodes: Node[] | undefined): FaqEntry[] { + const entries: FaqEntry[] = [] + let faqLevel = 0 + + for (const node of nodes ?? []) { + if (!Array.isArray(node) || typeof node[0] !== 'string') continue + const tag = node[0] + + const level = /^h([1-6])$/.exec(tag)?.[1] + if (level) { + const depth = Number(level) + if (FAQ_HEADING.test(plainText(node).trim())) faqLevel = depth + else if (faqLevel && depth <= faqLevel) faqLevel = 0 + continue + } + + if (!faqLevel || tag !== 'accordion') continue + for (const item of node.slice(2) as Node[]) { + if (!Array.isArray(item) || item[0] !== 'accordion-item') continue + const question = typeof item[1]?.label === 'string' ? item[1].label.trim() : '' + const answer = (item.slice(2) as Node[]).map(plainText).join('').replace(/\s+/g, ' ').trim() + if (question && answer) entries.push({ question, answer }) + } + } + + return entries +} + +const BLOCK_TAGS = new Set(['p', 'li', 'ul', 'ol', 'pre', 'blockquote', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']) + +/** Text of a node, without comments. Block elements are padded so their text does not run together. */ +function plainText(node: Node): string { + if (typeof node === 'string') return node + if (node[0] === null) return '' + const text = (node.slice(2) as Node[]).map(plainText).join('') + return BLOCK_TAGS.has(node[0]) ? ` ${text} ` : text +} diff --git a/modules/config/runtime/server/plugins/llms.ts b/modules/config/runtime/server/plugins/llms.ts index 60a5ffa..a279b69 100644 --- a/modules/config/runtime/server/plugins/llms.ts +++ b/modules/config/runtime/server/plugins/llms.ts @@ -2,10 +2,14 @@ import type { NavigationItem } from 'comark-content' import type { NitroApp } from 'nitropack/types' import type { LLMsSection } from 'nuxt-llms' import { useAppConfig } from 'nitropack/runtime' +import type { DocsPage } from '../../../../../utils/pages' /** * Builds `llms.txt` from the content navigation: one section per top-level directory, in sidebar - * order, plus the `docs.llms.links` extras. Registered from modules/config.ts rather than scanned from + * order, plus the `docs.llms.links` extras. Pages come from `listDocsPages()`, so a directory index is + * listed once and pages hidden from the sidebar (`navigation: false`) still appear. + * + * Registered from modules/config.ts rather than scanned from * `server/plugins/` so it runs ahead of the nuxt-agent-discovery bridge, which leaves sections * that carry links alone apart from rewriting every page link to its raw markdown twin, and renders * `llms-full.txt` from the same content adapter. @@ -23,9 +27,9 @@ export default defineNitroPlugin((nitroApp: NitroApp) => { // resolves those. Otherwise the intro goes ahead of the "Documentation Sets" entry nuxt-llms seeds. if (!options.sections.some((section) => 'navigation' in section)) { const content = await getProdContent() - const navigation = await content.navigation() - options.sections.unshift(documentationSection(navigation, siteName)) - options.sections.push(...navigationSections(navigation)) + const [navigation, pages] = await Promise.all([content.navigation(), listDocsPages(content)]) + options.sections.unshift(documentationSection(pages, siteName)) + options.sections.push(...navigationSections(navigation, pages)) } const extraLinks = (appConfig.docs?.llms?.links ?? []) as LLMsSection['links'] @@ -36,39 +40,28 @@ export default defineNitroPlugin((nitroApp: NitroApp) => { }) /** The landing page and the top-level pages that belong to no section. */ -function documentationSection(navigation: NavigationItem[], siteName: string): LLMsSection { +function documentationSection(pages: DocsPage[], siteName: string): LLMsSection { return { title: 'Documentation', description: 'Every page below is available as raw markdown. Fetch any URL directly.', links: [ { title: 'Landing page', description: `Overview of ${siteName}`, href: '/' }, - ...pageLinks(navigation.filter((item) => !item.children?.length)), + ...pages.filter((page) => !page.section).map(toLink), ], } } -/** One section per top-level directory, carrying its navigation description. */ -function navigationSections(navigation: NavigationItem[]): LLMsSection[] { +/** One section per top-level directory, in sidebar order, carrying its navigation description. */ +function navigationSections(navigation: NavigationItem[], pages: DocsPage[]): LLMsSection[] { const sections: LLMsSection[] = [] for (const item of navigation) { if (!item.children?.length) continue - const links = pageLinks([item]) + const links = pages.filter((page) => page.section === item.title).map(toLink) if (links.length) sections.push({ title: item.title, description: item.description, links }) } return sections } -/** Every page in the subtree, depth first, linked on its page URL. */ -function pageLinks(items: NavigationItem[]): NonNullable { - const links: NonNullable = [] - const collect = (entries: NavigationItem[]) => { - for (const entry of entries) { - if (entry.page !== false && entry.path && entry.path !== '/') { - links.push({ title: entry.title, description: entry.description, href: entry.path }) - } - if (entry.children?.length) collect(entry.children) - } - } - collect(items) - return links +function toLink(page: DocsPage): NonNullable[number] { + return { title: page.title, description: page.description, href: page.path } } diff --git a/playground/content/2.writing/3.components.md b/playground/content/2.writing/3.components.md index aa4d267..b83bda0 100644 --- a/playground/content/2.writing/3.components.md +++ b/playground/content/2.writing/3.components.md @@ -127,6 +127,8 @@ This is the third step. Other useful ones: `::tabs` with `:::tabs-item{label="..."}` children, `::collapsible`, `::accordion`, `::code-preview` (rendered output next to its source), and `::code-collapse`. See the [Nuxt UI typography docs](https://ui.nuxt.com/docs/typography) for the full list and props. +An `::accordion` under a `## FAQ` heading (or "Frequently asked questions") also emits [FAQPage JSON-LD](https://developers.google.com/search/docs/appearance/structured-data/faqpage): each item `label` is a question and its text is the answer. + ## Nuxt UI components Any Nuxt UI component works with the `u-` prefix. The [landing page](/writing/landing-page) uses this for its hero: diff --git a/playground/content/2.writing/5.navigation.md b/playground/content/2.writing/5.navigation.md index a2ded83..4e5a3bc 100644 --- a/playground/content/2.writing/5.navigation.md +++ b/playground/content/2.writing/5.navigation.md @@ -24,7 +24,7 @@ Section titles come from the `.navigation.yml` file in each directory: title: Getting Started ``` -Page titles come from frontmatter — `navigation.title` when set, `title` otherwise. Pages with `navigation: false` are excluded from the tree entirely (and therefore from prev/next links, search, and `llms.txt`). +Page titles come from frontmatter — `navigation.title` when set, `title` otherwise. Pages with `navigation: false` are excluded from the tree entirely (and therefore from prev/next links, search, and `llms.txt`). A directory `index.md` with `navigation: false` is the exception for the indexes: it is the landing of a section the header links to, so the sitemaps, `llms.txt` and the RSS feed still list it. ## Section layouts @@ -75,7 +75,7 @@ A GitHub tab is appended automatically when the repository is known. - **Headline** — the small label above a page title is the parent section's title. - **Breadcrumbs** — emitted as [BreadcrumbList JSON-LD](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb) for search engines. - **Search** — `⌘K` indexes every page section; results are grouped by the navigation hierarchy. -- **Sitemap and `llms.txt`** — both list the pages of the navigation tree. +- **Sitemaps, `llms.txt` and RSS** — all four list the pages of the navigation tree in sidebar order, plus each directory `index.md` hidden with `navigation: false`, first in its section. `sitemap.xml` carries a `lastmod` per page: the date of the last commit touching its source file. ## Version previews diff --git a/server/api/__sitemap__/urls.get.ts b/server/api/__sitemap__/urls.get.ts index a7a2977..766c1bc 100644 --- a/server/api/__sitemap__/urls.get.ts +++ b/server/api/__sitemap__/urls.get.ts @@ -1,18 +1,14 @@ -import type { NavigationItem } from 'comark-content' - -/** Sitemap source for `@nuxtjs/sitemap`. */ +/** + * Sitemap source for `@nuxtjs/sitemap`: every public page, with pages hidden from the sidebar included, + * and `lastmod` from the last commit touching each page's source file. + */ export default defineEventHandler(async () => { const content = await getProdContent() - const navigation = await content.navigation() + const [pages, dates] = await Promise.all([listDocsPages(content), docsPageDates(content)]) - const urls: string[] = ['/', '/logos'] - const collect = (items: NavigationItem[]) => { - for (const item of items) { - if (item.page !== false && item.path) urls.push(item.path) - if (item.children?.length) collect(item.children) - } + const url = (loc: string) => { + const lastmod = dates.get(loc) + return lastmod ? { loc, lastmod } : { loc } } - collect(navigation) - - return [...new Set(urls)].map((loc) => ({ loc })) + return [url('/'), { loc: '/logos' }, ...pages.map((page) => url(page.path))] }) diff --git a/server/api/last-modified.get.ts b/server/api/last-modified.get.ts new file mode 100644 index 0000000..5deadaf --- /dev/null +++ b/server/api/last-modified.get.ts @@ -0,0 +1,13 @@ +import { withLeadingSlash } from 'ufo' + +/** + * Date of the last commit touching a production page's source file, for its `dateModified`. Served + * from the per-commit map the sitemap and the RSS feed share, so a page costs no extra GitHub call. + */ +export default defineEventHandler(async (event): Promise<{ date: string | null }> => { + const raw = getQuery(event).path + const path = typeof raw === 'string' && raw ? withLeadingSlash(raw) : '/' + + const dates = await docsPageDates(await getProdContent()) + return { date: dates.get(path.toLowerCase()) ?? null } +}) diff --git a/server/routes/rss.xml.get.ts b/server/routes/rss.xml.get.ts index 893c6f1..6b87f8c 100644 --- a/server/routes/rss.xml.get.ts +++ b/server/routes/rss.xml.get.ts @@ -1,16 +1,8 @@ -import type { NavigationItem } from 'comark-content' import { useAppConfig } from 'nitropack/runtime' import { joinURL } from 'ufo' // RSS 2.0 feed of every docs page, last-modified dates from git. ISR-cached; purged by the push webhook. -interface FeedPage { - path: string - title: string - description?: string - repoPath: string -} - const cdata = (value: string) => `', ']]]]>')}]]>` // XML-escape element text: URLs and ids can't use CDATA the way titles and descriptions do, and one raw `&` @@ -25,38 +17,18 @@ const escapeXml = (value: string) => export default defineEventHandler(async (event) => { const content = await getProdContent() - const navigation = await content.navigation() const site = getSiteConfig(event) const appConfig = useAppConfig(event) const siteUrl = site.url || '/' const siteName = appConfig.seo?.siteName || site.name || '' const rssTitle = appConfig.docs?.rss?.title || `${siteName} Documentation` - // Every page from navigation, mapped to its repo file for git history. - const pages: FeedPage[] = [] - const collect = (items: NavigationItem[]) => { - for (const item of items) { - if (item.page !== false && item.path) { - const entry = content.stat(item.path) - if (entry && entry.meta.kind === 'document') { - pages.push({ - path: item.path, - title: item.title, - description: item.description, - repoPath: `${contentPrefix()}${entry.meta.stem}${entry.meta.extension}`, - }) - } - } - if (item.children?.length) collect(item.children) - } - } - collect(navigation) - - const dates = await lastModifiedDates(pages.map((page) => page.repoPath)) + const pages = await listDocsPages(content) + const dates = await docsPageDates(content) const items = pages .map((page) => { - const date = dates.get(page.repoPath) + const date = dates.get(page.path) return [ ' ', ` ${cdata(page.title)}`, @@ -94,70 +66,3 @@ ${items} setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8') return xml }) - -/** repoPath → ISO date of the last commit touching it. */ -async function lastModifiedDates(repoPaths: string[]): Promise> { - const dates = new Map() - - if (import.meta.dev) { - await Promise.all( - repoPaths.map(async (repoPath) => { - const [commit] = await gitLocalFileHistory(repoPath, 1) - if (commit?.date) dates.set(repoPath, commit.date) - }) - ) - return dates - } - - // One aliased GraphQL query for every file at the pinned SHA, cached per SHA (pushes advance it and purge). - const rev = getHeadRef() - const cache = shaCacheStorage(rev) - const cacheKey = 'gh:rss-dates' - const cached = await cache.getItem>(cacheKey) - if (cached) return new Map(Object.entries(cached)) - - const [owner, repo] = githubRepo().split('/') - const aliases = repoPaths - .map((repoPath, index) => `f${index}: history(first:1, path:${JSON.stringify(repoPath)}){ nodes{ committedDate } }`) - .join('\n ') - const query = ` - query($owner:String!,$repo:String!,$rev:String!){ - repository(owner:$owner,name:$repo){ - object(expression:$rev){ - ... on Commit { - ${aliases} - } - } - } - }` - - try { - const res = await $fetch<{ - data?: { repository?: { object?: Record }> } } - errors?: Array<{ message: string }> - }>('https://api.github.com/graphql', { - method: 'POST', - headers: { - Accept: 'application/vnd.github+json', - ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}), - }, - body: { query, variables: { owner, repo, rev } }, - }) - - if (res.errors?.length) { - throw new Error(res.errors.map((e) => e.message).join('; ')) - } - - const object = res.data?.repository?.object ?? {} - repoPaths.forEach((repoPath, index) => { - const date = object[`f${index}`]?.nodes?.[0]?.committedDate - if (date) dates.set(repoPath, date) - }) - - await cache.setItem(cacheKey, Object.fromEntries(dates), { ttl: 60 * 60 * 24 }) - } catch (error) { - console.error('[rss] git dates lookup failed', error) - } - - return dates -} diff --git a/server/utils/agent-source.ts b/server/utils/agent-source.ts index 0ec9df9..9552256 100644 --- a/server/utils/agent-source.ts +++ b/server/utils/agent-source.ts @@ -1,8 +1,26 @@ import { createComarkSource } from '#agent-discovery/comark' +const source = createComarkSource(() => getProdContent()) + /** * Content adapter behind nuxt-agent-discovery: the raw markdown route, `sitemap.md`, the `llms.txt` * bridge and the MCP helpers all read production content through it. Versioned previews (`/tree`, * `/blob`, `/pr`) are excluded from negotiation in nuxt.config.ts and keep serving HTML. + * + * The full listing (no selector) comes from `listDocsPages()` instead of the navigation tree alone, so + * `sitemap.md` lists a directory index once and keeps the pages hidden from the sidebar, the same as + * `sitemap.xml` and `llms.txt`. */ -export default createComarkSource(() => getProdContent()) +export default { + ...source, + async list(selector, event) { + if (selector) return source.list?.(selector, event) ?? null + const pages = await listDocsPages(await getProdContent()) + return pages.map((page) => ({ + route: page.path, + title: page.title, + description: page.description, + section: page.section, + })) + }, +} satisfies typeof source diff --git a/server/utils/docs-pages.ts b/server/utils/docs-pages.ts new file mode 100644 index 0000000..ce87613 --- /dev/null +++ b/server/utils/docs-pages.ts @@ -0,0 +1,131 @@ +import { collectDocsPages, type DocsPage } from '../../utils/pages.ts' + +/** A public page and the repo file it is read from, for git dates. */ +export interface DocsPageEntry extends DocsPage { + /** Repo-relative path of the source file (e.g. `docs/content/1.guide/2.install.md`). */ + repoPath?: string +} + +/** + * Every public page in sidebar order, once each, with directory indexes hidden from the sidebar + * (`navigation: false`) included. Source of `sitemap.xml`, `sitemap.md`, `llms.txt` and the RSS feed, so + * the four agree. + */ +export async function listDocsPages(content: DocsContent): Promise { + const [navigation, documents] = await Promise.all([content.navigation(), content.list()]) + // Only a directory `index.md` comes back: a section landing the header links to. Any other hidden page + // may be a draft (`navigation: false` is how the docs suggest keeping one out of sight). + const hidden = documents + .filter((doc) => doc.meta.kind === 'document' && doc.data?.navigation === false && isDirectoryIndex(doc.meta.stem)) + .map((doc) => ({ path: doc.path, title: doc.data?.title, description: doc.data?.description })) + + return collectDocsPages(navigation, hidden).map((page) => ({ ...page, repoPath: repoPathOf(content, page.path) })) +} + +/** Whether a stem names a directory `index` file (`4.plugins/index`, numeric prefixes allowed). */ +function isDirectoryIndex(stem: string): boolean { + const parts = stem.split('/') + return parts.length > 1 && parts.at(-1)!.replace(/^\d+\./, '') === 'index' +} + +/** Repo-relative source file of the document at `path`, if there is one. */ +function repoPathOf(content: DocsContent, path: string): string | undefined { + const entry = content.stat(path) + return entry?.meta.kind === 'document' ? `${contentPrefix()}${entry.meta.stem}${entry.meta.extension}` : undefined +} + +/** Bump when the shape of the cached map changes. */ +const DATES_CACHE_KEY = 'gh:page-dates:v1' + +/** + * Page path → ISO date of the last commit touching its source file, for every public page. + * + * Production reads one aliased GraphQL query at the pinned head SHA and caches it per SHA, so the sitemap, + * the feed and every page's `dateModified` share a single lookup per content commit. Development reads the + * local git log. A page without a commit yet (a new, uncommitted file) has no date. + */ +export async function docsPageDates(content: DocsContent): Promise> { + const pages = [...(await listDocsPages(content)), { path: '/', title: '', repoPath: repoPathOf(content, '/') }] + const files = pages.filter((page): page is DocsPageEntry & { repoPath: string } => Boolean(page.repoPath)) + const byFile = await lastModifiedByFile(files.map((page) => page.repoPath)) + + const dates = new Map() + for (const page of files) { + const date = byFile.get(page.repoPath) + if (date) dates.set(page.path, date) + } + return dates +} + +// One lookup in flight per content commit: a cold process renders many pages at once, and each one asks. +const pendingDates = new Map>>() + +/** repoPath → ISO date of the last commit touching it. */ +async function lastModifiedByFile(repoPaths: string[]): Promise> { + if (import.meta.dev) { + const all = await gitLocalLastModified(contentPrefix()) + return new Map(repoPaths.flatMap((repoPath) => (all.has(repoPath) ? [[repoPath, all.get(repoPath)!]] : []))) + } + + const rev = getHeadRef() + let pending = pendingDates.get(rev) + if (!pending) { + pending = fetchLastModified(rev, repoPaths).finally(() => pendingDates.delete(rev)) + pendingDates.set(rev, pending) + } + return pending +} + +async function fetchLastModified(rev: string, repoPaths: string[]): Promise> { + const cache = shaCacheStorage(rev) + const cached = await cache.getItem>(DATES_CACHE_KEY) + if (cached) return new Map(Object.entries(cached)) + + const dates = new Map() + if (!repoPaths.length) return dates + + const [owner, repo] = githubRepo().split('/') + const aliases = repoPaths + .map((repoPath, index) => `f${index}: history(first:1, path:${JSON.stringify(repoPath)}){ nodes{ committedDate } }`) + .join('\n ') + const query = ` + query($owner:String!,$repo:String!,$rev:String!){ + repository(owner:$owner,name:$repo){ + object(expression:$rev){ + ... on Commit { + ${aliases} + } + } + } + }` + + try { + const res = await $fetch<{ + data?: { repository?: { object?: Record }> } } + errors?: Array<{ message: string }> + }>('https://api.github.com/graphql', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}), + }, + body: { query, variables: { owner, repo, rev } }, + }) + + if (res.errors?.length) { + throw new Error(res.errors.map((e) => e.message).join('; ')) + } + + const object = res.data?.repository?.object ?? {} + repoPaths.forEach((repoPath, index) => { + const date = object[`f${index}`]?.nodes?.[0]?.committedDate + if (date) dates.set(repoPath, date) + }) + + await cache.setItem(DATES_CACHE_KEY, Object.fromEntries(dates), { ttl: 60 * 60 * 24 }) + } catch (error) { + console.error('[dates] git dates lookup failed', error) + } + + return dates +} diff --git a/server/utils/local.ts b/server/utils/local.ts index 1b7b43f..bc0f4bd 100644 --- a/server/utils/local.ts +++ b/server/utils/local.ts @@ -77,6 +77,31 @@ export function gitLocalSource(ref: string, dir: string): Source { } } +/** + * repoPath → committer date of the last commit touching it, for every file under `dir`, in one `git log` + * pass (local equivalent of the aliased GraphQL query in `docs-pages.ts`). Newest commits come first, so + * the first date seen for a file is its last change. + */ +export async function gitLocalLastModified(dir: string): Promise> { + const dates = new Map() + try { + const { stdout } = await exec('git', ['log', '--format=%x1e%cI', '--name-only', 'HEAD', '--', dir], { + cwd: repoRoot(), + maxBuffer: MAX_BUFFER, + }) + for (const block of stdout.split('\x1E')) { + const [date, ...files] = block.split('\n').map((line) => line.trim()).filter(Boolean) + if (!date) continue + for (const file of files) { + if (!dates.has(file)) dates.set(file, date) + } + } + } catch (error) { + console.error(`[dates] git log failed for ${dir}`, error) + } + return dates +} + /** The commits that touched a repo file, newest first (local equivalent of the GitHub commits API). */ export async function gitLocalFileHistory(repoPath: string, limit = 5, rev = 'HEAD'): Promise { try { diff --git a/test/faq.test.ts b/test/faq.test.ts new file mode 100644 index 0000000..d57936b --- /dev/null +++ b/test/faq.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { Node } from 'comark' +import { extractFaq } from '../app/utils/faq' + +const accordion = (...items: Array<[string, ...Node[]]>): Node => + ['accordion', {}, ...items.map(([label, ...children]) => ['accordion-item', { label }, ...children] as Node)] as Node + +describe('extractFaq', () => { + it('reads the accordion under a FAQ heading', () => { + const nodes: Node[] = [ + ['h2', { id: 'intro' }, 'Intro'], + ['p', {}, 'Some text.'], + ['h2', { id: 'faq' }, 'FAQ'], + accordion( + ['Is it free?', ['p', {}, 'Yes. MIT ', ['strong', {}, 'licensed'], '.']], + ['Does it stream?', ['p', {}, 'Yes.'], ['p', {}, 'See ', ['code', {}, 'autoClose'], '.']] + ), + ] + expect(extractFaq(nodes)).toEqual([ + { question: 'Is it free?', answer: 'Yes. MIT licensed.' }, + { question: 'Does it stream?', answer: 'Yes. See autoClose.' }, + ]) + }) + + it('keeps inline children of an unwrapped answer together', () => { + const nodes: Node[] = [ + ['h2', {}, 'FAQ'], + accordion(['Drop-in?', 'No. The syntax differs (', ['code', {}, '::alert'], ' instead of ', ['code', {}, ''], ').']), + ] + expect(extractFaq(nodes)).toEqual([{ question: 'Drop-in?', answer: 'No. The syntax differs (::alert instead of ).' }]) + }) + + it('accepts "Frequently asked questions" in any case', () => { + const nodes: Node[] = [['h2', {}, 'Frequently Asked Questions'], accordion(['Q?', ['p', {}, 'A.']])] + expect(extractFaq(nodes)).toEqual([{ question: 'Q?', answer: 'A.' }]) + }) + + it('ignores accordions outside the FAQ section', () => { + const nodes: Node[] = [ + accordion(['Before?', ['p', {}, 'No.']]), + ['h2', {}, 'FAQ'], + ['h3', {}, 'General'], + accordion(['Inside?', ['p', {}, 'Yes.']]), + ['h2', {}, 'Next section'], + accordion(['After?', ['p', {}, 'No.']]), + ] + expect(extractFaq(nodes)).toEqual([{ question: 'Inside?', answer: 'Yes.' }]) + }) + + it('separates block elements and drops comments', () => { + const nodes: Node[] = [ + ['h2', {}, 'FAQ'], + accordion(['Which?', ['ul', {}, ['li', {}, 'One'], ['li', {}, 'Two']], [null, {}, ' hidden '] as unknown as Node]), + ] + expect(extractFaq(nodes)).toEqual([{ question: 'Which?', answer: 'One Two' }]) + }) + + it('skips items without a label or an answer', () => { + const nodes: Node[] = [ + ['h2', {}, 'FAQ'], + ['accordion', {}, ['accordion-item', {}, ['p', {}, 'No label']], ['accordion-item', { label: 'Empty?' }]] as Node, + ] + expect(extractFaq(nodes)).toEqual([]) + expect(extractFaq(undefined)).toEqual([]) + }) +}) diff --git a/test/pages.test.ts b/test/pages.test.ts new file mode 100644 index 0000000..076b8fc --- /dev/null +++ b/test/pages.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { NavigationItem } from 'comark-content' +import { collectDocsPages } from '../utils/pages' + +const navigation = [ + { title: 'Overview', path: '/overview', description: 'Top-level page' }, + { + title: 'Getting started', + path: '/getting-started', + page: false, + children: [ + { title: 'Introduction', path: '/getting-started/introduction', description: 'Start here' }, + { title: 'Installation', path: '/getting-started/installation' }, + ], + }, + // A directory `index.md`: comark-content emits it as the section node *and* as its own first child. + { + title: 'Examples', + path: '/examples', + children: [ + { title: 'Examples', path: '/examples', description: 'All examples' }, + { title: 'Vue', path: '/examples/vue' }, + ], + }, + // A section whose `index.md` sets `navigation: false`: the node is not a page of its own. + { + title: 'Plugins', + path: '/plugins', + page: false, + children: [{ title: 'Emoji', path: '/plugins/emoji' }], + }, +] as unknown as NavigationItem[] + +describe('collectDocsPages', () => { + it('lists every page in sidebar order with its section', () => { + expect(collectDocsPages(navigation)).toEqual([ + { path: '/overview', title: 'Overview', description: 'Top-level page', section: undefined }, + { path: '/getting-started/introduction', title: 'Introduction', description: 'Start here', section: 'Getting started' }, + { path: '/getting-started/installation', title: 'Installation', description: undefined, section: 'Getting started' }, + { path: '/examples', title: 'Examples', description: 'All examples', section: 'Examples' }, + { path: '/examples/vue', title: 'Vue', description: undefined, section: 'Examples' }, + { path: '/plugins/emoji', title: 'Emoji', description: undefined, section: 'Plugins' }, + ]) + }) + + it('lists a directory index once, keeping the description', () => { + const examples = collectDocsPages(navigation).filter((page) => page.path === '/examples') + expect(examples).toEqual([{ path: '/examples', title: 'Examples', description: 'All examples', section: 'Examples' }]) + }) + + it('puts a page hidden from the sidebar first in its section', () => { + const pages = collectDocsPages(navigation, [{ path: '/plugins', title: 'Plugins', description: 'Extend it' }]) + expect(pages.map((page) => page.path)).toEqual([ + '/overview', + '/getting-started/introduction', + '/getting-started/installation', + '/examples', + '/examples/vue', + '/plugins', + '/plugins/emoji', + ]) + expect(pages.find((page) => page.path === '/plugins')).toEqual({ + path: '/plugins', + title: 'Plugins', + description: 'Extend it', + section: 'Plugins', + }) + }) + + it('appends a hidden page outside every section as a top-level page', () => { + const pages = collectDocsPages(navigation, [{ path: '/legal', description: 'Terms' }]) + expect(pages.at(-1)).toEqual({ path: '/legal', title: 'legal', description: 'Terms', section: undefined }) + }) + + it('skips the homepage and hidden pages already in the navigation', () => { + const pages = collectDocsPages(navigation, [ + { path: '/', title: 'Home' }, + { path: '/examples/vue', title: 'Duplicate' }, + ]) + expect(pages).toHaveLength(6) + expect(pages.find((page) => page.path === '/examples/vue')?.title).toBe('Vue') + }) + + it('matches a section on whole path segments only', () => { + const pages = collectDocsPages(navigation, [{ path: '/plugins-legacy', title: 'Legacy' }]) + expect(pages.at(-1)?.section).toBeUndefined() + }) +}) diff --git a/utils/pages.ts b/utils/pages.ts new file mode 100644 index 0000000..a5b35db --- /dev/null +++ b/utils/pages.ts @@ -0,0 +1,65 @@ +import type { NavigationItem } from 'comark-content' + +/** One public page, as the site indexes (sitemaps, `llms.txt`, RSS) list it. */ +export interface DocsPage { + path: string + title: string + description?: string + /** Title of the top-level navigation node the page sits under; none for a top-level page. */ + section?: string +} + +/** A document outside the navigation tree (`navigation: false`), as `content.list()` returns it. */ +export interface HiddenDocument { + path: string + title?: string + description?: string +} + +/** + * Every page of the site, in sidebar order and once each. + * + * - A directory `index.md` is emitted both as its section node and as that node's first child, so the + * same path comes up twice. The entry carrying a description wins. + * - A document hidden from the sidebar with `navigation: false` (a section landing such as `/plugins`) + * is still a public page: it goes first in the section its path falls under, or last when it falls + * under none. The homepage (`/`) is left to callers, which each list it their own way. + */ +export function collectDocsPages(navigation: NavigationItem[], hidden: HiddenDocument[] = []): DocsPage[] { + const pages: DocsPage[] = [] + const byPath = new Map() + + const add = (page: DocsPage, index = pages.length) => { + const existing = byPath.get(page.path) + if (existing) { + if (!existing.description && page.description) existing.description = page.description + return + } + byPath.set(page.path, page) + pages.splice(index, 0, page) + } + + const collect = (items: NavigationItem[], section?: string) => { + for (const item of items) { + const group = item.children?.length ? (section ?? item.title) : section + if (item.page !== false && item.path && item.path !== '/') { + add({ path: item.path, title: item.title, description: item.description, section: group }) + } + if (item.children?.length) collect(item.children, group) + } + } + collect(navigation) + + for (const doc of hidden) { + if (!doc.path || doc.path === '/' || byPath.has(doc.path)) continue + const parent = navigation.find( + (item) => item.children?.length && item.path && (doc.path === item.path || doc.path.startsWith(`${item.path}/`)) + ) + const title = doc.title || doc.path.split('/').filter(Boolean).pop() || doc.path + const page: DocsPage = { path: doc.path, title, description: doc.description, section: parent?.title } + const first = parent ? pages.findIndex((entry) => entry.section === parent.title) : -1 + add(page, first === -1 ? pages.length : first) + } + + return pages +} From 3dd6e3eaaf66afc418eef27b38bdb91b2c15abc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 7 Oct 2026 14:08:15 +0200 Subject: [PATCH 2/3] refactor(seo): keep the PR to FAQPage data and the llms.txt duplicate Drop listDocsPages(), the agent-source override, the last-modified endpoint and the sitemap lastmod. llms.txt now lists a directory index once inside its own plugin. --- app/pages/[...slug].vue | 8 -- modules/config/runtime/server/plugins/llms.ts | 42 ++++-- playground/content/2.writing/5.navigation.md | 4 +- server/api/__sitemap__/urls.get.ts | 22 +-- server/api/last-modified.get.ts | 13 -- server/routes/rss.xml.get.ts | 101 +++++++++++++- server/utils/agent-source.ts | 20 +-- server/utils/docs-pages.ts | 131 ------------------ server/utils/local.ts | 25 ---- test/pages.test.ts | 88 ------------ utils/pages.ts | 65 --------- 11 files changed, 141 insertions(+), 378 deletions(-) delete mode 100644 server/api/last-modified.get.ts delete mode 100644 server/utils/docs-pages.ts delete mode 100644 test/pages.test.ts delete mode 100644 utils/pages.ts diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue index 81fa574..9bd54cc 100644 --- a/app/pages/[...slug].vue +++ b/app/pages/[...slug].vue @@ -187,13 +187,6 @@ if (content.value.mode === 'prod') { const breadcrumb = computed(() => findBreadcrumb(navigation?.value, selfPath.value)) - // Last commit touching the page's source file, from the per-commit map the sitemap also reads. - const { data: modified } = await useFetch('/api/last-modified', { - key: `last-modified:${content.value.path}`, - query: { path: content.value.path }, - default: () => ({ date: null }), - }) - // A "FAQ" section built from an accordion doubles as FAQPage data. The page node is retyped rather // than a second one emitted, the same as LandingFaq, so the questions hang off the existing `WebPage`. const faq = extractFaq(page.value?.nodes) @@ -206,7 +199,6 @@ if (content.value.mode === 'prod') { headline: () => fm.value.title, description: () => fm.value.description, inLanguage: 'en', - ...(modified.value?.date ? { dateModified: modified.value.date } : {}), }), ...(faq.length ? [ diff --git a/modules/config/runtime/server/plugins/llms.ts b/modules/config/runtime/server/plugins/llms.ts index a279b69..80596c7 100644 --- a/modules/config/runtime/server/plugins/llms.ts +++ b/modules/config/runtime/server/plugins/llms.ts @@ -2,14 +2,10 @@ import type { NavigationItem } from 'comark-content' import type { NitroApp } from 'nitropack/types' import type { LLMsSection } from 'nuxt-llms' import { useAppConfig } from 'nitropack/runtime' -import type { DocsPage } from '../../../../../utils/pages' /** * Builds `llms.txt` from the content navigation: one section per top-level directory, in sidebar - * order, plus the `docs.llms.links` extras. Pages come from `listDocsPages()`, so a directory index is - * listed once and pages hidden from the sidebar (`navigation: false`) still appear. - * - * Registered from modules/config.ts rather than scanned from + * order, plus the `docs.llms.links` extras. Registered from modules/config.ts rather than scanned from * `server/plugins/` so it runs ahead of the nuxt-agent-discovery bridge, which leaves sections * that carry links alone apart from rewriting every page link to its raw markdown twin, and renders * `llms-full.txt` from the same content adapter. @@ -27,9 +23,9 @@ export default defineNitroPlugin((nitroApp: NitroApp) => { // resolves those. Otherwise the intro goes ahead of the "Documentation Sets" entry nuxt-llms seeds. if (!options.sections.some((section) => 'navigation' in section)) { const content = await getProdContent() - const [navigation, pages] = await Promise.all([content.navigation(), listDocsPages(content)]) - options.sections.unshift(documentationSection(pages, siteName)) - options.sections.push(...navigationSections(navigation, pages)) + const navigation = await content.navigation() + options.sections.unshift(documentationSection(navigation, siteName)) + options.sections.push(...navigationSections(navigation)) } const extraLinks = (appConfig.docs?.llms?.links ?? []) as LLMsSection['links'] @@ -40,28 +36,44 @@ export default defineNitroPlugin((nitroApp: NitroApp) => { }) /** The landing page and the top-level pages that belong to no section. */ -function documentationSection(pages: DocsPage[], siteName: string): LLMsSection { +function documentationSection(navigation: NavigationItem[], siteName: string): LLMsSection { return { title: 'Documentation', description: 'Every page below is available as raw markdown. Fetch any URL directly.', links: [ { title: 'Landing page', description: `Overview of ${siteName}`, href: '/' }, - ...pages.filter((page) => !page.section).map(toLink), + ...pageLinks(navigation.filter((item) => !item.children?.length)), ], } } -/** One section per top-level directory, in sidebar order, carrying its navigation description. */ -function navigationSections(navigation: NavigationItem[], pages: DocsPage[]): LLMsSection[] { +/** One section per top-level directory, carrying its navigation description. */ +function navigationSections(navigation: NavigationItem[]): LLMsSection[] { const sections: LLMsSection[] = [] for (const item of navigation) { if (!item.children?.length) continue - const links = pages.filter((page) => page.section === item.title).map(toLink) + const links = pageLinks([item]) if (links.length) sections.push({ title: item.title, description: item.description, links }) } return sections } -function toLink(page: DocsPage): NonNullable[number] { - return { title: page.title, description: page.description, href: page.path } +/** + * Every page in the subtree, depth first, linked on its page URL. A directory `index.md` is both the + * section node and its own first child, so a path seen twice is listed once, keeping its description. + */ +function pageLinks(items: NavigationItem[]): NonNullable { + const links: NonNullable = [] + const collect = (entries: NavigationItem[]) => { + for (const entry of entries) { + if (entry.page !== false && entry.path && entry.path !== '/') { + const seen = links.find((link) => link.href === entry.path) + if (seen) seen.description ||= entry.description + else links.push({ title: entry.title, description: entry.description, href: entry.path }) + } + if (entry.children?.length) collect(entry.children) + } + } + collect(items) + return links } diff --git a/playground/content/2.writing/5.navigation.md b/playground/content/2.writing/5.navigation.md index 4e5a3bc..a2ded83 100644 --- a/playground/content/2.writing/5.navigation.md +++ b/playground/content/2.writing/5.navigation.md @@ -24,7 +24,7 @@ Section titles come from the `.navigation.yml` file in each directory: title: Getting Started ``` -Page titles come from frontmatter — `navigation.title` when set, `title` otherwise. Pages with `navigation: false` are excluded from the tree entirely (and therefore from prev/next links, search, and `llms.txt`). A directory `index.md` with `navigation: false` is the exception for the indexes: it is the landing of a section the header links to, so the sitemaps, `llms.txt` and the RSS feed still list it. +Page titles come from frontmatter — `navigation.title` when set, `title` otherwise. Pages with `navigation: false` are excluded from the tree entirely (and therefore from prev/next links, search, and `llms.txt`). ## Section layouts @@ -75,7 +75,7 @@ A GitHub tab is appended automatically when the repository is known. - **Headline** — the small label above a page title is the parent section's title. - **Breadcrumbs** — emitted as [BreadcrumbList JSON-LD](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb) for search engines. - **Search** — `⌘K` indexes every page section; results are grouped by the navigation hierarchy. -- **Sitemaps, `llms.txt` and RSS** — all four list the pages of the navigation tree in sidebar order, plus each directory `index.md` hidden with `navigation: false`, first in its section. `sitemap.xml` carries a `lastmod` per page: the date of the last commit touching its source file. +- **Sitemap and `llms.txt`** — both list the pages of the navigation tree. ## Version previews diff --git a/server/api/__sitemap__/urls.get.ts b/server/api/__sitemap__/urls.get.ts index 766c1bc..a7a2977 100644 --- a/server/api/__sitemap__/urls.get.ts +++ b/server/api/__sitemap__/urls.get.ts @@ -1,14 +1,18 @@ -/** - * Sitemap source for `@nuxtjs/sitemap`: every public page, with pages hidden from the sidebar included, - * and `lastmod` from the last commit touching each page's source file. - */ +import type { NavigationItem } from 'comark-content' + +/** Sitemap source for `@nuxtjs/sitemap`. */ export default defineEventHandler(async () => { const content = await getProdContent() - const [pages, dates] = await Promise.all([listDocsPages(content), docsPageDates(content)]) + const navigation = await content.navigation() - const url = (loc: string) => { - const lastmod = dates.get(loc) - return lastmod ? { loc, lastmod } : { loc } + const urls: string[] = ['/', '/logos'] + const collect = (items: NavigationItem[]) => { + for (const item of items) { + if (item.page !== false && item.path) urls.push(item.path) + if (item.children?.length) collect(item.children) + } } - return [url('/'), { loc: '/logos' }, ...pages.map((page) => url(page.path))] + collect(navigation) + + return [...new Set(urls)].map((loc) => ({ loc })) }) diff --git a/server/api/last-modified.get.ts b/server/api/last-modified.get.ts deleted file mode 100644 index 5deadaf..0000000 --- a/server/api/last-modified.get.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { withLeadingSlash } from 'ufo' - -/** - * Date of the last commit touching a production page's source file, for its `dateModified`. Served - * from the per-commit map the sitemap and the RSS feed share, so a page costs no extra GitHub call. - */ -export default defineEventHandler(async (event): Promise<{ date: string | null }> => { - const raw = getQuery(event).path - const path = typeof raw === 'string' && raw ? withLeadingSlash(raw) : '/' - - const dates = await docsPageDates(await getProdContent()) - return { date: dates.get(path.toLowerCase()) ?? null } -}) diff --git a/server/routes/rss.xml.get.ts b/server/routes/rss.xml.get.ts index 6b87f8c..893c6f1 100644 --- a/server/routes/rss.xml.get.ts +++ b/server/routes/rss.xml.get.ts @@ -1,8 +1,16 @@ +import type { NavigationItem } from 'comark-content' import { useAppConfig } from 'nitropack/runtime' import { joinURL } from 'ufo' // RSS 2.0 feed of every docs page, last-modified dates from git. ISR-cached; purged by the push webhook. +interface FeedPage { + path: string + title: string + description?: string + repoPath: string +} + const cdata = (value: string) => `', ']]]]>')}]]>` // XML-escape element text: URLs and ids can't use CDATA the way titles and descriptions do, and one raw `&` @@ -17,18 +25,38 @@ const escapeXml = (value: string) => export default defineEventHandler(async (event) => { const content = await getProdContent() + const navigation = await content.navigation() const site = getSiteConfig(event) const appConfig = useAppConfig(event) const siteUrl = site.url || '/' const siteName = appConfig.seo?.siteName || site.name || '' const rssTitle = appConfig.docs?.rss?.title || `${siteName} Documentation` - const pages = await listDocsPages(content) - const dates = await docsPageDates(content) + // Every page from navigation, mapped to its repo file for git history. + const pages: FeedPage[] = [] + const collect = (items: NavigationItem[]) => { + for (const item of items) { + if (item.page !== false && item.path) { + const entry = content.stat(item.path) + if (entry && entry.meta.kind === 'document') { + pages.push({ + path: item.path, + title: item.title, + description: item.description, + repoPath: `${contentPrefix()}${entry.meta.stem}${entry.meta.extension}`, + }) + } + } + if (item.children?.length) collect(item.children) + } + } + collect(navigation) + + const dates = await lastModifiedDates(pages.map((page) => page.repoPath)) const items = pages .map((page) => { - const date = dates.get(page.path) + const date = dates.get(page.repoPath) return [ ' ', ` ${cdata(page.title)}`, @@ -66,3 +94,70 @@ ${items} setHeader(event, 'Content-Type', 'application/rss+xml; charset=utf-8') return xml }) + +/** repoPath → ISO date of the last commit touching it. */ +async function lastModifiedDates(repoPaths: string[]): Promise> { + const dates = new Map() + + if (import.meta.dev) { + await Promise.all( + repoPaths.map(async (repoPath) => { + const [commit] = await gitLocalFileHistory(repoPath, 1) + if (commit?.date) dates.set(repoPath, commit.date) + }) + ) + return dates + } + + // One aliased GraphQL query for every file at the pinned SHA, cached per SHA (pushes advance it and purge). + const rev = getHeadRef() + const cache = shaCacheStorage(rev) + const cacheKey = 'gh:rss-dates' + const cached = await cache.getItem>(cacheKey) + if (cached) return new Map(Object.entries(cached)) + + const [owner, repo] = githubRepo().split('/') + const aliases = repoPaths + .map((repoPath, index) => `f${index}: history(first:1, path:${JSON.stringify(repoPath)}){ nodes{ committedDate } }`) + .join('\n ') + const query = ` + query($owner:String!,$repo:String!,$rev:String!){ + repository(owner:$owner,name:$repo){ + object(expression:$rev){ + ... on Commit { + ${aliases} + } + } + } + }` + + try { + const res = await $fetch<{ + data?: { repository?: { object?: Record }> } } + errors?: Array<{ message: string }> + }>('https://api.github.com/graphql', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}), + }, + body: { query, variables: { owner, repo, rev } }, + }) + + if (res.errors?.length) { + throw new Error(res.errors.map((e) => e.message).join('; ')) + } + + const object = res.data?.repository?.object ?? {} + repoPaths.forEach((repoPath, index) => { + const date = object[`f${index}`]?.nodes?.[0]?.committedDate + if (date) dates.set(repoPath, date) + }) + + await cache.setItem(cacheKey, Object.fromEntries(dates), { ttl: 60 * 60 * 24 }) + } catch (error) { + console.error('[rss] git dates lookup failed', error) + } + + return dates +} diff --git a/server/utils/agent-source.ts b/server/utils/agent-source.ts index 9552256..0ec9df9 100644 --- a/server/utils/agent-source.ts +++ b/server/utils/agent-source.ts @@ -1,26 +1,8 @@ import { createComarkSource } from '#agent-discovery/comark' -const source = createComarkSource(() => getProdContent()) - /** * Content adapter behind nuxt-agent-discovery: the raw markdown route, `sitemap.md`, the `llms.txt` * bridge and the MCP helpers all read production content through it. Versioned previews (`/tree`, * `/blob`, `/pr`) are excluded from negotiation in nuxt.config.ts and keep serving HTML. - * - * The full listing (no selector) comes from `listDocsPages()` instead of the navigation tree alone, so - * `sitemap.md` lists a directory index once and keeps the pages hidden from the sidebar, the same as - * `sitemap.xml` and `llms.txt`. */ -export default { - ...source, - async list(selector, event) { - if (selector) return source.list?.(selector, event) ?? null - const pages = await listDocsPages(await getProdContent()) - return pages.map((page) => ({ - route: page.path, - title: page.title, - description: page.description, - section: page.section, - })) - }, -} satisfies typeof source +export default createComarkSource(() => getProdContent()) diff --git a/server/utils/docs-pages.ts b/server/utils/docs-pages.ts deleted file mode 100644 index ce87613..0000000 --- a/server/utils/docs-pages.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { collectDocsPages, type DocsPage } from '../../utils/pages.ts' - -/** A public page and the repo file it is read from, for git dates. */ -export interface DocsPageEntry extends DocsPage { - /** Repo-relative path of the source file (e.g. `docs/content/1.guide/2.install.md`). */ - repoPath?: string -} - -/** - * Every public page in sidebar order, once each, with directory indexes hidden from the sidebar - * (`navigation: false`) included. Source of `sitemap.xml`, `sitemap.md`, `llms.txt` and the RSS feed, so - * the four agree. - */ -export async function listDocsPages(content: DocsContent): Promise { - const [navigation, documents] = await Promise.all([content.navigation(), content.list()]) - // Only a directory `index.md` comes back: a section landing the header links to. Any other hidden page - // may be a draft (`navigation: false` is how the docs suggest keeping one out of sight). - const hidden = documents - .filter((doc) => doc.meta.kind === 'document' && doc.data?.navigation === false && isDirectoryIndex(doc.meta.stem)) - .map((doc) => ({ path: doc.path, title: doc.data?.title, description: doc.data?.description })) - - return collectDocsPages(navigation, hidden).map((page) => ({ ...page, repoPath: repoPathOf(content, page.path) })) -} - -/** Whether a stem names a directory `index` file (`4.plugins/index`, numeric prefixes allowed). */ -function isDirectoryIndex(stem: string): boolean { - const parts = stem.split('/') - return parts.length > 1 && parts.at(-1)!.replace(/^\d+\./, '') === 'index' -} - -/** Repo-relative source file of the document at `path`, if there is one. */ -function repoPathOf(content: DocsContent, path: string): string | undefined { - const entry = content.stat(path) - return entry?.meta.kind === 'document' ? `${contentPrefix()}${entry.meta.stem}${entry.meta.extension}` : undefined -} - -/** Bump when the shape of the cached map changes. */ -const DATES_CACHE_KEY = 'gh:page-dates:v1' - -/** - * Page path → ISO date of the last commit touching its source file, for every public page. - * - * Production reads one aliased GraphQL query at the pinned head SHA and caches it per SHA, so the sitemap, - * the feed and every page's `dateModified` share a single lookup per content commit. Development reads the - * local git log. A page without a commit yet (a new, uncommitted file) has no date. - */ -export async function docsPageDates(content: DocsContent): Promise> { - const pages = [...(await listDocsPages(content)), { path: '/', title: '', repoPath: repoPathOf(content, '/') }] - const files = pages.filter((page): page is DocsPageEntry & { repoPath: string } => Boolean(page.repoPath)) - const byFile = await lastModifiedByFile(files.map((page) => page.repoPath)) - - const dates = new Map() - for (const page of files) { - const date = byFile.get(page.repoPath) - if (date) dates.set(page.path, date) - } - return dates -} - -// One lookup in flight per content commit: a cold process renders many pages at once, and each one asks. -const pendingDates = new Map>>() - -/** repoPath → ISO date of the last commit touching it. */ -async function lastModifiedByFile(repoPaths: string[]): Promise> { - if (import.meta.dev) { - const all = await gitLocalLastModified(contentPrefix()) - return new Map(repoPaths.flatMap((repoPath) => (all.has(repoPath) ? [[repoPath, all.get(repoPath)!]] : []))) - } - - const rev = getHeadRef() - let pending = pendingDates.get(rev) - if (!pending) { - pending = fetchLastModified(rev, repoPaths).finally(() => pendingDates.delete(rev)) - pendingDates.set(rev, pending) - } - return pending -} - -async function fetchLastModified(rev: string, repoPaths: string[]): Promise> { - const cache = shaCacheStorage(rev) - const cached = await cache.getItem>(DATES_CACHE_KEY) - if (cached) return new Map(Object.entries(cached)) - - const dates = new Map() - if (!repoPaths.length) return dates - - const [owner, repo] = githubRepo().split('/') - const aliases = repoPaths - .map((repoPath, index) => `f${index}: history(first:1, path:${JSON.stringify(repoPath)}){ nodes{ committedDate } }`) - .join('\n ') - const query = ` - query($owner:String!,$repo:String!,$rev:String!){ - repository(owner:$owner,name:$repo){ - object(expression:$rev){ - ... on Commit { - ${aliases} - } - } - } - }` - - try { - const res = await $fetch<{ - data?: { repository?: { object?: Record }> } } - errors?: Array<{ message: string }> - }>('https://api.github.com/graphql', { - method: 'POST', - headers: { - Accept: 'application/vnd.github+json', - ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}), - }, - body: { query, variables: { owner, repo, rev } }, - }) - - if (res.errors?.length) { - throw new Error(res.errors.map((e) => e.message).join('; ')) - } - - const object = res.data?.repository?.object ?? {} - repoPaths.forEach((repoPath, index) => { - const date = object[`f${index}`]?.nodes?.[0]?.committedDate - if (date) dates.set(repoPath, date) - }) - - await cache.setItem(DATES_CACHE_KEY, Object.fromEntries(dates), { ttl: 60 * 60 * 24 }) - } catch (error) { - console.error('[dates] git dates lookup failed', error) - } - - return dates -} diff --git a/server/utils/local.ts b/server/utils/local.ts index bc0f4bd..1b7b43f 100644 --- a/server/utils/local.ts +++ b/server/utils/local.ts @@ -77,31 +77,6 @@ export function gitLocalSource(ref: string, dir: string): Source { } } -/** - * repoPath → committer date of the last commit touching it, for every file under `dir`, in one `git log` - * pass (local equivalent of the aliased GraphQL query in `docs-pages.ts`). Newest commits come first, so - * the first date seen for a file is its last change. - */ -export async function gitLocalLastModified(dir: string): Promise> { - const dates = new Map() - try { - const { stdout } = await exec('git', ['log', '--format=%x1e%cI', '--name-only', 'HEAD', '--', dir], { - cwd: repoRoot(), - maxBuffer: MAX_BUFFER, - }) - for (const block of stdout.split('\x1E')) { - const [date, ...files] = block.split('\n').map((line) => line.trim()).filter(Boolean) - if (!date) continue - for (const file of files) { - if (!dates.has(file)) dates.set(file, date) - } - } - } catch (error) { - console.error(`[dates] git log failed for ${dir}`, error) - } - return dates -} - /** The commits that touched a repo file, newest first (local equivalent of the GitHub commits API). */ export async function gitLocalFileHistory(repoPath: string, limit = 5, rev = 'HEAD'): Promise { try { diff --git a/test/pages.test.ts b/test/pages.test.ts deleted file mode 100644 index 076b8fc..0000000 --- a/test/pages.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { NavigationItem } from 'comark-content' -import { collectDocsPages } from '../utils/pages' - -const navigation = [ - { title: 'Overview', path: '/overview', description: 'Top-level page' }, - { - title: 'Getting started', - path: '/getting-started', - page: false, - children: [ - { title: 'Introduction', path: '/getting-started/introduction', description: 'Start here' }, - { title: 'Installation', path: '/getting-started/installation' }, - ], - }, - // A directory `index.md`: comark-content emits it as the section node *and* as its own first child. - { - title: 'Examples', - path: '/examples', - children: [ - { title: 'Examples', path: '/examples', description: 'All examples' }, - { title: 'Vue', path: '/examples/vue' }, - ], - }, - // A section whose `index.md` sets `navigation: false`: the node is not a page of its own. - { - title: 'Plugins', - path: '/plugins', - page: false, - children: [{ title: 'Emoji', path: '/plugins/emoji' }], - }, -] as unknown as NavigationItem[] - -describe('collectDocsPages', () => { - it('lists every page in sidebar order with its section', () => { - expect(collectDocsPages(navigation)).toEqual([ - { path: '/overview', title: 'Overview', description: 'Top-level page', section: undefined }, - { path: '/getting-started/introduction', title: 'Introduction', description: 'Start here', section: 'Getting started' }, - { path: '/getting-started/installation', title: 'Installation', description: undefined, section: 'Getting started' }, - { path: '/examples', title: 'Examples', description: 'All examples', section: 'Examples' }, - { path: '/examples/vue', title: 'Vue', description: undefined, section: 'Examples' }, - { path: '/plugins/emoji', title: 'Emoji', description: undefined, section: 'Plugins' }, - ]) - }) - - it('lists a directory index once, keeping the description', () => { - const examples = collectDocsPages(navigation).filter((page) => page.path === '/examples') - expect(examples).toEqual([{ path: '/examples', title: 'Examples', description: 'All examples', section: 'Examples' }]) - }) - - it('puts a page hidden from the sidebar first in its section', () => { - const pages = collectDocsPages(navigation, [{ path: '/plugins', title: 'Plugins', description: 'Extend it' }]) - expect(pages.map((page) => page.path)).toEqual([ - '/overview', - '/getting-started/introduction', - '/getting-started/installation', - '/examples', - '/examples/vue', - '/plugins', - '/plugins/emoji', - ]) - expect(pages.find((page) => page.path === '/plugins')).toEqual({ - path: '/plugins', - title: 'Plugins', - description: 'Extend it', - section: 'Plugins', - }) - }) - - it('appends a hidden page outside every section as a top-level page', () => { - const pages = collectDocsPages(navigation, [{ path: '/legal', description: 'Terms' }]) - expect(pages.at(-1)).toEqual({ path: '/legal', title: 'legal', description: 'Terms', section: undefined }) - }) - - it('skips the homepage and hidden pages already in the navigation', () => { - const pages = collectDocsPages(navigation, [ - { path: '/', title: 'Home' }, - { path: '/examples/vue', title: 'Duplicate' }, - ]) - expect(pages).toHaveLength(6) - expect(pages.find((page) => page.path === '/examples/vue')?.title).toBe('Vue') - }) - - it('matches a section on whole path segments only', () => { - const pages = collectDocsPages(navigation, [{ path: '/plugins-legacy', title: 'Legacy' }]) - expect(pages.at(-1)?.section).toBeUndefined() - }) -}) diff --git a/utils/pages.ts b/utils/pages.ts deleted file mode 100644 index a5b35db..0000000 --- a/utils/pages.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { NavigationItem } from 'comark-content' - -/** One public page, as the site indexes (sitemaps, `llms.txt`, RSS) list it. */ -export interface DocsPage { - path: string - title: string - description?: string - /** Title of the top-level navigation node the page sits under; none for a top-level page. */ - section?: string -} - -/** A document outside the navigation tree (`navigation: false`), as `content.list()` returns it. */ -export interface HiddenDocument { - path: string - title?: string - description?: string -} - -/** - * Every page of the site, in sidebar order and once each. - * - * - A directory `index.md` is emitted both as its section node and as that node's first child, so the - * same path comes up twice. The entry carrying a description wins. - * - A document hidden from the sidebar with `navigation: false` (a section landing such as `/plugins`) - * is still a public page: it goes first in the section its path falls under, or last when it falls - * under none. The homepage (`/`) is left to callers, which each list it their own way. - */ -export function collectDocsPages(navigation: NavigationItem[], hidden: HiddenDocument[] = []): DocsPage[] { - const pages: DocsPage[] = [] - const byPath = new Map() - - const add = (page: DocsPage, index = pages.length) => { - const existing = byPath.get(page.path) - if (existing) { - if (!existing.description && page.description) existing.description = page.description - return - } - byPath.set(page.path, page) - pages.splice(index, 0, page) - } - - const collect = (items: NavigationItem[], section?: string) => { - for (const item of items) { - const group = item.children?.length ? (section ?? item.title) : section - if (item.page !== false && item.path && item.path !== '/') { - add({ path: item.path, title: item.title, description: item.description, section: group }) - } - if (item.children?.length) collect(item.children, group) - } - } - collect(navigation) - - for (const doc of hidden) { - if (!doc.path || doc.path === '/' || byPath.has(doc.path)) continue - const parent = navigation.find( - (item) => item.children?.length && item.path && (doc.path === item.path || doc.path.startsWith(`${item.path}/`)) - ) - const title = doc.title || doc.path.split('/').filter(Boolean).pop() || doc.path - const page: DocsPage = { path: doc.path, title, description: doc.description, section: parent?.title } - const first = parent ? pages.findIndex((entry) => entry.section === parent.title) : -1 - add(page, first === -1 ? pages.length : first) - } - - return pages -} From 5f15eaf90776447e0be2b6490ab3f60a66c9c8d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 7 Oct 2026 14:13:03 +0200 Subject: [PATCH 3/3] revert(seo): drop FAQPage data on docs pages Google no longer shows FAQ rich results, and the answers are already in the page HTML. --- app/pages/[...slug].vue | 10 --- app/utils/faq.ts | 51 --------------- playground/content/2.writing/3.components.md | 2 - test/faq.test.ts | 66 -------------------- 4 files changed, 129 deletions(-) delete mode 100644 app/utils/faq.ts delete mode 100644 test/faq.test.ts diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue index 9bd54cc..d9b55e3 100644 --- a/app/pages/[...slug].vue +++ b/app/pages/[...slug].vue @@ -187,10 +187,6 @@ if (content.value.mode === 'prod') { const breadcrumb = computed(() => findBreadcrumb(navigation?.value, selfPath.value)) - // A "FAQ" section built from an accordion doubles as FAQPage data. The page node is retyped rather - // than a second one emitted, the same as LandingFaq, so the questions hang off the existing `WebPage`. - const faq = extractFaq(page.value?.nodes) - // `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([ @@ -200,12 +196,6 @@ if (content.value.mode === 'prod') { description: () => fm.value.description, inLanguage: 'en', }), - ...(faq.length - ? [ - defineWebPage({ '@type': 'FAQPage' }), - ...faq.map((entry) => defineQuestion({ name: entry.question, acceptedAnswer: entry.answer })), - ] - : []), defineBreadcrumb({ // Google requires `item` on every ListItem. Non-page section nodes // (page: false) only have a title — omit them from structured data. diff --git a/app/utils/faq.ts b/app/utils/faq.ts deleted file mode 100644 index 7a43230..0000000 --- a/app/utils/faq.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Node } from 'comark' - -export interface FaqEntry { - question: string - answer: string -} - -const FAQ_HEADING = /^(?:faqs?|frequently asked questions)$/i - -/** - * The questions of a page's FAQ, for FAQPage structured data: every `accordion-item` of an `accordion` - * placed under a top-level heading named "FAQ" (or "Frequently asked questions"), up to the next heading - * of the same level. The item `label` is the question and its text content the answer. - */ -export function extractFaq(nodes: Node[] | undefined): FaqEntry[] { - const entries: FaqEntry[] = [] - let faqLevel = 0 - - for (const node of nodes ?? []) { - if (!Array.isArray(node) || typeof node[0] !== 'string') continue - const tag = node[0] - - const level = /^h([1-6])$/.exec(tag)?.[1] - if (level) { - const depth = Number(level) - if (FAQ_HEADING.test(plainText(node).trim())) faqLevel = depth - else if (faqLevel && depth <= faqLevel) faqLevel = 0 - continue - } - - if (!faqLevel || tag !== 'accordion') continue - for (const item of node.slice(2) as Node[]) { - if (!Array.isArray(item) || item[0] !== 'accordion-item') continue - const question = typeof item[1]?.label === 'string' ? item[1].label.trim() : '' - const answer = (item.slice(2) as Node[]).map(plainText).join('').replace(/\s+/g, ' ').trim() - if (question && answer) entries.push({ question, answer }) - } - } - - return entries -} - -const BLOCK_TAGS = new Set(['p', 'li', 'ul', 'ol', 'pre', 'blockquote', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']) - -/** Text of a node, without comments. Block elements are padded so their text does not run together. */ -function plainText(node: Node): string { - if (typeof node === 'string') return node - if (node[0] === null) return '' - const text = (node.slice(2) as Node[]).map(plainText).join('') - return BLOCK_TAGS.has(node[0]) ? ` ${text} ` : text -} diff --git a/playground/content/2.writing/3.components.md b/playground/content/2.writing/3.components.md index b83bda0..aa4d267 100644 --- a/playground/content/2.writing/3.components.md +++ b/playground/content/2.writing/3.components.md @@ -127,8 +127,6 @@ This is the third step. Other useful ones: `::tabs` with `:::tabs-item{label="..."}` children, `::collapsible`, `::accordion`, `::code-preview` (rendered output next to its source), and `::code-collapse`. See the [Nuxt UI typography docs](https://ui.nuxt.com/docs/typography) for the full list and props. -An `::accordion` under a `## FAQ` heading (or "Frequently asked questions") also emits [FAQPage JSON-LD](https://developers.google.com/search/docs/appearance/structured-data/faqpage): each item `label` is a question and its text is the answer. - ## Nuxt UI components Any Nuxt UI component works with the `u-` prefix. The [landing page](/writing/landing-page) uses this for its hero: diff --git a/test/faq.test.ts b/test/faq.test.ts deleted file mode 100644 index d57936b..0000000 --- a/test/faq.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { Node } from 'comark' -import { extractFaq } from '../app/utils/faq' - -const accordion = (...items: Array<[string, ...Node[]]>): Node => - ['accordion', {}, ...items.map(([label, ...children]) => ['accordion-item', { label }, ...children] as Node)] as Node - -describe('extractFaq', () => { - it('reads the accordion under a FAQ heading', () => { - const nodes: Node[] = [ - ['h2', { id: 'intro' }, 'Intro'], - ['p', {}, 'Some text.'], - ['h2', { id: 'faq' }, 'FAQ'], - accordion( - ['Is it free?', ['p', {}, 'Yes. MIT ', ['strong', {}, 'licensed'], '.']], - ['Does it stream?', ['p', {}, 'Yes.'], ['p', {}, 'See ', ['code', {}, 'autoClose'], '.']] - ), - ] - expect(extractFaq(nodes)).toEqual([ - { question: 'Is it free?', answer: 'Yes. MIT licensed.' }, - { question: 'Does it stream?', answer: 'Yes. See autoClose.' }, - ]) - }) - - it('keeps inline children of an unwrapped answer together', () => { - const nodes: Node[] = [ - ['h2', {}, 'FAQ'], - accordion(['Drop-in?', 'No. The syntax differs (', ['code', {}, '::alert'], ' instead of ', ['code', {}, ''], ').']), - ] - expect(extractFaq(nodes)).toEqual([{ question: 'Drop-in?', answer: 'No. The syntax differs (::alert instead of ).' }]) - }) - - it('accepts "Frequently asked questions" in any case', () => { - const nodes: Node[] = [['h2', {}, 'Frequently Asked Questions'], accordion(['Q?', ['p', {}, 'A.']])] - expect(extractFaq(nodes)).toEqual([{ question: 'Q?', answer: 'A.' }]) - }) - - it('ignores accordions outside the FAQ section', () => { - const nodes: Node[] = [ - accordion(['Before?', ['p', {}, 'No.']]), - ['h2', {}, 'FAQ'], - ['h3', {}, 'General'], - accordion(['Inside?', ['p', {}, 'Yes.']]), - ['h2', {}, 'Next section'], - accordion(['After?', ['p', {}, 'No.']]), - ] - expect(extractFaq(nodes)).toEqual([{ question: 'Inside?', answer: 'Yes.' }]) - }) - - it('separates block elements and drops comments', () => { - const nodes: Node[] = [ - ['h2', {}, 'FAQ'], - accordion(['Which?', ['ul', {}, ['li', {}, 'One'], ['li', {}, 'Two']], [null, {}, ' hidden '] as unknown as Node]), - ] - expect(extractFaq(nodes)).toEqual([{ question: 'Which?', answer: 'One Two' }]) - }) - - it('skips items without a label or an answer', () => { - const nodes: Node[] = [ - ['h2', {}, 'FAQ'], - ['accordion', {}, ['accordion-item', {}, ['p', {}, 'No label']], ['accordion-item', { label: 'Empty?' }]] as Node, - ] - expect(extractFaq(nodes)).toEqual([]) - expect(extractFaq(undefined)).toEqual([]) - }) -})