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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
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