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([]), []) +})