openvibers/openvibe.shared Commits
Add the assets helper for hashed URLs and responsive pictures (#3)
Changes
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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@@@ -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.carray) 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)```jsnnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };@@@ -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",nnew file mode 100644@@@ -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 "logo" <x>"'), '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');
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 2.4.0 — 2026-10-02
- The SEO kit adds `llmsFull` for full-text AI maps, `pageSummary` for escaped page
- The SEO kit adds `llmsFull` for full-text AI maps, `pageSummary` for escaped page
@@ -20,7 +20,7 @@ were reconciled first.
@@ -20,7 +20,7 @@ were reconciled first.
| Kind | Files |
| 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` |
| 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`) |
| 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` |
| 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) |
| 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
@@ -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,
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`.
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)
### Metrics and readiness (server)
```js
```js
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
"./analytics/schema": "./analytics/schema.js",
"./analytics/schema": "./analytics/schema.js",
"./analytics/tracker": "./analytics/tracker.js",
"./analytics/tracker": "./analytics/tracker.js",
"./app-icon": "./app-icon.js",
"./app-icon": "./app-icon.js",
+ "./assets": "./assets.js",
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./brand": "./brand.js",
"./browser-harness": "./browser-harness.js",
"./browser-harness": "./browser-harness.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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 "logo" <x>"'), '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');