feat: vue-seo-layer, the Nuxt half of the SummerCMS SEO plugin

This commit is contained in:
Jakub Zych
2026-10-31 13:45:37 +01:00
commit ac59f3c80d
30 changed files with 7962 additions and 0 deletions

51
testing/expect.txt Normal file
View File

@@ -0,0 +1,51 @@
# SSR expectations against the fixture SEO API (testing/fixtures).
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] [header=NAME: TEXT]
# report PATH
# The playground pages set their own head; the answer's values win, an
# empty value leaves the page's own tag, and a page the API does not know
# keeps its own head.
# An ok answer: every value from the answer, none of the page's.
200 / contains=<title>Home | Acme</title> notcontains=Own home
200 / contains=<meta name="description" content="Acme home"> notcontains=Own home description
200 / contains=<meta property="og:title" content="Home | Acme"> notcontains=Own home og
200 / contains=<link rel="canonical" href="https://acme.test/">
200 / contains=<meta name="twitter:site" content="@acme">
200 / header=X-Robots-Tag: index, follow
200 /about contains=<title>About us | Acme</title> notcontains=Own about
200 /about contains=<link rel="canonical" href="https://acme.test/about"> notcontains=own-canonical.test
200 /about contains=<meta name="robots" content="index, follow, max-image-preview:large"> notcontains=content="noindex"
200 /about contains=<meta name="google-site-verification" content="fixture-google-code">
# Empty values keep the page's own tags; the template still applies.
200 /partial contains=<title>Partial own | Playground</title>
200 /partial contains=<meta name="description" content="Partial own description">
200 /partial contains=<link rel="canonical" href="https://acme.test/partial">
# Not known to the API: the page's own head, no robots added.
200 /plain contains=<title>Plain page | Playground</title> notcontains=name="robots"
# Staging: noindex wins over the page's index, also on a page the API does not know.
200 /staging contains=<meta name="robots" content="noindex, nofollow"> notcontains=index, follow
200 /staging header=X-Robots-Tag: noindex, nofollow
# Redirects: the site's own origin becomes a path, another host stays absolute.
301 /old-path header=Location: /about
302 /old-temp?ref=1 header=Location: /about?x=1
301 /moved header=Location: https://other.test/x
# 410 wins over the route, also over a page that would render.
410 /gone-path
410 /posts/gone-post
200 /posts/known contains=<title>Known post | Playground</title>
# 404s, from an unknown route and from a page, are reported to the log.
404 /missing-page
404 /posts/nope
report /missing-page
report /posts/nope
# Verification files at the root; anything else falls through.
200 /google0123.html contains=google-site-verification: google0123.html header=X-Content-Type-Options: nosniff
404 /unknown0123.html
404 /robots.txt

114
testing/fixture-api.mjs Normal file
View File

