diff --git a/nuxt/components/Breadcrumbs.vue b/nuxt/components/Breadcrumbs.vue new file mode 100644 index 0000000000..cb3680bd62 --- /dev/null +++ b/nuxt/components/Breadcrumbs.vue @@ -0,0 +1,32 @@ + + + diff --git a/nuxt/components/DocsLeftNav.vue b/nuxt/components/DocsLeftNav.vue index e88f0d38fd..ed7bdd3c8a 100644 --- a/nuxt/components/DocsLeftNav.vue +++ b/nuxt/components/DocsLeftNav.vue @@ -1,16 +1,9 @@ - - diff --git a/nuxt/composables/useDocsNav.ts b/nuxt/composables/useDocsNav.ts index 51892bed01..9a38efbb11 100644 --- a/nuxt/composables/useDocsNav.ts +++ b/nuxt/composables/useDocsNav.ts @@ -1,10 +1,11 @@ // 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' +import { buildDocsNav as build, findDocsBreadcrumb as findBreadcrumb } from '../lib/docs-nav.mjs' export interface DocsNavNode { - name: string + // Matches @nuxt/content's ContentNavigationItem shape (title/path/children) + title: string path: string group?: string groupOrder?: number @@ -38,3 +39,10 @@ export interface DocsNavPage { export function buildDocsNav (pages: DocsNavPage[]): DocsNavGroup[] { return build(pages) } + +export function findDocsBreadcrumb (groups: DocsNavGroup[], path: string): DocsNavNode[] { + return findBreadcrumb(groups, path) +} + +export const useDocsNavTree = () => + useAsyncData('docs-nav', async () => buildDocsNav(await queryCollection('docs').all() as DocsNavPage[])) diff --git a/nuxt/lib/docs-nav.mjs b/nuxt/lib/docs-nav.mjs index c6ad114218..e9dd7ffaea 100644 --- a/nuxt/lib/docs-nav.mjs +++ b/nuxt/lib/docs-nav.mjs @@ -8,6 +8,8 @@ // `navOrder` ranks pages within one. Nothing about the structure is declared here, so // restructuring the docs is a change in the docs repo alone. +import { findPageBreadcrumb } from '@nuxt/content/utils' + /** * @param {Array<{path: string, title?: string|null, navTitle?: string|null, navOrder?: number|null, navGroup?: string|null, navGroupOrder?: number|null, redirect?: {to: string}|null}>} pages */ @@ -33,11 +35,11 @@ export function buildDocsNav (pages) { 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 + const displayTitle = isLeaf ? (page.navTitle || page.title || part) : part if (!current[part]) { current[part] = { - name: displayName, + title: displayTitle, path: '/' + parts.slice(0, i + 1).join('/'), group: isLeaf ? (page.navGroup ?? undefined) : undefined, groupOrder: isLeaf ? (page.navGroupOrder ?? undefined) : undefined, @@ -45,8 +47,8 @@ export function buildDocsNav (pages) { children: {}, } } else if (isLeaf) { - // Update name/group/order when we reach the leaf for this node - current[part].name = displayName + // Update title/group/order when we reach the leaf for this node + current[part].title = displayTitle current[part].group = page.navGroup ?? undefined current[part].groupOrder = page.navGroupOrder ?? undefined current[part].order = page.navOrder ?? Infinity @@ -58,7 +60,7 @@ export function buildDocsNav (pages) { function toDocsNavNodes (obj) { return Object.values(obj).map(node => ({ - name: node.name, + title: node.title, path: node.path, group: node.group, groupOrder: node.groupOrder, @@ -69,7 +71,7 @@ export function buildDocsNav (pages) { function sortNodes (nodes) { return nodes - .sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name)) + .sort((a, b) => (a.order - b.order) || a.title.localeCompare(b.title)) .map(n => ({ ...n, children: sortNodes(n.children) })) } @@ -97,3 +99,17 @@ export function buildDocsNav (pages) { .filter(g => g.children.length > 0) .sort((a, b) => (a.order - b.order) || a.name.localeCompare(b.name)) } + +/** + * Ancestor chain for a docs path, for breadcrumbs. Groups themselves aren't pages (no + * `path` of their own), so this flattens straight to their sections before handing off + * to @nuxt/content's own findPageBreadcrumb - same helper handbook uses. + * + * @param {ReturnType} groups + * @param {string} path + */ +export function findDocsBreadcrumb (groups, path) { + // findPageBreadcrumb excludes the current page by default - callers here want the + // full chain (they decide themselves whether the last crumb should link anywhere). + return findPageBreadcrumb(groups.flatMap(g => g.children), path, { current: true }) +} diff --git a/nuxt/lib/docs-nav.test.mjs b/nuxt/lib/docs-nav.test.mjs index fe2d6db381..567589182d 100644 --- a/nuxt/lib/docs-nav.test.mjs +++ b/nuxt/lib/docs-nav.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { buildDocsNav } from './docs-nav.mjs' +import { buildDocsNav, findDocsBreadcrumb } 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 } = {}) { @@ -85,22 +85,44 @@ test('deeper pages nest under their section and keep navOrder', () => { ]) 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']) + assert.equal(user.title, 'Using FlowFuse') + assert.deepEqual(user.children.map(c => c.title), ['Introduction', 'Concepts', 'teams']) + assert.deepEqual(user.children[2].children.map(c => c.title), ['Billing']) }) -test('name falls back to title then to the path segment', () => { +test('title falls back to the page 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']) + assert.deepEqual(nav[0].children.map(c => c.title), ['Nav wins', 'Title used', 'c']) }) test('pages outside /docs are ignored', () => { assert.deepEqual(buildDocsNav([section('/handbook/company', { group: 'Company', groupOrder: 1 })]), []) assert.deepEqual(buildDocsNav([]), []) }) + +test('findDocsBreadcrumb returns the real-title ancestor chain, spanning groups', () => { + const nav = buildDocsNav([ + section('/docs/user', { group: 'User Manuals', groupOrder: 1, navTitle: 'Using FlowFuse' }), + section('/docs/user/teams/billing', { order: 1, navTitle: 'Billing' }), + section('/docs/cloud', { group: 'Cloud', groupOrder: 2, navTitle: 'FlowFuse Cloud' }), + ]) + + assert.deepEqual( + findDocsBreadcrumb(nav, '/docs/user/teams/billing').map(c => [c.title, c.path]), + [ + ['Using FlowFuse', '/docs/user'], + ['teams', '/docs/user/teams'], + ['Billing', '/docs/user/teams/billing'], + ], + ) +}) + +test('findDocsBreadcrumb returns nothing for an unknown path', () => { + const nav = buildDocsNav([section('/docs/user', { group: 'User Manuals', groupOrder: 1 })]) + assert.deepEqual(findDocsBreadcrumb(nav, '/docs/nonexistent'), []) +}) diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue index 5ea509a1e4..055c04bcc9 100644 --- a/nuxt/pages/docs/[...slug].vue +++ b/nuxt/pages/docs/[...slug].vue @@ -1,4 +1,6 @@ @@ -57,15 +62,7 @@ const breadcrumbs = computed(() => {
- +
diff --git a/nuxt/pages/handbook/[...slug].vue b/nuxt/pages/handbook/[...slug].vue index 9a1e018255..6ec7313a13 100644 --- a/nuxt/pages/handbook/[...slug].vue +++ b/nuxt/pages/handbook/[...slug].vue @@ -42,13 +42,17 @@ const githubEditUrl = computed(() => { return `https://github.com/FlowFuse/website/edit/main/nuxt/content/${stem}.md` }) +const breadcrumbItems = computed(() => { + // findPageBreadcrumb excludes the current page unless told otherwise - `current: true` + // includes it so it can be the last, unlinked crumb below. + const crumbs = findPageBreadcrumb(navTree.value ?? [], route.path, { current: true }) + return crumbs.map((crumb, i) => ({ + label: crumb.title ?? '', + ...(i === crumbs.length - 1 ? {} : { to: crumb.path }), + })) +}) + useSchemaOrg([ - // Exclude the last crumb (current page) — nuxt-schema-org appends it automatically - defineBreadcrumb({ - itemListElement: findPageBreadcrumb(navTree.value ?? [], route.path) - .slice(0, -1) - .map(crumb => ({ name: crumb.title, item: crumb.path })), - }), defineArticle({ headline: pageTitle, description: computed(() => page.value?.description || ''), @@ -75,7 +79,7 @@ defineOgImage('Default', {
- +