feat: WebP and srcset for featured images

- JournalFeaturedImage renders <picture> from featured_images[].variants:
  a WebP source and the original format as srcset fallback, the
  original's width and height, alt; lazy in cards, eager with
  fetchpriority high on the post page; unchanged without variants
- app/utils/srcset.ts (imageSrcsets) with node tests
- acme fixtures and SSR expectations for both cases
This commit is contained in:
Jakub Zych
2026-11-04 17:10:30 +01:00
parent adcea4d538
commit 1080a3f0c4
10 changed files with 220 additions and 10 deletions

View File

@@ -26,6 +26,7 @@ Route paths are configuration, not files. The layer's page components live in `a
- **RSS**: `/rss.xml` proxies the API feed, and list and post pages link it in the head.
- **SEO**: the head, canonical and JSON-LD come from the CMS through vue-seo-layer; the post's own title, excerpt and featured image are a fallback for a page without a CMS answer. Listing pages add an `ItemList` node. See [SEO](#seo).
- **Headings**: exactly one H1 per page in both Journal H1 modes (the post title is an `h1` while the Journal API reports `content_h1_to_h2` on and a `p.journal-post__title` while it is off; `journal.postTitleTag` overrides it), post cards at `h2` (`h3` under the related posts' `h2`). See [Headings](#headings).
- **Featured images**: with the Journal API's `featured_images[].variants` (sm-journal-plugin's image variants), a `<picture>` with a WebP `<source>` and the `<img>` in the original format, both with `srcset` and `sizes` (the original itself is the widest candidate), the original's width and height, and `alt`. Cards load it lazily; the post page loads its main image eagerly with `fetchpriority="high"`. An image without variants renders the single `<img>` as before. See [Featured images](#featured-images).
- **Post cards**: one link per card, the title; the whole card is clickable through a CSS stretched link, with the focus ring on the card.
- **Body links and images**: the CMS's phrase auto-links, the outbound link rules (nofollow or sponsored from the domain lists; new tab, `noopener` and a visually hidden hint while the CMS's links_new_tab is on) and lazy, async body images, applied at SSR on the sanitized body.
- **Breadcrumbs and pagination**: `<SeoBreadcrumbs>` at the top of every journal page (`journal.breadcrumbs`), and `JournalPagination` as `<SeoPagination>` with the journal's class names.
@@ -212,6 +213,19 @@ Override a component by creating the same file in the app, for example `app/comp
Pages and components can use the `useJournalApi()` client and the `useJournalLinks()` link helpers (`postLink`, `categoryLink`, `tagLink`, `authorLink`, `indexLink`, `searchLink`), which return `null` for a disabled view. The client's `suggestPosts(term, { perPage, signal })` returns the suggestion rows (`JournalPostSuggestion`).
## Featured images
`JournalFeaturedImage` takes `src` (the original's URL), `alt`, `eager`, and optionally `image` (the `featured_images` entry) and `sizes` (`100vw` by default). `JournalPostCard` passes the post's first featured image with `sizes="(min-width: 48rem) 33vw, 100vw"`, the post page with `sizes="(min-width: 46rem) 46rem, 100vw"` and `eager`. An app whose grid or reading column differs overrides the component, or wraps it with its own `sizes`. With variants the component renders:
```html
<figure class="journal-featured-image"><picture>
<source type="image/webp" srcset="…_480_288_0_0_exact.webp 480w, …_800_480_0_0_exact.webp 800w" sizes="…">
<img src="…/cover.jpg" srcset="…_480_288_0_0_exact.jpg 480w, …_800_480_0_0_exact.jpg 800w, …/cover.jpg 1000w" sizes="…" width="1000" height="600" alt="…" loading="eager" fetchpriority="high" decoding="async">
</picture></figure>
```
`imageSrcsets(variants, originalUrl, originalWidth?)` in `app/utils/srcset.ts` builds the two srcsets (narrowest first, each width once, addresses escaped for srcset) and the reserved size from the variants (`source_width`, `source_height` when the API sends them, else the largest variant); no usable variant answers `null`. The CSS is unchanged: `.journal-featured-image img` keeps the ratio and the crop, and the `picture` element is a block.
## i18n
The layer ships `i18n/locales/en.json` and `pl.json` under the `journal.*` key tree. It declares no locales itself: its module registers a string file only for a locale the app enables, so an app that enables only `pl` gets no `/en` routes. An app that enables another locale adds its own `journal.*` strings in its locale file. Dates are formatted with `Intl.DateTimeFormat` for the active locale's `language`, in UTC.
@@ -267,7 +281,7 @@ A release is an annotated git tag (`v0.1.0`) on `main`, pushed to git.golem15.co
## Testing
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher (including the suggest query), the search helpers (term rules, diacritic-insensitive marking, plural forms, combobox keys, and the suggestion controller's debounce, abort and stale-answer handling under mocked timers), pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the H1 demotion and the body pipeline order, the embed expansion and its sanitize-first order, the post page view, SEO values (fallback meta, the title element, ItemList), path-form routes, the old purge route's helpers and browser revalidation (`tests/siteCache.test.ts`), and pl/en key and placeholder parity. The shared purge route and the body link steps are tested in vue-seo-layer. They need no build, network or database.
- `pnpm test` runs the unit tests in `tests/*.test.ts` with `node --test` (Node strips the TypeScript types; no test framework dependency). They cover the pure helpers in `app/utils/`: route normalization and path filling, the client against a fake fetcher (including the suggest query), the search helpers (term rules, diacritic-insensitive marking, plural forms, combobox keys, and the suggestion controller's debounce, abort and stale-answer handling under mocked timers), pagination, the HTML sanitizer (the Journal plugin's FormatHTML cases, the bypasses from its code review, and the Blog Editor corpus in `tests/fixtures/editor-corpus.json`, a byte copy of the plugin's), the H1 demotion and the body pipeline order, the featured image srcsets (`tests/srcset.test.ts`), the embed expansion and its sanitize-first order, the post page view, SEO values (fallback meta, the title element, ItemList), path-form routes, the old purge route's helpers and browser revalidation (`tests/siteCache.test.ts`), and pl/en key and placeholder parity. The shared purge route and the body link steps are tested in vue-seo-layer. They need no build, network or database.
- `scripts/check-git-install.sh <tag>` installs the layer from the git tag into a scratch app, checks that `pnpm-lock.yaml` pins the tag's commit, runs `nuxt build`, and runs the SSR smoke against the build. Run it from a directory that is not inside a directory named `vue-journal-layer` (Nuxt treats such a path segment as the layer).
- `testing/smoke-ssr.mjs --output <.output> --fixtures <dir> --expect <file> [--env KEY=VALUE]...` boots a fixture Journal and SEO API and the built server and checks each line of the expectations file: `STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX] [header=NAME: VALUE] [noheader=NAME] [headings=strict] [cardlinks=N]`, every option repeatable, and `resolves PATH N` (the SEO resolve calls so far for a path, `*` for all). `headings=strict` wants exactly one `<h1` and no skipped level, `cardlinks=N` exactly N links in each post card. It also checks that SSR requests forwarded `x-forwarded-for` and that categories were requested with `include_empty=1`. `pnpm smoke:ssr` runs `expect-acme.txt` and the expectation files for `postTitleTag` forced to `h1` and `p`, the new-tab hint off and `journal.breadcrumbs` off.
- `testing/purge-check.mjs --output <.output> --fixtures <dir> --kind post|category --slug <slug> --path <page path>` proves the [site cache purge](#site-cache-purge) on a built server whose route rules cache pages: it caches the page, changes the entity in a temporary copy of the fixtures, requires the cached page to stay unchanged, purges with a random token, and requires the change on the next request. It also checks `max-age=0` on the page and its 304, the purged `_payload.json`, a second purge after re-caching, `401` without or with a wrong token, `404` behind a proxy and `404` on a server without a token. The last line is `purge-check: ok (<n> checks)`.

View File

@@ -90,6 +90,10 @@
margin: 0 0 var(--journal-space-4);
}
.journal-featured-image picture {
display: block;
}
.journal-featured-image img {
display: block;
width: 100%;

View File

@@ -1,11 +1,37 @@
<script setup lang="ts">
// A post's featured image. The intrinsic width/height reserve space before
// the image loads; the rendered size and crop come from --journal-image-ratio.
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean }>()
// A post's featured image. With the API's variants (D-85) it renders a
// <picture>: a WebP <source> and the <img> in the original format, both
// with srcset and sizes; without them, the single <img> as before. The
// intrinsic width/height reserve space before the image loads; the
// rendered size and crop come from --journal-image-ratio. eager is the
// post's main image: loaded at once, with fetchpriority high when the
// variants are there; cards load lazily.
import type { JournalFeaturedImage } from '../../utils/types.ts'
import { imageSrcsets } from '../../utils/srcset.ts'
const props = withDefaults(
defineProps<{ src: string | null | undefined; alt: string; eager?: boolean; image?: JournalFeaturedImage | null; sizes?: string }>(),
{ eager: false, image: null, sizes: '100vw' },
)
const sets = computed(() => (props.src ? imageSrcsets(props.image?.variants, props.src) : null))
</script>
<template>
<figure v-if="src" class="journal-featured-image">
<img :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
<picture v-if="sets">
<source type="image/webp" :srcset="sets.webp" :sizes="sizes" />
<img
:src="src"
:srcset="sets.original"
:sizes="sizes"
:width="sets.width"
:height="sets.height"
:alt="alt"
:loading="eager ? 'eager' : 'lazy'"
:fetchpriority="eager ? 'high' : undefined"
decoding="async"
/>
</picture>
<img v-else :src="src" :alt="alt" width="1200" height="630" :loading="eager ? 'eager' : 'lazy'" decoding="async" />
</figure>
</template>

View File

@@ -19,7 +19,7 @@ const heading = computed(() => (props.headingLevel === 3 ? 'h3' : 'h2'))
<template>
<article class="journal-post-card">
<div v-if="post.featured_image_url" class="journal-post-card__image">
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" />
<JournalFeaturedImage :src="post.featured_image_url" :image="post.featured_images?.[0]" :alt="post.title" sizes="(min-width: 48rem) 33vw, 100vw" />
</div>
<component :is="heading" class="journal-post-card__title">
<NuxtLink v-if="href" :to="href">{{ post.title }}</NuxtLink>

View File

@@ -77,7 +77,7 @@ const siblings = computed(() => (post.value ? postNeighbours(post.value) : { pre
<component :is="titleTag" class="journal-post__title">{{ post.title }}</component>
<JournalPostMeta :post="post" />
</header>
<JournalFeaturedImage :src="post.featured_image_url" :alt="post.title" eager />
<JournalFeaturedImage :src="post.featured_image_url" :image="post.featured_images?.[0]" :alt="post.title" sizes="(min-width: 46rem) 46rem, 100vw" eager />
<JournalPostBody :html="post.content_html" :demote-h1="demoteH1" />
<nav v-if="siblings.previous || siblings.next" class="journal-post__siblings">
<NuxtLink

81
app/utils/srcset.ts Normal file
View File

@@ -0,0 +1,81 @@
// srcset strings of a featured image from the Journal API's
// featured_images[].variants (D-85, D-71): one for the WebP <source> and
// one for the <img> in the original format, the original itself added
// when it is wider than every variant, plus the size the <img> reserves.
// Pure, no Vue or Nuxt imports, so `node --test` drives it.
/** One size of a featured image (featured_images[].variants). */
export interface JournalImageVariant {
width: number
height: number
/** The thumbnail in the original's format. */
url: string
/** Its WebP copy. */
webp_url: string
/** The original's size. */
source_width?: number
source_height?: number
}
/** What <picture> renders from the variants. */
export interface JournalImageSrcsets {
/** srcset of the type="image/webp" <source>. */
webp: string
/** srcset of the <img>, in the original format. */
original: string
/** The size the <img> reserves: the original's, else the largest variant's. */
width: number
height: number
}
/** A URL safe inside a srcset candidate (no spaces or commas). */
function candidateURL(url: string): string {
return url.replace(/ /g, '%20').replace(/,/g, '%2C')
}
function validVariant(v: unknown): v is JournalImageVariant {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
return (
Number.isInteger(o.width) && (o.width as number) > 0 &&
Number.isInteger(o.height) && (o.height as number) > 0 &&
typeof o.url === 'string' && o.url !== '' &&
typeof o.webp_url === 'string' && o.webp_url !== ''
)
}
/**
* The srcsets of an image from its variants, narrowest first and each
* width once. originalUrl and originalWidth (the variants' source_width
* when not given) add the original to the <img> srcset when it is wider
* than every variant. No usable variant answers null: render the original
* as before.
*/
export function imageSrcsets(
variants: readonly unknown[] | null | undefined,
originalUrl?: string | null,
originalWidth?: number | null,
): JournalImageSrcsets | null {
if (!Array.isArray(variants)) return null
const byWidth = new Map<number, JournalImageVariant>()
for (const v of variants) {
if (validVariant(v) && !byWidth.has(v.width)) byWidth.set(v.width, v)
}
const list = [...byWidth.values()].sort((a, b) => a.width - b.width)
const largest = list[list.length - 1]
if (!largest) return null
const webp = list.map((v) => `${candidateURL(v.webp_url)} ${v.width}w`)
const original = list.map((v) => `${candidateURL(v.url)} ${v.width}w`)
const sourceWidth = originalWidth ?? largest.source_width ?? null
const sourceHeight = largest.source_height ?? null
if (originalUrl && sourceWidth && Number.isInteger(sourceWidth) && sourceWidth > largest.width) {
original.push(`${candidateURL(originalUrl)} ${sourceWidth}w`)
}
const hasSource = !!sourceWidth && !!sourceHeight && Number.isInteger(sourceWidth) && Number.isInteger(sourceHeight)
return {
webp: webp.join(', '),
original: original.join(', '),
width: hasSource ? (sourceWidth as number) : largest.width,
height: hasSource ? (sourceHeight as number) : largest.height,
}
}

