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:
7
testing/expect-acme-title-h1.txt
Normal file
7
testing/expect-acme-title-h1.txt
Normal 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>
|
||||
7
testing/expect-acme-title-p.txt
Normal file
7
testing/expect-acme-title-p.txt
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user