feat!: one link per post card and the heading convention

- JournalPostCard: the title link is the card's only anchor; the image is
  no longer a link and the separate read-more link is gone; the whole card
  stays clickable through a CSS stretched link with the focus ring on the
  card; prop headingLevel (2 | 3, default 2)
- related posts keep their h2 and render their cards at h3; JournalPostList
  passes headingLevel through
- smoke checks headings=strict (one h1, no skipped level) and cardlinks=N
  on every fixture page; expectation files for postTitleTag forced to h1
  and p (the documented two-h1 and no-h1 consequences)

BREAKING CHANGE: .journal-post-card__more and the image link
(.journal-post-card__image is now a div) are removed from post cards.
This commit is contained in:
Jakub Zych
2026-11-04 13:49:29 +01:00
parent 1010a603f0
commit 51e5d67576
9 changed files with 108 additions and 19 deletions

View File

@@ -0,0 +1,7 @@
# SSR expectations with journal.postTitleTag forced to h1
# (NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1, UI-SPEC L6 override). The title is
# an h1 in both Journal H1 modes; with the setting off the body keeps its h1,
# so the page has two: the documented consequence of the override, which
# the crawler reports.
200 /blog/heading-on count=<h1:1 contains=<h1 class="journal-post__title"
200 /blog/heading-off count=<h1:2 contains=<h1 class="journal-post__title" contains=<h1>Body heading one</h1>

View File

@@ -0,0 +1,7 @@
# SSR expectations with journal.postTitleTag forced to p
# (NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=p, UI-SPEC L6 override). With the
# Journal H1 setting on, the body's h1 is demoted, so the page has no h1:
# the documented consequence of the override, which the crawler reports as
# h1_missing.
200 /blog/heading-on count=<h1:0 contains=<p class="journal-post__title"
200 /blog/heading-off count=<h1:1 contains=<p class="journal-post__title" contains=<h1>Body heading one</h1>

View File

@@ -87,3 +87,19 @@
# heading-off: new tab off keeps the editor rel and the domain-list nofollow,
# without target, noopener or the hint.
200 /blog/heading-off contains=<a href="https://partner.test/x" rel="ugc nofollow">the partner</a> contains=<a href="https://other.test/y">another site</a> notcontains=target="_blank" notcontains=noopener notcontains=(opens in a new tab)
# Heading convention (UI-SPEC L6) and one link per post card (L5) on every
# fixture page: exactly one <h1, no skipped level, each card one <a, post
# cards h2 in listings and h3 under the related posts' h2, no read-more link.
200 /blog headings=strict cardlinks=1 count=<h1:1 notcontains=journal-post-card__more
200 /blog?page=2 headings=strict cardlinks=1 count=<h1:1 contains=· Page 2
200 /pl/blog?page=2 headings=strict cardlinks=1 count=<h1:1 contains=· Strona 2
200 /blog/category/news headings=strict cardlinks=1 count=<h1:1
200 /blog/category/quiet headings=strict count=<h1:1
200 /blog/tag/release headings=strict cardlinks=1 count=<h1:1
200 /blog/author/jane-doe headings=strict cardlinks=1 count=<h1:1
200 /search?q=acme headings=strict cardlinks=1 count=<h1:1
200 /blog/hello-acme headings=strict cardlinks=1 count=<h1:1 contains=<h2 class="journal-related__title" contains=<h3 class="journal-post-card__title" notcontains=<h2 class="journal-post-card__title" notcontains=journal-post-card__more
200 /blog/heading-on headings=strict cardlinks=1 count=<h1:1
200 /blog/heading-off headings=strict cardlinks=1 count=<h1:1
200 /blog/uncategorised-note headings=strict cardlinks=1 count=<h1:1

View File

@@ -6,7 +6,8 @@
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
// [header=NAME: VALUE] [noheader=NAME]
// [header=NAME: VALUE] [noheader=NAME] [headings=strict]
// [cardlinks=N]
// 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=`,
@@ -15,7 +16,12 @@
// 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
// as vue-seo-layer's testing/smoke-ssr.mjs, plus two checks of the
// heading and card rules (UI-SPEC L5, L6), both on the markup outside
// <script> elements: headings=strict wants exactly one <h1 and no skipped
// level in document order (h2 then h4 fails; going back up is fine);
// cardlinks=N wants at least one .journal-post-card and exactly N <a in
// each. 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
@@ -57,7 +63,33 @@ 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']
const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader', 'headings', 'cardlinks']
/** The page markup without <script> elements (the payload, JSON-LD). @param {string} body */
function markup(body) {
return body.replace(/<script\b[\s\S]*?<\/script>/gi, '')
}
/** The heading rule's failures (UI-SPEC L6). @param {string} body @returns {string[]} */
export function headingFailures(body) {
const levels = [...markup(body).matchAll(/<h([1-6])(?=[\s>])/gi)].map((m) => Number(m[1]))
/** @type {string[]} */
const out = []
const h1 = levels.filter((l) => l === 1).length
if (h1 !== 1) out.push(`${h1} <h1 elements, want 1`)
let previous = 0
for (const level of levels) {
if (level > previous + 1) out.push(`heading h${level} after ${previous === 0 ? 'no heading' : `h${previous}`} skips a level (order ${levels.join(',')})`)
previous = level
}
return out
}
/** The anchors per post card. @param {string} body @returns {number[]} */
export function cardLinkCounts(body) {
const cards = markup(body).split(/<article class="journal-post-card"/).slice(1)
return cards.map((card) => (card.slice(0, card.indexOf('</article>')).match(/<a[\s>]/g) ?? []).length)
}
/**
* @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck
@@ -128,6 +160,15 @@ export function checkResponse(check, res) {
} else if (key === 'noheader') {
const got = res.headers.get(value)
if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`)
} else if (key === 'headings') {
if (value !== 'strict') out.push(`headings=${value}: want headings=strict`)
else out.push(...headingFailures(res.body))
} else if (key === 'cardlinks') {
const want = Number(value)
const counts = cardLinkCounts(res.body)
if (!Number.isInteger(want)) out.push(`cardlinks=${value}: want a number`)
else if (counts.length === 0) out.push('no .journal-post-card on the page')
else if (counts.some((n) => n !== want)) out.push(`post cards hold ${counts.join(',')} links, want ${want} each`)
}
}
return out