From 877c556d0d7957cfa070bb576486c0e1a4042e2b Mon Sep 17 00:00:00 2001 From: dimitrieh Date: Tue, 9 Jun 2026 10:46:35 +0000 Subject: [PATCH 1/2] Add native Nuxt /docs adoption layer (collection, renderer, copy script, prerender routes) --- nuxt/components/AlgoliaSearch.vue | 278 +++++++++++++++++++++++++ nuxt/components/HandbookNavSubtree.vue | 54 +++++ nuxt/components/HandbookNavTree.vue | 33 +++ nuxt/content.config.ts | 7 + nuxt/nuxt.config.ts | 14 +- nuxt/pages/docs/[...slug].vue | 81 +++++++ package.json | 5 +- scripts/copy_docs_nuxt.js | 266 +++++++++++++++++++++++ scripts/nav-tree.js | 127 +++++++++++ 9 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 nuxt/components/AlgoliaSearch.vue create mode 100644 nuxt/components/HandbookNavSubtree.vue create mode 100644 nuxt/components/HandbookNavTree.vue create mode 100644 nuxt/pages/docs/[...slug].vue create mode 100644 scripts/copy_docs_nuxt.js create mode 100644 scripts/nav-tree.js diff --git a/nuxt/components/AlgoliaSearch.vue b/nuxt/components/AlgoliaSearch.vue new file mode 100644 index 0000000000..dec9560ee7 --- /dev/null +++ b/nuxt/components/AlgoliaSearch.vue @@ -0,0 +1,278 @@ + + + diff --git a/nuxt/components/HandbookNavSubtree.vue b/nuxt/components/HandbookNavSubtree.vue new file mode 100644 index 0000000000..9d0ba7b6cb --- /dev/null +++ b/nuxt/components/HandbookNavSubtree.vue @@ -0,0 +1,54 @@ + + + diff --git a/nuxt/components/HandbookNavTree.vue b/nuxt/components/HandbookNavTree.vue new file mode 100644 index 0000000000..2fe535491b --- /dev/null +++ b/nuxt/components/HandbookNavTree.vue @@ -0,0 +1,33 @@ + + + diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index 3b5a037d17..68298e3efb 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -60,6 +60,13 @@ export default defineContentConfig({ formTitle: z.string().optional(), formSubtitle: z.string().optional(), }) + }), + // Product docs are generated from src/docs (itself synced from the + // external FlowFuse repo by scripts/copy_docs.js) into nuxt/content/docs + // by scripts/copy_docs_nuxt.js. This is the /docs migration to Nuxt. + docs: defineCollection({ + type: 'page', + source: 'docs/**/*.md' }) } }) diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index b20a3d7734..e1e4473513 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -1,7 +1,17 @@ -import { readdirSync, statSync } from 'node:fs' +import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs' import { join, basename } from 'node:path' +import { fileURLToPath } from 'node:url' import remarkHandbookLinks from './utils/remark-handbook-links' +// Routes generated from the markdown sources by the scripts/copy_*.js steps. +// docs.routes.json is written by scripts/copy_docs_nuxt.js before prod:nuxt; +// missing-file fallback keeps `nuxt dev` working before the copy step runs. +const readRoutes = (name: string): string[] => { + const f = fileURLToPath(new URL(`./${name}`, import.meta.url)) + return existsSync(f) ? JSON.parse(readFileSync(f, 'utf-8')) : [] +} +const docsRoutes = readRoutes('docs.routes.json') + // Collect all handbook routes from content files for SSG prerendering function collectHandbookRoutes(dir: string, basePath: string): string[] { const routes: string[] = [] @@ -80,6 +90,8 @@ export default defineNuxtConfig({ '/whitepaper/accelerating-industrial-innovation-with-low-code-platforms/', '/resources/publications/', ...collectHandbookRoutes(join(__dirname, 'content/handbook'), '/handbook'), + // /docs migration: native Nuxt docs routes (generated list). + ...docsRoutes, ], crawlLinks: false } diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue new file mode 100644 index 0000000000..5549789420 --- /dev/null +++ b/nuxt/pages/docs/[...slug].vue @@ -0,0 +1,81 @@ + + + diff --git a/package.json b/package.json index 2d5b3d85aa..297653baf6 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dev:postcss": "dotenv -v TAILWIND_MODE=watch -- npx postcss ./src/css/style.css -o ./_site/css/style.css --config ./postcss.config.js -w", "dev:postcss-nuxt": "dotenv -v TAILWIND_MODE=watch -- npx postcss ./src/css/style.css -o ./nuxt/public/css/style.css --config ./postcss.config.js -w", "docs": "node scripts/copy_docs.js", + "docs-nuxt": "node scripts/copy_docs_nuxt.js", "blueprints": "node scripts/copy_blueprints.js", "index:algolia": "node scripts/index-algolia.js", "build:indexed": "npm run build && npm run index:algolia", @@ -33,8 +34,8 @@ "prod:postcss-nuxt": "postcss ./src/css/style.css -o ./nuxt/public/css/style.css --config ./postcss.config.js", "prod:eleventy-nuxt": "npx @11ty/eleventy --output=./nuxt/public/", "prod:nuxt": "npm run build --workspace=nuxt", - "build:nuxt": "dotenv -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt docs blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt", - "build:nuxt:skip-images": "dotenv -v SKIP_IMAGES=true -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt docs blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt" + "build:nuxt": "dotenv -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt docs docs-nuxt blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt", + "build:nuxt:skip-images": "dotenv -v SKIP_IMAGES=true -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt docs docs-nuxt blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt" }, "devDependencies": { "@11ty/eleventy": "^3.1.2", diff --git a/scripts/copy_docs_nuxt.js b/scripts/copy_docs_nuxt.js new file mode 100644 index 0000000000..758517f761 --- /dev/null +++ b/scripts/copy_docs_nuxt.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node +// Copy the product docs markdown from the legacy 11ty tree (src/docs) into the +// Nuxt Content tree (nuxt/content/docs), mirroring scripts/copy_handbook.js. +// +// src/docs is itself generated at build time by scripts/copy_docs.js from the +// external FlowFuse repo (../flowfuse/docs); run that first. +// +// - relative `.md` links -> absolute `/docs/...` route URLs (trailing /) +// - relative image paths -> absolute `/docs-media/...` URLs, and the +// referenced image is copied into nuxt/public. +// - `navTitle` is promoted to `title` so the sidebar nav + 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 <pre> 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*<div\s+class="ff-(offering|product-feature)-tiles/.test(line)) { + inTiles = true + depth = 0 + } + if (inTiles) { + if (line.trim() === '') continue + out.push(line) + depth += (line.match(/<div\b/g) || []).length + depth -= (line.match(/<\/div>/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: ![alt](target "title") + 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: <a href="target"> (the docs include hand-written HTML). + body = body.replace(/(<a\b[^>]*\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) + + 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++ } + 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 } From 74a447fe9580e176191eecff0124d834e56c529e Mon Sep 17 00:00:00 2001 From: dimitrieh <hi@dimitr.ie> Date: Tue, 9 Jun 2026 11:17:26 +0000 Subject: [PATCH 2/2] Docs parity: Algolia search config, GitHub edit-this-page link, breadcrumbs --- nuxt/nuxt.config.ts | 11 ++++++++++ nuxt/pages/docs/[...slug].vue | 38 +++++++++++++++++++++++++++++++++++ scripts/copy_docs_nuxt.js | 4 ++++ 3 files changed, 53 insertions(+) diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index e1e4473513..533e5cdbb8 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -45,6 +45,17 @@ export default defineNuxtConfig({ 'handbook-links': join(__dirname, 'utils/remark-handbook-links'), }, + // Algolia docs search (parity with 11ty common-js.njk): the docs page's + // <AlgoliaSearch> reads these public runtime values. Defaults reproduce the + // 11ty appId/apiKey/index (search-only key, safe to ship), overridable via env. + runtimeConfig: { + public: { + algoliaAppId: process.env.NUXT_PUBLIC_ALGOLIA_APP_ID || 'ISKYOHIT7D', + algoliaApiKey: process.env.NUXT_PUBLIC_ALGOLIA_API_KEY || '68d4032f487d66423c37e6483e067272', + algoliaIndexName: process.env.NUXT_PUBLIC_ALGOLIA_INDEX_NAME || 'prod_netlify', + }, + }, + app: { head: { link: [ diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue index 5549789420..a61a1f9940 100644 --- a/nuxt/pages/docs/[...slug].vue +++ b/nuxt/pages/docs/[...slug].vue @@ -30,6 +30,28 @@ const nav = docsNav const toc = computed(() => page.value?.body?.toc?.links ?? []) +// Breadcrumbs — mirror the 11ty `handbookBreadcrumbs` filter: split the URL into +// parts (dropping a trailing `index`), each linking to its cumulative path with a +// trailing slash (URL parity), labelled with the raw slug. +const breadcrumbs = computed(() => { + const parts = route.path.split('/').filter(Boolean) + if (parts[parts.length - 1] === 'index') parts.pop() + let path = '' + return parts.map((name) => { + path += '/' + name + return { name, path: path + '/' } + }) +}) + +// Edit this page — mirror the 11ty `handbookEditLink` filter for `/docs`: point at +// the source file in the external FlowFuse/flowfuse repo. `editPath` (e.g. +// `docs/api/README.md`) is stamped into docs.index.json by copy_docs_nuxt.js from +// each doc's `originalPath` frontmatter; fall back to the content frontmatter. +const editPath = computed(() => meta.editPath || page.value?.originalPath && `docs/${page.value.originalPath}`) +const editLink = computed(() => + editPath.value ? `https://github.com/FlowFuse/flowfuse/edit/main/${editPath.value}` : null +) + // Sidebar is a collapsible disclosure below lg; always shown inline at lg+. const navOpen = ref(false) watch(() => route.path, () => { navOpen.value = false }) @@ -64,6 +86,22 @@ useHead({ <!-- Main content --> <article v-if="page" class="min-w-0 flex-1 prose prose-blue max-w-none main-content"> + <!-- Breadcrumbs + edit-this-page (parity with 11ty documentation.njk) --> + <div class="not-prose flex items-center justify-between gap-4 border-b pb-2 mb-6 text-sm"> + <nav aria-label="Breadcrumb" class="text-gray-500 min-w-0 truncate"> + <span v-for="(crumb, i) in breadcrumbs" :key="crumb.path"> + <NuxtLink :href="crumb.path" class="hover:text-blue-700">{{ crumb.name }}</NuxtLink> + <span v-if="i < breadcrumbs.length - 1" class="mx-1 text-gray-300">/</span> + </span> + </nav> + <a + v-if="editLink" + :href="editLink" + target="_blank" + rel="noopener" + class="flex-shrink-0 text-gray-500 hover:text-blue-700 italic" + >Edit this page</a> + </div> <ContentRenderer :value="page" /> </article> diff --git a/scripts/copy_docs_nuxt.js b/scripts/copy_docs_nuxt.js index 758517f761..1847eb5b7a 100644 --- a/scripts/copy_docs_nuxt.js +++ b/scripts/copy_docs_nuxt.js @@ -217,6 +217,9 @@ for (const absFile of mdFiles) { 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) @@ -237,6 +240,7 @@ for (const absFile of mdFiles) { 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({