Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 17 additions & 93 deletions nuxt/composables/useDocsNav.ts
Original file line number Diff line number Diff line change
@@ -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[]
}
Expand All @@ -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<string, TreeNode>
}

const GROUP_ORDER = [
'FlowFuse User Manuals',
'Device Agent',
'FlowFuse Cloud',
'FlowFuse Self-Hosted',
'Support',
'Contributing',
]

export function buildDocsNav(pages: RawPage[]): DocsNavGroup[] {
const tree: Record<string, TreeNode> = {}

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<string, TreeNode>): 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<string, DocsNavGroup> = {}

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)
}
3 changes: 3 additions & 0 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
93 changes: 93 additions & 0 deletions nuxt/lib/docs-nav.mjs
Original file line number Diff line number Diff line change
@@ -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))
}
106 changes: 106 additions & 0 deletions nuxt/lib/docs-nav.test.mjs
Original file line number Diff line number Diff line change
@@ -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([]), [])
})