@@ -0,0 +1,114 @@
// A small node:http stand-in for the SEO plugin's public API
// (/_seo/api/v1):
//
// GET /resolve?url=… the answer for that exact url from
// <dir>/resolve.json, else not_found
// POST /notfound 204 with `Authorization: Bearer <token>`,
// 401 with another token, 404 without one
// GET /verification/<name> <dir>/verification/<name>, else 404
//
// It records every request (with the parsed JSON body of a POST) so a smoke
// run can assert header forwarding and the 404 reports.
//
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
// node_modules, and consuming apps may import this file from there.
import { createServer } from 'node:http'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const PREFIX = '/_seo/api/v1'
/** The bearer token the fixture accepts on POST /notfound. */
export const FIXTURE_NOTFOUND_TOKEN = 'fixture-seo-notfound-token-0123456789'
/**
* @typedef {{ method: string, url: string, xff: string | null, authorization: string | null, body: unknown }} FixtureRequest
* @typedef {{ url: string, requests: FixtureRequest[], close: () => Promise<void> }} FixtureApi
*/
/**
* Starts the fixture API on 127.0.0.1.
* @param {{ dir: string, port?: number, token?: string }} opts
* @returns {Promise<FixtureApi>}
*/
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN }) {
/** @type {FixtureRequest[]} */
const requests = []
/** @type {Record<string, unknown>} */
const answers = JSON.parse(readFileSync(join(dir, 'resolve.json'), 'utf8'))
/**
* @param {import('node:http').IncomingMessage} req
* @returns {{ status: number, body?: unknown, text?: string, type?: string }}
*/
const route = (req) => {
const url = new URL(req.url ?? '/', 'http://fixture.local')
if (!url.pathname.startsWith(PREFIX + '/')) return { status: 404, body: { error: 'not_found' } }
const path = url.pathname.slice(PREFIX.length)
if (path === '/resolve' && req.method === 'GET') {
const target = url.searchParams.get('url') ?? ''
if (!target.startsWith('/')) return { status: 422, body: { error: 'bad_url' } }
const hit = answers[target]
if (hit) return { status: 200, body: hit }
return { status: 200, body: { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null } }
}
if (path === '/notfound' && req.method === 'POST') {
if (!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') {
try {
return { status: 200, text: readFileSync(join(dir, 'verification', v[1]), 'utf8'), type: 'text/html; charset=utf-8' }
} catch {
return { status: 404, body: { error: 'not_found' } }
}
}
return { status: 404, body: { error: 'not_found' } }
}
const server = createServer((req, res) => {
const chunks = /** @type {Buffer[]} */ ([])
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8')
let body = null
try {
body = raw ? JSON.parse(raw) : null
} catch {
body = raw
}
const header = (/** @type {string} */ name) => {
const v = req.headers[name]
return Array.isArray(v) ? v.join(', ') : (v ?? null)
}
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: header('x-forwarded-for'), authorization: header('authorization'), body })
let out
try {
out = route(req)
} catch (err) {
out = { status: 500, body: { error: String(err) } }
}
if (out.text !== undefined) {
res.writeHead(out.status, { 'content-type': out.type ?? 'text/plain' })
res.end(out.text)
return
}
res.writeHead(out.status, out.body === undefined ? {} : { 'content-type': 'application/json' })
res.end(out.body === undefined ? undefined : JSON.stringify(out.body))
})
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => {
const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
resolve({
url: `http://127.0.0.1:${addr.port}`,
requests,
close: () => new Promise((done) => server.close(() => done())),
})
})
})
}

View File

@@ -0,0 +1,38 @@
{
"/": {
"action": "ok", "status": 200, "url": "https://acme.test/", "locale": "en", "staging": false,
"seo": {
"title": "Home | Acme", "description": "Acme home", "canonical": "https://acme.test/",
"robots": "index, follow", "x_robots_tag": "index, follow",
"og": { "title": "Home | Acme", "description": "Acme home", "image": "https://acme.test/home.png", "url": "https://acme.test/" },
"twitter": { "card": "summary_large_image", "site": "@acme" },
"verification": []
}
},
"/about": {
"action": "ok", "status": 200, "url": "https://acme.test/about", "locale": "en", "staging": false,
"seo": {
"title": "About us | Acme", "description": "Who we are", "canonical": "https://acme.test/about",
"robots": "index, follow, max-image-preview:large", "x_robots_tag": "index, follow, max-image-preview:large",
"og": { "title": "About us | Acme", "description": "Who we are", "image": "", "url": "https://acme.test/about" },
"twitter": { "card": "summary", "site": "@acme" },
"verification": [{ "name": "google-site-verification", "content": "fixture-google-code" }]
}
},
"/partial": {
"action": "ok", "status": 200, "url": "https://acme.test/partial", "locale": "en", "staging": false,
"seo": {
"title": "", "description": "", "canonical": "https://acme.test/partial",
"robots": "", "x_robots_tag": "",
"og": { "title": "", "description": "", "image": "", "url": "" },
"twitter": { "card": "", "site": "" },
"verification": []
}
},
"/staging": { "action": "not_found", "status": 404, "url": "https://acme.test/staging", "locale": "", "staging": true, "seo": null },
"/old-path": { "action": "redirect", "status": 301, "location": "https://acme.test/about", "url": "https://acme.test/old-path", "locale": "", "staging": false, "seo": null },
"/old-temp?ref=1": { "action": "redirect", "status": 302, "location": "https://acme.test/about?x=1", "url": "https://acme.test/old-temp?ref=1", "locale": "", "staging": false, "seo": null },
"/moved": { "action": "redirect", "status": 301, "location": "https://other.test/x", "url": "https://acme.test/moved", "locale": "", "staging": false, "seo": null },
"/gone-path": { "action": "gone", "status": 410, "url": "https://acme.test/gone-path", "locale": "", "staging": false, "seo": null },
"/posts/gone-post": { "action": "gone", "status": 410, "url": "https://acme.test/posts/gone-post", "locale": "", "staging": false, "seo": null }
}

