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..533e5cdbb8 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[] = [] @@ -35,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 + // 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: [ @@ -80,6 +101,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..a61a1f9940 --- /dev/null +++ b/nuxt/pages/docs/[...slug].vue @@ -0,0 +1,119 @@ + + + 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..1847eb5b7a --- /dev/null +++ b/scripts/copy_docs_nuxt.js @@ -0,0 +1,270 @@ +#!/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) + // 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 }