diff --git a/nuxt/lib/sitemap-coverage.mjs b/nuxt/lib/sitemap-coverage.mjs new file mode 100644 index 0000000000..9b5a18bfe5 --- /dev/null +++ b/nuxt/lib/sitemap-coverage.mjs @@ -0,0 +1,48 @@ +// Checks the generated sitemap for the two failures that otherwise pass unnoticed. +// +// server/api/__sitemap__/content-urls.get.ts fills the sitemap from the @nuxt/content +// collections, and it catches its own errors: a collection that fails to query, or a +// source glob that matches nothing, just leaves that section out. The lastmod it adds +// comes from lib/git-lastmod.mjs, which returns nothing when `git log` fails (no .git in +// the build, say), so every page silently loses its date. Neither fails a build, and the +// sitemap still looks fine at a glance. nuxt.config.ts runs this against the rendered +// /sitemap.xml in the sitemap:prerender:done hook and fails the build on any problem. + +// Top-level path of each section content-urls.get.ts publishes. The blueprints are left +// out: a build without access to the private library has none, by design. +export const CONTENT_SECTIONS = ['docs', 'handbook', 'changelog', 'blog', 'customer-stories', 'ebooks', 'whitepaper'] + +// Sections whose lastmod comes only from git history, so a failed walk empties them all. +// The blog is not listed: posts with `lastUpdated` keep a date either way. +export const GIT_DATED_SECTIONS = ['handbook', 'changelog', 'customer-stories', 'ebooks', 'whitepaper'] + +/** + * @param {string} xml the rendered /sitemap.xml + * @returns {string[]} one message per problem; empty when the sitemap is complete + */ +export function sitemapProblems (xml) { + const counts = new Map() + for (const [, body] of xml.matchAll(/([\s\S]*?)<\/url>/g)) { + const loc = body.match(/([^<]+)<\/loc>/)?.[1] + if (!loc) continue + const section = new URL(loc, 'https://flowfuse.com').pathname.split('/')[1] + const entry = counts.get(section) ?? { urls: 0, dated: 0 } + entry.urls++ + if (//.test(body)) entry.dated++ + counts.set(section, entry) + } + + const problems = [] + for (const section of CONTENT_SECTIONS) { + if (!counts.get(section)?.urls) { + problems.push(`no /${section}/ URLs: its collection returned nothing to server/api/__sitemap__/content-urls.get.ts`) + } + } + for (const section of GIT_DATED_SECTIONS) { + const entry = counts.get(section) + if (entry?.urls && entry.dated === 0) { + problems.push(`none of the ${entry.urls} /${section}/ URLs has a lastmod: lib/git-lastmod.mjs found no git history (is the build running in a git checkout?)`) + } + } + return problems +} diff --git a/nuxt/lib/sitemap-coverage.test.mjs b/nuxt/lib/sitemap-coverage.test.mjs new file mode 100644 index 0000000000..d69931a1ae --- /dev/null +++ b/nuxt/lib/sitemap-coverage.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { CONTENT_SECTIONS, sitemapProblems } from './sitemap-coverage.mjs' + +const url = (path, lastmod) => + `https://flowfuse.com${path}${lastmod ? `${lastmod}` : ''}` +const sitemap = urls => `${urls.join('')}` +const complete = () => CONTENT_SECTIONS.map(section => url(`/${section}/page/`, '2026-01-01')) + +test('a sitemap with every section, all dated, has no problems', () => { + assert.deepEqual(sitemapProblems(sitemap(complete())), []) +}) + +test('a missing section is reported by name', () => { + const urls = complete().filter(u => !u.includes('/changelog/')) + const problems = sitemapProblems(sitemap(urls)) + assert.equal(problems.length, 1) + assert.match(problems[0], /no \/changelog\/ URLs/) +}) + +test('a git-dated section with no lastmod at all is reported', () => { + const urls = complete().map(u => u.includes('/handbook/') ? url('/handbook/page/') : u) + const problems = sitemapProblems(sitemap(urls)) + assert.equal(problems.length, 1) + assert.match(problems[0], /\/handbook\/ URLs has a lastmod/) +}) + +test('a git-dated section with some dates is fine', () => { + const urls = [...complete(), url('/handbook/undated/')] + assert.deepEqual(sitemapProblems(sitemap(urls)), []) +}) + +test('the blog may be undated, since only some posts take their date from git', () => { + const urls = complete().map(u => u.includes('/blog/') ? url('/blog/page/') : u) + assert.deepEqual(sitemapProblems(sitemap(urls)), []) +}) + +test('pages outside the content sections do not count towards a section', () => { + const urls = [...complete().filter(u => !u.includes('/ebooks/')), url('/ebooks-landing/')] + assert.match(sitemapProblems(sitemap(urls))[0], /no \/ebooks\/ URLs/) +}) diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index cd80c3e8a2..65b37bde62 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -6,6 +6,7 @@ import remarkDocsLinks from './utils/remark-docs-links' import remarkSiteLinks from './utils/remark-site-links' import { BLOG_TAGS } from './composables/useBlogList' import { redirects } from './redirects' +import { sitemapProblems } from './lib/sitemap-coverage.mjs' import site from '../src/_data/site.json' // Collect all handbook routes from content files for SSG prerendering @@ -626,6 +627,17 @@ export default defineNuxtConfig({ nitroConfig.prerender = nitroConfig.prerender || {} nitroConfig.prerender.routes = [...new Set([...(nitroConfig.prerender.routes || []), ...routes])] console.log(`[nuxt] enumerated ${routes.length} /integrations/{id}/ routes for prerender`) + }, + // Fail the build when /sitemap.xml comes out missing a content section, or with no + // dates on a section that takes them from git. Neither fails it otherwise; see + // lib/sitemap-coverage.mjs. @nuxtjs/sitemap renders the file itself once + // prerendering is done, outside the route loop that prerender:generate sees, and + // hands it over here. A throw from this hook fails the build. + 'sitemap:prerender:done' ({ sitemaps }) { + const problems = sitemaps + .filter(sitemap => sitemap.name === '/sitemap.xml') + .flatMap(sitemap => sitemapProblems(sitemap.content)) + if (problems.length) throw new Error(`[sitemap] ${problems.join('; ')}`) } },