View File

@@ -0,0 +1,2 @@
<!-- fixture -->
google-site-verification: google0123.html

218
testing/smoke-ssr.mjs Normal file
View File

@@ -0,0 +1,218 @@
#!/usr/bin/env node
// SSR smoke check: boots the fixture SEO API and the built playground
// server, then checks each line of an expectations file.
//
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] [header=NAME: TEXT]
// report PATH
// A value runs until the next ` contains=`, ` notcontains=`, ` type=`,
// ` header=` or the end of the line, so it may contain spaces. header=
// checks that the response header NAME contains TEXT. A `report PATH` line
// requires a POST /notfound for PATH to have reached the fixture API by the
// end of the run (the server is started with the fixture's 404 token). Requests are plain GETs that do
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7` and
// `Accept: text/html` (as a browser does, so errors render as pages); afterwards
// at least one fixture API request must have carried that address (the SSR
// fetches forward the visitor's x-forwarded-for).
//
// The same approach as vue-faq-layer's testing/smoke-ssr.mjs; plain .mjs
// with JSDoc types so it runs from node_modules unchanged.
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { join, resolve } from 'node:path'
import { FIXTURE_NOTFOUND_TOKEN, startFixtureApi } from './fixture-api.mjs'
const VISITOR_IP = '203.0.113.7'
const API_PREFIX = '/_seo/api/v1'
const API_ENV = 'NUXT_SEO_API_BASE'
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {{ output?: string, fixtures?: string, expect?: string, env: Record<string, string> }} */
const out = { env: {} }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = () => {
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
return v
}
if (arg === '--output') out.output = next()
else if (arg === '--fixtures') out.fixtures = next()
else if (arg === '--expect') out.expect = next()
else if (arg === '--env') {
const kv = next()
const eq = kv.indexOf('=')
if (eq < 1) throw new Error(`--env expects KEY=VALUE, got ${kv}`)
out.env[kv.slice(0, eq)] = kv.slice(eq + 1)
} else throw new Error(`unknown argument ${arg}`)
}
if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required')
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
}
/**
* @param {string} line
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }}
*/
export function parseExpectLine(line) {
const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim())
if (!m) throw new Error('expected "STATUS PATH [options]"')
/** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }} */
const check = { status: Number(m[1]), path: m[2] }
const rest = m[3]
const re = /\s(contains|notcontains|type|header)=/g
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
const marks = []
let hit
while ((hit = re.exec(rest)) !== null) marks.push({ key: hit[1], start: hit.index, valueStart: re.lastIndex })
if (marks.length === 0 && rest.trim() !== '') throw new Error(`unknown option text "${rest.trim()}"`)
if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') {
throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`)
}
/** @type {Record<string, string>} */
const options = {}
marks.forEach((mark, i) => {
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
options[mark.key] = rest.slice(mark.valueStart, end).trim()
})
return { ...check, ...options }
}
/** @returns {Promise<number>} */
function freePort() {
return new Promise((done, fail) => {
const srv = createServer()
srv.once('error', fail)
srv.listen(0, '127.0.0.1', () => {
const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address())
srv.close(() => done(port))
})
})
}
/**
* Boots `<outputDir>/server/index.mjs` on 127.0.0.1 and waits until it answers.
* @param {{ outputDir: string, env: Record<string, string>, timeoutMs?: number }} opts
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
*/
async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) {
const entry = join(resolve(outputDir), 'server', 'index.mjs')
if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`)
const port = await freePort()
const url = `http://127.0.0.1:${port}`
const child = spawn(process.execPath, [entry], {
env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env },
stdio: ['ignore', 'pipe', 'pipe'],
})
let log = ''
child.stdout.on('data', (b) => (log += b))
child.stderr.on('data', (b) => (log += b))
let exited = false
child.once('exit', () => (exited = true))
const close = () =>
new Promise((/** @type {(v?: void) => void} */ done) => {
if (exited) return done()
child.once('exit', () => done())
child.kill('SIGTERM')
setTimeout(() => {
if (!exited) child.kill('SIGKILL')
}, 5000).unref()
})
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (exited) throw new Error(`server exited before answering\n${log}`)
try {
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
return { url, close }
} catch {
await new Promise((r) => setTimeout(r, 200))
}
}
await close()
throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`)
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const lines = readFileSync(resolve(args.expect), 'utf8')
.split('\n')
.map((l) => l.trim())
.filter((l) => l !== '' && !l.startsWith('#'))
const reports = lines.filter((l) => l.startsWith('report ')).map((l) => l.slice('report '.length).trim())
const checks = lines.filter((l) => !l.startsWith('report '))
const api = await startFixtureApi({ dir: resolve(args.fixtures) })
/** @type {{ url: string, close: () => Promise<void> } | null} */
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
nuxt = await startNuxtServer({
outputDir: args.output,
env: { [API_ENV]: `${api.url}${API_PREFIX}`, NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env },
})
for (const line of checks) {
let check
try {
check = parseExpectLine(line)
} catch (err) {
failures.push(`${line}: ${/** @type {Error} */ (err).message}`)
continue
}
let res
try {
res = await fetch(nuxt.url + check.path, {
redirect: 'manual',
headers: { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' },
signal: AbortSignal.timeout(20000),
})
} catch (err) {
failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`)
continue
}
const body = await res.text()
const type = res.headers.get('content-type') ?? ''
if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`)
else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`)
else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`)
else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`)
else if (check.header !== undefined) {
const colon = check.header.indexOf(':')
const name = colon > 0 ? check.header.slice(0, colon).trim() : check.header
const want = colon > 0 ? check.header.slice(colon + 1).trim() : ''
const got = res.headers.get(name)
if (got === null || !got.includes(want)) failures.push(`${line}: header ${name} is ${JSON.stringify(got)}, want it to contain "${want}"`)
}
}
// The 404 reports go out after the response; give them a moment.
const reported = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/notfound')).map((r) => /** @type {any} */ (r.body)?.url)
for (let i = 0; i < 20 && reports.some((p) => !reported().includes(p)); i++) await new Promise((r) => setTimeout(r, 100))
for (const p of reports) {
if (!reported().includes(p)) failures.push(`report ${p}: no 404 report reached the API (got ${JSON.stringify(reported())})`)
}
if (api.requests.some((r) => r.url.endsWith('/notfound') && r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) {
failures.push('a 404 report carried the wrong token')
}
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address')
}
} finally {
if (nuxt) await nuxt.close()
await api.close()
}
if (failures.length > 0) {
for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`)
process.exit(1)
}
console.log(`smoke-ssr: ok (${lines.length} checks)`)
}
main().catch((err) => {
console.error(`smoke-ssr: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})