- extend vue-seo-layer (link dependency during development) and drop
@nuxtjs/seo: the page head, canonical and the one JSON-LD graph come
from the CMS resolve answer; the post's own title, excerpt and image
apply only while there is no CMS answer
- remove the journal sitemap source and the copied shared purge route,
library and plugin (the SEO layer serves POST /api/__site__/cache/purge);
the deprecated POST /api/__journal__/cache/purge keeps working
- post title is h1 while the Journal API reports content_h1_to_h2 on
(or absent) and p.journal-post__title while off; journal.postTitleTag
('auto' | 'h1' | 'p') overrides it; body h1 headings are demoted to h2
while the setting is on (gateJournalHtml/sanitizeJournalHtml demoteH1)
- journal.breadcrumbs option and post-title CSS tokens
- smoke harness: count=, header=, noheader= and resolves lines; the
fixture API also answers the SEO resolve and site endpoints
BREAKING CHANGE: the layer no longer installs @nuxtjs/seo (no
nuxt-site-config, nuxt-schema-org, robots or sitemap module, no
/api/__sitemap__/journal source, no useSiteConfig()); apps take their
head, sitemaps and robots from the SEO plugin through vue-seo-layer.
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
// A small node:http stand-in for the anonymous Journal API
|
|
// (/_journal/api/v1), answering from JSON fixtures. It follows the Go
|
|
// handlers' query rules (page, per_page clamp, sort allow-list, category with
|
|
// nested children, tag, search, fields=suggest, include_empty) and the author routes, and it
|
|
// records every request so a smoke run can assert header forwarding.
|
|
//
|
|
// It also stands in for the SEO plugin's public API (/_seo/api/v1), which
|
|
// vue-seo-layer calls for every page:
|
|
//
|
|
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
|
|
// <dir>/seo/resolve.json, else not_found
|
|
// GET /_seo/api/v1/site <dir>/seo/site.json, else 404 (an older
|
|
// backend)
|
|
//
|
|
// Anything else under /_seo answers 404 (no 404 reports or route reports:
|
|
// the smoke sets no token).
|
|
//
|
|
// Plain .mjs with JSDoc types: Node does not strip TypeScript under
|
|
// node_modules, and consuming apps import this file from there.
|
|
|
|
import { createServer } from 'node:http'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
const PREFIX = '/_journal/api/v1'
|
|
const SEO_PREFIX = '/_seo/api/v1'
|
|
const DEFAULT_PER_PAGE = 9
|
|
const MAX_PER_PAGE = 30
|
|
const SORT_COLUMNS = new Set(['published_at', 'created_at', 'updated_at', 'title', 'id'])
|
|
|
|
/**
|
|
* @typedef {{ method: string, url: string, xff: string | null }} FixtureRequest
|
|
* @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
|
|
*/
|
|
|
|
/** @param {string} dir @param {string} name @param {any} fallback */
|
|
function readJson(dir, name, fallback) {
|
|
try {
|
|
return JSON.parse(readFileSync(join(dir, name), 'utf8'))
|
|
} catch (err) {
|
|
if (/** @type {any} */ (err).code === 'ENOENT') return fallback
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/** @param {any} post */
|
|
function isPublished(post) {
|
|
return post.published === true && !!post.published_at && Date.parse(post.published_at) <= Date.now()
|
|
}
|
|
|
|
/** @param {string | null} raw */
|
|
function parsePage(raw) {
|
|
const n = Number.parseInt(raw ?? '', 10)
|
|
return Number.isFinite(n) && n > 0 ? n : 1
|
|
}
|
|
|
|
/** @param {string | null} raw */
|
|
function parsePerPage(raw) {
|
|
if (raw === null || raw.trim() === '') return DEFAULT_PER_PAGE
|
|
const n = Number.parseInt(raw, 10)
|
|
if (!Number.isFinite(n)) return DEFAULT_PER_PAGE
|
|
return Math.min(MAX_PER_PAGE, Math.max(1, n))
|
|
}
|
|
|
|
/** @param {string | null} raw @returns {[string, 'asc' | 'desc']} */
|
|
function parseSort(raw) {
|
|
const [col, dir] = (raw ?? '').trim().split(/\s+/)
|
|
if (col && SORT_COLUMNS.has(col)) return [col, dir === 'asc' ? 'asc' : 'desc']
|
|
return ['published_at', 'desc']
|
|
}
|
|
|
|
/** @param {any[]} rows @param {[string, 'asc' | 'desc']} sort */
|
|
function sortRows(rows, [col, dir]) {
|
|
const sign = dir === 'asc' ? 1 : -1
|
|
return [...rows].sort((a, b) => {
|
|
const av = a[col]
|
|
const bv = b[col]
|
|
if (av === bv) return (a.id - b.id) * sign
|
|
return (av < bv ? -1 : 1) * sign
|
|
})
|
|
}
|
|
|
|
/** @param {any[]} tree @returns {any[]} */
|
|
function flatten(tree) {
|
|
/** @type {any[]} */
|
|
const out = []
|
|
const walk = (/** @type {any[]} */ nodes) => {
|
|
for (const n of nodes ?? []) {
|
|
out.push(n)
|
|
walk(n.children)
|
|
}
|
|
}
|
|
walk(tree)
|
|
return out
|
|
}
|
|
|
|
/** @param {any[]} tree @param {boolean} includeEmpty @returns {any[]} */
|
|
function filterTree(tree, includeEmpty) {
|
|
/** @type {any[]} */
|
|
const out = []
|
|
for (const node of tree ?? []) {
|
|
const children = filterTree(node.children ?? [], includeEmpty)
|
|
if (!includeEmpty && node.post_count === 0 && children.length === 0) continue
|
|
const { children: _drop, ...row } = node
|
|
if (children.length > 0) row.children = children
|
|
out.push(row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** @param {any[]} rows @param {number} page @param {number} perPage */
|
|
function paginate(rows, page, perPage) {
|
|
const total = rows.length
|
|
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
return {
|
|
data: rows.slice((page - 1) * perPage, page * perPage),
|
|
meta: { current_page: page, last_page: lastPage, per_page: perPage, total },
|
|
}
|
|
}
|
|
|
|
/** The ?fields=suggest row of the Go serializer. @param {any} post */
|
|
function suggestion(post) {
|
|
const text = String(post.excerpt || post.content_html || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
|
return {
|
|
id: post.id,
|
|
title: post.title,
|
|
slug: post.slug,
|
|
excerpt: text.length > 160 ? `${text.slice(0, 160).replace(/\s+\S*$/, '')}…` : text,
|
|
featured_image_url: post.featured_image_url ?? null,
|
|
published_at: post.published_at,
|
|
}
|
|
}
|
|
|
|
/** @param {any} post */
|
|
function sibling(post) {
|
|
return post ? { id: post.id, title: post.title, slug: post.slug } : null
|
|
}
|
|
|
|
/**
|
|
* Starts the fixture API on 127.0.0.1.
|
|
* @param {{ dir: string, port?: number }} opts
|
|
* @returns {Promise<FixtureApi>}
|
|
*/
|
|
export function startFixtureApi({ dir, port = 0 }) {
|
|
/** @type {any[]} */
|
|
let posts = []
|
|
/** @type {any[]} */
|
|
let categories = []
|
|
/** @type {any[]} */
|
|
let tags = []
|
|
/** @type {any[]} */
|
|
let authors = []
|
|
let rss = ''
|
|
/** @type {Record<string, unknown>} */
|
|
let seoAnswers = {}
|
|
/** @type {unknown} */
|
|
let seoSite = null
|
|
// Reads (or re-reads) the fixtures, so a check can change a fixture file
|
|
// while the API runs.
|
|
const load = () => {
|
|
posts = readJson(dir, 'posts.json', [])
|
|
categories = readJson(dir, 'categories.json', [])
|
|
tags = readJson(dir, 'tags.json', [])
|
|
authors = readJson(dir, 'authors.json', [])
|
|
seoAnswers = readJson(join(dir, 'seo'), 'resolve.json', {})
|
|
seoSite = readJson(join(dir, 'seo'), 'site.json', null)
|
|
try {
|
|
rss = readFileSync(join(dir, 'rss.xml'), 'utf8')
|
|
} catch {
|
|
rss = '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>'
|
|
}
|
|
}
|
|
load()
|
|
|
|
/** @type {FixtureRequest[]} */
|
|
const requests = []
|
|
const published = () => posts.filter(isPublished)
|
|
|
|
/** @param {string} value */
|
|
const resolveAuthor = (value) => {
|
|
const bySlug = authors.find((a) => a.slug === value)
|
|
if (bySlug) return bySlug
|
|
const byLogin = authors.find((a) => a.login === value)
|
|
if (byLogin && published().some((p) => p.author && p.author.id === byLogin.id)) return byLogin
|
|
return null
|
|
}
|
|
|
|
/** @param {URLSearchParams} q */
|
|
const listPosts = (q, base = published()) => {
|
|
let rows = base
|
|
const category = q.get('category')
|
|
if (category) {
|
|
const flat = flatten(categories)
|
|
const root = flat.find((c) => c.slug === category || String(c.id) === category)
|
|
const ids = new Set(root ? flatten([root]).map((c) => c.id) : [])
|
|
rows = rows.filter((p) => p.categories.some((/** @type {any} */ c) => ids.has(c.id)))
|
|
}
|
|
const tag = q.get('tag')
|
|
if (tag) rows = rows.filter((p) => p.tags.some((/** @type {any} */ t) => t.slug === tag))
|
|
const search = (q.get('search') ?? '').trim()
|
|
if (search.length >= 3) {
|
|
const s = search.toLowerCase()
|
|
rows = rows.filter((p) => p.title.toLowerCase().includes(s))
|
|
}
|
|
rows = sortRows(rows, parseSort(q.get('sort')))
|
|
const out = paginate(rows, parsePage(q.get('page')), parsePerPage(q.get('per_page')))
|
|
if (q.get('fields') !== 'suggest') return out
|
|
return { ...out, data: out.data.map(suggestion) }
|
|
}
|
|
|
|
/**
|
|
* @param {import('node:http').IncomingMessage} req
|
|
* @returns {{ status: number, body: string, type: string }}
|
|
*/
|
|
const route = (req) => {
|
|
const url = new URL(req.url ?? '/', 'http://fixture.local')
|
|
const json = (/** @type {number} */ status, /** @type {any} */ value) => ({
|
|
status,
|
|
body: JSON.stringify(value),
|
|
type: 'application/json',
|
|
})
|
|
if (url.pathname.startsWith(SEO_PREFIX + '/')) {
|
|
const seoPath = url.pathname.slice(SEO_PREFIX.length)
|
|
if (req.method === 'GET' && seoPath === '/resolve') {
|
|
const target = url.searchParams.get('url') ?? ''
|
|
if (!target.startsWith('/')) return json(422, { error: 'bad_url' })
|
|
const hit = seoAnswers[target]
|
|
return json(200, hit ?? { action: 'not_found', status: 404, url: target, locale: '', staging: false, seo: null })
|
|
}
|
|
if (req.method === 'GET' && seoPath === '/site' && seoSite) return json(200, seoSite)
|
|
return json(404, { error: 'not_found' })
|
|
}
|
|
if (!url.pathname.startsWith(PREFIX + '/')) return json(404, { error: 'Not found' })
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return json(405, { error: 'Method not allowed' })
|
|
const path = decodeURIComponent(url.pathname.slice(PREFIX.length))
|
|
const q = url.searchParams
|
|
|
|
if (path === '/posts') return json(200, listPosts(q))
|
|
if (path.startsWith('/posts/')) {
|
|
const slug = path.slice('/posts/'.length)
|
|
const live = sortRows(published(), ['published_at', 'desc'])
|
|
const post = live.find((p) => p.slug === slug || String(p.id) === slug)
|
|
if (!post) return json(404, { error: 'Post not found' })
|
|
const idx = live.indexOf(post)
|
|
const catIds = new Set(post.categories.map((/** @type {any} */ c) => c.id))
|
|
const related =
|
|
catIds.size === 0
|
|
? []
|
|
: live
|
|
.filter((p) => p.id !== post.id && p.categories.some((/** @type {any} */ c) => catIds.has(c.id)))
|
|
.slice(0, 3)
|
|
return json(200, {
|
|
data: { ...post, previous_post: sibling(live[idx + 1]), next_post: sibling(live[idx - 1]), related_posts: related },
|
|
})
|
|
}
|
|
if (path === '/categories') {
|
|
const includeEmpty = ['1', 'true'].includes(q.get('include_empty') ?? '')
|
|
return json(200, { data: filterTree(categories, includeEmpty) })
|
|
}
|
|
if (path === '/tags') return json(200, { data: tags })
|
|
if (path === '/rss') return { status: 200, body: rss, type: 'application/rss+xml; charset=utf-8' }
|
|
|
|
const author = /^\/authors\/([^/]+)(\/posts)?$/.exec(path)
|
|
if (author) {
|
|
const row = resolveAuthor(author[1].trim())
|
|
if (!row) return json(404, { error: 'Author not found' })
|
|
if (!author[2]) {
|
|
return json(200, { data: { id: row.id, slug: row.slug, first_name: row.first_name, last_name: row.last_name } })
|
|
}
|
|
const mine = published().filter((p) => p.author && p.author.id === row.id)
|
|
return json(200, listPosts(new URLSearchParams({ page: q.get('page') ?? '', per_page: q.get('per_page') ?? '', sort: q.get('sort') ?? '' }), mine))
|
|
}
|
|
return json(404, { error: 'Not found' })
|
|
}
|
|
|
|
const server = createServer((req, res) => {
|
|
const xff = req.headers['x-forwarded-for']
|
|
requests.push({ method: req.method ?? 'GET', url: req.url ?? '/', xff: Array.isArray(xff) ? xff.join(', ') : (xff ?? null) })
|
|
let out
|
|
try {
|
|
out = route(req)
|
|
} catch (err) {
|
|
out = { status: 500, body: JSON.stringify({ error: String(err) }), type: 'application/json' }
|
|
}
|
|
res.writeHead(out.status, { 'content-type': out.type })
|
|
res.end(req.method === 'HEAD' ? undefined : 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 === `${SEO_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())),
|
|
})
|
|
})
|
|
})
|
|
}
|