View File

@@ -2,6 +2,10 @@
// mirror the Go serializers exactly (snake_case); a field is nullable where
// Go can emit null. Types only, so `node --test` can import any util.
import type { JournalImageVariant } from './srcset.ts'
export type { JournalImageVariant }
/** The author object embedded in a post row. `null` when the post has none. */
export interface JournalAuthorRef {
id: number
@@ -24,6 +28,8 @@ export interface JournalFeaturedImage {
url: string
file_name: string
file_size: number
/** Its sizes with WebP copies, narrowest first; [] until they are made (Journal API, D-85). */
variants?: JournalImageVariant[]
}
/** One post row, as GET /posts and GET /authors/{author}/posts list it. */

View File

@@ -141,3 +141,12 @@
# previous_post and next_post stay.
200 /blog/heading-on contains=href="/blog/acme-note-04" class="journal-post__previous" rel="prev"><span>Previous post</span> Acme Note 04</a> contains=href="/blog/acme-note-06" class="journal-post__next" rel="next"><span>Next post</span> Acme Note 06</a>
200 /blog/heading-off contains=href="/blog/heading-on" class="journal-post__next" rel="next"><span>Next post</span> Heading On Sample</a> notcontains=journal-post__previous
# Featured images with the API's variants (D-85, D-71): <picture> with a
# WebP source and the original format as the srcset fallback (the
# original itself as the widest candidate), the original's width and
# height, alt; the post's main image eager with fetchpriority high, cards
# lazy. An image without variants renders the single <img> as before.
200 /blog/hello-acme contains=<figure class="journal-featured-image"><picture><source contains=image/webp" srcset="/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.webp 480w, /storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.webp 800w" sizes="(min-width: 46rem) 46rem, 100vw"><img src="/storage/app/uploads/public/acme/hello.png" srcset="/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.png 480w, /storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.png 800w, /storage/app/uploads/public/acme/hello.png 1200w" sizes="(min-width: 46rem) 46rem, 100vw" width="1200" height="630" alt="Hello Acme" loading="eager" fetchpriority="high" decoding="async"></picture></figure> count=fetchpriority="high":1
200 /blog contains=image/webp" srcset="/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.webp 480w, /storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.webp 800w" sizes="(min-width: 48rem) 33vw, 100vw"> contains=alt="Hello Acme" loading="lazy" decoding="async"></picture> notcontains=fetchpriority
200 /blog/acme-note-03 contains=<figure class="journal-featured-image"><img src="/storage/app/uploads/public/acme/note-03.jpg" alt="Acme Note 03" width="1200" height="630" loading="eager" decoding="async"></figure> notcontains=note-03.jpg 1200w notcontains=fetchpriority

View File

@@ -38,7 +38,25 @@
"id": 1,
"url": "/storage/app/uploads/public/acme/hello.png",
"file_name": "hello.png",
"file_size": 1024
"file_size": 1024,
"variants": [
{
"width": 480,
"height": 252,
"url": "/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.png",
"webp_url": "/storage/app/uploads/public/acme/thumb_1_480_252_0_0_exact.webp",
"source_width": 1200,
"source_height": 630
},
{
"width": 800,
"height": 420,
"url": "/storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.png",
"webp_url": "/storage/app/uploads/public/acme/thumb_1_800_420_0_0_exact.webp",
"source_width": 1200,
"source_height": 630
}
]
}
],
"featured_image_url": "/storage/app/uploads/public/acme/hello.png",
@@ -108,8 +126,15 @@
}
],
"tags": [],
"featured_images": [],
"featured_image_url": null,
"featured_images": [
{
"id": 3,
"url": "/storage/app/uploads/public/acme/note-03.jpg",
"file_name": "note-03.jpg",
"file_size": 2048
}
],
"featured_image_url": "/storage/app/uploads/public/acme/note-03.jpg",
"translations": {},
"created_at": "2026-01-12T09:00:00+00:00",
"updated_at": "2026-01-12T09:00:00+00:00"

