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
6 changes: 5 additions & 1 deletion nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export default defineContentConfig({
redirect: z.object({
to: z.string(),
}).optional(),
meta: z.object({
// Frontmatter writes this as "meta:", which @nuxt/content reserves; the
// content:file:beforeParse hook in nuxt.config.ts renames it first, as it
// does for the blog and webinars. Declared as `meta`, every page's
// description was silently dropped.
structuredData: z.object({
description: z.string().optional(),
}).optional(),
// No `sitemap` schema field here on purpose - @nuxtjs/sitemap's own
Expand Down
44 changes: 44 additions & 0 deletions nuxt/lib/docs-seo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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'
import { docsPageTitle } from './docs-page-title.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 written description exists only under the
* frontmatter's `meta:` block (renamed to `structuredData` before parsing, because
* @nuxt/content reserves `meta`), and `updated` carries a git commit date that is often
* the empty string. `description` is the paragraph @nuxt/content takes from directly
* under the H1, when there is one.
*
* @param {{ metaTitle?: string, navTitle?: string, title?: string, description?: string, structuredData?: { 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.
// docsPageTitle owns the precedence, so the <title>, og:title and the og-image card
// all read the same string and a new tier added there reaches all three at once.
const heading = docsPageTitle(page, slugParts)

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',
// The written description first, then the lead paragraph. Undefined, not '':
// useSeoMeta drops the tag instead of emitting an empty one.
description: page?.structuredData?.description || page?.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,
}
}
89 changes: 89 additions & 0 deletions nuxt/lib/docs-seo.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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('the heading is whatever docsPageTitle resolves, metaTitle tier included', () => {
// docsSeo does not re-derive the title; it delegates, so a page carrying metaTitle
// gets the same string in <title>, og:title and the og-image card.
const seo = docsSeo(page({ metaTitle: 'Bill of Materials for Node-RED', navTitle: 'Bill of Materials' }), '/docs/user/bill-of-materials', ['user', 'bill-of-materials'])
assert.equal(seo.heading, 'Bill of Materials for Node-RED')
assert.equal(seo.ogImage.title, 'Bill of Materials for Node-RED')
})

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 written description from structuredData, where the frontmatter meta: block lands', () => {
// The beforeParse hook renames "meta:" before @nuxt/content parses the file, because
// a declared `meta` field is overwritten with the parser's own leftovers.
const seo = docsSeo(page({ structuredData: { description: 'Explore comprehensive documentation for FlowFuse.' } }), '/docs', [])
assert.equal(seo.description, 'Explore comprehensive documentation for FlowFuse.')
})

test('the written description wins over the lead paragraph', () => {
const seo = docsSeo(page({ structuredData: { description: 'Written.' }, description: 'First paragraph under the H1.' }), '/docs', [])
assert.equal(seo.description, 'Written.')
})

test('falls back to the lead paragraph @nuxt/content takes from under the H1', () => {
const seo = docsSeo(page({ navTitle: 'Custom Hostnames', description: 'Serve an instance on your own domain.' }), '/docs/user/custom-hostnames', ['user', 'custom-hostnames'])
assert.equal(seo.description, 'Serve an instance on your own domain.')
})

test('ignores a nested meta description, which @nuxt/content never delivers', () => {
// Guards the rename: reading `meta` again would compile and silently emit nothing.
assert.equal(docsSeo(page({ meta: { description: 'Lost at parse time.' } }), '/docs', []).description, undefined)
})

test('leaves the description undefined rather than empty when a page has none', () => {
// A page that opens with a component or a list, not a paragraph, gets no lead
// description. 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({ structuredData: { description: '' }, 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)
})
2 changes: 1 addition & 1 deletion nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ export default defineNuxtConfig({
// under it. These collections' frontmatter still writes "meta:", so rewrite the key
// to "structuredData:" before parsing rather than editing hundreds of content files.
'content:file:beforeParse' ({ file, collection }) {
if (!['blog', 'webinars'].includes(collection.name)) return
if (!['blog', 'webinars', 'docs'].includes(collection.name)) return
file.body = file.body.replace(
/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*/,
(block) => block.replace(/^meta:[ \t]*\r?$/m, 'structuredData:')
Expand Down
48 changes: 35 additions & 13 deletions nuxt/pages/docs/[...slug].vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useDocsNavTree, findDocsBreadcrumb, findDocsSurround } from '~/composables/useDocsNav'
import { docsPageTitle } from '~/lib/docs-page-title.mjs'
import { docsSeo } from '~/lib/docs-seo.mjs'

definePageMeta({ layout: 'default' })

Expand All @@ -26,11 +26,6 @@ if (!page.value) {
// here meant the prerenderer wrote a `<meta http-equiv="refresh">` stub served with a 200
// instead of a 301.

// The order is asserted in nuxt/lib/docs-page-title.mjs, which explains why it is that
// order. It decides the <title> of every page under /docs and getting it wrong shows up
// nowhere except in the rendered title, so it is not left inline as a bare expression.
const pageTitle = computed(() => docsPageTitle(page.value, slugParts.value))

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

Expand All @@ -46,16 +41,43 @@ const editHref = computed(() => {
: undefined
})

// The heading it returns is docsPageTitle's, so the <title>, og:title and the og-image
// card can never drift apart. The rest of the SEO surface is derived alongside it.
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
Loading