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
48 changes: 48 additions & 0 deletions nuxt/lib/sitemap-coverage.mjs
Original file line number Diff line number Diff line change
@@ -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(/<url>([\s\S]*?)<\/url>/g)) {
const loc = body.match(/<loc>([^<]+)<\/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 (/<lastmod>/.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
}
42 changes: 42 additions & 0 deletions nuxt/lib/sitemap-coverage.test.mjs
Original file line number Diff line number Diff line change
@@ -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) =>
`<url><loc>https://flowfuse.com${path}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>`
const sitemap = urls => `<?xml version="1.0" encoding="UTF-8"?><urlset>${urls.join('')}</urlset>`
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/)
})
12 changes: 12 additions & 0 deletions nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('; ')}`)
}
},

Expand Down
Loading