From 1080a3f0c4914eaa0fe9807415485a15e821211d Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 17:10:30 +0100 Subject: [PATCH] feat: WebP and srcset for featured images - JournalFeaturedImage renders 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 --- README.md | 16 ++++- app/assets/css/journal-base.css | 4 ++ app/components/Journal/FeaturedImage.vue | 34 ++++++++-- app/components/Journal/PostCard.vue | 2 +- app/journal-pages/PostPage.vue | 2 +- app/utils/srcset.ts | 81 ++++++++++++++++++++++++ app/utils/types.ts | 6 ++ testing/expect-acme.txt | 9 +++ testing/fixtures/acme/posts.json | 31 ++++++++- tests/srcset.test.ts | 45 +++++++++++++ 10 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 app/utils/srcset.ts create mode 100644 tests/srcset.test.ts diff --git a/README.md b/README.md index 066a4c2..06d9ede 100644 --- a/README.md +++ b/README.md @@ -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 `` with a WebP `` and the `` 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 `` 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**: `` at the top of every journal page (`journal.breadcrumbs`), and `JournalPagination` as `` 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 + +``` + +`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 ` 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 --expect [--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 ` --fixtures --kind post|category --slug --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 ( checks)`. diff --git a/app/assets/css/journal-base.css b/app/assets/css/journal-base.css index 6c5c0b3..45cfc98 100644 --- a/app/assets/css/journal-base.css +++ b/app/assets/css/journal-base.css @@ -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%; diff --git a/app/components/Journal/FeaturedImage.vue b/app/components/Journal/FeaturedImage.vue index 1c93524..8abe228 100644 --- a/app/components/Journal/FeaturedImage.vue +++ b/app/components/Journal/FeaturedImage.vue @@ -1,11 +1,37 @@ diff --git a/app/components/Journal/PostCard.vue b/app/components/Journal/PostCard.vue index ae7ca1c..fd7da80 100644 --- a/app/components/Journal/PostCard.vue +++ b/app/components/Journal/PostCard.vue @@ -19,7 +19,7 @@ const heading = computed(() => (props.headingLevel === 3 ? 'h3' : 'h2'))