Skip to content
Open
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
37 changes: 37 additions & 0 deletions nuxt/lib/docs-seo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Derives the SEO surface of a docs page from its frontmatter: the title, the brand
// qualifier the global title template appends to it, the description, the canonical url,
// the og-image props and the article's dateModified. Kept free of Nuxt and Vue imports so
// it can be unit tested with `node --test`; the page component only wires the result into
// useHead/useSeoMeta/useSchemaOrg/defineOgImage.

import { toIso } from './relative-time.mjs'

const SITE_URL = 'https://flowfuse.com'

/**
* Docs frontmatter is written by docs-sync from the FlowFuse/flowfuse repo, so the shape
* is narrower than a hand-authored page: `navTitle` is always present, `title` comes from
* the H1 that @nuxt/content parses, a description exists only under `meta`, and `updated`
* carries a git commit date that is often the empty string.
*
* @param {{ navTitle?: string, title?: string, meta?: { description?: string }, updated?: string } | null} page
* @param {string} path the route path, used as-is for the canonical url
* @param {string[]} slugParts the `[...slug]` segments; empty on the docs root
*/
export function docsSeo (page, path, slugParts = []) {
// Bare, with no brand: the global title template appends "• {siteName}" to it.
const heading = page?.navTitle || page?.title || slugParts.at(-1) || 'Documentation'

return {
heading,
// Nested pages qualify the brand with "Docs"; the section root does not, because
// its own title already reads Documentation.
siteName: slugParts.length ? 'FlowFuse Docs' : 'FlowFuse',
// Undefined, not '': useSeoMeta drops the tag instead of emitting an empty one.
description: page?.meta?.description || undefined,
canonicalUrl: `${SITE_URL}${path}`,
// The heading alone, because the card template prints "FlowFuse / Docs" above it.
ogImage: { title: heading, section: 'Docs' },
dateModified: toIso(page?.updated) ?? undefined,
}
}
63 changes: 63 additions & 0 deletions nuxt/lib/docs-seo.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'

import { docsSeo } from './docs-seo.mjs'

const page = (fields = {}) => ({ ...fields })

test('a page below /docs qualifies the brand the title template appends', () => {
const seo = docsSeo(page({ navTitle: 'Bill of Materials' }), '/docs/user/bill-of-materials', ['user', 'bill-of-materials'])
assert.equal(seo.heading, 'Bill of Materials')
assert.equal(seo.siteName, 'FlowFuse Docs')
})

test('the docs root leaves the brand unqualified', () => {
// Its own title already reads Documentation, so "FlowFuse Docs" would repeat it.
const seo = docsSeo(page({ navTitle: 'Documentation' }), '/docs', [])
assert.equal(seo.siteName, 'FlowFuse')
})

test('prefers navTitle over the heading @nuxt/content derives from the H1', () => {
const seo = docsSeo(page({ navTitle: 'Changing the Stack', title: 'Changing the stack of an instance' }), '/docs/user/changestack', ['user', 'changestack'])
assert.equal(seo.heading, 'Changing the Stack')
})

test('falls back to the H1 title, then the last slug segment, then Documentation', () => {
assert.equal(docsSeo(page({ title: 'Concepts' }), '/docs/user/concepts', ['user', 'concepts']).heading, 'Concepts')
assert.equal(docsSeo(page(), '/docs/user/concepts', ['user', 'concepts']).heading, 'concepts')
assert.equal(docsSeo(null, '/docs', []).heading, 'Documentation')
})

test('reads the description from nested meta, where docs-sync writes it', () => {
const seo = docsSeo(page({ meta: { description: 'Explore comprehensive documentation for FlowFuse.' } }), '/docs', [])
assert.equal(seo.description, 'Explore comprehensive documentation for FlowFuse.')
})

