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:
@@ -1,7 +1,19 @@
|
||||
# SSR expectations against the acme fixtures (testing/fixtures/acme).
|
||||
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX]
|
||||
# The consumer enables en (default, no prefix) and pl (/pl prefix), and sets
|
||||
# site.url to https://acme.test.
|
||||
# Grammar: STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N]
|
||||
# [type=PREFIX] [header=NAME: VALUE] [noheader=NAME]; resolves PATH N
|
||||
# The consumer enables en (default, no prefix) and pl (/pl prefix). The
|
||||
# fixture SEO API (fixtures/acme/seo) answers the site URL https://acme.test.
|
||||
|
||||
# The post page on the shared SEO layer: exactly one H1 in both Journal H1
|
||||
# modes, one canonical and one JSON-LD script (UI-SPEC L6, D-97).
|
||||
200 /blog/heading-on count=<h1:1 contains=<h1 class="journal-post__title" count=application/ld+json:1 count=rel="canonical":1
|
||||
200 /blog/heading-on contains=<h2>Body heading one</h2> notcontains=<p class="journal-post__title"
|
||||
200 /blog/heading-off count=<h1:1 contains=<p class="journal-post__title" count=application/ld+json:1 count=rel="canonical":1
|
||||
200 /blog/heading-off contains=<h1>Body heading one</h1>
|
||||
# The CMS head wins: its title, no second title.
|
||||
200 /blog/heading-on contains=<title>Heading On Sample | Acme Blog</title> count=<title>:1
|
||||
# Without a CMS answer the post's own title and description are the fallback.
|
||||
200 /blog/uncategorised-note contains=<title>Uncategorised Note</title> contains=Uncategorised Note: a short summary.
|
||||
|
||||
# Single post
|
||||
200 /blog/hello-acme contains=Hello Acme
|
||||
@@ -34,23 +46,20 @@
|
||||
200 /blog/tag/release contains=Hello Acme
|
||||
404 /blog/tag/nope
|
||||
|
||||
# RSS, sitemap, canonical and meta
|
||||
# RSS, canonical and meta (sitemaps come from the CMS, not the layer)
|
||||
200 /rss.xml type=application/rss+xml
|
||||
200 /blog contains=href="/rss.xml"
|
||||
200 /sitemap.xml contains=/blog/hello-acme
|
||||
200 /sitemap.xml contains=/blog/category/quiet
|
||||
200 /sitemap.xml notcontains=/blog/draft-note
|
||||
200 /blog?page=2 contains=?page=2
|
||||
200 /blog?page=2 contains=<link rel="canonical" href="https://acme.test/blog?page=2">
|
||||
200 /blog/hello-acme contains=og:title
|
||||
200 /blog/hello-acme contains=Acme says hello and introduces its blog.
|
||||
|
||||
# Search (the playground sets journal.routes.search: '/search'). 11 posts
|
||||
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**),
|
||||
# noindex, follow, and left out of the sitemap.
|
||||
# match "acme": 2 pages of 9. Not route-cached (the playground caches /**)
|
||||
# and noindex, follow.
|
||||
200 /search?q=acme contains=Hello Acme
|
||||
200 /search?q=acme contains=11 results
|
||||
200 /search?q=acme contains=noindex, follow
|
||||
200 /search?q=acme contains=noindex, follow header=x-robots-tag: noindex, follow
|
||||
200 /search?q=acme&page=2 contains=Acme Note 12
|
||||
404 /search?q=acme&page=3
|
||||
404 /search?q=acme&page=abc
|
||||
@@ -63,7 +72,6 @@
|
||||
200 /search?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E contains=Search results: <script>alert(1) notcontains=>Search results: <script>
|
||||
200 /pl/search?q=acme contains=11 wyników
|
||||
200 /pl/search?q=unsafe contains=1 wynik
|
||||
200 /sitemap.xml notcontains=/search
|
||||
# The header box renders its no-JS form to the search route on every page.
|
||||
200 /blog contains=action="/search"
|
||||
200 /pl/blog contains=action="/pl/search"
|
||||
|
||||
@@ -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())),
|
||||
})
|
||||
|
||||
@@ -475,5 +475,51 @@
|
||||
"translations": {},
|
||||
"created_at": "2026-01-20T09:00:00+00:00",
|
||||
"updated_at": "2026-01-20T09:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Heading On Sample",
|
||||
"slug": "heading-on",
|
||||
"excerpt": "Heading On Sample shows the Journal H1 setting on.",
|
||||
"content": null,
|
||||
"content_html": "<h1>Body heading one</h1>\n<p>The body opens with its own top heading.</p>\n<h2>Body heading two</h2>\n<p>And continues below it.</p>\n",
|
||||
"reading_time": 1,
|
||||
"published": true,
|
||||
"published_at": "2025-12-20T09:00:00+00:00",
|
||||
"is_pinned": false,
|
||||
"metadata": null,
|
||||
"sources": null,
|
||||
"author": null,
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"featured_images": [],
|
||||
"featured_image_url": null,
|
||||
"translations": {},
|
||||
"created_at": "2025-12-20T09:00:00+00:00",
|
||||
"updated_at": "2025-12-20T09:00:00+00:00",
|
||||
"content_h1_to_h2": true
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"title": "Heading Off Sample",
|
||||
"slug": "heading-off",
|
||||
"excerpt": "Heading Off Sample shows the Journal H1 setting off.",
|
||||
"content": null,
|
||||
"content_html": "<h1>Body heading one</h1>\n<p>The body opens with its own top heading.</p>\n<h2>Body heading two</h2>\n<p>And continues below it.</p>\n",
|
||||
"reading_time": 1,
|
||||
"published": true,
|
||||
"published_at": "2025-12-19T09:00:00+00:00",
|
||||
"is_pinned": false,
|
||||
"metadata": null,
|
||||
"sources": null,
|
||||
"author": null,
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"featured_images": [],
|
||||
"featured_image_url": null,
|
||||
"translations": {},
|
||||
"created_at": "2025-12-19T09:00:00+00:00",
|
||||
"updated_at": "2025-12-19T09:00:00+00:00",
|
||||
"content_h1_to_h2": false
|
||||
}
|
||||
]
|
||||
|
||||
412
testing/fixtures/acme/seo/resolve.json
Normal file
412
testing/fixtures/acme/seo/resolve.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"/blog/hello-acme": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://acme.test/blog/hello-acme",
|
||||
"locale": "en",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Hello Acme | Acme Blog",
|
||||
"description": "Acme says hello and introduces its blog.",
|
||||
"canonical": "https://acme.test/blog/hello-acme",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Hello Acme",
|
||||
"description": "Acme says hello and introduces its blog.",
|
||||
"image": "",
|
||||
"url": "https://acme.test/blog/hello-acme",
|
||||
"locale": "en_US",
|
||||
"locale_alternates": []
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": ""
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "en",
|
||||
"alternates": [],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://acme.test/#website",
|
||||
"url": "https://acme.test/",
|
||||
"name": "Acme Blog"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://acme.test/blog/hello-acme#webpage",
|
||||
"url": "https://acme.test/blog/hello-acme",
|
||||
"name": "Hello Acme",
|
||||
"isPartOf": {
|
||||
"@id": "https://acme.test/#website"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "BlogPosting",
|
||||
"@id": "https://acme.test/blog/hello-acme#article",
|
||||
"headline": "Hello Acme",
|
||||
"mainEntityOfPage": {
|
||||
"@id": "https://acme.test/blog/hello-acme#webpage"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://acme.test/blog",
|
||||
"path": "/blog"
|
||||
},
|
||||
{
|
||||
"name": "Hello Acme",
|
||||
"url": "https://acme.test/blog/hello-acme",
|
||||
"path": "/blog/hello-acme"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/blog/heading-on": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://acme.test/blog/heading-on",
|
||||
"locale": "en",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Heading On Sample | Acme Blog",
|
||||
"description": "Heading On Sample shows the Journal H1 setting on.",
|
||||
"canonical": "https://acme.test/blog/heading-on",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Heading On Sample",
|
||||
"description": "Heading On Sample shows the Journal H1 setting on.",
|
||||
"image": "",
|
||||
"url": "https://acme.test/blog/heading-on",
|
||||
"locale": "en_US",
|
||||
"locale_alternates": []
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": ""
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "en",
|
||||
"alternates": [],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://acme.test/#website",
|
||||
"url": "https://acme.test/",
|
||||
"name": "Acme Blog"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://acme.test/blog/heading-on#webpage",
|
||||
"url": "https://acme.test/blog/heading-on",
|
||||
"name": "Heading On Sample",
|
||||
"isPartOf": {
|
||||
"@id": "https://acme.test/#website"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "BlogPosting",
|
||||
"@id": "https://acme.test/blog/heading-on#article",
|
||||
"headline": "Heading On Sample",
|
||||
"mainEntityOfPage": {
|
||||
"@id": "https://acme.test/blog/heading-on#webpage"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://acme.test/blog",
|
||||
"path": "/blog"
|
||||
},
|
||||
{
|
||||
"name": "Heading On Sample",
|
||||
"url": "https://acme.test/blog/heading-on",
|
||||
"path": "/blog/heading-on"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/blog/heading-off": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://acme.test/blog/heading-off",
|
||||
"locale": "en",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Heading Off Sample | Acme Blog",
|
||||
"description": "Heading Off Sample shows the Journal H1 setting off.",
|
||||
"canonical": "https://acme.test/blog/heading-off",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Heading Off Sample",
|
||||
"description": "Heading Off Sample shows the Journal H1 setting off.",
|
||||
"image": "",
|
||||
"url": "https://acme.test/blog/heading-off",
|
||||
"locale": "en_US",
|
||||
"locale_alternates": []
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": ""
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "en",
|
||||
"alternates": [],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://acme.test/#website",
|
||||
"url": "https://acme.test/",
|
||||
"name": "Acme Blog"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://acme.test/blog/heading-off#webpage",
|
||||
"url": "https://acme.test/blog/heading-off",
|
||||
"name": "Heading Off Sample",
|
||||
"isPartOf": {
|
||||
"@id": "https://acme.test/#website"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "BlogPosting",
|
||||
"@id": "https://acme.test/blog/heading-off#article",
|
||||
"headline": "Heading Off Sample",
|
||||
"mainEntityOfPage": {
|
||||
"@id": "https://acme.test/blog/heading-off#webpage"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://acme.test/blog",
|
||||
"path": "/blog"
|
||||
},
|
||||
{
|
||||
"name": "Heading Off Sample",
|
||||
"url": "https://acme.test/blog/heading-off",
|
||||
"path": "/blog/heading-off"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/blog": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://acme.test/blog",
|
||||
"locale": "en",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Latest posts | Acme Blog",
|
||||
"description": "Every post of the Acme blog.",
|
||||
"canonical": "https://acme.test/blog",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Latest posts",
|
||||
"description": "Every post of the Acme blog.",
|
||||
"image": "",
|
||||
"url": "https://acme.test/blog",
|
||||
"locale": "en_US",
|
||||
"locale_alternates": []
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": ""
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "en",
|
||||
"alternates": [],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://acme.test/#website",
|
||||
"url": "https://acme.test/",
|
||||
"name": "Acme Blog"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://acme.test/blog#webpage",
|
||||
"url": "https://acme.test/blog",
|
||||
"name": "Latest posts",
|
||||
"isPartOf": {
|
||||
"@id": "https://acme.test/#website"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://acme.test/blog",
|
||||
"path": "/blog"
|
||||
}
|
||||
]
|
||||
},
|
||||
"/blog?page=2": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://acme.test/blog?page=2",
|
||||
"locale": "en",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Latest posts | Acme Blog",
|
||||
"description": "Every post of the Acme blog, page 2.",
|
||||
"canonical": "https://acme.test/blog?page=2",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Latest posts",
|
||||
"description": "Every post of the Acme blog, page 2.",
|
||||
"image": "",
|
||||
"url": "https://acme.test/blog?page=2",
|
||||
"locale": "en_US",
|
||||
"locale_alternates": []
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": ""
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "en",
|
||||
"alternates": [],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://acme.test/#website",
|
||||
"url": "https://acme.test/",
|
||||
"name": "Acme Blog"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://acme.test/blog?page=2#webpage",
|
||||
"url": "https://acme.test/blog?page=2",
|
||||
"name": "Latest posts",
|
||||
"isPartOf": {
|
||||
"@id": "https://acme.test/#website"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://acme.test/blog",
|
||||
"path": "/blog"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"form": "query",
|
||||
"param": "page",
|
||||
"page": 2
|
||||
}
|
||||
},
|
||||
"/blog/author/jdoe": {
|
||||
"action": "ok",
|
||||
"status": 200,
|
||||
"url": "https://acme.test/blog/author/jdoe",
|
||||
"locale": "en",
|
||||
"staging": false,
|
||||
"seo": {
|
||||
"title": "Jane Doe | Acme Blog",
|
||||
"description": "Posts by Jane Doe.",
|
||||
"canonical": "",
|
||||
"robots": "index, follow",
|
||||
"x_robots_tag": "",
|
||||
"og": {
|
||||
"title": "Jane Doe",
|
||||
"description": "Posts by Jane Doe.",
|
||||
"image": "",
|
||||
"url": "https://acme.test/blog/author/jdoe",
|
||||
"locale": "en_US",
|
||||
"locale_alternates": []
|
||||
},
|
||||
"twitter": {
|
||||
"card": "summary_large_image",
|
||||
"site": ""
|
||||
},
|
||||
"verification": []
|
||||
},
|
||||
"html_lang": "en",
|
||||
"alternates": [],
|
||||
"jsonld": {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "https://acme.test/#website",
|
||||
"url": "https://acme.test/",
|
||||
"name": "Acme Blog"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "https://acme.test/blog/author/jdoe#webpage",
|
||||
"url": "https://acme.test/blog/author/jdoe",
|
||||
"name": "Jane Doe",
|
||||
"isPartOf": {
|
||||
"@id": "https://acme.test/#website"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/",
|
||||
"path": "/"
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://acme.test/blog",
|
||||
"path": "/blog"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
38
testing/fixtures/acme/seo/site.json
Normal file
38
testing/fixtures/acme/seo/site.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"site_url": "https://acme.test",
|
||||
"default_locale": "en",
|
||||
"locales": [
|
||||
"en",
|
||||
"pl"
|
||||
],
|
||||
"unprefixed_locale": "en",
|
||||
"links": {
|
||||
"new_tab": true
|
||||
},
|
||||
"html_sitemap": {
|
||||
"enabled": false,
|
||||
"paths": {},
|
||||
"page_size": 100
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"type": "post",
|
||||
"pattern": "/blog/:slug"
|
||||
},
|
||||
{
|
||||
"type": "category",
|
||||
"pattern": "/blog/category/:category"
|
||||
}
|
||||
],
|
||||
"root_files": [],
|
||||
"llms": false,
|
||||
"pagination": {
|
||||
"form": "query",
|
||||
"param": "page",
|
||||
"segments": {}
|
||||
},
|
||||
"home_crumb": {
|
||||
"name": "Home",
|
||||
"url": "https://acme.test/"
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,9 @@ export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {},
|
||||
while (Date.now() < deadline) {
|
||||
if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`)
|
||||
try {
|
||||
await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
// A Nuxt-internal path: it touches no page, so the boot probe never
|
||||
// resolves through the SEO API or fills a route cache.
|
||||
await fetch(url + '/_nuxt/', { redirect: 'manual', signal: AbortSignal.timeout(2000) })
|
||||
return { url, close, output: () => log }
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// SSR smoke check: boots the fixture Journal API and a built Nuxt server,
|
||||
// then checks each line of an expectations file.
|
||||
// SSR smoke check: boots the fixture Journal and SEO API and a built Nuxt
|
||||
// server, then checks each line of an expectations file.
|
||||
//
|
||||
// node 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]
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` type=` or the
|
||||
// end of the line, so it may contain spaces. Requests are plain GETs that do
|
||||
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
|
||||
// [header=NAME: VALUE] [noheader=NAME]
|
||||
// resolves PATH N the fixture SEO API has seen exactly N resolve calls for
|
||||
// PATH so far (PATH * counts every resolve call)
|
||||
// A value runs until the next ` contains=`, ` notcontains=`, ` count=`,
|
||||
// ` type=`, ` header=`, ` noheader=` or the end of the line, so it may
|
||||
// contain spaces. Every option may repeat on a line and every occurrence
|
||||
// applies. count= wants exactly N occurrences of TEXT in the body (N is the
|
||||
// number after the last colon); header= wants the response header NAME to
|
||||
// equal VALUE (both trimmed); noheader= wants it absent. The same grammar
|
||||
// as vue-seo-layer's testing/smoke-ssr.mjs. Requests are plain GETs that do
|
||||
// not follow redirects and carry `X-Forwarded-For: 203.0.113.7`. After the
|
||||
// lines, at least one fixture API request must have carried that address
|
||||
// (the SSR fetches forward the visitor's x-forwarded-for), and every
|
||||
// categories request must have carried include_empty=1.
|
||||
//
|
||||
// The server gets the fixture API as NUXT_JOURNAL_API_BASE and, for the
|
||||
// vue-seo-layer resolve calls, NUXT_SEO_API_BASE (<fixtures>/seo/resolve.json
|
||||
// and site.json); --env overrides either.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
@@ -45,17 +57,21 @@ function parseArgs(argv) {
|
||||
return /** @type {{ output: string, fixtures: string, expect: string, env: Record<string, string> }} */ (out)
|
||||
}
|
||||
|
||||
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader']
|
||||
|
||||
/**
|
||||
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string }}
|
||||
* @returns {ExpectCheck}
|
||||
*/
|
||||
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 }} */
|
||||
const check = { status: Number(m[1]), path: m[2] }
|
||||
const rest = m[3]
|
||||
const re = /\s(contains|notcontains|type)=/g
|
||||
const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g')
|
||||
/** @type {Array<{ key: string, start: number, valueStart: number }>} */
|
||||
const marks = []
|
||||
let hit
|
||||
@@ -64,13 +80,57 @@ export function parseExpectLine(line) {
|
||||
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 options = marks.map((mark, i) => {
|
||||
const end = i + 1 < marks.length ? marks[i + 1].start : rest.length
|
||||
options[mark.key] = rest.slice(mark.valueStart, end).trim()
|
||||
return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() }
|
||||
})
|
||||
return { ...check, ...options }
|
||||
return { status: Number(m[1]), path: m[2], options }
|
||||
}
|
||||
|
||||
/** @param {string} body @param {string} text */
|
||||
function occurrences(body, text) {
|
||||
if (text === '') return 0
|
||||
let n = 0
|
||||
for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* The failures of one response against its check.
|
||||
* @param {ExpectCheck} check @param {{ status: number, headers: Headers, body: string }} res
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function checkResponse(check, res) {
|
||||
if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`]
|
||||
/** @type {string[]} */
|
||||
const out = []
|
||||
const type = res.headers.get('content-type') ?? ''
|
||||
for (const { key, value } of check.options) {
|
||||
if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`)
|
||||
else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`)
|
||||
else if (key === 'count') {
|
||||
const colon = value.lastIndexOf(':')
|
||||
const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN
|
||||
if (!Number.isInteger(want)) {
|
||||
out.push(`count=${value}: want TEXT:N`)
|
||||
continue
|
||||
}
|
||||
const text = value.slice(0, colon)
|
||||
const got = occurrences(res.body, text)
|
||||
if (got !== want) out.push(`body has ${got} of "${text}", want ${want}`)
|
||||
} else if (key === 'type' && !type.startsWith(value)) out.push(`content-type "${type}", want prefix "${value}"`)
|
||||
else if (key === 'header') {
|
||||
const colon = value.indexOf(':')
|
||||
const name = colon > 0 ? value.slice(0, colon).trim() : value.trim()
|
||||
const want = colon > 0 ? value.slice(colon + 1).trim() : ''
|
||||
const got = res.headers.get(name)
|
||||
if (got === null || got.trim() !== want) out.push(`header ${name} is ${JSON.stringify(got)}, want "${want}"`)
|
||||
} else if (key === 'noheader') {
|
||||
const got = res.headers.get(value)
|
||||
if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -85,8 +145,18 @@ async function main() {
|
||||
let nuxt = null
|
||||
const failures = /** @type {string[]} */ ([])
|
||||
try {
|
||||
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_journal/api/v1`, env: args.env })
|
||||
nuxt = await startNuxtServer({
|
||||
outputDir: args.output,
|
||||
apiBase: `${api.url}/_journal/api/v1`,
|
||||
env: { NUXT_SEO_API_BASE: `${api.url}/_seo/api/v1`, ...args.env },
|
||||
})
|
||||
for (const line of lines) {
|
||||
const resolves = /^resolves\s+(\S+)\s+(\d+)$/.exec(line)
|
||||
if (resolves) {
|
||||
const got = api.resolveCount(resolves[1] === '*' ? undefined : resolves[1])
|
||||
if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`)
|
||||
continue
|
||||
}
|
||||
let check
|
||||
try {
|
||||
check = parseExpectLine(line)
|
||||
@@ -106,11 +176,7 @@ async function main() {
|
||||
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}"`)
|
||||
for (const f of checkResponse(check, { status: res.status, headers: res.headers, body })) failures.push(`${line}: ${f}`)
|
||||
}
|
||||
if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) {
|
||||
failures.push('x-forwarded-for: x-forwarded-for not forwarded')
|
||||
|
||||
Reference in New Issue
Block a user