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

@@ -203,7 +203,10 @@
color: var(--journal-color-muted);
}
/* The card is one link (UI-SPEC L5): the title anchor stretches over the
whole card, and the card shows the keyboard focus. */
.journal-post-card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--journal-space-2);
@@ -213,6 +216,11 @@
background: var(--journal-color-bg);
}
.journal-post-card:has(.journal-post-card__title a:focus-visible) {
outline: 2px solid var(--journal-color-accent);
outline-offset: 2px;
}
.journal-post-card .journal-featured-image {
margin: calc(-1 * var(--journal-space-4)) calc(-1 * var(--journal-space-4)) var(--journal-space-2);
}
@@ -230,6 +238,13 @@
text-decoration: none;
}
.journal-post-card__title a::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
.journal-post-card__date {
color: var(--journal-color-muted);
font-size: 0.85em;
@@ -239,10 +254,6 @@
margin: 0;
}
.journal-post-card__more {
margin-top: auto;
}
.journal-related {
margin-top: var(--journal-space-5);
}

View File

@@ -1,26 +1,31 @@
<script setup lang="ts">
// One post in a listing: image, title link, date and excerpt.
// One post in a listing: image, title link, date and excerpt (UI-SPEC L5).
// The title link is the card's only anchor (D-80): the image is not a link
// and there is no separate "read more" link; the whole card is clickable
// through a CSS stretched link on the title (journal-base.css), with the
// focus ring on the card. Post links carry no rel. headingLevel is 2 in a
// listing and 3 under the related posts' own h2 (D-52).
import type { JournalPost } from '../../utils/types.ts'
import { formatJournalDate } from '../../utils/view.ts'
const props = defineProps<{ post: JournalPost }>()
const props = withDefaults(defineProps<{ post: JournalPost; headingLevel?: 2 | 3 }>(), { headingLevel: 2 })
const { localeProperties } = useI18n()
const links = useJournalLinks()
const href = computed(() => links.postLink(props.post.slug))
const date = computed(() => formatJournalDate(props.post.published_at, localeProperties.value.language ?? 'en'))
const heading = computed(() => (props.headingLevel === 3 ? 'h3' : 'h2'))
</script>
<template>
<article class="journal-post-card">
<NuxtLink v-if="href && post.featured_image_url" :to="href" class="journal-post-card__image" tabindex="-1" aria-hidden="true">
<div v-if="post.featured_image_url" class="journal-post-card__image">
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
</NuxtLink>
<h2 class="journal-post-card__title">
</div>
<component :is="heading" class="journal-post-card__title">
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>
<template v-else>{{ post.title }}</template>
</h2>
</component>
<time v-if="date" class="journal-post-card__date" :datetime="post.published_at ?? undefined">{{ date }}</time>
<p v-if="post.excerpt" class="journal-post-card__excerpt">{{ post.excerpt }}</p>
<NuxtLink v-if="href" :to="href" class="journal-post-card__more">{{ $t('journal.readMore') }}</NuxtLink>
</article>
</template>

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
// A grid of post cards, or the empty-state text when there are none.
// headingLevel goes to every card (3 under the related posts' h2).
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[]; empty?: string }>()
defineProps<{ posts: JournalPost[]; empty?: string; headingLevel?: 2 | 3 }>()
</script>
<template>
<div class="journal-post-list">
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" />
<JournalPostCard v-for="post in posts" :key="post.id" :post="post" :heading-level="headingLevel ?? 2" />
<p v-if="posts.length === 0" class="journal-post-list__empty">{{ empty ?? $t('journal.index.empty') }}</p>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
// Related posts under a post. The page passes the API's related_posts, or
// the latest posts when the post has no category.
// the latest posts when the post has no category. The section heading is an
// h2 and the cards' titles h3 (D-52).
import type { JournalPost } from '../../utils/types.ts'
defineProps<{ posts: JournalPost[] }>()
@@ -9,6 +10,6 @@ defineProps<{ posts: JournalPost[] }>()
<template>
<section v-if="posts.length" class="journal-related">
<h2 class="journal-related__title">{{ $t('journal.post.related') }}</h2>
<JournalPostList :posts="posts" />
<JournalPostList :posts="posts" :heading-level="3" />
</section>
</template>

View File

@@ -8,7 +8,7 @@
"dev": "nuxi dev .playground",
"build:playground": "nuxt build .playground",
"test": "node --test --test-reporter=tap tests/*.test.ts",
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-hint-off.txt --env NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false",
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-title-h1.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-title-p.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=p && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme-hint-off.txt --env NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false",
"dev:prepare": "nuxt prepare .playground",
"check:git-install": "bash scripts/check-git-install.sh"
},

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