test('leaves the description undefined rather than empty when a page has none', () => {
// Most synced docs pages carry no description at all. An empty string would put
// <meta name="description" content=""> on the page, which is worse than no tag.
assert.equal(docsSeo(page({ navTitle: 'Custom Hostnames' }), '/docs/user/custom-hostnames', ['user', 'custom-hostnames']).description, undefined)
assert.equal(docsSeo(page({ meta: { description: '' } }), '/docs', []).description, undefined)
})

test('canonical url is absolute on the production host', () => {
assert.equal(docsSeo(page(), '/docs/user/concepts', ['user', 'concepts']).canonicalUrl, 'https://flowfuse.com/docs/user/concepts')
})

test('the og image gets the bare heading, since the card already prints the section', () => {
const seo = docsSeo(page({ navTitle: 'Bill of Materials' }), '/docs/user/bill-of-materials', ['user', 'bill-of-materials'])
assert.deepEqual(seo.ogImage, { title: 'Bill of Materials', section: 'Docs' })
})

test('normalises the git commit stamp docs-sync writes into dateModified', () => {
const seo = docsSeo(page({ updated: '2026-08-11 15:07:47 +0200' }), '/docs', [])
assert.equal(seo.dateModified, '2026-08-11T15:07:47+02:00')
})

test('dateModified is undefined when the updated stamp is blank or unparseable', () => {
// The synced frontmatter always carries the key; the value is empty whenever
// git could not date the file.
assert.equal(docsSeo(page({ updated: '' }), '/docs', []).dateModified, undefined)
assert.equal(docsSeo(page({ updated: 'last tuesday' }), '/docs', []).dateModified, undefined)
assert.equal(docsSeo(page(), '/docs', []).dateModified, undefined)
})
42 changes: 33 additions & 9 deletions nuxt/pages/docs/[...slug].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useDocsNavTree, findDocsBreadcrumb } from '~/composables/useDocsNav'
import { docsSeo } from '../../lib/docs-seo.mjs'

definePageMeta({ layout: 'default' })

Expand Down Expand Up @@ -27,21 +28,44 @@ if (page.value.layout === 'redirect' && page.value.redirect?.to) {
throw navigateTo(target, { redirectCode: 301, external: isExternal })
}

const pageTitle = computed(() => page.value?.navTitle || page.value?.title || slugParts.value.at(-1) || 'Documentation')

// Empty on most docs pages: only the ones a catalog feature names as its docsLink get badges.
const plans = useDocsPlans(contentPath)

const seo = computed(() => docsSeo(page.value as any, route.path, slugParts.value))

useHead({
title: pageTitle,
meta: [
{ name: 'description', content: computed(() => (page.value as any)?.meta?.description || '') },
],
})
useHead({
templateParams: { siteName: () => slugParts.value.length ? 'FlowFuse Docs' : 'FlowFuse' },
templateParams: { siteName: () => seo.value.siteName },
}, { tagPriority: 1000 })

useSeoMeta({
// og:title is left out on purpose: it infers from the resolved title, brand suffix
// and all, so setting it here would only strip the suffix back off.
title: computed(() => seo.value.heading),
description: computed(() => seo.value.description),
ogDescription: computed(() => seo.value.description),
ogUrl: computed(() => seo.value.canonicalUrl),
ogType: 'article',
twitterCard: 'summary_large_image',
twitterSite: '@FlowFuseinc',
})

useSchemaOrg([
// TechArticle, not Article: these pages are product documentation, and it is the
// schema.org subtype for exactly that.
defineArticle({
'@type': 'TechArticle',
headline: computed(() => seo.value.heading),
description: computed(() => seo.value.description),
// The git commit date docs-sync stamps on the synced file, when it has one.
dateModified: computed(() => seo.value.dateModified),
author: [{ name: 'FlowFuse', url: 'https://flowfuse.com' }],
}),
])

// Server-side only, like every other defineOgImage call on the site: the tag it writes is
// for crawlers reading the prerendered HTML, so it resolves once per page build.
defineOgImage('Default', seo.value.ogImage)

// Same key+handler DocsLeftNav uses, so useAsyncData dedupes into one fetch per request.
const { data: navGroups } = await useDocsNavTree()

Expand Down