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
32 changes: 32 additions & 0 deletions nuxt/components/Breadcrumbs.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
// Shared by docs, handbook to render the visual breadcrumb and registers the
// matching schema.org BreadcrumbList in one place, so a page are up to date.
interface BreadcrumbItem {
label: string
to?: string
}

const props = defineProps<{ items: BreadcrumbItem[] }>()

useSchemaOrg([
defineBreadcrumb({
itemListElement: props.items.map(item => ({
name: item.label,
...(item.to ? { item: item.to } : {}),
})),
}),
])
</script>

<template>
<UBreadcrumb
:items="items"
color="neutral"
class="capitalize"
:ui="{ link: 'text-sm hover:text-indigo-600' }"
>
<template #separator>
<span class="mx-1 text-gray-400">/</span>
</template>
</UBreadcrumb>
</template>
23 changes: 8 additions & 15 deletions nuxt/components/DocsLeftNav.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
<script setup lang="ts">
import { buildDocsNav, type DocsNavNode, type DocsNavGroup } from '~/composables/useDocsNav'
import { useDocsNavTree, type DocsNavNode, type DocsNavGroup } from '~/composables/useDocsNav'

const route = useRoute()

const { data: pages } = await useAsyncData('docs-nav', () =>
queryCollection('docs').all()
)

const navGroups = computed<DocsNavGroup[]>(() => {
if (!pages.value) return []
return buildDocsNav(pages.value as any[])
})
const { data: navGroups } = await useDocsNavTree()

function normPath(p: string) {
return p.replace(/\/$/, '') || '/'
Expand Down Expand Up @@ -53,16 +46,16 @@ function ulStyle(node: DocsNavNode) {
<NuxtLink href="/docs">Documentation</NuxtLink>
</li>

<template v-for="group in navGroups" :key="group.name">
<template v-for="group in navGroups ?? []" :key="group.name">
<li class="handbook-nav-group">{{ group.name }}</li>

<template v-for="entry in group.children" :key="entry.path">
<li :class="{ active: isActive(entry.path), open: isOpen(entry) && entry.children.length > 0 }">
<NuxtLink :href="entry.path">{{ entry.name }}</NuxtLink>
<NuxtLink :href="entry.path">{{ entry.title }}</NuxtLink>
<button v-if="entry.children.length"
@click="toggle(entry.path)"
:aria-expanded="isOpen(entry).toString()"
:aria-label="`Toggle ${entry.name} submenu`">
:aria-label="`Toggle ${entry.title} submenu`">
<span class="ff-icon icon-expand">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
Expand All @@ -76,11 +69,11 @@ function ulStyle(node: DocsNavNode) {
<ul class="handbook-nav-nested" :style="ulStyle(entry)">
<template v-for="child in entry.children" :key="child.path">
<li :class="{ active: isActive(child.path), open: isOpen(child) && child.children.length > 0 }">
<NuxtLink :href="child.path">{{ child.name }}</NuxtLink>
<NuxtLink :href="child.path">{{ child.title }}</NuxtLink>
<button v-if="child.children.length"
@click="toggle(child.path)"
:aria-expanded="isOpen(child).toString()"
:aria-label="`Toggle ${child.name} submenu`">
:aria-label="`Toggle ${child.title} submenu`">
<span class="ff-icon icon-expand">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</span>
Expand All @@ -94,7 +87,7 @@ function ulStyle(node: DocsNavNode) {
<ul class="handbook-nav-nested-2" :style="ulStyle(child)">
<li v-for="grandchild in child.children" :key="grandchild.path"
:class="{ active: isActive(grandchild.path) }">
<NuxtLink :href="grandchild.path">{{ grandchild.name }}</NuxtLink>
<NuxtLink :href="grandchild.path">{{ grandchild.title }}</NuxtLink>
</li>
</ul>
</li>
Expand Down
33 changes: 0 additions & 33 deletions nuxt/components/HandbookBreadcrumbs.vue

This file was deleted.

12 changes: 10 additions & 2 deletions nuxt/composables/useDocsNav.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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[]))
28 changes: 22 additions & 6 deletions nuxt/lib/docs-nav.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -33,20 +35,20 @@ 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,
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
// 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
Expand All @@ -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,
Expand All @@ -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) }))
}

Expand Down Expand Up @@ -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<typeof buildDocsNav>} 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 })
}
34 changes: 28 additions & 6 deletions nuxt/lib/docs-nav.test.mjs
Original file line number Diff line number Diff line change
@@ -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 } = {}) {
Expand Down Expand Up @@ -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'), [])
})
29 changes: 13 additions & 16 deletions nuxt/pages/docs/[...slug].vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useDocsNavTree, findDocsBreadcrumb } from '~/composables/useDocsNav'

definePageMeta({ layout: 'default' })

const route = useRoute()
Expand Down Expand Up @@ -34,13 +36,16 @@ useHead({
],
})

const breadcrumbs = computed(() => {
const parts = ['docs', ...slugParts.value]
let path = ''
return parts.map(part => {
path += '/' + part
return { name: part, path }
})
// Same key+handler DocsLeftNav uses, so useAsyncData dedupes into one fetch per request.
const { data: navGroups } = await useDocsNavTree()

const breadcrumbItems = computed(() => {
const crumbs = findDocsBreadcrumb(navGroups.value ?? [], route.path)
const withRoot = [{ title: 'Docs', path: '/docs' }, ...crumbs]
return withRoot.map((crumb, i) => ({
label: crumb.title,
...(i === withRoot.length - 1 ? {} : { to: crumb.path }),
}))
})
</script>

Expand All @@ -57,15 +62,7 @@ const breadcrumbs = computed(() => {
<!-- Breadcrumbs + Search bar -->
<div class="font-medium pb-1 flex flex-col gap-1">
<div class="md:flex-1">
<nav aria-label="Breadcrumb" class="text-sm text-gray-500">
<span v-for="(crumb, i) in breadcrumbs" :key="crumb.path">
<NuxtLink v-if="i < breadcrumbs.length - 1"
:href="crumb.path"
class="hover:text-indigo-600 capitalize">{{ crumb.name }}</NuxtLink>
<span v-else class="capitalize text-gray-700">{{ crumb.name }}</span>
<span v-if="i < breadcrumbs.length - 1" class="mx-1">/</span>
</span>
</nav>
<Breadcrumbs :items="breadcrumbItems" />
</div>
<div class="w-full mb-1">
<AlgoliaSearch index-filter="category:docs" placeholder="Search in Docs..." source-id="docs" />
Expand Down
18 changes: 11 additions & 7 deletions nuxt/pages/handbook/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ''),
Expand All @@ -75,7 +79,7 @@ defineOgImage('Default', {
<!-- Breadcrumbs + Search bar -->
<div class="font-medium pb-1 flex flex-col gap-1">
<div class="md:flex-1">
<HandbookBreadcrumbs />
<Breadcrumbs :items="breadcrumbItems" />
</div>
<div class="w-full mb-1">
<HandbookSearch />
Expand Down