From e44eb85f64efe01bd5753ea3b90a0cc147a1088f Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 09:44:49 +0000 Subject: [PATCH 01/12] refactor(content): share the repo image plugin beyond the CLI docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename `cliDocs(version)` to `repoImages(repo, branch)` so other sources read from their own repository can resolve root-relative images. Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- server/utils/content/plugins/index.ts | 6 +++--- .../content/plugins/{cli-docs.ts => repo-images.ts} | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) rename server/utils/content/plugins/{cli-docs.ts => repo-images.ts} (68%) diff --git a/server/utils/content/plugins/index.ts b/server/utils/content/plugins/index.ts index 8fc6f5611..6417ae4f6 100644 --- a/server/utils/content/plugins/index.ts +++ b/server/utils/content/plugins/index.ts @@ -7,10 +7,10 @@ import type { ContentPlugin } from 'comark-content' import { highlightPlugin } from './highlight' import { dottedFrontmatter } from './dotted-frontmatter' import { docsLinks } from './docs-links' -import { cliDocs } from './cli-docs' +import { repoImages } from './repo-images' import { configDocs } from './config-docs' import { sanitizeData } from './sanitize-data' -import type { CliDocVersion } from '../../../../shared/utils/cli' +import { CLI_DOCS_REFS, CLI_DOCS_REPO, type CliDocVersion } from '../../../../shared/utils/cli' import type { DocVersion } from '../../../../shared/utils/docs' import type { ContentInstanceKey } from '../../../../shared/utils/content' @@ -46,7 +46,7 @@ export function instancePlugins(key: ContentInstanceKey): ContentPlugin[] { if (key.startsWith('cli:')) { const version = key.slice('cli:'.length) as CliDocVersion // Command pages are written with the same unversioned `/docs/*` links as the docs. - return [dottedFrontmatter(), docsLinks(version), cliDocs(version)] + return [dottedFrontmatter(), docsLinks(version), repoImages(CLI_DOCS_REPO, CLI_DOCS_REFS[version].branch)] } const version = key.slice('docs:'.length) as DocVersion diff --git a/server/utils/content/plugins/cli-docs.ts b/server/utils/content/plugins/repo-images.ts similarity index 68% rename from server/utils/content/plugins/cli-docs.ts rename to server/utils/content/plugins/repo-images.ts index 6062db92d..599b53322 100644 --- a/server/utils/content/plugins/cli-docs.ts +++ b/server/utils/content/plugins/repo-images.ts @@ -1,18 +1,18 @@ import type { Node } from 'comark' import { visit } from 'comark/utils' import type { ContentPlugin } from 'comark-content' -import { CLI_DOCS_REFS, CLI_DOCS_REPO, type CliDocVersion } from '../../../../shared/utils/cli' const isImage = (node: Node): boolean => Array.isArray(node) && node[0] === 'img' /** - * Resolve the `nuxt/cli` command reference's image sources. + * Resolve root-relative image sources against the repository a source reads + * (the `nuxt/cli` command reference, a module's docs). */ -export function cliDocs(version: CliDocVersion): ContentPlugin { +export function repoImages(repo: string, branch: string): ContentPlugin { return { - name: 'nuxt-cli-docs', + name: 'repo-images', setup(content) { - const rawBase = `https://raw.githubusercontent.com/${CLI_DOCS_REPO}/${CLI_DOCS_REFS[version].branch}` + const rawBase = `https://raw.githubusercontent.com/${repo}/${branch}` content.hooks.hook('file:parsed', ({ file }) => { if (!file?.nodes?.length) return From 38354d9751af3dcfa6620f8755139b22491e1d59 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 09:44:50 +0000 Subject: [PATCH 02/12] feat(modules): host module docs under their module page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modules with a docs source configured in `shared/utils/module-docs.ts` get their own content instance (`module:`), mounted at `/modules/`. The module page renders the docs `index.md` instead of the README, with a docs navigation aside (drawers on mobile), and serves every docs page at `/modules//...`. Starts with @nuxt/icon (nuxt/icon#466). Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- .env.example | 2 + .../{[slug].vue => [slug]/[...path].vue} | 221 ++++++++++++++++-- server/utils/content/instances.ts | 33 ++- server/utils/content/plugins/index.ts | 5 + shared/utils/content.ts | 15 +- shared/utils/module-docs.ts | 25 ++ shared/utils/pull.ts | 6 +- test/unit/pull-paths.spec.ts | 9 +- 8 files changed, 289 insertions(+), 27 deletions(-) rename app/pages/modules/{[slug].vue => [slug]/[...path].vue} (56%) create mode 100644 shared/utils/module-docs.ts diff --git a/.env.example b/.env.example index b1fce0684..69ab884ab 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,8 @@ NUXT_V5_PATH= NUXT_EXAMPLES_PATH= # Path to a local clone of github.com/nuxt/cli (shared by all doc versions' command reference) NUXT_CLI_PATH= +# Path to a local clone of github.com/nuxt/icon (docs hosted under /modules/icon) +NUXT_ICON_PATH= # ---------------------------------------------------------------------------- # Content revalidation webhook (optional — only needed to test the GitHub diff --git a/app/pages/modules/[slug].vue b/app/pages/modules/[slug]/[...path].vue similarity index 56% rename from app/pages/modules/[slug].vue rename to app/pages/modules/[slug]/[...path].vue index e390e20b3..48e79baa2 100644 --- a/app/pages/modules/[slug].vue +++ b/app/pages/modules/[slug]/[...path].vue @@ -1,10 +1,14 @@ @@ -136,7 +219,10 @@ if (import.meta.server) { -
+
@@ -227,17 +314,94 @@ if (import.meta.server) {
- +
+
+ + + + + + + + +
+ + + -
+

Supported providers @@ -268,8 +432,33 @@ if (import.meta.server) {

+ + From 32b500cbd986132a4bb6990aff75ed5f188ce2bd Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 11:06:40 +0000 Subject: [PATCH 04/12] feat(modules): SEO for module docs pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OG image: the docs trail as headline (`@nuxt/icon > Guide`), the page title as title - TechArticle and BreadcrumbList structured data, like `/docs` pages - List module docs pages in the sitemap Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- app/pages/modules/[slug]/[...path].vue | 23 ++++++++++++++++++++--- server/api/__sitemap__/urls.get.ts | 9 ++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/pages/modules/[slug]/[...path].vue b/app/pages/modules/[slug]/[...path].vue index f3de9677f..9638190ca 100644 --- a/app/pages/modules/[slug]/[...path].vue +++ b/app/pages/modules/[slug]/[...path].vue @@ -82,7 +82,7 @@ const breadcrumb = computed(() => { return { full: [ { label: 'Modules', to: '/modules' }, - { to: { name: 'modules', query: { category: module.value!.category } }, label: module.value!.category }, + { to: `/modules?category=${encodeURIComponent(module.value!.category)}`, label: module.value!.category }, moduleLink, ...trail ], @@ -193,12 +193,29 @@ if (import.meta.server) { ogTitle: `${title.value} · Nuxt Modules` }) + // A docs page reads like `/docs` ones: the trail above the page title, e.g. `@nuxt/icon > Guide` + `Server Bundle`. defineOgImage('Module.takumi', { icon: module.value?.icon, - headline: 'Nuxt Modules', - title: title.value, + headline: isDocsPage.value ? breadcrumb.value.compact.slice(0, -1).map(item => item.label).join(' > ') : 'Nuxt Modules', + title: isDocsPage.value ? docsFm.value.title : module.value!.npm, description: description.value }) + + if (isDocsPage.value) { + useSchemaOrg([ + defineArticle({ + '@type': 'TechArticle', + 'headline': docsFm.value.title, + 'description': description.value + }), + defineBreadcrumb({ + // Sections without a page of their own (`Guide`) have no URL to point at. + itemListElement: breadcrumb.value.full + .filter((item, index, all) => item.to || index === all.length - 1) + .map(item => ({ name: item.label, item: item.to })) + }) + ]) + } } diff --git a/server/api/__sitemap__/urls.get.ts b/server/api/__sitemap__/urls.get.ts index bb8768539..be946c4cf 100644 --- a/server/api/__sitemap__/urls.get.ts +++ b/server/api/__sitemap__/urls.get.ts @@ -1,13 +1,15 @@ -import { cliInstanceKey, docsInstanceKey } from '#shared/utils/content' +import { cliInstanceKey, docsInstanceKey, moduleInstanceKey } from '#shared/utils/content' import { CURRENT_DOCS_VERSION } from '#shared/utils/docs' +import { MODULE_DOCS_NAMES } from '#shared/utils/module-docs' // Sitemap source for @nuxtjs/sitemap (see `sitemap.sources` in nuxt.config). export default defineEventHandler(async () => { - const [docs, cli, examples, siteContent] = await Promise.all([ + const [docs, cli, examples, siteContent, moduleDocs] = await Promise.all([ listInstancePages(docsInstanceKey(CURRENT_DOCS_VERSION)), listInstancePages(cliInstanceKey(CURRENT_DOCS_VERSION)).catch(() => []), listInstancePages('examples').catch(() => []), - getInstanceAtHead('site') + getInstanceAtHead('site'), + Promise.all(MODULE_DOCS_NAMES.map(name => listInstancePages(moduleInstanceKey(name)).catch(() => []))) ]) // The site instance holds markdown pages and the yml-backed landing pages @@ -28,6 +30,7 @@ export default defineEventHandler(async () => { ...docs.map(page => ({ loc: page.path })), ...cli.map(page => ({ loc: page.path })), ...examples.map(page => ({ loc: page.path })), + ...moduleDocs.flat().map(page => ({ loc: page.path })), ...site ] }) From 4c719b25588050ae1cf9d263f05f12dbaf8cc4cc Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 11:27:52 +0000 Subject: [PATCH 05/12] fix(modules): module docs page crash, data fetching and bundle split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass the docs page `useAsyncData` key as a `computed`: the build's async-data handler extraction took the getter key for a handler, so production pages failed to hydrate with NUXT_E3008 - Fetch the module, the docs navigation and the docs page in parallel, the navigation once per module - Skip the README in `/api/v1/modules/` for modules with docs: it was shipped in every docs page payload (~140 KB) - Lazy-load the docs UI through `ModuleDocs*` components rather than lazy library components, which split shared chunks on every page - Keep a single h1 on docs pages: the page title, not the module name Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- app/components/module/ModuleDocsAside.vue | 18 ++ app/components/module/ModuleDocsMobileBar.vue | 81 +++++++++ app/components/module/ModuleDocsPage.vue | 43 +++++ app/pages/modules/[slug]/[...path].vue | 172 ++++-------------- server/api/v1/modules/[name].get.ts | 7 +- shared/utils/module-docs.ts | 1 + 6 files changed, 187 insertions(+), 135 deletions(-) create mode 100644 app/components/module/ModuleDocsAside.vue create mode 100644 app/components/module/ModuleDocsMobileBar.vue create mode 100644 app/components/module/ModuleDocsPage.vue diff --git a/app/components/module/ModuleDocsAside.vue b/app/components/module/ModuleDocsAside.vue new file mode 100644 index 000000000..7181c5e66 --- /dev/null +++ b/app/components/module/ModuleDocsAside.vue @@ -0,0 +1,18 @@ + + + diff --git a/app/components/module/ModuleDocsMobileBar.vue b/app/components/module/ModuleDocsMobileBar.vue new file mode 100644 index 000000000..54d5bf9de --- /dev/null +++ b/app/components/module/ModuleDocsMobileBar.vue @@ -0,0 +1,81 @@ + + + diff --git a/app/components/module/ModuleDocsPage.vue b/app/components/module/ModuleDocsPage.vue new file mode 100644 index 000000000..fbb2754a9 --- /dev/null +++ b/app/components/module/ModuleDocsPage.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/pages/modules/[slug]/[...path].vue b/app/pages/modules/[slug]/[...path].vue index 9638190ca..b72de709a 100644 --- a/app/pages/modules/[slug]/[...path].vue +++ b/app/pages/modules/[slug]/[...path].vue @@ -3,7 +3,7 @@ import type { Module } from '#shared/types' import { supportsNuxt3OrLater } from '#shared/utils/modules' import { MODULE_DOCS, hasModuleDocs, moduleDocsPathPrefix } from '#shared/utils/module-docs' import { moduleInstanceKey } from '#shared/utils/content' -import { LazyDocsProseImg, ModuleProseA, ModuleProseKbd, ModuleProseImg } from '#components' +import { ModuleProseA, ModuleProseKbd, ModuleProseImg } from '#components' definePageMeta({ heroBackground: 'opacity-30 -z-10', @@ -18,48 +18,40 @@ const markdownComponents = { ProseKbd: ModuleProseKbd } -// Hosted docs are authored for nuxt.com, so their links and images need no README rewriting. -// Docs-only components are `Lazy*` throughout: modules without docs never load them. -const docsMarkdownComponents = { - ...proseComponents, - ProseImg: LazyDocsProseImg -} - const route = useRoute() const { isAgentDocked } = useNuxtAgent() -const { data: module } = await useFetch(`/api/v1/modules/${route.params.slug}`, { key: `module-${route.params.slug}` }) -if (!module.value) { - throw createError({ statusCode: 404, statusMessage: 'Module not found', fatal: true }) -} - -// Docs navigation and pages only exist for modules with a configured docs source. -const docsName = hasModuleDocs(module.value.name) ? module.value.name : undefined +// The slug is the module's registry name, so the docs can be fetched alongside the module. +const slug = route.params.slug as string +const docsName = hasModuleDocs(slug) ? slug : undefined const docsRoot = docsName && moduleDocsPathPrefix(docsName) const docsContent = docsName && useContent(moduleInstanceKey(docsName)) const pagePath = computed(() => route.path.replace(/\/$/, '')) /** A docs page below the module page, as opposed to the module page itself. */ const isDocsPage = computed(() => !!docsRoot && pagePath.value !== docsRoot) - -const { data: docsNavigation } = await useAsyncData(`module-docs-nav-${route.params.slug}`, async () => { - if (!docsContent || !docsRoot) return [] - const tree = await docsContent.navigation() - const children = navPageFromPath(docsRoot, tree)?.children ?? [] - // The docs `index.md` is the module page; without one, the README stands in for it. - const items = children.some(item => item.path === docsRoot) ? children : [{ title: 'Overview', path: docsRoot }, ...children] - // Every docs path starts with the module page's, so only an exact match makes it active. - return items.map(item => item.path === docsRoot ? { ...item, exact: true } : item) -}, { default: () => [] }) - -const { data: docsPage, status: docsStatus } = await useAsyncData( - () => `module-docs-page-${pagePath.value}`, - () => docsContent ? docsContent.get(pagePath.value) : Promise.resolve(null) -) +// A `computed` key, not a getter: the build's async-data handler extraction mistakes a getter key for a handler. +const docsPageKey = computed(() => `module-docs-page-${pagePath.value}`) + +const [{ data: module }, { data: docsNavigation }, { data: docsPage, status: docsStatus }] = await Promise.all([ + useFetch(`/api/v1/modules/${slug}`, { key: `module-${slug}` }), + // Fetched once per module: the page instance is kept across its docs pages (`key` above). + useAsyncData(`module-docs-nav-${slug}`, async () => { + if (!docsContent || !docsRoot) return [] + const tree = await docsContent.navigation() + // Every docs path starts with the module page's (the docs `index.md`), so only an exact match makes it active. + return (navPageFromPath(docsRoot, tree)?.children ?? []) + .map(item => item.path === docsRoot ? { ...item, exact: true } : item) + }, { default: () => [] }), + useAsyncData(docsPageKey, () => docsContent ? docsContent.get(pagePath.value) : Promise.resolve(null)) +]) +if (!module.value) { + throw createError({ statusCode: 404, statusMessage: 'Module not found', fatal: true }) +} watch([pagePath, docsStatus], () => { if (docsStatus.value === 'pending') return // `/modules/` always renders (docs index or README); any other path must be a docs page. - if (pagePath.value !== `/modules/${route.params.slug}` && !(isDocsPage.value && docsPage.value)) { + if (pagePath.value !== `/modules/${slug}` && !(isDocsPage.value && docsPage.value)) { throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }) } }, { immediate: true }) @@ -91,14 +83,6 @@ const breadcrumb = computed(() => { } }) const surround = computed(() => findSurroundLinks(docsNavigation.value, pagePath.value)) - -// Below `lg` the asides collapse into drawers, as on `/docs`. -const menuDrawerOpen = ref(false) -const onThisPageDrawerOpen = ref(false) -watch(pagePath, () => { - menuDrawerOpen.value = false - onThisPageDrawerOpen.value = false -}) const docsEditLink = computed(() => { const meta = docsPage.value?.meta if (!docsName || !meta) return '' @@ -234,7 +218,9 @@ if (import.meta.server) { + -
+ - - - - - - - - -
+ :title="module.npm" + :navigation="docsNavigation" + :toc-links="tocLinks" + /> @@ -450,31 +374,13 @@ if (import.meta.server) {
- - + { @@ -19,10 +20,12 @@ export default defineCachedEventHandler(async (event) => { // Health served separately (/api/v1/modules/health), merged client-side — // keeps nuxt.care off this page's SSR path. + // A module with hosted docs renders them instead of its README: skip it, it would ship in every docs page payload. + const withReadme = !hasModuleDocs(module.name) const [stats, contributors, readme] = await Promise.all([ fetchModuleStats(event, module), fetchModuleContributors(event, module), - fetchModuleReadme(event, module) + withReadme ? fetchModuleReadme(event, module) : undefined ]) // Who has a Nuxter profile, to link there instead of GitHub. Not blocking: GitHub links on an error. const known = await nuxters.known({ @@ -39,7 +42,7 @@ export default defineCachedEventHandler(async (event) => { generatedAt: new Date().toISOString(), contributors: contributors.map(contributor => ({ ...contributor, nuxter: known.githubIds.has(contributor.id) })), stats, - readme: resolveReadmeUrls(readme, module.repo, stats.defaultBranch) + readme: readme && resolveReadmeUrls(readme, module.repo, stats.defaultBranch) } satisfies Module }, { // Bumped when the payload changes: `modules:v4` entries have no `providers`. diff --git a/shared/utils/module-docs.ts b/shared/utils/module-docs.ts index 559b6ba3d..7aa39ccc2 100644 --- a/shared/utils/module-docs.ts +++ b/shared/utils/module-docs.ts @@ -3,6 +3,7 @@ * * Keyed by the module's `name` in the `nuxt/modules` registry. A module without an entry keeps * the README-only page: the docs navigation only exists when a source is configured here. + * The source's `index.md` replaces the README on the module page, so it must have one. */ export const MODULE_DOCS = { // TODO: back to `main` once nuxt/icon's `docs` branch is merged. From 26abbfa4fb21a9ca1a9ea53098dc84195ae8f313 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 11:27:52 +0000 Subject: [PATCH 06/12] feat(modules): markdown twins of module docs for agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module docs pages negotiate markdown and link their `/raw/**.md` twin like `/docs` pages, and are listed in `/sitemap.md`, `/llms.txt` and `/llms-full.txt`. Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- app/pages/modules/[slug]/[...path].vue | 3 ++- nuxt.config.ts | 8 ++++++++ server/utils/agent-source.ts | 19 +++++++++++++++---- shared/utils/module-docs.ts | 11 +++++++++-- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/app/pages/modules/[slug]/[...path].vue b/app/pages/modules/[slug]/[...path].vue index b72de709a..68886afd1 100644 --- a/app/pages/modules/[slug]/[...path].vue +++ b/app/pages/modules/[slug]/[...path].vue @@ -169,7 +169,8 @@ useSeoMeta({ title, description }) -useCanonical() +// Hosted docs have a markdown twin for agents, like `/docs` pages. +useCanonical(() => docsName ? `/raw${pagePath.value}.md` : undefined) if (import.meta.server) { useSeoMeta({ diff --git a/nuxt.config.ts b/nuxt.config.ts index 95708eeb9..9765700d9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,6 +2,7 @@ import { createResolver } from 'nuxt/kit' import type { LLMsSection } from 'nuxt-llms' import { agentHowToCall, agentWhenToUse } from './shared/utils/agents' import { ARCHIVED_DOC_VERSIONS, CURRENT_DOCS_VERSION, NEXT_DOC_VERSIONS } from './shared/utils/docs' +import { MODULE_DOCS, MODULE_DOCS_NAMES, moduleDocsPathPrefix } from './shared/utils/module-docs' const { resolve } = createResolver(import.meta.url) @@ -612,6 +613,8 @@ export default defineNuxtConfig({ '/blog/**', '/deploy/**', { path: '/modules', raw: '/raw/modules.md' }, + // Hosted module docs: the module page is the docs `index.md`, so it negotiates too. + ...MODULE_DOCS_NAMES.flatMap(name => [moduleDocsPathPrefix(name), `${moduleDocsPathPrefix(name)}/**`]), { path: '/changelog', raw: '/raw/changelog.md' }, { path: '/updates', raw: '/raw/updates.md' } ], @@ -775,6 +778,11 @@ export default defineNuxtConfig({ description: 'Release announcements and deep dives', navigation: '/blog' }, + ...MODULE_DOCS_NAMES.map(name => ({ + title: `${MODULE_DOCS[name].title} Documentation`, + description: 'Documentation of an official Nuxt module', + navigation: moduleDocsPathPrefix(name) + })), { title: 'Optional', links: [ diff --git a/server/utils/agent-source.ts b/server/utils/agent-source.ts index 1c6101060..a6d920c8c 100644 --- a/server/utils/agent-source.ts +++ b/server/utils/agent-source.ts @@ -1,12 +1,14 @@ import type { ComarkContent, NavigationItem } from 'comark-content' import { createComarkSource } from '#agent-discovery/comark' import { AGENT_DOC_VERSIONS } from '#shared/utils/docs' +import { moduleInstanceKey } from '#shared/utils/content' +import { MODULE_DOCS_NAMES, moduleDocsPathPrefix } from '#shared/utils/module-docs' /** * Content source for nuxt-agent-discovery, bridging the comark instances. * * The module's comark adapter works on one content surface; nuxt.com serves - * eight instances (see `server/utils/content/instances.ts`). This facade + * several instances (see `server/utils/content/instances.ts`). This facade * routes `get()` to the instance owning the path and merges every instance's * navigation for `list()`/`firstLeaf()` (the cli reference grafted into its * docs version through the same `docTree()` the UI navigation uses), so the @@ -23,11 +25,19 @@ function prune(items: NavigationItem[]): NavigationItem[] { const facade = { async navigation() { - const [site, examplesContent, versions] = await Promise.all([ + const [site, examplesContent, versions, moduleDocs] = await Promise.all([ getInstanceAtHead('site').then(content => content.navigation()), getInstanceAtHead('examples').catch(() => null), // Examples are left out of the version trees - Promise.all(AGENT_DOC_VERSIONS.map(version => docTree(version, { withExamples: false }).catch(() => [] as NavigationItem[]))) + Promise.all(AGENT_DOC_VERSIONS.map(version => docTree(version, { withExamples: false }).catch(() => [] as NavigationItem[]))), + // Each module's docs, rooted at its module page + Promise.all(MODULE_DOCS_NAMES.map(name => getInstanceAtHead(moduleInstanceKey(name)) + .then(async (content) => { + const root = findByPath(await content.navigation(), moduleDocsPathPrefix(name)) + // The root already is the module page (`index.md`): drop its duplicate `Overview` child. + return root && { ...root, children: root.children?.filter(item => item.path !== root.path) } + }) + .catch(() => undefined))) ]) const examples = examplesContent ? findByPath(await examplesContent.navigation(), '/docs/examples') : undefined @@ -35,7 +45,8 @@ const facade = { return [ ...prune(site), ...versions.flat(), - ...(examples ? [examples] : []) + ...(examples ? [examples] : []), + ...moduleDocs.filter(item => !!item) ] }, async get(path: string) { diff --git a/shared/utils/module-docs.ts b/shared/utils/module-docs.ts index 7aa39ccc2..c846219b2 100644 --- a/shared/utils/module-docs.ts +++ b/shared/utils/module-docs.ts @@ -7,8 +7,15 @@ */ export const MODULE_DOCS = { // TODO: back to `main` once nuxt/icon's `docs` branch is merged. - icon: { repo: 'nuxt/icon', branch: 'docs', contentDir: 'docs', envOverride: 'NUXT_ICON_PATH' } -} as const satisfies Record + icon: { title: 'Nuxt Icon', repo: 'nuxt/icon', branch: 'docs', contentDir: 'docs', envOverride: 'NUXT_ICON_PATH' } +} as const satisfies Record /** Modules with hosted docs. */ export type ModuleDocsName = keyof typeof MODULE_DOCS From cfc010f88f561f3ac4885132563ec067ff33fc89 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 11:32:23 +0000 Subject: [PATCH 07/12] fix(modules): bump the module page cache for the README-less payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `modules:v5` entries still carry the README of modules with docs. Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- server/api/v1/modules/[name].get.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/v1/modules/[name].get.ts b/server/api/v1/modules/[name].get.ts index f1d37fe01..635addd9a 100644 --- a/server/api/v1/modules/[name].get.ts +++ b/server/api/v1/modules/[name].get.ts @@ -45,8 +45,8 @@ export default defineCachedEventHandler(async (event) => { readme: readme && resolveReadmeUrls(readme, module.repo, stats.defaultBranch) } satisfies Module }, { - // Bumped when the payload changes: `modules:v4` entries have no `providers`. - name: 'modules:v5', + // Bumped when the payload changes: `modules:v5` entries carry the README of modules with docs. + name: 'modules:v6', getKey: event => event.context.params!.name!, swr: true, maxAge: 60 * 60 // 1 hour From d7426cf3eb115ff762e9750d04564e21a766a774 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 15:29:55 +0000 Subject: [PATCH 08/12] feat(modules): keep the module header on the module page only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs pages open on their breadcrumb, where the module icon marks the module, so moving between them does not land below a tall header. Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- app/pages/modules/[slug]/[...path].vue | 29 ++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/app/pages/modules/[slug]/[...path].vue b/app/pages/modules/[slug]/[...path].vue index 68886afd1..45afe2945 100644 --- a/app/pages/modules/[slug]/[...path].vue +++ b/app/pages/modules/[slug]/[...path].vue @@ -62,7 +62,14 @@ const tocLinks = computed(() => docsPage.value : module.value?.readme?.meta?.toc?.links ?? []) const breadcrumb = computed(() => { - const moduleLink = { label: module.value!.npm, to: isDocsPage.value ? docsRoot : undefined } + // On docs pages the module header is gone: its icon marks the module in the breadcrumb instead. + const moduleLink = isDocsPage.value + ? { + label: module.value!.npm, + to: docsRoot, + avatar: { src: moduleImage(module.value!.icon), icon: moduleIcon(module.value!.category), alt: module.value!.name, class: 'rounded-none bg-transparent' } + } + : { label: module.value!.npm } // Sections without a page of their own (`Guide`) are not links, and the current page is the last item. const trail = isDocsPage.value ? findBreadcrumb(docsNavigation.value, pagePath.value).map((item, index, all) => ({ @@ -219,15 +226,16 @@ if (import.meta.server) { - + +
+
-
+
@@ -319,7 +326,7 @@ if (import.meta.server) {
- +
From e62224c2f643f8fdf7d35f6e5d1f8b63aee12395 Mon Sep 17 00:00:00 2001 From: Amp Date: Sun, 25 Oct 2026 15:43:15 +0000 Subject: [PATCH 09/12] feat(modules): put the docs menu first on mobile, as on /docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For modules with docs, the Menu / On this page bar sits right under the site header, and docs page titles follow their breadcrumb closely. Amp-Thread-ID: https://ampcode.com/threads/T-01a1721c-4512-7150-9905-efce9f4b7f68 Co-authored-by: Sébastien Chopin --- app/pages/modules/[slug]/[...path].vue | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/pages/modules/[slug]/[...path].vue b/app/pages/modules/[slug]/[...path].vue index 45afe2945..b30c6ee3d 100644 --- a/app/pages/modules/[slug]/[...path].vue +++ b/app/pages/modules/[slug]/[...path].vue @@ -226,6 +226,15 @@ if (import.meta.server) { + + +