feat: route report and drift check at server start

- the module writes #seo-routes (static paths and route patterns) from the
  resolved pages; options seo.routeReport, seo.pagination and
  seo.htmlSitemap reach the public runtime config
- seo-route-report: POST /registry/routes once per process with the
  route-report token, one retry after 60 s on failure, nothing without a
  token, with the layer off or with routeReport false
- seo-drift: one "[vue-seo-layer] drift:" warning per mismatch between
  the CMS's site answer (URL patterns, pagination, HTML sitemap paths)
  and the build
- GET /api/__seo__/site and useSeoSite()
- smoke grammar routereport and log, fixture registry route, drift run
This commit is contained in:
Jakub Zych
2026-11-04 11:49:21 +01:00
parent 9cc2cda924
commit 3abc986f42
21 changed files with 599 additions and 20 deletions

View File

@@ -4,11 +4,14 @@
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
// <dir>/seo/resolve.json, then
// <dir>/resolve.json, else not_found
// GET /_seo/api/v1/site <dir>/seo/site.json (404 without the
// file or with { site: false }: an
// older backend)
// GET /_seo/api/v1/site <dir>/seo/site.json, or <dir>/seo/<siteFile>
// (404 without the file or with
// { site: false }: an older backend)
// POST /_seo/api/v1/notfound 204 with `Authorization: Bearer <token>`,
// 401 with another token, 404 without one
// POST /_seo/api/v1/registry/routes the route report: the same token rules;
// 404 with { site: false } (an older
// backend has no page registry)
// GET /_seo/api/v1/verification/<name> <dir>/verification/<name> or
// <dir>/seo/verification/<name>, else 404
// GET /robots.txt, /llms.txt, <dir>/seo/root/<name>, else 404
@@ -52,10 +55,10 @@ function contentType(name) {
/**
* Starts the fixture API on 127.0.0.1.
* @param {{ dir: string, port?: number, token?: string, site?: boolean }} opts
* @param {{ dir: string, port?: number, token?: string, site?: boolean, siteFile?: string }} opts
* @returns {Promise<FixtureApi>}
*/
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true }) {
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true, siteFile = 'site.json' }) {
/** @type {FixtureRequest[]} */
const requests = []
/** @type {Record<string, unknown>} */
@@ -64,7 +67,7 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
let siteAnswer = null
const load = () => {
answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) }
siteAnswer = site ? readJson(join(dir, 'seo', 'site.json')) : null
siteAnswer = site ? readJson(join(dir, 'seo', siteFile)) : null
}
load()
@@ -103,6 +106,10 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
if (!token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
if (path === '/registry/routes' && req.method === 'POST') {
if (!site || !token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
const v = /^\/verification\/([A-Za-z0-9._-]+)$/.exec(path)
if (v && req.method === 'GET') {
for (const file of [join(dir, 'verification', v[1]), join(dir, 'seo', 'verification', v[1])]) {