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:
16
README.md
16
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 `<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)`.
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
81
app/utils/srcset.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
45
tests/srcset.test.ts
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user