diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c966ef62d0..96c11e5373 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -17,6 +17,7 @@ The site is migrating from Eleventy (11ty) to Nuxt 3. Nuxt is the primary framew | Section | Status | |---------|--------| | `/handbook/**` | **Migrated** — served by Nuxt (`nuxt/content/handbook/`) | +| `/docs/**` | **Migrated** — served by Nuxt; source cloned from `flowfuse/flowfuse` at build time | | All other routes | Still on 11ty, proxied through Nuxt in dev | ### Production build order @@ -25,20 +26,21 @@ The site is migrating from Eleventy (11ty) to Nuxt 3. Nuxt is the primary framew clean:nuxt → build:js:nuxt → prod:postcss-nuxt → prod:eleventy-nuxt → prod:nuxt ``` -11ty outputs to `nuxt/public/` so Nuxt can serve 11ty-generated assets. `nuxt/public/` is gitignored (fully build-generated). +The `docs-source` Nuxt module runs automatically during `prod:nuxt` and sparse-clones `docs/` from `flowfuse/flowfuse` (public repo, no token needed). 11ty outputs to `nuxt/public/` so Nuxt can serve 11ty-generated assets. `nuxt/public/` is gitignored (fully build-generated). ## Dev commands ```bash -npm start # all watchers in parallel (11ty + nuxt + postcss + docs + blueprints) +npm start # all watchers in parallel (11ty + nuxt + postcss + blueprints) npm run dev # eleventy + postcss + nuxt only npm run dev:eleventy # 11ty only, port 8080 (legacy; most work doesn't need this) -npm run dev:nuxt # Nuxt only, port 3000 — use this for handbook and migrated pages -npm run docs # sync docs from external source once +npm run dev:nuxt # Nuxt only, port 3000 — use this for handbook, docs, and migrated pages npm run build # production build ``` -> When working on the handbook or other migrated sections, `npm run dev:nuxt` is sufficient. `npm start` is only needed when also touching 11ty-served pages. +> When working on the handbook, docs, or other migrated sections, `npm run dev:nuxt` is sufficient. `npm start` is only needed when also touching 11ty-served pages. +> +> **Local docs development:** set `FLOWFUSE_DOCS_LOCAL=/path/to/flowfuse` to point the docs module at a local checkout instead of cloning from GitHub. If the env var is not set and `nuxt/content/docs/` already exists, that cached copy is used. If neither is true, the module clones fresh from GitHub (public, no token needed). ## Directory layout @@ -51,14 +53,27 @@ src/ ├── blog/ # Blog posts → /blog/YYYY/MM/slug/ ├── changelog/ # Changelog entries → /changelog/YYYY/MM/slug/ ├── customer-stories/ # Case studies → /customer-stories/slug/ -├── docs/ # Product docs → /docs/section/slug/ (synced from external) -├── handbook/ # Employee handbook → /handbook/section/slug/ ├── css/ # Tailwind + custom CSS ├── images/ # Static images └── public/ # Pass-through static files -scripts/ # Build-time scripts (copy_docs.js, copy_blueprints.js, etc.) +nuxt/ +├── content/ +│ ├── handbook/ # Handbook pages (edit here) +│ └── docs/ # Product docs (build-generated, gitignored — do not edit) +├── modules/ +│ └── docs-source.ts # Clones docs from flowfuse/flowfuse at build time +├── composables/ +│ ├── useHandbookNav.ts +│ └── useDocsNav.ts +├── components/ +│ ├── HandbookLeftNav.vue +│ └── DocsLeftNav.vue +└── pages/ + ├── handbook/[...slug].vue + └── docs/[...slug].vue +scripts/ # Build-time scripts (copy_blueprints.js, etc.) lib/ # Shared helpers used by .eleventy.js and scripts -.eleventy.js # Main Eleventy config (1100+ lines) +.eleventy.js # Main Eleventy config ``` --- @@ -149,25 +164,29 @@ Collection config: `nuxt/content.config.ts` (defines the `handbook` collection) ### Product docs -**Source:** `src/docs/{section}/{slug}.md` — **do not edit directly**; synced via `node scripts/copy_docs.js` from the external `flowfuse/flowfuse` monorepo. +**Source:** `flowfuse/flowfuse` repo, `docs/` directory — **do not edit in this repo**; cloned automatically at build time by `nuxt/modules/docs-source.ts`. **URL:** `/docs/{section}/{slug}/` -**Layout:** `layouts/documentation.njk` +**Rendered by:** Nuxt — `nuxt/pages/docs/[...slug].vue` + `DocsLeftNav` component +**Local content:** `nuxt/content/docs/` (gitignored, build-generated) +**Local assets:** `nuxt/public/docs/` (images, etc.) ```yaml --- navTitle: "Page title for sidebar" -navGroup: "Section heading" +navGroup: "Section heading" # set on section index pages only navOrder: 3 meta: description: "Page description" -# optional redirect: +# optional redirect (section index pages): redirect: - to: https://example.com + to: /docs/section/first-page layout: redirect --- ``` -Collection config: `src/docs/docs.json` +**Nav groups** (in order): FlowFuse User Manuals · Device Agent · FlowFuse Cloud · FlowFuse Self-Hosted · Support · Contributing +**Nav composable:** `nuxt/composables/useDocsNav.ts` +**Collection config:** `nuxt/content.config.ts` (defines the `docs` collection) --- @@ -227,7 +246,7 @@ Collection config: `src/customer-stories/customer-stories.json` | `layouts/base.njk` | HTML shell | | `layouts/post.njk` | Blog posts | | `layouts/post-changelog.njk` | Changelog entries | -| `layouts/documentation.njk` | Docs + handbook (with sidebar nav) | +| `layouts/documentation.njk` | Node-RED learning resources (with sidebar nav) | | `layouts/story.njk` | Customer stories | | `layouts/nohero.njk` | General pages without hero | diff --git a/.eleventy.js b/.eleventy.js index 62f8577d31..59b24e37aa 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -157,7 +157,6 @@ module.exports = function(eleventyConfig) { eleventyConfig.addDataExtension("yaml", contents => yaml.load(contents)); // Add support for YAML data files - eleventyConfig.setUseGitIgnore(false); // Otherwise docs are ignored eleventyConfig.setWatchThrottleWaitTime(500); // in milliseconds eleventyConfig.setFrontMatterParsingOptions({ excerpt: true, @@ -926,10 +925,10 @@ module.exports = function(eleventyConfig) { return results; } - // Inject tier badges into docs pages: parent feature after H1, subfeatures after their headings + // Inject tier badges into node-red pages: parent feature after H1, subfeatures after their headings eleventyConfig.addTransform("docsFeatureBadges", function(content) { if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content; - if (!this.page.url || !/^(\/docs\/|\/node-red\/)/.test(this.page.url)) return content; + if (!this.page.url || !/^\/node-red\//.test(this.page.url)) return content; const parentFeature = findFeatureByDocsLink(this.page.url); const subfeatures = findSubfeaturesForDocsPage(this.page.url); @@ -1112,135 +1111,9 @@ module.exports = function(eleventyConfig) { return await imageHandler(imageSrc, imageDescription, title, [imageSize], null, currentWorkingFilePath, eleventyConfig, async=true, SKIP_IMAGES, priority); }); - // Create a collection for sidebar navigation - eleventyConfig.addCollection('nav', function(collection) { - let nav = {} - - createNav('docs') - - function createNav(tag) { - const groupOrder = { - docs: [ - 'FlowFuse User Manuals', - 'Device Agent', - 'FlowFuse Cloud', - 'FlowFuse Self-Hosted', - 'Support', - 'Contributing' - ] - } - - collection.getFilteredByTag(tag).filter((page) => { - return !page.url.includes('README') - }).sort((a, b) => { - // sort by depth, so we catch all the correct index.md routes - const hierarchyA = a.url.split('/').filter(n => n) - const hierarchyB = b.url.split('/').filter(n => n) - return hierarchyA.length - hierarchyB.length - }).forEach((page) => { - let url = page.url - - // work out ToC Hierarchy - // split the folder URI/URL, as this defines our TOC Hierarchy - const hierarchy = url.split('/').filter(n => n) - // recursively parse the folder hierarchy and created our collection object - // pass nav = {} as the first accumulator - build up hierarchy map of TOC - hierarchy.reduce((accumulator, currentValue, i) => { - // create a nested object detailing the full docs hierarchy - if (!accumulator[currentValue]) { - accumulator[currentValue] = { - 'name': currentValue, - 'url': page.data.redirect?.to || page.data.redirect || page.url, - 'order': page.data.navOrder || Number.MAX_SAFE_INTEGER, - 'children': {} - } - if (page.data.navTitle) { - accumulator[currentValue].name = page.data.navTitle - } - // TODO: navGroup will be used in the rendering of the ToC at a later stage - if (page.data.navGroup) { - accumulator[currentValue].group = page.data.navGroup - } - } - return accumulator[currentValue].children - }, nav) - }) - - // recursive functions to format our nav map to arrays - function childrenToArray (children) { - return Object.values(children) - } - function nestedChildrenToArray (value) { - for (const [key, entry] of Object.entries(value)) { - if (entry.children && Object.keys(entry.children).length > 0) { - // ensure our grandchildren are all converted to arrays before - // we convert the higher level object to an array - nestedChildrenToArray(entry.children) - // now we have converted all grandchildren, - // we can convert our children to an array - entry.children = childrenToArray(entry.children) - } else { - delete entry.children - } - } - - } - // convert our objects to arrays so we can render in nunjucks - nestedChildrenToArray(nav) - - // add functionality to group to-level items for better navigation. - let groups = { - 'Other': { - name: 'Other', - order: Number.MAX_SAFE_INTEGER, // always render last - children: [] - } - } - - if (nav[tag]) { - for (child of nav[tag].children) { - if (child.group) { - const group = child.group - if (!groups[group]) { - groups[group] = { - name: group, - order: groupOrder[tag] && groupOrder[tag].includes(group) ? groupOrder[tag].indexOf(group) : Number.MAX_SAFE_INTEGER, - children: [] - } - } - groups[group].children.push(child) - } else { - // capture & flag top-level docs that haven't had a group assigned - groups['Other'].children.push(child) - } - } - - function sortChildren (a, b) { - // sort children by 'order', then alphabetical - return (a.order - b.order) || a.name.localeCompare(b.name) - } - - function sortTree (node) { - if (!node || !node.children || !Array.isArray(node.children)) { - return - } - - node.children.sort(sortChildren) - node.children.forEach(sortTree) - } - - nav[tag].groups = Object.values(groups).sort(sortChildren) - - nav[tag].groups.forEach((group) => { - if (group.children) { - sortTree(group) - } - }) - } - } - - return nav; - }); + // Placeholder: docs nav collection removed (docs served by Nuxt) + // Docs nav moved to Nuxt; this empty collection keeps left-nav.njk from erroring + eleventyConfig.addCollection('nav', function() { return {} }); eleventyConfig.addCollection("aiBlog", function(collectionApi) { return collectionApi.getFilteredByTag("ai").filter(item => { diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0087e1501e..bd244bfcf8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,12 +16,6 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: path: 'website' - - name: Check out FlowFuse/flowfuse repository (to access the docs) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: 'FlowFuse/flowfuse' - ref: main - path: 'flowfuse' - name: Generate a token id: generate_token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -43,17 +37,8 @@ jobs: node-version: 24 cache: 'npm' cache-dependency-path: './website/package-lock.json' - - run: npm run docs - working-directory: 'website' - run: npm run blueprints working-directory: 'website' - - name: Commit Latest Docs - run: | - cd ./website - git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git add src/docs/* -A -f - git commit -a -m "Bot: update docs" - name: Commit Latest Blueprints run: | cd ./website diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e67320c5b9..7a98eb119d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,20 +15,12 @@ jobs: owner: ${{ github.repository_owner }} repositories: | website - flowfuse blueprint-library - name: Check out website repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: path: 'website' token: ${{ steps.generate_token.outputs.token }} - - name: Check out FlowFuse/flowfuse repository (to access the docs) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: 'FlowFuse/flowfuse' - ref: main - path: 'flowfuse' - token: ${{ steps.generate_token.outputs.token }} - name: Check out FlowFuse/blueprint-library repository (to access the blueprints) uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -41,8 +33,6 @@ jobs: node-version: 24 cache: 'npm' cache-dependency-path: './website/package-lock.json' - - run: npm run docs - working-directory: 'website' - run: npm run blueprints working-directory: 'website' - name: Install Dependencies diff --git a/.gitignore b/.gitignore index d0ff276216..33e937894b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,7 @@ _site node_modules src/handbook/media -src/docs/* -!src/docs/docs.json +nuxt/content/docs/ src/blueprints/* !src/blueprints/*.njk diff --git a/nuxt/components/DocsLeftNav.vue b/nuxt/components/DocsLeftNav.vue new file mode 100644 index 0000000000..762b43fe58 --- /dev/null +++ b/nuxt/components/DocsLeftNav.vue @@ -0,0 +1,108 @@ + + + diff --git a/nuxt/components/content/ChecklistItem.vue b/nuxt/components/content/ChecklistItem.vue new file mode 100644 index 0000000000..6092f27ac1 --- /dev/null +++ b/nuxt/components/content/ChecklistItem.vue @@ -0,0 +1,33 @@ + + + diff --git a/nuxt/composables/useDocsNav.ts b/nuxt/composables/useDocsNav.ts new file mode 100644 index 0000000000..bffd308374 --- /dev/null +++ b/nuxt/composables/useDocsNav.ts @@ -0,0 +1,115 @@ +export interface DocsNavNode { + name: string + path: string + group?: string + order: number + children: DocsNavNode[] +} + +export interface DocsNavGroup { + name: string + order: number + children: DocsNavNode[] +} + +interface RawPage { + path: string + title?: string | null + navTitle?: string | null + navOrder?: number | null + navGroup?: string | null +} + +interface TreeNode { + name: string + path: string + group?: string + order: number + children: Record +} + +const GROUP_ORDER = [ + 'FlowFuse User Manuals', + 'Device Agent', + 'FlowFuse Cloud', + 'FlowFuse Self-Hosted', + 'Support', + 'Contributing', +] + +export function buildDocsNav(pages: RawPage[]): DocsNavGroup[] { + const tree: Record = {} + + const sorted = [...pages].sort((a, b) => { + const depthA = a.path.split('/').filter(Boolean).length + const depthB = b.path.split('/').filter(Boolean).length + return depthA - depthB + }) + + for (const page of sorted) { + const parts = page.path.split('/').filter(Boolean) + let current = tree + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const isLeaf = i === parts.length - 1 + const displayName = isLeaf ? (page.navTitle || page.title || part) : part + + if (!current[part]) { + current[part] = { + name: displayName, + path: '/' + parts.slice(0, i + 1).join('/'), + group: isLeaf ? (page.navGroup ?? undefined) : undefined, + order: isLeaf ? (page.navOrder ?? Infinity) : Infinity, + children: {}, + } + } else if (isLeaf) { + // Update name/group/order when we reach the leaf for this node + current[part].name = displayName + current[part].group = page.navGroup ?? undefined + current[part].order = page.navOrder ?? Infinity + } + + current = current[part].children + } + } + + function toDocsNavNodes(obj: Record): DocsNavNode[] { + return Object.values(obj).map(node => ({ + name: node.name, + path: node.path, + group: node.group, + order: node.order, + children: toDocsNavNodes(node.children), + })) + } + + function sortNodes(nodes: DocsNavNode[]): DocsNavNode[] { + return nodes + .sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name)) + .map(n => ({ ...n, children: sortNodes(n.children) })) + } + + const root = toDocsNavNodes(tree) + const docsRoot = root.find(n => n.path === '/docs') + if (!docsRoot) return [] + + const groups: Record = {} + + for (const section of sortNodes(docsRoot.children)) { + const groupName = section.group || 'Other' + if (!groups[groupName]) { + const groupIdx = GROUP_ORDER.indexOf(groupName) + groups[groupName] = { + name: groupName, + order: groupIdx >= 0 ? groupIdx : GROUP_ORDER.length, + children: [], + } + } + groups[groupName].children.push(section) + } + + return Object.values(groups) + .filter(g => g.children.length > 0) + .sort((a, b) => a.order - b.order) +} diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index 3b5a037d17..32fb393aa5 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -6,6 +6,25 @@ export default defineContentConfig({ type: 'page', source: '*.md' }), + docs: defineCollection({ + type: 'page', + source: 'docs/**/*.md', + schema: z.object({ + navTitle: z.string().optional(), + navGroup: z.string().optional(), + navOrder: z.number().optional(), + originalPath: z.string().optional(), + updated: z.string().optional(), + version: z.string().optional(), + layout: z.string().optional(), + redirect: z.object({ + to: z.string(), + }).optional(), + meta: z.object({ + description: z.string().optional(), + }).optional(), + }) + }), handbook: defineCollection({ type: 'page', source: 'handbook/**/*.md', diff --git a/nuxt/modules/docs-source.ts b/nuxt/modules/docs-source.ts new file mode 100644 index 0000000000..62624573f1 --- /dev/null +++ b/nuxt/modules/docs-source.ts @@ -0,0 +1,153 @@ +import { defineNuxtModule, useLogger } from '@nuxt/kit' +import { execSync } from 'node:child_process' +import { mkdirSync, cpSync, writeFileSync, readFileSync, rmSync, existsSync, readdirSync } from 'node:fs' +import { join, basename, relative, dirname } from 'node:path' +import { tmpdir } from 'node:os' + +const logger = useLogger('docs-source') + +const GROUP_ORDER = [ + 'FlowFuse User Manuals', + 'Device Agent', + 'FlowFuse Cloud', + 'FlowFuse Self-Hosted', + 'Support', + 'Contributing', +] + +function processMarkdown(content: string, originalPath: string, updated: string, version: string): string { + const injected = `originalPath: ${originalPath}\nupdated: ${updated}\nversion: ${version}\n` + + if (/^---/.test(content)) { + content = content.replace(/^---\n/, `---\n${injected}`) + } else { + content = `---\n${injected}---\n${content}` + } + + // Remove Nunjucks-specific frontmatter field + content = content.replace(/^templateEngineOverride:[^\n]*\n/m, '') + + // Convert callout shortcodes to HTML divs + content = content + .replace(/\{%-?\s*note\s*-?%\}([\s\S]*?)\{%-?\s*endnote\s*-?%\}/g, + (_, body) => `