45
tests/srcset.test.ts Normal file
View File

@@ -0,0 +1,45 @@
// srcset strings of featured images from the API's variants (D-85, D-71).
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { imageSrcsets } from '../app/utils/srcset.ts'
const v = (width: number, height: number) => ({
width,
height,
url: `/u/thumb_1_${width}_${height}_0_0_exact.jpg`,
webp_url: `/u/thumb_1_${width}_${height}_0_0_exact.webp`,
source_width: 1000,
source_height: 600,
})
test('WebP and original srcsets from the variants, narrowest first, with the original wider than every variant', () => {
const sets = imageSrcsets([v(800, 480), v(480, 288)], '/u/cover.jpg')
assert.deepEqual(sets, {
webp: '/u/thumb_1_480_288_0_0_exact.webp 480w, /u/thumb_1_800_480_0_0_exact.webp 800w',
original: '/u/thumb_1_480_288_0_0_exact.jpg 480w, /u/thumb_1_800_480_0_0_exact.jpg 800w, /u/cover.jpg 1000w',
width: 1000,
height: 600,
})
})
test('the original is left out when a variant is as wide, and an explicit width wins', () => {
assert.equal(imageSrcsets([v(480, 288)], '/u/cover.jpg', 480)?.original, '/u/thumb_1_480_288_0_0_exact.jpg 480w')
assert.equal(imageSrcsets([v(480, 288)], null)?.original, '/u/thumb_1_480_288_0_0_exact.jpg 480w')
})
test('no variants give no srcset', () => {
assert.equal(imageSrcsets([], '/u/cover.jpg'), null)
assert.equal(imageSrcsets(undefined, '/u/cover.jpg'), null)
assert.equal(imageSrcsets(null), null)
assert.equal(imageSrcsets([{ width: 0, height: 1, url: 'a', webp_url: 'b' }, { width: 480 }], '/x'), null)
})
test('a width appears once, and without the source size the largest variant reserves the space', () => {
const plain = [{ width: 480, height: 300, url: '/a.jpg', webp_url: '/a.webp' }, { width: 480, height: 300, url: '/b.jpg', webp_url: '/b.webp' }]
assert.deepEqual(imageSrcsets(plain, '/o.jpg'), { webp: '/a.webp 480w', original: '/a.jpg 480w', width: 480, height: 300 })
})
test('spaces and commas in addresses cannot split a candidate', () => {
const sets = imageSrcsets([{ width: 480, height: 300, url: '/a b,c.jpg', webp_url: '/a b,c.webp' }])
assert.equal(sets?.webp, '/a%20b%2Cc.webp 480w')
})