From fc4388a5a3f4a58c48674a4b893240cf157181a0 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 7 Sep 2026 19:05:54 +0200 Subject: [PATCH 1/2] docs: source guides from a content-collection instead of copying them in A reviewer on #5495 asked why docs-sync/guides-sync materialize files into nuxt/content/docs by hand instead of using @nuxt/content's own collection primitives. Checked against the installed @nuxt/content (3.15.2, satisfies the ^3.13.0 this repo pins): a collection's `source` can be an array, each entry with its own `cwd`, and each is globbed and parsed independently at build time. That is enough for the guides authored in nuxt/content-guides/, since they are already MDC and already local: the `docs` collection now reads them straight from that directory as a second source (prefixed onto `docs/` so they land at the same paths), and the `content:file:beforeParse` hook that already existed for the blog collection now also stamps guide pages with their editUrl/updated frontmatter, in place of a copy step that used to write it into a duplicated file. guides-sync.mjs is what is left after that: copying the guides' non-markdown assets to public/docs (a content-collection source cannot do that), and a proactive collision check against FlowFuse/flowfuse's pages (a real collision now fails anyway, since the `docs` collection's `id` is a primary key, but as a SQL error rather than a message naming the file). The section-index README.md convention the guides borrowed from GitHub is renamed to index.md throughout, since @nuxt/content only special-cases the latter and this tree is ours to rename. docs-sync.mjs (the FlowFuse/flowfuse clone) is unchanged: its full-history clone can't become @nuxt/content's own git source, which only supports a shallow clone and would flatten every page's `updated` date to the sync commit - the reason the clone is not shallow already, predating this change. core-nodes-sync.mjs is unchanged for the same reason it was never a candidate: its input is an HTTP fetch, not files on disk, so it still writes into nuxt/content/docs like a build artifact, because it is one. Verified the source/prefix mechanics against @nuxt/content's own module code (resolveSource, defineLocalSource, the collection build loop) and with a standalone experiment reproducing the exact id/path computation for both sources - not just theoretically, but with real @nuxt/content code executed against toy directories, confirming the guides source resolves to the same /docs/... paths the old copy step produced. nuxt/lib/guides-sync.test.mjs is rewritten for the slimmed-down API. --- .../{README.md => index.md} | 0 .../architectures/{README.md => index.md} | 0 .../application-guide/{README.md => index.md} | 0 .../worked-examples/{README.md => index.md} | 0 .../flowfuse-nodes/ai/{README.md => index.md} | 0 .../edge/{README.md => index.md} | 0 .../flowfuse-tables/{README.md => index.md} | 0 .../hub/{README.md => index.md} | 0 .../flowfuse-nodes/{README.md => index.md} | 0 .../mcp/{README.md => index.md} | 0 .../mqtt/{README.md => index.md} | 0 .../node-red-guide/{README.md => index.md} | 0 .../patterns/{README.md => index.md} | 0 .../worked-examples/{README.md => index.md} | 0 .../node-red/database/{README.md => index.md} | 0 .../editor/{README.md => index.md} | 0 .../getting-started/{README.md => index.md} | 0 .../library/{README.md => index.md} | 0 .../programming/{README.md => index.md} | 0 .../node-red/hardware/{README.md => index.md} | 0 .../node-red/{README.md => index.md} | 0 .../{README.md => index.md} | 0 .../node-red/keyboard/{README.md => index.md} | 0 .../notification/{README.md => index.md} | 0 .../peripheral/{README.md => index.md} | 0 .../node-red/protocol/{README.md => index.md} | 0 .../terminology/{README.md => index.md} | 0 nuxt/content.config.ts | 18 +- nuxt/lib/guides-sync.mjs | 158 +++++++++--------- nuxt/lib/guides-sync.test.mjs | 140 ++++++++++++---- nuxt/modules/docs-source.ts | 31 ++-- nuxt/nuxt.config.ts | 22 ++- scripts/sync_docs.mjs | 16 +- scripts/watch_docs.mjs | 30 ++-- 34 files changed, 259 insertions(+), 156 deletions(-) rename nuxt/content-guides/application-guide/app-delivery-methods/{README.md => index.md} (100%) rename nuxt/content-guides/application-guide/architectures/{README.md => index.md} (100%) rename nuxt/content-guides/application-guide/{README.md => index.md} (100%) rename nuxt/content-guides/application-guide/worked-examples/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/ai/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/edge/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/flowfuse-tables/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/hub/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/mcp/{README.md => index.md} (100%) rename nuxt/content-guides/flowfuse-nodes/mqtt/{README.md => index.md} (100%) rename nuxt/content-guides/node-red-guide/{README.md => index.md} (100%) rename nuxt/content-guides/node-red-guide/patterns/{README.md => index.md} (100%) rename nuxt/content-guides/node-red-guide/worked-examples/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/database/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/getting-started/editor/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/getting-started/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/getting-started/library/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/getting-started/programming/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/hardware/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/integration-technologies/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/keyboard/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/notification/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/peripheral/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/protocol/{README.md => index.md} (100%) rename nuxt/content-guides/node-red/terminology/{README.md => index.md} (100%) diff --git a/nuxt/content-guides/application-guide/app-delivery-methods/README.md b/nuxt/content-guides/application-guide/app-delivery-methods/index.md similarity index 100% rename from nuxt/content-guides/application-guide/app-delivery-methods/README.md rename to nuxt/content-guides/application-guide/app-delivery-methods/index.md diff --git a/nuxt/content-guides/application-guide/architectures/README.md b/nuxt/content-guides/application-guide/architectures/index.md similarity index 100% rename from nuxt/content-guides/application-guide/architectures/README.md rename to nuxt/content-guides/application-guide/architectures/index.md diff --git a/nuxt/content-guides/application-guide/README.md b/nuxt/content-guides/application-guide/index.md similarity index 100% rename from nuxt/content-guides/application-guide/README.md rename to nuxt/content-guides/application-guide/index.md diff --git a/nuxt/content-guides/application-guide/worked-examples/README.md b/nuxt/content-guides/application-guide/worked-examples/index.md similarity index 100% rename from nuxt/content-guides/application-guide/worked-examples/README.md rename to nuxt/content-guides/application-guide/worked-examples/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/ai/README.md b/nuxt/content-guides/flowfuse-nodes/ai/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/ai/README.md rename to nuxt/content-guides/flowfuse-nodes/ai/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/edge/README.md b/nuxt/content-guides/flowfuse-nodes/edge/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/edge/README.md rename to nuxt/content-guides/flowfuse-nodes/edge/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/flowfuse-tables/README.md b/nuxt/content-guides/flowfuse-nodes/flowfuse-tables/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/flowfuse-tables/README.md rename to nuxt/content-guides/flowfuse-nodes/flowfuse-tables/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/hub/README.md b/nuxt/content-guides/flowfuse-nodes/hub/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/hub/README.md rename to nuxt/content-guides/flowfuse-nodes/hub/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/README.md b/nuxt/content-guides/flowfuse-nodes/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/README.md rename to nuxt/content-guides/flowfuse-nodes/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/mcp/README.md b/nuxt/content-guides/flowfuse-nodes/mcp/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/mcp/README.md rename to nuxt/content-guides/flowfuse-nodes/mcp/index.md diff --git a/nuxt/content-guides/flowfuse-nodes/mqtt/README.md b/nuxt/content-guides/flowfuse-nodes/mqtt/index.md similarity index 100% rename from nuxt/content-guides/flowfuse-nodes/mqtt/README.md rename to nuxt/content-guides/flowfuse-nodes/mqtt/index.md diff --git a/nuxt/content-guides/node-red-guide/README.md b/nuxt/content-guides/node-red-guide/index.md similarity index 100% rename from nuxt/content-guides/node-red-guide/README.md rename to nuxt/content-guides/node-red-guide/index.md diff --git a/nuxt/content-guides/node-red-guide/patterns/README.md b/nuxt/content-guides/node-red-guide/patterns/index.md similarity index 100% rename from nuxt/content-guides/node-red-guide/patterns/README.md rename to nuxt/content-guides/node-red-guide/patterns/index.md diff --git a/nuxt/content-guides/node-red-guide/worked-examples/README.md b/nuxt/content-guides/node-red-guide/worked-examples/index.md similarity index 100% rename from nuxt/content-guides/node-red-guide/worked-examples/README.md rename to nuxt/content-guides/node-red-guide/worked-examples/index.md diff --git a/nuxt/content-guides/node-red/database/README.md b/nuxt/content-guides/node-red/database/index.md similarity index 100% rename from nuxt/content-guides/node-red/database/README.md rename to nuxt/content-guides/node-red/database/index.md diff --git a/nuxt/content-guides/node-red/getting-started/editor/README.md b/nuxt/content-guides/node-red/getting-started/editor/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/editor/README.md rename to nuxt/content-guides/node-red/getting-started/editor/index.md diff --git a/nuxt/content-guides/node-red/getting-started/README.md b/nuxt/content-guides/node-red/getting-started/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/README.md rename to nuxt/content-guides/node-red/getting-started/index.md diff --git a/nuxt/content-guides/node-red/getting-started/library/README.md b/nuxt/content-guides/node-red/getting-started/library/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/library/README.md rename to nuxt/content-guides/node-red/getting-started/library/index.md diff --git a/nuxt/content-guides/node-red/getting-started/programming/README.md b/nuxt/content-guides/node-red/getting-started/programming/index.md similarity index 100% rename from nuxt/content-guides/node-red/getting-started/programming/README.md rename to nuxt/content-guides/node-red/getting-started/programming/index.md diff --git a/nuxt/content-guides/node-red/hardware/README.md b/nuxt/content-guides/node-red/hardware/index.md similarity index 100% rename from nuxt/content-guides/node-red/hardware/README.md rename to nuxt/content-guides/node-red/hardware/index.md diff --git a/nuxt/content-guides/node-red/README.md b/nuxt/content-guides/node-red/index.md similarity index 100% rename from nuxt/content-guides/node-red/README.md rename to nuxt/content-guides/node-red/index.md diff --git a/nuxt/content-guides/node-red/integration-technologies/README.md b/nuxt/content-guides/node-red/integration-technologies/index.md similarity index 100% rename from nuxt/content-guides/node-red/integration-technologies/README.md rename to nuxt/content-guides/node-red/integration-technologies/index.md diff --git a/nuxt/content-guides/node-red/keyboard/README.md b/nuxt/content-guides/node-red/keyboard/index.md similarity index 100% rename from nuxt/content-guides/node-red/keyboard/README.md rename to nuxt/content-guides/node-red/keyboard/index.md diff --git a/nuxt/content-guides/node-red/notification/README.md b/nuxt/content-guides/node-red/notification/index.md similarity index 100% rename from nuxt/content-guides/node-red/notification/README.md rename to nuxt/content-guides/node-red/notification/index.md diff --git a/nuxt/content-guides/node-red/peripheral/README.md b/nuxt/content-guides/node-red/peripheral/index.md similarity index 100% rename from nuxt/content-guides/node-red/peripheral/README.md rename to nuxt/content-guides/node-red/peripheral/index.md diff --git a/nuxt/content-guides/node-red/protocol/README.md b/nuxt/content-guides/node-red/protocol/index.md similarity index 100% rename from nuxt/content-guides/node-red/protocol/README.md rename to nuxt/content-guides/node-red/protocol/index.md diff --git a/nuxt/content-guides/node-red/terminology/README.md b/nuxt/content-guides/node-red/terminology/index.md similarity index 100% rename from nuxt/content-guides/node-red/terminology/README.md rename to nuxt/content-guides/node-red/terminology/index.md diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index 1b17fecc69..841dab414c 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -26,7 +26,21 @@ export default defineContentConfig({ }), docs: defineCollection({ type: 'page', - source: 'docs/**/*.md', + // Two sources, not one directory: FlowFuse/flowfuse's docs land in + // nuxt/content/docs (materialized there by nuxt/lib/docs-sync.mjs, which + // still needs a real git clone for per-file history - see that file), and + // this repo's own guides are read straight out of nuxt/content-guides/ with + // no copy step. `prefix: 'docs'` puts the second source's pages at the same + // `docs/...` path the first source's `docs/**/*.md` glob derives from its own + // directory name, so both land under /docs/ and a path collision between them + // fails the build (the `docs` collection's `id` is a primary key). The + // `content:file:beforeParse` hook in nuxt.config.ts stamps guide pages with + // `editUrl`/`updated` frontmatter as they're read; nuxt/lib/guides-sync.mjs + // only still copies the guides' non-markdown assets to public/docs. + source: [ + { include: 'docs/**/*.md' }, + { cwd: join(__dirname, 'content-guides'), include: '**/*.md', prefix: 'docs' }, + ], schema: z.object({ navTitle: z.string().optional(), // The browser/search-result title, when the sidebar label is too short to @@ -40,7 +54,7 @@ export default defineContentConfig({ navGroupOrder: z.number().optional(), navOrder: z.number().optional(), originalPath: z.string().optional(), - // Set only on pages overlaid from this repo's nuxt/content-guides/ tree + // Set only on pages read from this repo's nuxt/content-guides/ source // (see nuxt/lib/guides-sync.mjs). `originalPath` marks a page imported // from FlowFuse/flowfuse and the docs page builds a flowfuse edit link // from it; a page carrying `editUrl` links back here instead. diff --git a/nuxt/lib/guides-sync.mjs b/nuxt/lib/guides-sync.mjs index be5faeab5e..5c9762fca8 100644 --- a/nuxt/lib/guides-sync.mjs +++ b/nuxt/lib/guides-sync.mjs @@ -1,29 +1,35 @@ -// Overlays the website-authored guides onto the docs content tree. +// Wires the website-authored guides into the docs content tree. // // /docs is assembled from two repos. FlowFuse/flowfuse owns the product documentation - // how-to and reference, versioned with the code it describes - and docs-sync.mjs copies -// it in. This module copies the second source: the guides authored in *this* repo under -// nuxt/content-guides/, which explain how to shape an application rather than how to -// drive a feature, and so are not tied to a product release. +// it into nuxt/content/docs. This module covers the second source: the guides authored in +// *this* repo under nuxt/content-guides/, which explain how to shape an application rather +// than how to drive a feature, and so are not tied to a product release. // -// Both land in nuxt/content/docs, so @nuxt/content sees a single `docs` collection and -// the sidebar, breadcrumbs, prerender list, sitemap and search treat the two sources -// identically. nuxt/content/docs is gitignored and wiped on every sync, which is why the -// guides cannot simply be authored there. +// Unlike the flowfuse docs, the guides are already MDC and already live in this repo, so +// they do not need copying to become a `docs` collection page: nuxt/content.config.ts +// declares nuxt/content-guides/ as a second source of the `docs` collection (its own `cwd`, +// prefixed onto the `docs/` path so it lands next to the flowfuse pages), and the +// `content:file:beforeParse` hook in nuxt.config.ts calls injectGuideFrontmatter below to +// stamp each guide page with the same `editUrl`/`updated` provenance this module used to +// write by hand. What is left here is what a content-collection source cannot do by +// itself: copying the guides' non-markdown assets (images, mostly) to nuxt/public/docs so +// they resolve at runtime, and failing the build if a guide's path would collide with a +// page FlowFuse/flowfuse already publishes. // // Kept free of Nuxt imports, like docs-sync.mjs, so `scripts/sync_docs.mjs` can run it // before `npm install` and `node --test` can exercise it directly. import { execFileSync } from 'node:child_process' -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' // Repo-relative, so it can be both the source directory and the tail of the edit URL. export const GUIDES_SOURCE = 'nuxt/content-guides' -const EDIT_BASE = 'https://github.com/FlowFuse/website/edit/main' +export const EDIT_BASE = 'https://github.com/FlowFuse/website/edit/main' -function gitOutput (cwd, args) { +export function gitOutput (cwd, args) { try { // stderr is discarded rather than inherited: outside a git checkout (a unit test, // a tarball build) git's "not a git repository" is expected and handled below. @@ -33,21 +39,6 @@ function gitOutput (cwd, args) { } } -/** - * Where one guide file lands. Same rules docs-sync uses for the flowfuse tree - markdown - * becomes a page, README.md becomes its section index, anything else is a public asset - - * so a directory of guides nests in the sidebar exactly like a directory of docs. - */ -export function destinationFor (relPath, contentDocsDir, publicDocsDir) { - const name = basename(relPath) - const dir = dirname(relPath) - const prefix = dir === '.' ? '' : dir - - return name.endsWith('.md') - ? join(contentDocsDir, prefix, name === 'README.md' ? 'index.md' : name) - : join(publicDocsDir, prefix, name) -} - /** * Stamp build-time provenance onto a guide page. * @@ -73,36 +64,32 @@ export function injectFrontmatter (content, { editUrl, updated }) { } /** - * Copy one guide file into the docs tree. - * - * Deliberately does NOT run docs-markdown's processMarkdown: that exists to repair - * Eleventy-era markup in the flowfuse docs (Nunjucks callouts, inline custom-element - * scripts, blank lines inside raw HTML blocks). The guides are authored as MDC against - * the components in nuxt/components/content/, and those transforms would mangle them. + * Called from the `content:file:beforeParse` hook for every file @nuxt/content reads out + * of the content-guides source. `absPath` is that hook's `file.path` - the real path on + * disk, which is what lets this run entirely inside the hook rather than needing a + * separate copy step: the git history it reads is this repo's own, at the guide's real + * location, not a location this module chose. */ -export function writeGuideFile ({ guidesDir, repoRoot, contentDocsDir, publicDocsDir, relPath }) { - const srcPath = join(guidesDir, relPath) - const destPath = destinationFor(relPath, contentDocsDir, publicDocsDir) - - mkdirSync(dirname(destPath), { recursive: true }) - - if (!relPath.endsWith('.md')) { - cpSync(srcPath, destPath) - return destPath - } - - const sourcePath = `${GUIDES_SOURCE}/${relPath}` - // Argument array, not a shell string: the path comes from filenames on disk, so - // interpolating it into a shell command would be an injection path. +export function injectGuideFrontmatter (content, { repoRoot, absPath }) { + const guidesDir = join(repoRoot, GUIDES_SOURCE) + const sourcePath = `${GUIDES_SOURCE}/${stripPrefix(guidesDir, absPath)}` const updated = gitOutput(repoRoot, ['log', '-1', '--pretty=format:%ci', '--', sourcePath]) - const raw = readFileSync(srcPath, 'utf8') - writeFileSync(destPath, injectFrontmatter(raw, { + return injectFrontmatter(content, { editUrl: `${EDIT_BASE}/${sourcePath}`, updated, - }), 'utf8') + }) +} + +/** Whether a `content:file:beforeParse` file came from the guides source. */ +export function isGuidePath (absPath, repoRoot) { + const guidesDir = join(repoRoot, GUIDES_SOURCE) + return absPath === guidesDir || absPath.startsWith(guidesDir + '/') +} - return destPath +// The guide's path under GUIDES_SOURCE, with no leading slash. +function stripPrefix (from, to) { + return to.startsWith(from) ? to.slice(from.length).replace(/^\/+/, '') : to } /** Every file under the guides tree, as paths relative to it. */ @@ -121,55 +108,60 @@ export function listGuideFiles (guidesDir, relDir = '') { } /** - * Copy the whole guides tree into nuxt/content/docs, after docs-sync has populated it. + * Copy one guide asset (a non-markdown file) into nuxt/public/docs, or remove it if it has + * gone. Markdown is not handled here: @nuxt/content reads it straight out of + * nuxt/content-guides/ as a source of the `docs` collection. + */ +export function syncGuideAssetPath ({ repoRoot, nuxtRoot, relPath }) { + if (relPath.endsWith('.md')) return + + const guidesDir = join(repoRoot, GUIDES_SOURCE) + const destPath = join(nuxtRoot, 'public', 'docs', relPath) + + if (!existsSync(join(guidesDir, relPath))) { + rmSync(destPath, { force: true }) + return + } + + mkdirSync(dirname(destPath), { recursive: true }) + cpSync(join(guidesDir, relPath), destPath) +} + +/** + * Copy the guides' non-markdown assets into nuxt/public/docs, and fail the build if a + * guide's path would collide with a page FlowFuse/flowfuse already publishes. * - * A guide that lands on a path the flowfuse docs already occupy would silently replace - * that page - the overlay runs second - and the loss would only show up as a docs page - * mysteriously missing from production. Collisions therefore fail the build. + * The collision check used to be the only thing standing between a colliding guide and a + * docs page it would silently replace, because both were written into the same directory + * and the second write won. Now that guides are a separate content-collection source, a + * real collision - the same `docs` path served by both sources - fails anyway (the `docs` + * table's `id` is a primary key), but as a SQL constraint error naming a key, not a guide + * file. Checking here first keeps the friendlier message. */ -export function syncGuides ({ repoRoot, nuxtRoot, logger = console } = {}) { +export function syncGuideAssets ({ repoRoot, nuxtRoot, logger = console } = {}) { const guidesDir = join(repoRoot, GUIDES_SOURCE) const contentDocsDir = join(nuxtRoot, 'content', 'docs') - const publicDocsDir = join(nuxtRoot, 'public', 'docs') if (!existsSync(guidesDir)) { logger.warn(`No guides to overlay: ${GUIDES_SOURCE} does not exist`) - return { count: 0 } + return { pages: 0, assets: 0 } } const files = listGuideFiles(guidesDir) - const collisions = files.filter(relPath => - existsSync(destinationFor(relPath, contentDocsDir, publicDocsDir))) + const pages = files.filter(relPath => relPath.endsWith('.md')) + const assets = files.filter(relPath => !relPath.endsWith('.md')) + const collisions = pages.filter(relPath => existsSync(join(contentDocsDir, relPath))) if (collisions.length) { throw new Error( `Guide files collide with pages from FlowFuse/flowfuse and would overwrite them: ${collisions.join(', ')}` ) } - for (const relPath of files) { - writeGuideFile({ guidesDir, repoRoot, contentDocsDir, publicDocsDir, relPath }) - } - - logger.info(`Overlaid ${files.length} guide files from ${GUIDES_SOURCE} onto content/docs`) - return { count: files.length } -} - -/** - * Sync a single guide file, for the dev watcher. Mirrors syncDocsPath: a full re-sync on - * every save would delete and recreate every page in the collection, and @nuxt/content - * re-indexing all of them at once exhausts the dev server's heap. - */ -export function syncGuidePath ({ repoRoot, nuxtRoot, relPath }) { - const guidesDir = join(repoRoot, GUIDES_SOURCE) - const contentDocsDir = join(nuxtRoot, 'content', 'docs') - const publicDocsDir = join(nuxtRoot, 'public', 'docs') - - if (!existsSync(join(guidesDir, relPath))) { - // Same destination mapping as the write, so deleting a README removes its index.md. - rmSync(destinationFor(relPath, contentDocsDir, publicDocsDir), { force: true }) - return + for (const relPath of assets) { + syncGuideAssetPath({ repoRoot, nuxtRoot, relPath }) } - writeGuideFile({ guidesDir, repoRoot, contentDocsDir, publicDocsDir, relPath }) + logger.info(`Copied ${assets.length} guide assets to public/docs; ${pages.length} guide pages read directly from ${GUIDES_SOURCE}`) + return { pages: pages.length, assets: assets.length } } diff --git a/nuxt/lib/guides-sync.test.mjs b/nuxt/lib/guides-sync.test.mjs index b3971b90a1..0e3efbca6d 100644 --- a/nuxt/lib/guides-sync.test.mjs +++ b/nuxt/lib/guides-sync.test.mjs @@ -1,15 +1,18 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { GUIDES_SOURCE, - destinationFor, injectFrontmatter, + injectGuideFrontmatter, + isGuidePath, listGuideFiles, - syncGuides, + syncGuideAssetPath, + syncGuideAssets, } from './guides-sync.mjs' const silent = { info: () => {}, warn: () => {}, error: () => {} } @@ -43,24 +46,6 @@ test('a timestamp git could not supply is left out, not emitted empty', () => { assert.ok(!/updated:/.test(without), 'an empty timestamp must not leave a valueless key') }) -test('a README becomes its section index, so a directory of guides nests like a directory of docs', () => { - assert.equal( - destinationFor('application-guide/README.md', '/content/docs', '/public/docs'), - '/content/docs/application-guide/index.md' - ) - assert.equal( - destinationFor('application-guide/architectures/it.md', '/content/docs', '/public/docs'), - '/content/docs/application-guide/architectures/it.md' - ) -}) - -test('non-markdown files are served as assets rather than parsed as pages', () => { - assert.equal( - destinationFor('application-guide/images/oee.png', '/content/docs', '/public/docs'), - '/public/docs/application-guide/images/oee.png' - ) -}) - test('provenance is added to existing frontmatter without disturbing it', () => { const out = injectFrontmatter('---\ntitle: Foundations\n---\n\n# Foundations\n', { editUrl: 'https://example.test/edit', @@ -76,38 +61,119 @@ test('a guide with no frontmatter still gets a block', () => { assert.equal(out, '---\neditUrl: e\nupdated: u\n---\n# Foundations\n') }) -test('the whole guides tree lands in the docs content tree, stamped with an edit link back to this repo', () => { - const { root, nuxtRoot, contentDocsDir, publicDocsDir, cleanup } = scratch() +test('isGuidePath matches only files under the guides source', () => { + const root = '/repo' + assert.equal(isGuidePath(join(root, GUIDES_SOURCE, 'application-guide/index.md'), root), true) + assert.equal(isGuidePath(join(root, 'nuxt/content/docs/user/index.md'), root), false) + // Not a false-positive on a directory that merely shares the prefix. + assert.equal(isGuidePath(join(root, 'nuxt/content-guides-other/index.md'), root), false) +}) + +test('injectGuideFrontmatter stamps an edit URL back to this repo, keyed off the real file path', () => { + const { root, cleanup } = scratch() + try { + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '---\ntitle: Guide\n---\n\n# Guide\n') + + const out = injectGuideFrontmatter('---\ntitle: Guide\n---\n\n# Guide\n', { + repoRoot: root, + absPath: join(root, GUIDES_SOURCE, 'application-guide/index.md'), + }) + + assert.match(out, /editUrl: https:\/\/github\.com\/FlowFuse\/website\/edit\/main\/nuxt\/content-guides\/application-guide\/index\.md/) + assert.match(out, /updated: \n/) // not a git checkout, so gitOutput falls back to '' + assert.match(out, /title: Guide/) + } finally { + cleanup() + } +}) + +test('injectGuideFrontmatter reads this repo\'s own git history for the guide, not flowfuse\'s', () => { + const { root, cleanup } = scratch() + try { + execFileSync('git', ['init', '-q'], { cwd: root }) + execFileSync('git', ['config', 'user.email', 'test@example.test'], { cwd: root }) + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root }) + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '# Guide\n') + execFileSync('git', ['add', '.'], { cwd: root }) + execFileSync('git', ['commit', '-q', '-m', 'add guide'], { cwd: root }) + + const out = injectGuideFrontmatter('# Guide\n', { + repoRoot: root, + absPath: join(root, GUIDES_SOURCE, 'application-guide/index.md'), + }) + + assert.doesNotMatch(out, /updated: \n/) + assert.match(out, /updated: \d{4}-\d{2}-\d{2}/) + } finally { + cleanup() + } +}) + +test('syncGuideAssetPath copies a non-markdown asset to public/docs', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() + try { + write(join(root, GUIDES_SOURCE, 'application-guide/diagram.svg'), '') + + syncGuideAssetPath({ repoRoot: root, nuxtRoot, relPath: 'application-guide/diagram.svg' }) + + assert.equal(readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'), 'utf8'), '') + } finally { + cleanup() + } +}) + +test('syncGuideAssetPath removes the copy when the asset is gone', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() + try { + write(join(publicDocsDir, 'application-guide/diagram.svg'), '') + + syncGuideAssetPath({ repoRoot: root, nuxtRoot, relPath: 'application-guide/diagram.svg' }) + + assert.throws(() => readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'))) + } finally { + cleanup() + } +}) + +test('syncGuideAssetPath ignores markdown - that is @nuxt/content\'s job now', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() + try { + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '# Guide\n') + + syncGuideAssetPath({ repoRoot: root, nuxtRoot, relPath: 'application-guide/index.md' }) + + assert.throws(() => readFileSync(join(publicDocsDir, 'application-guide/index.md'))) + } finally { + cleanup() + } +}) + +test('syncGuideAssets copies only the non-markdown files', () => { + const { root, nuxtRoot, publicDocsDir, cleanup } = scratch() try { - write(join(root, GUIDES_SOURCE, 'application-guide/README.md'), '---\ntitle: Guide\n---\n\n# Guide\n') - write(join(root, GUIDES_SOURCE, 'application-guide/architectures/it.md'), '---\ntitle: IT\n---\n\n# IT\n') + write(join(root, GUIDES_SOURCE, 'application-guide/index.md'), '# Guide\n') write(join(root, GUIDES_SOURCE, 'application-guide/diagram.svg'), '') - mkdirSync(contentDocsDir, { recursive: true }) - const { count } = syncGuides({ repoRoot: root, nuxtRoot, logger: silent }) + const result = syncGuideAssets({ repoRoot: root, nuxtRoot, logger: silent }) - assert.equal(count, 3) - const index = readFileSync(join(contentDocsDir, 'application-guide/index.md'), 'utf8') - assert.match(index, /editUrl: https:\/\/github\.com\/FlowFuse\/website\/edit\/main\/nuxt\/content-guides\/application-guide\/README\.md/) - assert.match(index, /title: Guide/) - assert.ok(readFileSync(join(contentDocsDir, 'application-guide/architectures/it.md'), 'utf8')) + assert.deepEqual(result, { pages: 1, assets: 1 }) assert.equal(readFileSync(join(publicDocsDir, 'application-guide/diagram.svg'), 'utf8'), '') } finally { cleanup() } }) -test('a guide that would overwrite a page from FlowFuse/flowfuse fails the build', () => { +test('a guide that would collide with a page from FlowFuse/flowfuse fails the build', () => { const { root, nuxtRoot, contentDocsDir, cleanup } = scratch() try { - // The overlay runs after the product docs are copied in, so without this guard a - // colliding guide would silently replace a docs page and the loss would only show - // up as a page missing from production. + // @nuxt/content would also refuse this - the docs collection's `id` is a primary + // key - but as a SQL constraint error, not a message naming the file. This check + // runs first so the build fails with the friendlier one. write(join(root, GUIDES_SOURCE, 'user/concepts.md'), '# Concepts\n') write(join(contentDocsDir, 'user/concepts.md'), '# Concepts from flowfuse\n') assert.throws( - () => syncGuides({ repoRoot: root, nuxtRoot, logger: silent }), + () => syncGuideAssets({ repoRoot: root, nuxtRoot, logger: silent }), /collide with pages from FlowFuse\/flowfuse/ ) } finally { @@ -118,7 +184,7 @@ test('a guide that would overwrite a page from FlowFuse/flowfuse fails the build test('a missing guides directory is reported, not fatal', () => { const { root, nuxtRoot, cleanup } = scratch() try { - assert.deepEqual(syncGuides({ repoRoot: root, nuxtRoot, logger: silent }), { count: 0 }) + assert.deepEqual(syncGuideAssets({ repoRoot: root, nuxtRoot, logger: silent }), { pages: 0, assets: 0 }) } finally { cleanup() } diff --git a/nuxt/modules/docs-source.ts b/nuxt/modules/docs-source.ts index db3a8a91b3..ad869680a9 100644 --- a/nuxt/modules/docs-source.ts +++ b/nuxt/modules/docs-source.ts @@ -7,7 +7,7 @@ import { join, basename, dirname } from 'node:path' // @ts-ignore untyped module, kept as plain JS so `node --test` can run it directly import { syncDocs } from '../lib/docs-sync.mjs' // @ts-ignore same -import { syncGuides } from '../lib/guides-sync.mjs' +import { GUIDES_SOURCE, syncGuideAssets } from '../lib/guides-sync.mjs' // @ts-ignore same import { docsRedirectRules, prerenderableRoutes } from '../lib/docs-redirects.mjs' @@ -35,21 +35,28 @@ export default defineNuxtModule({ const contentDocsDir = join(nuxtRoot, 'content', 'docs') const repoRoot = dirname(nuxtRoot) + const guidesDir = join(repoRoot, GUIDES_SOURCE) - // Order matters: syncDocs wipes content/docs before writing, so the guides overlaid - // from this repo have to land after it, not before. await syncDocs({ repoRoot, nuxtRoot, logger }) - syncGuides({ repoRoot, nuxtRoot, logger }) + // The guide pages themselves are a second source of the `docs` collection (see + // content.config.ts) and are never copied into content/docs - this only copies + // their non-markdown assets and fails the build on a path collision with a page + // from FlowFuse/flowfuse. Order no longer matters against syncDocs because of + // that: nothing here writes into contentDocsDir any more. + syncGuideAssets({ repoRoot, nuxtRoot, logger }) - if (!existsSync(contentDocsDir)) return - - // Collected after the overlay, so the guide pages get prerendered with the rest - // of /docs and need no route list of their own in nuxt.config. - const docsPages = collectPages(contentDocsDir, '/docs') + // Collected from both of the `docs` collection's sources, so every page gets + // prerendered with the rest of /docs and neither source needs a route list of its + // own in nuxt.config. contentDocsDir covers FlowFuse/flowfuse's docs, materialized + // by syncDocs; guidesDir covers this repo's guides, read directly. + const docsPages = [ + ...(existsSync(contentDocsDir) ? collectPages(contentDocsDir, '/docs') : []), + ...(existsSync(guidesDir) ? collectPages(guidesDir, '/docs') : []), + ] // A floor rather than a log line, following the same call in nuxt.config.ts for the - // integrations. Collecting nothing means the tree above did not land, and on the - // netlify preset unprerendered routes still SSR, so the only symptom would be a - // thinner static site and slow cold pages - nothing that looks like a failure. + // integrations. Both arms above swallow a missing tree, so collecting nothing means + // a source did not land; on the netlify preset unprerendered routes still SSR, so + // the only symptom would be a thinner static site and slow cold pages. // Checked on what was collected, not on what survives the redirect filter below: // that filter is meant to drop routes, so a floor on its output would be a floor // on how many redirects the docs happen to declare. diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index cd80c3e8a2..369c755f22 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -7,6 +7,10 @@ import remarkSiteLinks from './utils/remark-site-links' import { BLOG_TAGS } from './composables/useBlogList' import { redirects } from './redirects' import site from '../src/_data/site.json' +// @ts-ignore untyped module, kept as plain JS so `node --test` can run it directly +import { injectGuideFrontmatter, isGuidePath } from './lib/guides-sync.mjs' + +const REPO_ROOT = join(__dirname, '..') // Collect all handbook routes from content files for SSG prerendering function collectHandbookRoutes(dir: string, basePath: string): string[] { @@ -607,11 +611,19 @@ export default defineNuxtConfig({ // under it. These collections' frontmatter still writes "meta:", so rewrite the key // to "structuredData:" before parsing rather than editing hundreds of content files. 'content:file:beforeParse' ({ file, collection }) { - if (!['blog', 'webinars'].includes(collection.name)) return - file.body = file.body.replace( - /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*/, - (block) => block.replace(/^meta:[ \t]*\r?$/m, 'structuredData:') - ) + if (['blog', 'webinars'].includes(collection.name)) { + file.body = file.body.replace( + /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*/, + (block) => block.replace(/^meta:[ \t]*\r?$/m, 'structuredData:') + ) + return + } + // The `docs` collection's second source reads nuxt/content-guides/ directly + // (see content.config.ts and nuxt/lib/guides-sync.mjs) - this is where those + // pages get the editUrl/updated provenance a copy step used to write by hand. + if (collection.name === 'docs' && file.path && isGuidePath(file.path, REPO_ROOT)) { + file.body = injectGuideFrontmatter(file.body, { repoRoot: REPO_ROOT, absPath: file.path }) + } }, // Enumerate /integrations/{id}/ routes at config-time so SSG prerenders them. // Can't use Nuxt's $fetch here — it only exists at nitro runtime. diff --git a/scripts/sync_docs.mjs b/scripts/sync_docs.mjs index 6bc44c7ca4..390f0defe1 100644 --- a/scripts/sync_docs.mjs +++ b/scripts/sync_docs.mjs @@ -1,17 +1,23 @@ #!/usr/bin/env node -// Populates nuxt/content/docs outside of a Nuxt build, so CI can resolve the docs before -// installing dependencies. Uses only node builtins: this runs before `npm install`. +// Populates nuxt/content/docs outside of a Nuxt build, so CI can resolve the FlowFuse/flowfuse +// docs before installing dependencies. Uses only node builtins: this runs before `npm install`. +// +// Does not materialize the guides (nuxt/content-guides/): they are a native +// content-collection source read straight from that directory (see content.config.ts), not +// a copy this script produces, so a caller after just this script's own docs will not see +// them under nuxt/content/docs. It still copies the guides' non-markdown assets and still +// fails on a path collision with a flowfuse page, same as a full Nuxt build would. import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { syncDocs } from '../nuxt/lib/docs-sync.mjs' -import { syncGuides } from '../nuxt/lib/guides-sync.mjs' +import { syncGuideAssets } from '../nuxt/lib/guides-sync.mjs' const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') const nuxtRoot = join(repoRoot, 'nuxt') await syncDocs({ repoRoot, nuxtRoot }) -// After syncDocs, which wipes the tree it writes into. -syncGuides({ repoRoot, nuxtRoot }) +// Assets only: the guide pages themselves are a collection source, never copied. +syncGuideAssets({ repoRoot, nuxtRoot }) diff --git a/scripts/watch_docs.mjs b/scripts/watch_docs.mjs index c479b8a59d..779ee08992 100644 --- a/scripts/watch_docs.mjs +++ b/scripts/watch_docs.mjs @@ -1,12 +1,19 @@ #!/usr/bin/env node -// Keeps nuxt/content/docs in step with its two sources while the dev server runs: -// a local flowfuse checkout (when the docs resolve to one) and this repo's own -// nuxt/content-guides tree. nuxt/modules/docs-source.ts syncs once during setup and never -// again, so without this an edit to either only shows up after a restart. +// Keeps the docs collection's two sources in step while the dev server runs. +// +// The flowfuse docs are still a copy (nuxt/lib/docs-sync.mjs materializes them into +// nuxt/content/docs, once, at Nuxt module setup), so an edit to a local checkout of them +// only shows up after a restart without this watcher re-running that copy per file. +// +// The guides, by contrast, are a native content-collection source read straight out of +// nuxt/content-guides/ (see content.config.ts) - @nuxt/content's own dev-mode watcher +// re-parses a changed guide page on its own, incrementally, so this script only still +// needs to watch that tree for its non-markdown assets, which nuxt/lib/guides-sync.mjs +// copies to public/docs and which nothing else watches. // // One edit syncs one file. Re-running a whole sync instead would delete and recreate all -// 130-odd pages on every save, and @nuxt/content re-indexing the entire collection that way -// exhausts the dev server's heap. +// 130-odd flowfuse pages on every save, and @nuxt/content re-indexing the entire collection +// that way exhausts the dev server's heap. import { basename, dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' @@ -14,7 +21,7 @@ import { fileURLToPath } from 'node:url' import chokidar from 'chokidar' import { resolveSource, syncDocsPath } from '../nuxt/lib/docs-sync.mjs' -import { GUIDES_SOURCE, syncGuidePath } from '../nuxt/lib/guides-sync.mjs' +import { GUIDES_SOURCE, syncGuideAssetPath } from '../nuxt/lib/guides-sync.mjs' const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') const nuxtRoot = join(repoRoot, 'nuxt') @@ -25,11 +32,11 @@ const verbs = { add: 'Added', change: 'Synced', unlink: 'Removed' } * @param {string} root directory to watch * @param {(relPath: string) => void} sync */ -function watch (root, sync) { +function watch (root, sync, { ignoreMarkdown = false } = {}) { const watcher = chokidar.watch(root, { // The Nuxt module has already synced by the time this starts. ignoreInitial: true, - ignored: (path) => basename(path).startsWith('.'), + ignored: (path) => basename(path).startsWith('.') || (ignoreMarkdown && path.endsWith('.md')), // Editors write a file in more than one step, so wait for it to settle rather than // publish a half-written page. awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, @@ -57,9 +64,8 @@ function watch (root, sync) { watcher.on('ready', () => console.log(`Watching ${root} for docs changes`)) } -// The guides live in this repo, so unlike the flowfuse docs there is always something -// local to watch. -watch(join(repoRoot, GUIDES_SOURCE), relPath => syncGuidePath({ repoRoot, nuxtRoot, relPath })) +// Assets only - see the module comment above for why markdown is @nuxt/content's job now. +watch(join(repoRoot, GUIDES_SOURCE), relPath => syncGuideAssetPath({ repoRoot, nuxtRoot, relPath }), { ignoreMarkdown: true }) // The same precedence the build uses, rather than a second hardcoded path that could drift // from it: FLOWFUSE_DOCS_LOCAL, then a sibling checkout, then a clone. From ddb9c47d612de9dfe408e23cb4ea091f857de3e8 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Tue, 8 Sep 2026 11:41:53 +0200 Subject: [PATCH 2/2] docs: keep resolving relative URLs once the guides are read in place Dropping the copy step took the guides out of the only path remark-docs-links recognised. It keys off a `/docs/` segment in the file's path on disk, which held while guides-sync materialized them into nuxt/content/docs, and stops holding now they are read straight out of nuxt/content-guides/. The plugin returned early on every guide page, so their relative image URLs reached the browser unresolved and the browser resolved them against the page's own URL: ./images/x.png on /docs/node-red/database/influxdb/ asked for /docs/node-red/database/influxdb/images/x.png and 404'd. The asset was copied to the right place throughout; only the URL was wrong. Three hundred-odd references across 33 pages, and nothing failed: the link checker sees the built HTML, where these are valid relative URLs, and the images themselves are not fetched. The path mapping moves to nuxt/lib so node --test can reach it, and it now understands both of the collection's sources. The test asserts over the real tree that every guide using a relative asset URL is one this can resolve, so the two cannot drift apart again without failing. --- .claude/CLAUDE.md | 2 +- nuxt/lib/docs-content-path.mjs | 34 ++++++++++++++++++ nuxt/lib/docs-content-path.test.mjs | 53 +++++++++++++++++++++++++++++ nuxt/lib/guides-sync.test.mjs | 5 ++- nuxt/utils/remark-docs-links.ts | 9 +++-- 5 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 nuxt/lib/docs-content-path.mjs create mode 100644 nuxt/lib/docs-content-path.test.mjs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0c880342c7..6800b3ec05 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -217,7 +217,7 @@ reference costs a page its whole Node help section with nothing failing, which i **URL:** `/docs/{section}/{slug}/` **Rendered by:** Nuxt — `nuxt/pages/docs/[...slug].vue` + `DocsLeftNav` component -**Local content:** `nuxt/content/docs/` (gitignored, build-generated — never edit, it is wiped every build). Both sources are copied into it: `nuxt/lib/docs-sync.mjs` brings in the flowfuse tree and `nuxt/lib/guides-sync.mjs` overlays `nuxt/content-guides/` on top (stamping each guide with an `editUrl`), so `@nuxt/content` sees one `docs` collection. A guide edit therefore only reaches a running dev server once that overlay re-runs: `npm run dev:docs` is the watcher that does it, and without it an edit under `nuxt/content-guides/` shows up on the page only after a restart. +**Local content:** `nuxt/content/docs/` (gitignored, build-generated — never edit, it is wiped every build) holds only the flowfuse tree, materialized by `nuxt/lib/docs-sync.mjs`. The guides are **not** copied into it: they are a second source of the `docs` collection, read straight out of `nuxt/content-guides/` (see `nuxt/content.config.ts`), so editing one shows up without a re-sync. `nuxt/lib/guides-sync.mjs` copies only their non-markdown assets, into `nuxt/public/docs/`, and fails the build on a path collision between the two sources. **Local assets:** `nuxt/public/docs/` (images, etc.) A page's browser title is `metaTitle || navTitle || title` (`nuxt/lib/docs-page-title.mjs`). diff --git a/nuxt/lib/docs-content-path.mjs b/nuxt/lib/docs-content-path.mjs new file mode 100644 index 0000000000..e9ff990aa5 --- /dev/null +++ b/nuxt/lib/docs-content-path.mjs @@ -0,0 +1,34 @@ +// Maps a docs page's path on disk to the /docs path it will be served at. +// +// /docs is assembled from two sources and they sit in different places on disk. +// FlowFuse/flowfuse's docs are materialized into nuxt/content/docs by docs-sync.mjs, so +// their path already contains the `/docs/` segment. This repo's own guides are read +// straight out of nuxt/content-guides/, which does not contain it, and the collection +// gives that source `prefix: 'docs'` to put them under the same URL space. +// +// Anything resolving a relative URL inside a page needs the served path rather than the +// on-disk one, and keying only off `/docs/` silently skipped the whole second source: +// every relative image URL in the guides reached the browser unresolved and 404'd +// against the page's own URL instead. Kept in nuxt/lib as plain JS so `node --test` can +// exercise it directly, like docs-nav.mjs. + +const GUIDES_SEGMENT = '/content-guides/' +const DOCS_SEGMENT = '/docs/' + +/** + * The `/docs/...` path a source file is served at, or `null` if it is not a docs page. + * + * @param {string} filePath absolute or repo-relative path of the source file + * @returns {string|null} + */ +export function docsPathForSourceFile (filePath) { + const path = String(filePath || '') + + const guidesIndex = path.lastIndexOf(GUIDES_SEGMENT) + if (guidesIndex !== -1) { + return DOCS_SEGMENT + path.slice(guidesIndex + GUIDES_SEGMENT.length) + } + + const docsIndex = path.lastIndexOf(DOCS_SEGMENT) + return docsIndex === -1 ? null : path.slice(docsIndex) +} diff --git a/nuxt/lib/docs-content-path.test.mjs b/nuxt/lib/docs-content-path.test.mjs new file mode 100644 index 0000000000..b4d304b9ec --- /dev/null +++ b/nuxt/lib/docs-content-path.test.mjs @@ -0,0 +1,53 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { docsPathForSourceFile } from './docs-content-path.mjs' +import { GUIDES_SOURCE, listGuideFiles } from './guides-sync.mjs' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..') + +test('a guide read from content-guides maps to the /docs path it is served at', () => { + // The whole reason this exists: keying off `/docs/` alone skipped this source, and + // nuxt/utils/remark-docs-links.ts then left every relative image URL in the guides + // unresolved, so the browser resolved it against the page's own URL and 404'd. + assert.equal( + docsPathForSourceFile('/repo/nuxt/content-guides/node-red/database/influxdb.md'), + '/docs/node-red/database/influxdb.md' + ) + assert.equal( + docsPathForSourceFile('/repo/nuxt/content-guides/application-guide/index.md'), + '/docs/application-guide/index.md' + ) +}) + +test('a materialized page from FlowFuse/flowfuse keeps resolving as it did', () => { + assert.equal( + docsPathForSourceFile('/repo/nuxt/content/docs/user/concepts.md'), + '/docs/user/concepts.md' + ) +}) + +test('a page from neither source is not a docs page', () => { + assert.equal(docsPathForSourceFile('/repo/nuxt/content/handbook/team.md'), null) + assert.equal(docsPathForSourceFile(''), null) + assert.equal(docsPathForSourceFile(undefined), null) +}) + +test('every guide that uses a relative asset URL is a page this can resolve', () => { + // A relative URL is only safe because something rewrites it. If a guide's path stops + // being recognised here, the rewrite goes back to silently not happening, so this + // asserts the two stay in step over the real tree rather than over a fixture. + const guidesDir = join(repoRoot, GUIDES_SOURCE) + const unresolvable = [] + + for (const relPath of listGuideFiles(guidesDir).filter(f => f.endsWith('.md'))) { + const body = readFileSync(join(guidesDir, relPath), 'utf8') + if (!/!\[[^\]]*\]\((\.\.?\/)/.test(body)) continue + if (!docsPathForSourceFile(join(guidesDir, relPath))) unresolvable.push(relPath) + } + + assert.deepEqual(unresolvable, [], unresolvable.join('\n')) +}) diff --git a/nuxt/lib/guides-sync.test.mjs b/nuxt/lib/guides-sync.test.mjs index 0e3efbca6d..96a0dc1306 100644 --- a/nuxt/lib/guides-sync.test.mjs +++ b/nuxt/lib/guides-sync.test.mjs @@ -80,7 +80,10 @@ test('injectGuideFrontmatter stamps an edit URL back to this repo, keyed off the }) assert.match(out, /editUrl: https:\/\/github\.com\/FlowFuse\/website\/edit\/main\/nuxt\/content-guides\/application-guide\/index\.md/) - assert.match(out, /updated: \n/) // not a git checkout, so gitOutput falls back to '' + // Not a git checkout, so gitOutput has nothing to report and the key is left out + // rather than written empty: a valueless key is YAML null, which the collection + // schema takes as neither a string nor absent. + assert.ok(!/updated:/.test(out), 'an unanswerable timestamp must not leave a valueless key') assert.match(out, /title: Guide/) } finally { cleanup() diff --git a/nuxt/utils/remark-docs-links.ts b/nuxt/utils/remark-docs-links.ts index 7472638686..5d12e55c23 100644 --- a/nuxt/utils/remark-docs-links.ts +++ b/nuxt/utils/remark-docs-links.ts @@ -2,6 +2,7 @@ import { visit } from 'unist-util-visit' import type { Root } from 'mdast' import type { VFile } from 'vfile' import { slugifyAnchor } from './slugify-anchor' +import { docsPathForSourceFile } from '../lib/docs-content-path.mjs' function posixDirname(path: string): string { const i = path.lastIndexOf('/') @@ -27,10 +28,12 @@ function posixResolve(base: string, rel: string): string { export default function remarkDocsLinks() { return (tree: Root, file: VFile) => { const filePath: string = (file.path || file.history?.[0] || '') as string - if (!filePath.includes('/docs/')) return + // Both sources of the docs collection, not just the materialized one: the guides + // are read from nuxt/content-guides/, whose path has no `/docs/` segment to key + // off. See nuxt/lib/docs-content-path.mjs. + const relPath = docsPathForSourceFile(filePath) + if (!relPath) return - const docsIdx = filePath.lastIndexOf('/docs/') - const relPath = filePath.slice(docsIdx) const baseDir = posixDirname(relPath) + '/' function resolveUrl(url: string): string {