From 38e5419c08c58023ec4aefef704351d1e49eba2a Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Wed, 5 Aug 2026 12:12:56 +0200 Subject: [PATCH] Docs: take sidebar group order from frontmatter, not a hardcoded list The docs sidebar ranked its group headings with a GROUP_ORDER array in useDocsNav.ts, so adding, renaming or reordering a group meant a change here even though the docs themselves live in FlowFuse/flowfuse and already declare the group names in navGroup. A group now takes the lowest navGroupOrder declared by its sections, so the whole docs structure is owned by the docs repo. Groups nobody ranks fall to the end, sorted by name. The builder moves to nuxt/lib/docs-nav.mjs as plain JS, matching docs-sync.mjs, so `npm test` can cover it; the composable keeps the types and stays the import surface for DocsLeftNav. Verified against the real docs tree: identical nav to the GROUP_ORDER implementation for all 115 pages. --- nuxt/composables/useDocsNav.ts | 110 +++++---------------------------- nuxt/content.config.ts | 3 + nuxt/lib/docs-nav.mjs | 93 ++++++++++++++++++++++++++++ nuxt/lib/docs-nav.test.mjs | 106 +++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 93 deletions(-) create mode 100644 nuxt/lib/docs-nav.mjs create mode 100644 nuxt/lib/docs-nav.test.mjs diff --git a/nuxt/composables/useDocsNav.ts b/nuxt/composables/useDocsNav.ts index bffd308374..28a62c66bd 100644 --- a/nuxt/composables/useDocsNav.ts +++ b/nuxt/composables/useDocsNav.ts @@ -1,7 +1,13 @@ +// The builder lives in nuxt/lib/ as plain JS so `node --test` can run it directly +// (same reason as docs-sync.mjs); this file is the typed surface components import. +// @ts-ignore untyped module +import { buildDocsNav as build } from '../lib/docs-nav.mjs' + export interface DocsNavNode { name: string path: string group?: string + groupOrder?: number order: number children: DocsNavNode[] } @@ -12,104 +18,22 @@ export interface DocsNavGroup { children: DocsNavNode[] } -interface RawPage { +export interface DocsNavPage { path: string title?: string | null navTitle?: string | null navOrder?: number | null navGroup?: string | null + navGroupOrder?: number | 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) +/** + * Sidebar tree for the docs section. + * + * Group headings are ranked by the `navGroupOrder` frontmatter of the sections they + * hold, so reordering, renaming or adding a group is a change in FlowFuse/flowfuse's + * docs/ tree and needs nothing here. + */ +export function buildDocsNav (pages: DocsNavPage[]): DocsNavGroup[] { + return build(pages) } diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index ecbb0f2138..bfc15bd3c1 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -31,6 +31,9 @@ export default defineContentConfig({ schema: z.object({ navTitle: z.string().optional(), navGroup: z.string().optional(), + // Read by useDocsNav to rank the sidebar group headings; without it + // declared here @nuxt/content strips the key from frontmatter. + navGroupOrder: z.number().optional(), navOrder: z.number().optional(), originalPath: z.string().optional(), updated: z.string().optional(), diff --git a/nuxt/lib/docs-nav.mjs b/nuxt/lib/docs-nav.mjs new file mode 100644 index 0000000000..9304c2834f --- /dev/null +++ b/nuxt/lib/docs-nav.mjs @@ -0,0 +1,93 @@ +// Builds the docs sidebar tree from the pages of the `docs` content collection. +// +// Lives in nuxt/lib/ as plain JS, like docs-sync.mjs, so `node --test` can run it +// directly. nuxt/composables/useDocsNav.ts re-exports it with types for components. +// +// Every input is frontmatter from FlowFuse/flowfuse's docs/ tree: `navGroup` names the +// sidebar heading a section sits under, `navGroupOrder` ranks those headings and +// `navOrder` ranks pages within one. Nothing about the structure is declared here, so +// restructuring the docs is a change in the docs repo alone. + +/** + * @param {Array<{path: string, title?: string|null, navTitle?: string|null, navOrder?: number|null, navGroup?: string|null, navGroupOrder?: number|null}>} pages + */ +export function buildDocsNav (pages) { + const tree = {} + + 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, + groupOrder: isLeaf ? (page.navGroupOrder ?? 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].groupOrder = page.navGroupOrder ?? undefined + current[part].order = page.navOrder ?? Infinity + } + + current = current[part].children + } + } + + function toDocsNavNodes (obj) { + return Object.values(obj).map(node => ({ + name: node.name, + path: node.path, + group: node.group, + groupOrder: node.groupOrder, + order: node.order, + children: toDocsNavNodes(node.children), + })) + } + + function sortNodes (nodes) { + 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 = {} + + for (const section of sortNodes(docsRoot.children)) { + const groupName = section.group || 'Other' + if (!groups[groupName]) { + groups[groupName] = { name: groupName, order: Infinity, children: [] } + } + // Lowest wins: several sections share a heading and each declares the rank, so a + // section that omits navGroupOrder (or disagrees) cannot drag the group out of + // place. Groups nobody ranked keep Infinity and fall to the end, sorted by name. + if (typeof section.groupOrder === 'number') { + groups[groupName].order = Math.min(groups[groupName].order, section.groupOrder) + } + groups[groupName].children.push(section) + } + + return Object.values(groups) + .filter(g => g.children.length > 0) + .sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name)) +} diff --git a/nuxt/lib/docs-nav.test.mjs b/nuxt/lib/docs-nav.test.mjs new file mode 100644 index 0000000000..fe2d6db381 --- /dev/null +++ b/nuxt/lib/docs-nav.test.mjs @@ -0,0 +1,106 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { buildDocsNav } from './docs-nav.mjs' + +// A section is a direct child of /docs; its index page carries the group frontmatter. +function section (path, { group, groupOrder, order, navTitle } = {}) { + return { path, navGroup: group, navGroupOrder: groupOrder, navOrder: order, navTitle } +} + +const names = groups => groups.map(g => g.name) + +test('groups render in declared navGroupOrder, not the order they were read', () => { + const nav = buildDocsNav([ + section('/docs/contribute', { group: 'Contributing', groupOrder: 6 }), + section('/docs/user', { group: 'User Manuals', groupOrder: 1 }), + section('/docs/cloud', { group: 'Cloud', groupOrder: 3 }), + ]) + + assert.deepEqual(names(nav), ['User Manuals', 'Cloud', 'Contributing']) +}) + +test('a group takes the lowest navGroupOrder among its sections', () => { + const nav = buildDocsNav([ + section('/docs/install', { group: 'Self-Hosted', groupOrder: 4 }), + section('/docs/admin', { group: 'Self-Hosted', groupOrder: 9 }), + section('/docs/cloud', { group: 'Cloud', groupOrder: 5 }), + ]) + + // 4 wins for Self-Hosted, so it sorts ahead of Cloud despite admin's 9. + assert.deepEqual(names(nav), ['Self-Hosted', 'Cloud']) +}) + +test('a section with a group but no navGroupOrder still joins that group', () => { + const nav = buildDocsNav([ + section('/docs/community-support', { group: 'Support', groupOrder: 5 }), + section('/docs/debugging', { group: 'Support' }), + ]) + + assert.deepEqual(names(nav), ['Support']) + assert.deepEqual(nav[0].children.map(c => c.path), ['/docs/community-support', '/docs/debugging']) +}) + +test('groups with no navGroupOrder at all sort last, alphabetically', () => { + const nav = buildDocsNav([ + section('/docs/zebra', { group: 'Zebra' }), + section('/docs/apple', { group: 'Apple' }), + section('/docs/user', { group: 'User Manuals', groupOrder: 1 }), + ]) + + assert.deepEqual(names(nav), ['User Manuals', 'Apple', 'Zebra']) +}) + +test('a section with no navGroup lands in Other', () => { + const nav = buildDocsNav([ + section('/docs/user', { group: 'User Manuals', groupOrder: 1 }), + section('/docs/stray'), + ]) + + assert.deepEqual(names(nav), ['User Manuals', 'Other']) +}) + +test('sections sort by navOrder inside a group, unordered ones last by name', () => { + const nav = buildDocsNav([ + section('/docs/upgrade', { group: 'Self-Hosted', groupOrder: 4, order: 3 }), + section('/docs/quick-start', { group: 'Self-Hosted', groupOrder: 4, order: 1 }), + section('/docs/zzz', { group: 'Self-Hosted', groupOrder: 4 }), + section('/docs/admin', { group: 'Self-Hosted', groupOrder: 4 }), + ]) + + assert.deepEqual(nav[0].children.map(c => c.path), [ + '/docs/quick-start', + '/docs/upgrade', + '/docs/admin', + '/docs/zzz', + ]) +}) + +test('deeper pages nest under their section and keep navOrder', () => { + const nav = buildDocsNav([ + section('/docs/user', { group: 'User Manuals', groupOrder: 1, navTitle: 'Using FlowFuse' }), + section('/docs/user/concepts', { order: 2, navTitle: 'Concepts' }), + section('/docs/user/introduction', { order: 1, navTitle: 'Introduction' }), + section('/docs/user/teams/billing', { order: 1, navTitle: 'Billing' }), + ]) + + const user = nav[0].children[0] + assert.equal(user.name, 'Using FlowFuse') + assert.deepEqual(user.children.map(c => c.name), ['Introduction', 'Concepts', 'teams']) + assert.deepEqual(user.children[2].children.map(c => c.name), ['Billing']) +}) + +test('name falls back to title then to the path segment', () => { + const nav = buildDocsNav([ + { path: '/docs/a', navGroup: 'G', navGroupOrder: 1, navOrder: 1, navTitle: 'Nav wins', title: 'Title loses' }, + { path: '/docs/b', navGroup: 'G', navGroupOrder: 1, navOrder: 2, title: 'Title used' }, + { path: '/docs/c', navGroup: 'G', navGroupOrder: 1, navOrder: 3 }, + ]) + + assert.deepEqual(nav[0].children.map(c => c.name), ['Nav wins', 'Title used', 'c']) +}) + +test('pages outside /docs are ignored', () => { + assert.deepEqual(buildDocsNav([section('/handbook/company', { group: 'Company', groupOrder: 1 })]), []) + assert.deepEqual(buildDocsNav([]), []) +})