- the module registers seo-sitemap-<locale> at seo.htmlSitemap.paths (defaults pl /mapa-strony, en /sitemap) and seo-sitemap-group-<locale> at <path>/:group (plus <path>/:group/<segment>/:page in the path form); a path an app page already has stays the app's - SeoHtmlSitemapPage (global, overridable): H1, an H2 per non-empty group, categories nested, a large group's link to its group page, sitemap.empty; group pages with a built trail, page suffix and <SeoPagination>; 404 for an unknown group or a page past the last, 301 for ?page=1, 503 when the sitemap API fails - GET /api/__seo__/sitemap proxies the sitemap plugin's HTML API (only while the CMS has the HTML sitemap on for the locale) - <SeoSitemapLink>: the footer link while the HTML sitemap is on - L3 default styles
193 lines
9.4 KiB
JavaScript
193 lines
9.4 KiB
JavaScript
// A small node:http stand-in for the SEO plugin's public API
|
|
// (/_seo/api/v1) and the Go binary's root files:
|
|
//
|
|
// 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, 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
|
|
// /sitemap.xml
|
|
// GET /_sitemap/files/<file> <dir>/seo/root/<file>, else 404;
|
|
// sitemap-fail-*.xml answers 500
|
|
// GET /_sitemap/indexnow/<key>.txt <dir>/seo/indexnow/<key>.txt, else 404
|
|
// GET /_sitemap/api/v1/html?locale= the HTML sitemap overview from
|
|
// <dir>/seo/html.json (overview.<locale>),
|
|
// else 404
|
|
// GET /_sitemap/api/v1/html/<group> one group page (groups.<locale>.<group>.<page>,
|
|
// ?locale=&page= page 1 by default), else 404; the
|
|
// group fail answers 500
|
|
//
|
|
// It records every request (with the parsed JSON body of a POST) so a smoke
|
|
// run can assert header forwarding, resolve counts and the 404 reports.
|
|
// `reload()` reads the fixture files again (purge-check changes them).
|
|
//
|
|
// 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 { existsSync, 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[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
|
|
*/
|
|
|
|
/** @param {string} file @returns {any} */
|
|
function readJson(file) {
|
|
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
|
}
|
|
|
|
/** @param {string} name */
|
|
function contentType(name) {
|
|
if (name.endsWith('.xml')) return 'application/xml; charset=utf-8'
|
|
if (name.endsWith('.html')) return 'text/html; charset=utf-8'
|
|
return 'text/plain; charset=utf-8'
|
|
}
|
|
|
|
/**
|
|
* Starts the fixture API on 127.0.0.1.
|
|
* @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, siteFile = 'site.json' }) {
|
|
/** @type {FixtureRequest[]} */
|
|
const requests = []
|
|
/** @type {Record<string, unknown>} */
|
|
let answers = {}
|
|
/** @type {unknown} */
|
|
let siteAnswer = null
|
|
/** @type {any} */
|
|
let html = {}
|
|
const load = () => {
|
|
html = readJson(join(dir, 'seo', 'html.json')) ?? {}
|
|
answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) }
|
|
siteAnswer = site ? readJson(join(dir, 'seo', siteFile)) : null
|
|
}
|
|
load()
|
|
|
|
/** @param {string} file @param {string} name */
|
|
const fileAnswer = (file, name) => {
|
|
if (/^sitemap-fail-/.test(name)) return { status: 500, text: 'backend failure', type: 'text/plain' }
|
|
if (!existsSync(file)) return { status: 404, body: { error: 'not_found' } }
|
|
return { status: 200, text: readFileSync(file, 'utf8'), type: contentType(name), robots: name.startsWith('sitemap-') || name === 'llms.txt' ? 'noindex' : undefined }
|
|
}
|
|
|
|
/**
|
|
* @param {import('node:http').IncomingMessage} req
|
|
* @returns {{ status: number, body?: unknown, text?: string, type?: string, robots?: string }}
|
|
*/
|
|
const route = (req) => {
|
|
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
|
const root = /^\/(robots\.txt|llms\.txt|sitemap\.xml)$/.exec(url.pathname)
|
|
if (root && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', root[1]), root[1])
|
|
const files = /^\/_sitemap\/files\/(sitemap-[a-z0-9-]+\.xml)$/.exec(url.pathname)
|
|
if (files && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'root', files[1]), files[1])
|
|
const key = /^\/_sitemap\/indexnow\/([A-Za-z0-9-]{8,128}\.txt)$/.exec(url.pathname)
|
|
if (key && req.method === 'GET') return fileAnswer(join(dir, 'seo', 'indexnow', key[1]), key[1])
|
|
const htmlGroup = /^\/_sitemap\/api\/v1\/html(?:\/([a-z0-9-]+))?$/.exec(url.pathname)
|
|
if (htmlGroup && req.method === 'GET') {
|
|
const locale = url.searchParams.get('locale') ?? ''
|
|
if (htmlGroup[1] === 'fail') return { status: 500, body: { error: 'backend failure' } }
|
|
const hit = htmlGroup[1] ? html.groups?.[locale]?.[htmlGroup[1]]?.[url.searchParams.get('page') ?? '1'] : html.overview?.[locale]
|
|
return hit ? { status: 200, body: hit } : { status: 404, body: { error: 'not_found' } }
|
|
}
|
|
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 === '/site' && req.method === 'GET') {
|
|
return siteAnswer ? { status: 200, body: siteAnswer } : { status: 404, body: { error: 'not_found' } }
|
|
}
|
|
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' } }
|
|
}
|
|
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])]) {
|
|
if (existsSync(file)) return { status: 200, text: readFileSync(file, 'utf8'), type: 'text/html; charset=utf-8' }
|
|
}
|
|
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', ...(out.robots ? { 'x-robots-tag': out.robots } : {}) })
|
|
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))
|
|
})
|
|
})
|
|
|
|
/** Resolve calls so far, for one target url or (without one) all of them. @param {string} [target] */
|
|
const resolveCount = (target) =>
|
|
requests.filter((r) => {
|
|
if (r.method !== 'GET') return false
|
|
const u = new URL(r.url, 'http://fixture.local')
|
|
return u.pathname === `${PREFIX}/resolve` && (target === undefined || u.searchParams.get('url') === target)
|
|
}).length
|
|
|
|
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,
|
|
resolveCount,
|
|
reload: load,
|
|
close: () => new Promise((done) => server.close(() => done())),
|
|
})
|
|
})
|
|
})
|
|
}
|