From fe3fc6ddf56c6555844f3cef8e87696b93151b0f Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Tue, 18 Aug 2026 08:26:58 +0200 Subject: [PATCH] Give docs pages social cards and article structured data Docs pages carried a title and a description and nothing else: no og:image, no og:url, no article-level structured data. Links to them unfurled bare, while the handbook already had all of it. useSeoMeta now covers the og and twitter tags, keeping the brand suffix that #5593 moved into the global title template (og:title infers from the resolved title, so it stays unset). defineOgImage renders the shared Default card with "Docs" as its section, and useSchemaOrg emits a TechArticle, the schema.org subtype for product documentation, with dateModified read from the git commit stamp docs-sync writes into the synced frontmatter. The derivation moved into nuxt/lib/docs-seo.mjs so node --test can cover it. That also drops an empty tag: pages with no description emitted before. --- nuxt/lib/docs-seo.mjs | 37 ++++++++++++++++++++ nuxt/lib/docs-seo.test.mjs | 63 +++++++++++++++++++++++++++++++++++ nuxt/pages/docs/[...slug].vue | 42 ++++++++++++++++++----- 3 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 nuxt/lib/docs-seo.mjs create mode 100644 nuxt/lib/docs-seo.test.mjs diff --git a/nuxt/lib/docs-seo.mjs b/nuxt/lib/docs-seo.mjs new file mode 100644 index 0000000000..1cfc80ef84 --- /dev/null +++ b/nuxt/lib/docs-seo.mjs @@ -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, + } +} diff --git a/nuxt/lib/docs-seo.test.mjs b/nuxt/lib/docs-seo.test.mjs new file mode 100644 index 0000000000..59e2098b84 --- /dev/null +++ b/nuxt/lib/docs-seo.test.mjs @@ -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 + // 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) +}) diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue index 77b2cf4fd8..e6a450be90 100644 --- a/nuxt/pages/docs/[...slug].vue +++ b/nuxt/pages/docs/[...slug].vue @@ -1,5 +1,6 @@