Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
18 changes: 16 additions & 2 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions nuxt/lib/docs-content-path.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
53 changes: 53 additions & 0 deletions nuxt/lib/docs-content-path.test.mjs
Original file line number Diff line number Diff line change
@@ -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'))
})
158 changes: 75 additions & 83 deletions nuxt/lib/guides-sync.mjs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
*
Expand All @@ -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. */
Expand All @@ -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 }
}
Loading
Loading