openvibers/openvibe.shared Commits

Add the assets helper for hashed URLs and responsive pictures (#3)

OP OpenVibers committed 2 Oct 2026 · 705a46c6df · 5 files, +260 −1

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Shared/pull/3

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9d7850c..7374b1c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## Unreleased
++
++- `openvibe-shared/assets` (plan T11): content-addressed asset URLs and responsive `<picture>` markup for
++ a product page. `hash(bytes)` is the estate's sha256 truncated to 12 hex; `url(src, hash)` appends
++ `?v=`, replaces an old one and leaves the path unchanged when there is no hash; `srcset(entries)` builds
++ a srcset; `picture({ src, hash?, alt, width, height, sizes, sources })` emits AVIF before WebP before
++ the fallback `<img>` (any other type ranks after WebP), renders a plain `<img>` when there is nothing to
++ choose between, and renders nothing without a `src`. Formats come from the caller: this package does not
++ transcode images, so it only emits the AVIF/WebP variants a site's own build produced.
++
## 2.4.0 — 2026-10-02
- The SEO kit adds `llmsFull` for full-text AI maps, `pageSummary` for escaped page
ddiff --git a/README.md b/README.md
iindex b6b4775..cc561d4 100644
---- a/README.md
++++ b/README.md
@@@ -20,7 +20,7 @@ were reconciled first.
| Kind | Files |
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
@@@ -77,6 +77,45 @@ seo.feedLinkTags({ rss: 'https://example.com/feed.xml', atom: 'https://example.c
array) and `videoObject` (the same VideoObject shape as `video`). Feed URLs should be absolute,
or relative to the absolute `link`; `llmsFull` resolves section URLs against `site.url`.
++### Images and responsive pictures (`assets.js`)
++
++`openvibe-shared/assets` gives an image the same content-addressed URL the rest of the estate uses —
++`?v=<first 12 hex of sha256>`, which `cache-policy` and every CDN already treat as immutable — and
++renders the `<picture>` around it, without the page hand-writing a srcset:
++
++```js
++const fs = require('fs');
++const assets = require('openvibe-shared/assets');
++const hash = assets.hash(fs.readFileSync('public/img/hero.png')); // 12 hex
++assets.url('/img/hero.png', hash); // /img/hero.png?v=<hash>
++const html = assets.picture({
++ src: '/img/hero.png', hash, alt: 'The dashboard', width: 1600, height: 900,
++ sizes: '(max-width: 800px) 100vw, 800px',
++ sources: [
++ { type: 'image/avif', entries: [{ src: '/img/hero-800.avif', hash: h800, w: 800 },
++ { src: '/img/hero-1600.avif', hash: h1600, w: 1600 }] },
++ { type: 'image/webp', entries: [{ src: '/img/hero-800.webp', hash: w800, w: 800 },
++ { src: '/img/hero-1600.webp', hash: w1600, w: 1600 }] },
++ ],
++});
++// <picture><source type="image/avif" …><source type="image/webp" …><img src="/img/hero.png?v=…" alt="…" …></picture>
++```
++
++`hash(data)` is sha256 over bytes (`Buffer`/`string`), truncated to the estate's 12 hex. `url(src,
++hash)` appends `?v=`, uses `&` after an existing query, replaces an old `?v=` instead of stacking it,
++and **returns the path unchanged when there is no hash** — so a site with no build step keeps its
++existing asset paths. `srcset(entries)` builds one from `[{ src, hash?, w? | x? }]`.
++
++`picture(o)` orders the `<source>`s itself — AVIF, then WebP, then any other type, with the caller's
++order kept inside a format — and always puts the fallback `<img>` last. With no usable source it
++renders a plain `<img>`, never an empty `<picture>`; with no `src` it renders nothing. `alt`, `type`,
++`srcset`, `sizes`, `media`, `class`, `id`, `loading` (`lazy`), `decoding` (`async`) and `fetchpriority`
++all pass through escaped, and a fixed attribute order means the same input renders the same bytes.
++
++**Formats are the caller's.** Shared does not transcode images and there is no image pipeline in this
++package: pass the AVIF/WebP variants your own build produced, and leave `sources` off when only the
++original exists. This module only emits the sources it is given, in the order a browser needs them.
++
### Metrics and readiness (server)
```js
ddiff --git a/assets.js b/assets.js
nnew file mode 100644
iindex 0000000..8154870
---- /dev/null
++++ b/assets.js
@@@ -0,0 +1,140 @@
++'use strict';
++/**
++ * openvibe-shared/assets — content-addressed image/asset URLs and responsive <picture> markup, so a
++ * page that shows an image gets an immutable URL, a srcset, the right format order and a fallback
++ * without hand-writing any of it. The estate's convention is `?v=<first 12 hex of sha256(file)>`
++ * (serve.js, and the product asset helpers); this module speaks the same URL shape, so
++ * cache-policy.assetHeaders and every CDN already treat the result as immutable.
++ *
++ * const assets = require('openvibe-shared/assets');
++ * const hash = assets.hash(fs.readFileSync('public/img/hero.png')); // 12 hex
++ * assets.url('/img/hero.png', hash); // /img/hero.png?v=<hash>
++ * assets.picture({
++ * src: '/img/hero.png', hash,
++ * alt: 'The dashboard', width: 1600, height: 900,
++ * sizes: '(max-width: 800px) 100vw, 800px',
++ * sources: [
++ * { type: 'image/webp', entries: [{ src: '/img/hero-800.webp', hash: h8, w: 800 }, { src: '/img/hero-1600.webp', hash: h16, w: 1600 }] },
++ * { type: 'image/avif', entries: [{ src: '/img/hero-800.avif', hash: a8, w: 800 }, { src: '/img/hero-1600.avif', hash: a16, w: 1600 }] },
++ * ],
++ * });
++ *
++ * Rules it keeps for the page:
++ * - No hash → the path is returned exactly as given, so a site that has no build step keeps its
++ * existing URLs; no `?v=` is invented.
++ * - <source> order is AVIF, then WebP, then anything else (first match wins in a browser), and the
++ * <img> is always last. The caller's order inside a format is kept.
++ * - No usable source → a plain <img>, never an empty <picture>.
++ * - Every value is escaped; attribute order is fixed, so the same input renders the same bytes.
++ *
++ * Formats are the caller's: this package does not transcode images. Pass the AVIF/WebP variants your
++ * own build produced; when only the original exists, `sources` is left empty and you get the <img>.
++ */
++
++const crypto = require('crypto');
++
++const HASH_LEN = 12; // ?v= length across the estate (serve.js, product asset helpers)
++const FORMAT_RANK = { 'image/avif': 0, 'image/webp': 1 };
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** The content hash of bytes (Buffer|string|Uint8Array): sha256, first `HASH_LEN` hex. */
++function hash(data) {
++ if (data == null) return '';
++ const buf = Buffer.isBuffer(data) || data instanceof Uint8Array ? data : Buffer.from(String(data));
++ return crypto.createHash('sha256').update(buf).digest('hex').slice(0, HASH_LEN);
++}
++
++/** Drop an existing `?v=` (and a dangling `?`/`&`) from the path part, keeping its query and fragment. */
++function versionless(src) {
++ const at = src.indexOf('#');
++ const head = at < 0 ? src : src.slice(0, at);
++ const frag = at < 0 ? '' : src.slice(at);
++ const clean = head.replace(/([?&])v=[^&#]*/g, '$1')
++ .replace(/\?&/g, '?').replace(/&&+/g, '&').replace(/[?&]+$/, '');
++ return clean + frag;
++}
++
++/**
++ * The content-addressed URL of an asset. `hash` comes from assets.hash (or a build manifest). With no
++ * hash the path is returned unchanged; a replaced `?v=` never stacks.
++ */
++function url(src, hashValue) {
++ const s = String(src == null ? '' : src);
++ if (!s) return '';
++ const clean = versionless(s);
++ const h = hashValue == null ? '' : String(hashValue).trim();
++ if (!h) return clean;
++ const at = clean.indexOf('#');
++ const head = at < 0 ? clean : clean.slice(0, at);
++ const frag = at < 0 ? '' : clean.slice(at);
++ return `${head}${head.includes('?') ? '&' : '?'}v=${encodeURIComponent(h)}${frag}`;
++}
++
++/** ` 800w` for a width, ` 2x` for a density, nothing when neither is a finite number. */
++function descriptor(e) {
++ const w = e.w != null ? e.w : e.width;
++ if (Number.isFinite(Number(w))) return ` ${Number(w)}w`;
++ const x = e.x != null ? e.x : (e.d != null ? e.d : e.density);
++ if (Number.isFinite(Number(x))) return ` ${Number(x)}x`;
++ return '';
++}
++
++/** A srcset attribute value from [{ src, hash?, w? | x? }] (a bare string works too). '' when empty. */
++function srcset(entries) {
++ if (!Array.isArray(entries)) return '';
++ return entries
++ .map((e) => (typeof e === 'string' ? { src: e } : (e || {})))
++ .filter((e) => e.src)
++ .map((e) => `${url(e.src, e.hash)}${descriptor(e)}`)
++ .join(', ');
++}
++
++const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ? FORMAT_RANK[String(type).toLowerCase()] : 2);
++
++/**
++ * Responsive <picture> HTML (or a plain <img> when there is nothing to choose between).
++ *
++ * @param {object} o
++ * src, hash the fallback image (?v= added when hash is given)
++ * alt alt text (escaped; pass '' only for decorative images)
++ * width, height intrinsic pixels, so the box is reserved before the image loads
++ * sizes sizes attribute (given to the <img> only when it also has a srcset)
++ * srcset fallback entries for the <img>: [{ src, hash?, w? | x? }]
++ * sources [{ type, src? , hash?, entries? | srcset?, media?, sizes? }] — a source with
++ * no srcset is dropped; AVIF sorts before WebP before the rest
++ * className, id, loading, decoding, fetchpriority pass-through attributes
++ * @returns {string} escaped HTML; '' when there is no src
++ */
++function picture(o = {}) {
++ const { src, alt = '', width, height, sizes, className, id, loading = 'lazy', decoding = 'async', fetchpriority, srcset: fallbackEntries } = o;
++ if (!src) return '';
++ const built = (Array.isArray(o.sources) ? o.sources : [])
++ .map((s, i) => ({ s, i }))
++ .filter(({ s }) => s && s.type && (s.src || s.srcset || s.entries))
++ .map(({ s, i }) => {
++ const set = s.srcset != null ? (typeof s.srcset === 'string' ? s.srcset : srcset(s.srcset))
++ : s.src ? url(s.src, s.hash) : srcset(s.entries);
++ return { s, i, set };
++ })
++ .filter(({ set }) => set)
++ .sort((a, b) => formatRank(a.s.type) - formatRank(b.s.type) || a.i - b.i)
++ .map(({ s, set }) => `<source type="${esc(s.type)}"${s.media ? ` media="${esc(s.media)}"` : ''} srcset="${esc(set)}"${s.sizes || sizes ? ` sizes="${esc(s.sizes || sizes)}"` : ''}>`);
++ const fallback = fallbackEntries ? srcset(fallbackEntries) : '';
++ const attrs = [
++ `src="${esc(url(src, o.hash))}"`,
++ fallback ? `srcset="${esc(fallback)}"` : '',
++ `alt="${esc(alt)}"`,
++ width != null ? `width="${esc(width)}"` : '',
++ height != null ? `height="${esc(height)}"` : '',
++ id ? `id="${esc(id)}"` : '',
++ className ? `class="${esc(className)}"` : '',
++ sizes && fallback ? `sizes="${esc(sizes)}"` : '',
++ loading ? `loading="${esc(loading)}"` : '',
++ decoding ? `decoding="${esc(decoding)}"` : '',
++ fetchpriority ? `fetchpriority="${esc(fetchpriority)}"` : '',
++ ].filter(Boolean).join(' ');
++ const img = `<img ${attrs}>`;
++ return built.length ? `<picture>${built.join('')}${img}</picture>` : img;
++}
++
++module.exports = { hash, url, srcset, picture };
ddiff --git a/package.json b/package.json
iindex 32c73a5..0724104 100644
---- a/package.json
++++ b/package.json
@@@ -16,6 +16,7 @@
"./analytics/schema": "./analytics/schema.js",
"./analytics/tracker": "./analytics/tracker.js",
"./app-icon": "./app-icon.js",
++ "./assets": "./assets.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./browser-harness": "./browser-harness.js",
ddiff --git a/test/assets.test.js b/test/assets.test.js
nnew file mode 100644
iindex 0000000..e376436
---- /dev/null
++++ b/test/assets.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++// openvibe-shared/assets: hash stability, content-addressed URL shape, source/format ordering and fallback behavior.
++const assert = require('assert');
++const crypto = require('crypto');
++const assets = require('../assets');
++
++// hash: stable, 12 hex, the estate's sha256 convention, and content-sensitive.
++const bytes = Buffer.from([0, 1, 2, 253, 254, 255, 0x89, 0x50]);
++const h = assets.hash(bytes);
++assert.strictEqual(h.length, 12, '12 hex characters, like every ?v= in the estate');
++assert.ok(/^[0-9a-f]{12}$/.test(h));
++assert.strictEqual(h, assets.hash(bytes), 'the same bytes hash the same');
++assert.strictEqual(h, crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 12), 'sha256, first 12 hex');
++assert.notStrictEqual(h, assets.hash(bytes.slice(1)), 'a changed byte changes the hash');
++assert.strictEqual(assets.hash(''), crypto.createHash('sha256').update('').digest('hex').slice(0, 12));
++assert.strictEqual(assets.hash(null), '', 'nothing to hash is no hash');
++
++// url: no hash keeps the path exactly; a hash appends ?v= / &v= and replaces an old one, never stacks it.
++assert.strictEqual(assets.url('/img/hero.png'), '/img/hero.png');
++assert.strictEqual(assets.url('/img/hero.png', ''), '/img/hero.png');
++assert.strictEqual(assets.url('/img/hero.png', h), `/img/hero.png?v=${h}`);
++assert.strictEqual(assets.url('/img/hero.png?s=2', h), `/img/hero.png?s=2&v=${h}`);
++assert.strictEqual(assets.url('/img/hero.png?v=old&s=2', h), `/img/hero.png?s=2&v=${h}`, 'an old version is replaced');
++assert.strictEqual(assets.url('/img/hero.png?x=1&v=old&y=2', h), `/img/hero.png?x=1&y=2&v=${h}`, 'a version in the middle is replaced cleanly');
++assert.strictEqual(assets.url('/img/hero.png?v=old', h), `/img/hero.png?v=${h}`);
++assert.strictEqual(assets.url('/img/hero.png#x', h), `/img/hero.png?v=${h}#x`, 'a fragment survives');
++assert.strictEqual(assets.url('', h), '');
++
++// srcset: entries in order, w/x descriptors, empty entries dropped.
++assert.strictEqual(assets.srcset([{ src: '/a.png', hash: h, w: 400 }, { src: '/b.png', w: 800 }]), `/a.png?v=${h} 400w, /b.png 800w`);
++assert.strictEqual(assets.srcset(['/a.png', { src: '/[email protected]', x: 2 }]), '/a.png, /[email protected] 2x');
++assert.strictEqual(assets.srcset([{ w: 5 }, null, '/a.png']), '/a.png');
++assert.strictEqual(assets.srcset([]), '');
++assert.strictEqual(assets.srcset('nope'), '');
++
++// picture: AVIF before WebP before the <img>, even when passed in another order; caller order kept within a format.
++const p = assets.picture({
++ src: '/img/hero.png', hash: h, alt: 'Hero', width: 1600, height: 900,
++ sizes: '(max-width: 800px) 100vw, 800px',
++ sources: [
++ { type: 'image/webp', entries: [{ src: '/img/hero-800.webp', hash: 'b'.repeat(12), w: 800 }] },
++ { type: 'image/avif', entries: [{ src: '/img/hero-800.avif', hash: 'a'.repeat(12), w: 800 }, { src: '/img/hero-1600.avif', w: 1600 }] },
++ ],
++});
++assert.ok(p.startsWith('<picture>'), 'sources are wrapped around the image');
++assert.ok(p.indexOf('type="image/avif"') < p.indexOf('type="image/webp"'), 'AVIF is offered first');
++assert.ok(p.indexOf('type="image/webp"') < p.indexOf('<img '), 'the fallback <img> is last');
++assert.ok(p.indexOf('/img/hero-800.avif') < p.indexOf('/img/hero-1600.avif'), 'within a format the caller order is kept');
++assert.ok(p.includes(`src="/img/hero.png?v=${h}"`) && p.includes('alt="Hero"') && p.includes('width="1600"') && p.includes('height="900"'));
++assert.ok(p.includes('sizes="(max-width: 800px) 100vw, 800px"'), 'sizes reaches the sources');
++assert.ok(p.includes('loading="lazy"') && p.includes('decoding="async"'), 'defaults keep the image off the critical path');
++assert.ok(p.endsWith('</picture>'));
++
++// fallback behavior: no sources is a plain <img> with the original path; no src is nothing; values are escaped.
++const plain = assets.picture({ src: '/img/logo.png', alt: 'A "logo" <x>' });
++assert.ok(plain.startsWith('<img ') && !plain.includes('<picture>'), 'no sources is a plain image');
++assert.ok(plain.includes('src="/img/logo.png"'), 'an unhashed path is preserved exactly');
++assert.ok(plain.includes('alt="A &quot;logo&quot; &lt;x&gt;"'), 'alt is escaped');
++assert.strictEqual(assets.picture({ src: '' }), '', 'no src renders nothing');
++assert.strictEqual(assets.picture({}), '');
++assert.strictEqual(assets.picture({ src: '/a.png', sources: [{ type: 'image/avif' }, null] }), '<img src="/a.png" alt="" loading="lazy" decoding="async">', 'a source with no srcset is dropped');
++
++// a single-format source keeps its own srcset, media and sizes.
++const single = assets.picture({ src: '/p.png', sources: [{ type: 'image/webp', src: '/p.webp', hash: h, media: '(min-width: 60em)', sizes: '50vw' }] });
++assert.ok(single.includes(`<source type="image/webp" media="(min-width: 60em)" srcset="/p.webp?v=${h}" sizes="50vw">`), 'a plain source carries media and sizes');
++
++// the same input renders the same bytes (ETags, caches).
++assert.strictEqual(assets.picture({ src: '/a.png', hash: h, alt: 'x', sources: [{ type: 'image/avif', src: '/a.avif' }] }), assets.picture({ src: '/a.png', hash: h, alt: 'x', sources: [{ type: 'image/avif', src: '/a.avif' }] }));
++console.log('assets: all checks passed');