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/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/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.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..96a0dc1306 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,122 @@ 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/)
+ // 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()
+ }
+})
+
+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 +187,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/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 {
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.