Note

\n\n${body.trim()}\n\n
`) + .replace(/\{%-?\s*warning\s*-?%\}([\s\S]*?)\{%-?\s*endwarning\s*-?%\}/g, + (_, body) => `

Warning

\n\n${body.trim()}\n\n
`) + .replace(/\{%-?\s*critical\s*-?%\}([\s\S]*?)\{%-?\s*endcritical\s*-?%\}/g, + (_, body) => `

Critical

\n\n${body.trim()}\n\n
`) + // Strip remaining Nunjucks tags (set, include, if, for, etc.) + .replace(/\{%[^%]*%\}\n?/g, '') + + return content +} + +function copyDocsDir( + srcDir: string, + repoRoot: string, + contentDir: string, + publicDir: string, + version: string, +) { + mkdirSync(contentDir, { recursive: true }) + mkdirSync(publicDir, { recursive: true }) + + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue + + const srcPath = join(srcDir, entry.name) + const destName = entry.name === 'README.md' ? 'index.md' : entry.name + + if (entry.isDirectory()) { + copyDocsDir(srcPath, repoRoot, join(contentDir, entry.name), join(publicDir, entry.name), version) + } else if (entry.name.endsWith('.md')) { + const relFromRepo = relative(repoRoot, srcPath) + const docsRoot = join(repoRoot, 'docs') + const originalPath = relative(docsRoot, srcPath) + + let updated = '' + try { + updated = execSync(`git log -1 --pretty=format:%ci -- "${relFromRepo}"`, { + cwd: repoRoot, encoding: 'utf8', + }).trim() + } catch { /* not fatal */ } + + const raw = readFileSync(srcPath, 'utf8') + const processed = processMarkdown(raw, originalPath, updated, version) + writeFileSync(join(contentDir, destName), processed, 'utf8') + } else { + cpSync(srcPath, join(publicDir, entry.name)) + } + } +} + +function collectRoutes(dir: string, basePath: string): string[] { + const routes: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue + if (entry.isDirectory()) { + routes.push(...collectRoutes(join(dir, entry.name), `${basePath}/${entry.name}`)) + } else if (entry.name.endsWith('.md')) { + const slug = basename(entry.name, '.md') + routes.push(slug === 'index' ? `${basePath}/` : `${basePath}/${slug}/`) + } + } + return routes +} + +export default defineNuxtModule({ + meta: { name: 'docs-source' }, + async setup(_options, nuxt) { + const nuxtRoot = nuxt.options.rootDir + const contentDocsDir = join(nuxtRoot, 'content', 'docs') + const publicDocsDir = join(nuxtRoot, 'public', 'docs') + + const localPath = process.env.FLOWFUSE_DOCS_LOCAL + + if (localPath) { + logger.info(`Using local docs from ${localPath}`) + const docsDir = localPath.endsWith('/docs') ? localPath : join(localPath, 'docs') + if (!existsSync(docsDir)) { + logger.warn(`FLOWFUSE_DOCS_LOCAL path not found: ${docsDir}`) + } else { + let version = '' + try { + const pkg = JSON.parse(readFileSync(join(dirname(docsDir), 'package.json'), 'utf8')) + version = pkg.version || '' + } catch { /* ignore */ } + if (existsSync(contentDocsDir)) rmSync(contentDocsDir, { recursive: true, force: true }) + if (existsSync(publicDocsDir)) rmSync(publicDocsDir, { recursive: true, force: true }) + copyDocsDir(docsDir, dirname(docsDir), contentDocsDir, publicDocsDir, version) + logger.success('Local docs copied') + } + } else if (existsSync(contentDocsDir)) { + logger.info('Using existing content/docs (set FLOWFUSE_DOCS_LOCAL to refresh)') + } else { + logger.info('Cloning FlowFuse docs...') + const tmpDir = join(tmpdir(), `flowfuse-docs-${Date.now()}`) + try { + const repoUrl = 'https://github.com/FlowFuse/flowfuse.git' + execSync(`git clone --filter=blob:none --no-checkout --depth=1 ${repoUrl} "${tmpDir}"`, { stdio: 'pipe' }) + execSync('git sparse-checkout set docs', { cwd: tmpDir, stdio: 'pipe' }) + execSync('git checkout', { cwd: tmpDir, stdio: 'pipe' }) + + const pkg = JSON.parse(readFileSync(join(tmpDir, 'package.json'), 'utf8')) + const version: string = pkg.version || '' + + if (existsSync(contentDocsDir)) rmSync(contentDocsDir, { recursive: true, force: true }) + if (existsSync(publicDocsDir)) rmSync(publicDocsDir, { recursive: true, force: true }) + copyDocsDir(join(tmpDir, 'docs'), tmpDir, contentDocsDir, publicDocsDir, version) + logger.success(`Docs cloned (version ${version})`) + } finally { + if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) + } + } + + if (!existsSync(contentDocsDir)) return + + const docsRoutes = collectRoutes(contentDocsDir, '/docs') + nuxt.options.nitro.prerender ??= {} + const existing = (nuxt.options.nitro.prerender.routes as string[] | undefined) ?? [] + nuxt.options.nitro.prerender.routes = [...existing, ...docsRoutes] + logger.info(`Added ${docsRoutes.length} docs routes for prerendering`) + }, +}) diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index b20a3d7734..3c9121fbd3 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -1,6 +1,7 @@ import { readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' import remarkHandbookLinks from './utils/remark-handbook-links' +import remarkDocsLinks from './utils/remark-docs-links' // Collect all handbook routes from content files for SSG prerendering function collectHandbookRoutes(dir: string, basePath: string): string[] { @@ -20,7 +21,7 @@ function collectHandbookRoutes(dir: string, basePath: string): string[] { // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ devtools: { enabled: true }, - modules: ['@nuxt/content', 'nuxt-link-checker', 'nuxt-studio', '@nuxt/image'], + modules: ['@nuxt/content', 'nuxt-link-checker', 'nuxt-studio', '@nuxt/image', './modules/docs-source'], linkChecker: { failOnError: true, @@ -29,10 +30,11 @@ export default defineNuxtConfig({ skipInspections: ['trailing-slash', 'no-error-response'], }, - // @nuxt/content generates `import X from 'handbook-links'` for the remark plugin key. - // This alias makes that import resolvable in the Vite bundle context. + // @nuxt/content generates import statements for remark plugin keys. + // These aliases make them resolvable in the Vite bundle context. alias: { 'handbook-links': join(__dirname, 'utils/remark-handbook-links'), + 'docs-links': join(__dirname, 'utils/remark-docs-links'), }, app: { @@ -100,6 +102,7 @@ export default defineNuxtConfig({ markdown: { remarkPlugins: { 'handbook-links': { instance: remarkHandbookLinks }, + 'docs-links': { instance: remarkDocsLinks }, }, }, }, diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue new file mode 100644 index 0000000000..77b9371963 --- /dev/null +++ b/nuxt/pages/docs/[...slug].vue @@ -0,0 +1,101 @@ + + + diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index ca923de05b..359cdb38c6 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -5,7 +5,7 @@ import { proxyRequest } from 'h3' const NUXT_ROUTES = new Set(['/terms', '/privacy-policy', '/resources/publications']) // Route prefixes handled by Nuxt (all paths starting with these are served by Nuxt). -const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper'] +const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/docs'] export default defineEventHandler(async (event) => { if (process.env.NODE_ENV !== 'development') return diff --git a/nuxt/utils/remark-docs-links.ts b/nuxt/utils/remark-docs-links.ts new file mode 100644 index 0000000000..4f61f0cbc5 --- /dev/null +++ b/nuxt/utils/remark-docs-links.ts @@ -0,0 +1,61 @@ +import { visit } from 'unist-util-visit' +import type { Root } from 'mdast' +import type { VFile } from 'vfile' + +function posixDirname(path: string): string { + const i = path.lastIndexOf('/') + return i <= 0 ? '/' : path.slice(0, i) +} + +function posixResolve(base: string, rel: string): string { + const parts = (base + rel).split('/') + const out: string[] = [] + for (const p of parts) { + if (p === '..') out.pop() + else if (p !== '.') out.push(p) + } + return '/' + out.filter(Boolean).join('/') +} + +// Converts relative image/link URLs in docs markdown to absolute /docs/... paths. +// Needed because @nuxt/content serves pages with trailing-slash URLs which +// would mis-resolve relative paths without this fix. +export default function remarkDocsLinks() { + return (tree: Root, file: VFile) => { + const filePath: string = (file.path || file.history?.[0] || '') as string + if (!filePath.includes('/docs/')) return + + const docsIdx = filePath.lastIndexOf('/docs/') + const relPath = filePath.slice(docsIdx) + const baseDir = posixDirname(relPath) + '/' + + function resolveUrl(url: string): string { + if (!url) return url + if (/^(https?:|#|mailto:|\/|data:)/.test(url)) return url + // Strip .md extension (+ optional anchor) + url = url.replace(/\.md(#.*)?$/, (_, anchor) => anchor ?? '') + // Strip README reference — resolves to the directory index + url = url.replace(/README(#.*)?$/, (_, anchor) => anchor ?? '.') + const anchor = url.match(/#.*/)?.[0] ?? '' + const pathPart = url.replace(/#.*$/, '') + const resolved = posixResolve(baseDir, pathPart) + return resolved + anchor + } + + visit(tree, 'image', (node: any) => { + node.url = resolveUrl(node.url) + }) + + visit(tree, 'link', (node: any) => { + node.url = resolveUrl(node.url) + }) + + // Resolve src in raw HTML tags + visit(tree, 'html', (node: any) => { + node.value = node.value.replace( + /(]*?\s)src="([^"]*?)"/gi, + (_: string, prefix: string, url: string) => `${prefix}src="${resolveUrl(url)}"`, + ) + }) + } +} diff --git a/package.json b/package.json index 2d5b3d85aa..5e1771400e 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,16 @@ "workspaces": ["nuxt"], "scripts": { "dev": "concurrently \"npm run dev:eleventy\" \"npm run dev:postcss\" \"npm run dev:postcss-nuxt\" \"dotenv -- npm run dev --workspace=nuxt\"", - "start": "npm-run-all2 clean:dev build:js docs blueprints --parallel dev:*", + "start": "npm-run-all2 clean:dev build:js blueprints --parallel dev:*", "build:js": "terser -c -m -o _site/js/cc.min.js node_modules/vanilla-cookieconsent/dist/cookieconsent.umd.js src/js/cookieconsent-config.js && cp node_modules/@flowfuse/flow-renderer/index.min.js _site/js/flowrenderer.min.js", "build": "dotenv -v NODE_ENV=production -- npm-run-all2 clean build:js --parallel prod:*", "build:skip-images": "dotenv -v SKIP_IMAGES=true -- npm run build", - "clean:dev": "dotenv -- npx del-cli '_site/!(img)' 'src/docs/**/*.md' 'src/blueprints/**/!(*submit.njk)' && dotenv -- npx mkdirp '_site/js/flows'", + "clean:dev": "dotenv -- npx del-cli '_site/!(img)' 'src/blueprints/**/!(*submit.njk)' && dotenv -- npx mkdirp '_site/js/flows'", "clean": "dotenv -- npx del-cli '_site/!(img)' && dotenv -- mkdir -p '_site/js'", - "dev:docs": "node scripts/copy_docs.js --watch", "dev:blueprints": "node scripts/watch_blueprints.js", "dev:netlify": "npx netlify dev -c \"dotenv -- npx @11ty/eleventy --serve --quiet --incremental\"", "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", "blueprints": "node scripts/copy_blueprints.js", "index:algolia": "node scripts/index-algolia.js", "build:indexed": "npm run build && npm run index:algolia", @@ -33,8 +31,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 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 blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt" }, "devDependencies": { "@11ty/eleventy": "^3.1.2", diff --git a/scripts/copy_docs.js b/scripts/copy_docs.js deleted file mode 100644 index fc4201af68..0000000000 --- a/scripts/copy_docs.js +++ /dev/null @@ -1,146 +0,0 @@ -const fs = require('fs/promises'); -const { watch, existsSync, statSync, readdirSync, rmSync, mkdirSync } = require('fs') -const WATCHMODE = process.argv.includes('--watch') -const path = require('path') -const util = require('util') -const exec = util.promisify(require('child_process').exec) - -const watched = {} - -async function copyFile(src, dest, filename, version) { - const srcFile = path.join(src, filename) - const destFile = path.join(dest, filename.replace(/README/,"index")) - if (!filename.endsWith('.md')) { - await fs.copyFile(srcFile, destFile) - } else { - const { stdout } = await exec(`git log -1 --pretty=format:%ci ${filename}`, { - cwd: src - }) - const header = '---\n' + - `originalPath: ${path.join(src, filename).replace(/^..\/(flowforge|flowfuse)\/docs\//,'')}\n` + - `updated: ${stdout}\n` + - `version: ${version}\n` + - '---\n' - const content = await fs.readFile(srcFile, 'utf-8') - let body = header + content - if (/^---/.test(content)) { - // The original file starts with yaml front-matter, so - // remove the double-delimter we've just introduced - body = body.replace(/---\r?\n---\r?\n/s, '') - } - await fs.writeFile(destFile, body) - } -} - -async function copyFiles (src, dest, version) { - const files = await fs.readdir(src, {withFileTypes: true}) - for (const file of files) { - if (!file.name.startsWith('.')) { - if (file.isDirectory()) { - const newSrc = path.join(src,file.name) - const newDest = path.join(dest, file.name) - await fs.mkdir(newDest, {recursive: true}) - await copyFiles(newSrc, newDest, version) - } else { - await copyFile(src, dest, file.name, version) - } - } - } -} - -(async () => { - // Check we are in the root of the website repo - if (!existsSync('src')) { - console.log('Run this from the top of the website repository') - process.exit(-1) - } - - const repoPaths = ['../dev-env/packages/flowfuse', '../flowfuse', '../flowforge']; - - // For the first repoPath to exist, we will use that one - const ffRepo = repoPaths.find(p => existsSync(path.join(p, 'docs'))) - if (!ffRepo) { - console.log(`FlowFuse repository not found (${repoPaths}) - skipping`) - process.exit() - } - - const docsDir = path.join(ffRepo, 'docs') - if (!existsSync(docsDir)) { - console.log(`FlowFuse Docs folder not found ${docsDir} - skipping`) - process.exit() - } - - const packFile = await fs.readFile(path.join(ffRepo, 'package.json')) - const version = JSON.parse(packFile).version - const dest = 'src/docs' - if (!WATCHMODE) { - await copyFiles(docsDir, dest, version) - } else { - console.log('Running in watch mode - skipping initial copy') - const watcher = new Watcher(docsDir, (updates) => { - updates.forEach(filename => { - const srcFile = path.join(docsDir, filename) - const destFile = path.join(dest, filename) - if (!existsSync(srcFile)) { - // src deleted - console.log('Docs content removed:', destFile) - rmSync(destFile, { force: true, recursive: true}) - } else { - const stat = statSync(srcFile) - if (stat.isDirectory()) { - console.log('Docs directory created:', destFile) - mkdirSync(destFile) - } else { - console.log('Docs file updated:', destFile) - copyFile(docsDir, dest, filename, version) - } - } - }) - }) - setInterval(() => {}, 1 << 30); - } -})() - - -class Watcher { - constructor(rootPath, callback) { - this.watched = {} - this.callback = callback - this.rootPath = rootPath - this.watch(rootPath) - this.pendingUpdates = new Set() - this.updateTimeout = null - } - - queueFileChange(filename) { - this.pendingUpdates.add(filename) - clearTimeout(this.updateTimeout) - this.updateTimeout = setTimeout(() => { - const updates = Array.from(this.pendingUpdates) - this.pendingUpdates.clear() - this.callback(updates) - }, 300) - } - - watch(filePath) { - const stats = statSync(filePath) - const isDir = stats.isDirectory() - if (isDir) { - const files = readdirSync(filePath) - for (let i = 0, len = files.length; i < len; i++) { - this.watch(path.join(filePath, files[i])) - } - this.watched[filePath] = watch(filePath, (eventType, filename) => { - if (!filename.startsWith('.')) { - const fullPath = path.join(filePath, filename) - if (existsSync(fullPath)) { - if (!this.watched[fullPath]) { - this.watch(fullPath) - } - } - this.queueFileChange(path.relative(this.rootPath, fullPath)) - } - }) - } - } -} \ No newline at end of file diff --git a/src/docs/docs.json b/src/docs/docs.json deleted file mode 100644 index c52c07eef4..0000000000 --- a/src/docs/docs.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "layout": "layouts/documentation.njk", - "nav": "docs", - "searchTitle": "Docs", - "tags": [ - "docs" - ] -}