resolve.
+// - redirect pages (`layout: redirect`) keep their route but the served page
+// performs the client redirect (captured in docs.index.json).
+//
+// URL parity is the hard constraint: a file maps to the same route 11ty served
+// src/docs/index.md -> /docs/
+// src/docs/a/b.md -> /docs/a/b/
+// src/docs/a/index.md -> /docs/a/
+const fs = require('fs')
+const path = require('path')
+
+const SRC = path.resolve(__dirname, '../src/docs')
+const CONTENT = path.resolve(__dirname, '../nuxt/content/docs')
+const PUBLIC_MEDIA = path.resolve(__dirname, '../nuxt/public/docs-media')
+const ROUTES_FILE = path.resolve(__dirname, '../nuxt/docs.routes.json')
+const INDEX_FILE = path.resolve(__dirname, '../nuxt/docs.index.json')
+const NAV_FILE = path.resolve(__dirname, '../nuxt/docs.nav.json')
+const { buildNav } = require('./nav-tree')
+
+// 11ty groupOrder.docs — controls the order of the docs sidebar group headings.
+const DOCS_GROUP_ORDER = [
+ 'FlowFuse User Manuals',
+ 'Device Agent',
+ 'FlowFuse Cloud',
+ 'FlowFuse Self-Hosted',
+ 'Support',
+ 'Contributing',
+]
+
+function fileToRoute(absFile) {
+ let rel = path.relative(SRC, absFile).split(path.sep).join('/')
+ if (rel === 'index.md') return '/docs/'
+ if (rel.endsWith('/index.md')) return '/docs/' + rel.slice(0, -'index.md'.length)
+ return '/docs/' + rel.slice(0, -'.md'.length) + '/'
+}
+
+function fileToContentPath(absFile) {
+ const route = fileToRoute(absFile)
+ return route === '/docs/' ? '/docs' : route.replace(/\/$/, '')
+}
+
+const mdFiles = []
+const skipped = []
+function walk(dir) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name.startsWith('.')) continue
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ walk(full)
+ } else if (entry.name.endsWith('.md')) {
+ const rel = path.relative(SRC, full)
+ if (/[ %?#]/.test(rel)) {
+ skipped.push(rel)
+ continue
+ }
+ mdFiles.push(full)
+ }
+ }
+}
+walk(SRC)
+
+function splitTarget(target) {
+ const m = target.match(/^([^#?]*)([#?].*)?$/)
+ return { p: m[1], suffix: m[2] || '' }
+}
+
+// Map an absolute resolved source path (a `.md` file, a README/index, or a
+// bare slug written without `.md`) to its docs route, mirroring 11ty: links
+// were resolved relative to the source file with `.md` and `README`/`index`
+// stripped to the directory URL.
+function targetToRoute(absPath) {
+ let rel = path.relative(SRC, absPath).split(path.sep).join('/')
+ rel = rel.replace(/\.md$/i, '')
+ rel = rel.replace(/(^|\/)(README|index)$/i, '$1')
+ rel = rel.replace(/\/$/, '')
+ return rel ? '/docs/' + rel + '/' : '/docs/'
+}
+
+function isExternalOrAsset(p, target) {
+ if (/^([a-z][\w+.-]*:|#|\/)/i.test(target)) return true
+ return /\.[a-z0-9]+$/i.test(p) && !/\.md$/i.test(p)
+}
+
+const copiedMedia = new Set()
+function copyMedia(absImage) {
+ const rel = path.relative(SRC, absImage).split(path.sep).join('/')
+ const dest = path.join(PUBLIC_MEDIA, rel)
+ if (!copiedMedia.has(dest) && fs.existsSync(absImage)) {
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
+ fs.copyFileSync(absImage, dest)
+ copiedMedia.add(dest)
+ }
+ return '/docs-media/' + rel
+}
+
+// Blank lines inside the docs-index `ff-*-tiles` HTML containers (e.g. after a
+// multi-line inline SVG) terminate the markdown HTML block, so the deeply
+// indented continuation gets parsed as indented code blocks and renders as
+// stray of raw HTML. Drop blank lines within those containers so the
+// block stays intact and renders as the intended card grid.
+function stripBlankLinesInTileBlocks(body) {
+ const out = []
+ let inTiles = false
+ let depth = 0
+ for (const line of body.split('\n')) {
+ if (!inTiles && /^\s*/g) || []).length
+ if (depth <= 0) inTiles = false
+ } else {
+ out.push(line)
+ }
+ }
+ return out.join('\n')
+}
+
+function rewriteLinks(body, absFile) {
+ const dir = path.dirname(absFile)
+ body = stripBlankLinesInTileBlocks(body)
+
+ // Images: 
+ body = body.replace(/(!\[[^\]]*\]\()([^)\s]+)(\s+"[^"]*")?(\))/g, (full, pre, target, title, post) => {
+ if (/^(https?:|data:|\/)/.test(target)) return full
+ const { p, suffix } = splitTarget(target)
+ const abs = path.resolve(dir, p)
+ if (!fs.existsSync(abs)) return full
+ return pre + copyMedia(abs) + suffix + (title || '') + post
+ })
+
+ // Markdown links: resolve every relative internal link (with or without a
+ // `.md` extension, including README -> directory) against the source dir,
+ // the way 11ty did, so @nuxt/content never mis-resolves a relative link.
+ body = body.replace(/(\]\()([^)\s]+)(\))/g, (full, pre, target, post) => {
+ const { p, suffix } = splitTarget(target)
+ if (!p || isExternalOrAsset(p, target)) return full
+ const abs = path.resolve(dir, p)
+ return pre + targetToRoute(abs) + suffix + post
+ })
+
+ // Raw-HTML links:
(the docs include hand-written HTML).
+ body = body.replace(/(]*\shref=")([^"]+)(")/gi, (full, pre, target, post) => {
+ const { p, suffix } = splitTarget(target)
+ if (!p || isExternalOrAsset(p, target)) return full
+ const abs = path.resolve(dir, p)
+ return pre + targetToRoute(abs) + suffix + post
+ })
+
+ return body
+}
+
+// Minimal frontmatter parse: returns { fm: rawYamlLines[], body }.
+function splitFrontmatter(raw) {
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
+ if (!m) return { fmLines: [], body: raw }
+ return { fmLines: m[1].split(/\r?\n/), body: m[2] }
+}
+
+// Pull a top-level scalar (navTitle:, navGroup:, navOrder:) from frontmatter lines.
+function scalar(fmLines, key) {
+ const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`)
+ for (const line of fmLines) {
+ const mm = line.match(re)
+ if (mm) return mm[1].replace(/^['"]|['"]$/g, '')
+ }
+ return undefined
+}
+
+// Pull redirect.to (nested under `redirect:`) from frontmatter lines.
+function redirectTo(fmLines) {
+ let inRedirect = false
+ for (const line of fmLines) {
+ if (/^redirect:\s*$/.test(line)) { inRedirect = true; continue }
+ if (inRedirect) {
+ const mm = line.match(/^\s+to:\s*(.+?)\s*$/)
+ if (mm) return mm[1].replace(/^['"]|['"]$/g, '')
+ if (/^\S/.test(line)) inRedirect = false
+ }
+ // single-line form: redirect: { to: ... }
+ const inline = line.match(/^redirect:\s*\{\s*to:\s*([^}]+?)\s*\}/)
+ if (inline) return inline[1].replace(/^['"]|['"]$/g, '')
+ }
+ return undefined
+}
+
+fs.rmSync(CONTENT, { recursive: true, force: true })
+fs.rmSync(PUBLIC_MEDIA, { recursive: true, force: true })
+
+const routes = []
+const index = {}
+const navEntries = []
+let redirectCount = 0
+for (const absFile of mdFiles) {
+ const contentRel = path.relative(SRC, absFile).split(path.sep).join('/')
+ const dest = path.join(CONTENT, contentRel)
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
+
+ const raw = fs.readFileSync(absFile, 'utf-8')
+ const { fmLines, body: rawBody } = splitFrontmatter(raw)
+ const navTitle = scalar(fmLines, 'navTitle')
+ const navGroup = scalar(fmLines, 'navGroup')
+ const navOrderRaw = scalar(fmLines, 'navOrder')
+ const navOrder = navOrderRaw !== undefined ? Number(navOrderRaw) : undefined
+ const redir = redirectTo(fmLines)
+ // originalPath (stamped by copy_docs.js, e.g. `api/README.md`) is the source
+ // path in the external FlowFuse/flowfuse repo -> used for the edit-this-page link.
+ const originalPath = scalar(fmLines, 'originalPath')
+
+ const route = fileToRoute(absFile)
+ const contentPath = fileToContentPath(absFile)
+
+ // Promote navTitle -> title so @nuxt/content's nav + page title resolve,
+ // unless a title is already present.
+ const hasTitle = fmLines.some((l) => /^title:\s*/.test(l))
+ const extra = []
+ if (!hasTitle && navTitle) extra.push(`title: ${JSON.stringify(navTitle)}`)
+
+ const newFm = ['---', ...fmLines.filter((l) => l.length), ...extra, '---', '']
+ const body = rewriteLinks(newFm.join('\n') + rawBody, absFile)
+ fs.writeFileSync(dest, body)
+
+ routes.push(contentPath)
+ const entry = {}
+ if (navTitle) entry.navTitle = navTitle
+ if (navGroup) entry.navGroup = navGroup
+ if (navOrder !== undefined && !Number.isNaN(navOrder)) entry.navOrder = navOrder
+ if (redir) { entry.redirect = redir; redirectCount++ }
+ if (originalPath) entry.editPath = `docs/${originalPath}`
+ index[route] = entry
+
+ navEntries.push({
+ route,
+ url: redir || route,
+ navTitle,
+ navGroup,
+ navOrder,
+ })
+}
+
+routes.sort()
+fs.mkdirSync(path.dirname(ROUTES_FILE), { recursive: true })
+fs.writeFileSync(ROUTES_FILE, JSON.stringify(routes, null, 2) + '\n')
+fs.writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2) + '\n')
+
+const nav = buildNav(navEntries, 'docs', DOCS_GROUP_ORDER)
+fs.writeFileSync(NAV_FILE, JSON.stringify(nav, null, 2) + '\n')
+
+console.log(`copy_docs_nuxt: ${mdFiles.length} markdown pages -> nuxt/content/docs`)
+console.log(`copy_docs_nuxt: nav -> nuxt/docs.nav.json (${nav.groups.length} groups)`)
+console.log(`copy_docs_nuxt: ${copiedMedia.size} images -> nuxt/public/docs-media`)
+console.log(`copy_docs_nuxt: ${redirectCount} redirect pages`)
+console.log(`copy_docs_nuxt: ${routes.length} routes -> ${path.relative(process.cwd(), ROUTES_FILE)}`)
+if (skipped.length) {
+ console.log(`copy_docs_nuxt: ${skipped.length} page(s) skipped (unsafe URL chars): ${skipped.join(', ')}`)
+}
diff --git a/scripts/nav-tree.js b/scripts/nav-tree.js
new file mode 100644
index 0000000000..8fbf6a0984
--- /dev/null
+++ b/scripts/nav-tree.js
@@ -0,0 +1,127 @@
+// scripts/nav-tree.js
+// Reproduce the 11ty `.eleventy.js` addCollection('nav') grouping/ordering for a
+// documentation section (docs / handbook), emitted as a static nav JSON that the
+// section page imports (mirrors nuxt/node-red.nav.json). Pure data, no routes.
+//
+// Input: an array of "entries", one per page that has nav metadata:
+// { route, url, navTitle, navGroup, navOrder }
+// route served URL with trailing slash, e.g. '/docs/user/' (hierarchy key)
+// url link target (redirect.to || route)
+// navTitle sidebar label (falls back to the path segment, humanised)
+// navGroup top-level group heading (only honoured at the section's top level)
+// navOrder numeric order within its parent (default MAX_SAFE_INTEGER)
+//
+// Output (matching node-red.nav.json item shape, plus grouping):
+// { root: { title, url }, groups: [ { name, children: [navItem,...] } ] }
+// navItem = { title, url, children: [navItem,...] }
+const MAX = Number.MAX_SAFE_INTEGER
+
+// Humanise a bare path segment when a directory node has no own page/title.
+function humanise(seg) {
+ return (seg || '').replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+// Build the nested hierarchy map exactly like the 11ty reduce(): split each
+// route into segments under `base` (e.g. 'docs'), and nest by segment.
+// NOTE: like 11ty, a node is only created the FIRST time a segment is seen
+// (shallowest-first via the depth sort); deeper entries walking through an
+// existing parent segment must NOT overwrite that parent's metadata (the
+// dim-1 review's C1 blocker — an `else` overwrite branch corrupted parent/root
+// titles, urls, groups and order).
+function buildHierarchy(entries) {
+ const root = {}
+ // Shallow routes first so parent index nodes are created before children.
+ const sorted = entries.slice().sort((a, b) => {
+ const da = a.route.split('/').filter(Boolean).length
+ const db = b.route.split('/').filter(Boolean).length
+ return da - db
+ })
+ for (const e of sorted) {
+ // skip README artefacts (11ty filtered page.url.includes('README'))
+ if (/README/i.test(e.route)) continue
+ const segs = e.route.split('/').filter(Boolean) // ['docs','user',...]
+ let acc = root
+ for (const seg of segs) {
+ if (!acc[seg]) {
+ acc[seg] = {
+ name: e.navTitle || humanise(seg),
+ url: e.url,
+ order: e.navOrder ?? MAX,
+ group: e.navGroup, // only meaningful at top level
+ children: {},
+ }
+ }
+ acc = acc[seg].children
+ }
+ }
+ return root
+}
+
+// Sort by navOrder then by visible label. toArray nodes carry `title` (not
+// `name`), so the tie-break compares titles.
+const sortByOrderTitle = (a, b) => (a.order - b.order) || a.title.localeCompare(b.title)
+
+// Convert {seg:node} maps to sorted arrays of { title, url, children }.
+function toArray(map) {
+ const arr = Object.values(map).map((n) => {
+ const node = { title: n.name, url: n.url }
+ const kids = toArray(n.children)
+ if (kids.length) node.children = kids
+ node.order = n.order
+ return node
+ })
+ arr.sort(sortByOrderTitle)
+ // strip the helper `order` field from the emitted tree
+ for (const n of arr) delete n.order
+ return arr
+}
+
+// Public: build { root, groups } for a section.
+// base 'docs' | 'handbook'
+// groupOrder array of group names in desired order (docs only; [] for handbook)
+function buildNav(entries, base, groupOrder = []) {
+ const hierarchy = buildHierarchy(entries)
+ const section = hierarchy[base] || { name: base, url: `/${base}/`, children: {} }
+ const root = { title: section.name, url: `/${base}/` }
+
+ // Top-level children of the section, with group + order retained.
+ const topNodes = Object.values(section.children).map((n) => ({
+ node: n,
+ group: n.group,
+ order: n.order ?? MAX,
+ name: n.name,
+ }))
+
+ const groups = {
+ Other: { name: 'Other', order: MAX, children: [] },
+ }
+ for (const t of topNodes) {
+ const built = toArray({ _: t.node })[0] // build this subtree once
+ if (t.group) {
+ if (!groups[t.group]) {
+ const idx = groupOrder.indexOf(t.group)
+ groups[t.group] = {
+ name: t.group,
+ order: idx >= 0 ? idx : MAX,
+ children: [],
+ }
+ }
+ groups[t.group].children.push({ ...built, _order: t.order, _name: t.name })
+ } else {
+ groups.Other.children.push({ ...built, _order: t.order, _name: t.name })
+ }
+ }
+
+ const groupArr = Object.values(groups)
+ .filter((g) => g.children.length > 0)
+ .sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name))
+ .map((g) => {
+ g.children.sort((a, b) => (a._order - b._order) || a._name.localeCompare(b._name))
+ for (const c of g.children) { delete c._order; delete c._name }
+ return { name: g.name, children: g.children }
+ })
+
+ return { root, groups: groupArr }
+}
+
+module.exports = { buildNav }