feat!: render the post page on the shared SEO layer with one H1 in both Journal H1 modes (tracer)
- 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.
This commit is contained in:
@@ -4,6 +4,17 @@
|
||||
// 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.
|
||||
|
||||
@@ -12,13 +23,14 @@ 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[], reload: () => void, close: () => Promise<void> }} FixtureApi
|
||||
* @typedef {{ url: string, requests: FixtureRequest[], resolveCount: (target?: string) => number, reload: () => void, close: () => Promise<void> }} FixtureApi
|
||||
*/
|
||||
|
||||
/** @param {string} dir @param {string} name @param {any} fallback */
|
||||
@@ -139,6 +151,10 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
/** @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 = () => {
|
||||
@@ -146,6 +162,8 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
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 {
|
||||
@@ -201,6 +219,17 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
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))
|
||||
@@ -257,6 +286,14 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
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', () => {
|
||||
@@ -264,6 +301,7 @@ export function startFixtureApi({ dir, port = 0 }) {
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
requests,
|
||||
resolveCount,
|
||||
reload: load,
|
||||
close: () => new Promise((done) => server.close(() => done())),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user