openvibers/openvibe.shared Commits

Add hashed asset builder and responsive picture variants (#4)

OP OpenVibers committed 2 Oct 2026 · 4cfa2a38d9 · 10 files, +144 −13

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

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

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7374b1c..452f4f1 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++- The asset pipeline adds `hashAsset(bytes)`, generated AVIF and WebP srcsets from
++ `picture({ src, widths, ... })`, and `openvibe-build-assets` to copy files to
++ hashed names with a `manifest.json` lookup. The builder does not transcode images.
++
- `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
ddiff --git a/README.md b/README.md
iindex cc561d4..0dc7783 100644
---- a/README.md
++++ b/README.md
@@@ -112,9 +112,21 @@ renders a plain `<img>`, never an empty `<picture>`; with no `src` it renders no
`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.
++For variants named `hero-400.avif`, `hero-800.avif`, and their WebP equivalents, pass
++`widths: [400, 800]` with `src: '/img/hero.jpg'`. `picture()` derives both srcsets and keeps the
++original JPEG as the `<img>` fallback. Supply widths only for variants the site has generated.
++`hashAsset(bytes)` is the same 12-hex content hash used by `serve.hashOf()`; `hash()` remains an alias.
++
++To copy a directory of static files to hashed filenames, run:
++
++```sh
++openvibe-build-assets public/img dist/img
++```
++
++The command writes `dist/img/manifest.json`, mapping paths such as `hero.jpg` to
++`hero.<hash>.jpg` (and preserving subdirectories). `cache-policy.isHashed()` recognises these
++names as immutable. Shared does not transcode images: generate AVIF and WebP variants in the
++site build before using `widths`, or pass existing variants through `sources`.
### Metrics and readiness (server)
ddiff --git a/assets.js b/assets.js
iindex 8154870..2170434 100644
---- a/assets.js
++++ b/assets.js
@@@ -38,11 +38,12 @@ 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) {
++function hashAsset(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);
}
++const hash = hashAsset;
/** Drop an existing `?v=` (and a dangling `?`/`&`) from the path part, keeping its query and fragment. */
function versionless(src) {
@@@ -98,7 +99,8 @@ const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ?
* 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)
++ * sizes sizes attribute on generated sources and the fallback <img>
++ * widths widths of existing <base>-<width>.avif and .webp variants
* 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
@@@ -108,7 +110,14 @@ const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ?
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 : [])
++ const variantWidths = Array.isArray(o.widths) ? [...new Set(o.widths.map(Number))]
++ .filter((w) => Number.isSafeInteger(w) && w > 0) : [];
++ const base = String(src).split(/[?#]/)[0].replace(/\.[^./]+$/, '');
++ const generatedSources = variantWidths.length ? ['avif', 'webp'].map((format) => ({
++ type: `image/${format}`,
++ entries: variantWidths.map((w) => ({ src: `${base}-${w}.${format}`, w })),
++ })) : [];
++ const built = [...generatedSources, ...(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 }) => {
@@@ -128,7 +137,7 @@ function picture(o = {}) {
height != null ? `height="${esc(height)}"` : '',
id ? `id="${esc(id)}"` : '',
className ? `class="${esc(className)}"` : '',
-- sizes && fallback ? `sizes="${esc(sizes)}"` : '',
++ sizes ? `sizes="${esc(sizes)}"` : '',
loading ? `loading="${esc(loading)}"` : '',
decoding ? `decoding="${esc(decoding)}"` : '',
fetchpriority ? `fetchpriority="${esc(fetchpriority)}"` : '',
@@@ -137,4 +146,4 @@ function picture(o = {}) {
return built.length ? `<picture>${built.join('')}${img}</picture>` : img;
}
--module.exports = { hash, url, srcset, picture };
++module.exports = { hash, hashAsset, url, srcset, picture };
ddiff --git a/package-lock.json b/package-lock.json
iindex 1543a71..3fd45e0 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -8,6 +8,9 @@
"name": "openvibe-shared",
"version": "2.4.0",
"license": "MIT",
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ },
"dependencies": {
"jsonwebtoken": "^9.0.0"
},
ddiff --git a/package.json b/package.json
iindex 0724104..de0f395 100644
---- a/package.json
++++ b/package.json
@@@ -3,6 +3,9 @@
"version": "2.4.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
++ "bin": {
++ "openvibe-build-assets": "./scripts/build-assets.js"
++ },
"exports": {
".": "./index.js",
"./account-switcher": "./account-switcher.js",
ddiff --git a/scripts/build-assets.js b/scripts/build-assets.js
nnew file mode 100755
iindex 0000000..3b48156
---- /dev/null
++++ b/scripts/build-assets.js
@@@ -0,0 +1,50 @@
++#!/usr/bin/env node
++'use strict';
++// Copy a site's static files to content-addressed names and write a lookup manifest.
++// Usage: openvibe-build-assets <source-dir> <output-dir>
++const fs = require('fs');
++const path = require('path');
++const { hashAsset } = require('../assets');
++
++function build(sourceDir, outputDir) {
++ const source = path.resolve(sourceDir);
++ const output = path.resolve(outputDir);
++ if (!fs.statSync(source).isDirectory()) throw new Error('source must be a directory');
++ if (output === source || output.startsWith(source + path.sep)) {
++ throw new Error('output must be outside the source directory');
++ }
++ const manifest = {};
++ function visit(dir, relative = '') {
++ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
++ const name = relative ? `${relative}/${entry.name}` : entry.name;
++ const input = path.join(dir, entry.name);
++ if (entry.isDirectory()) { visit(input, name); continue; }
++ if (!entry.isFile()) continue;
++ const bytes = fs.readFileSync(input);
++ const ext = path.extname(entry.name);
++ const stem = entry.name.slice(0, entry.name.length - ext.length);
++ const hashed = `${stem}.${hashAsset(bytes)}${ext}`;
++ const target = relative ? `${relative}/${hashed}` : hashed;
++ const destination = path.join(output, ...target.split('/'));
++ fs.mkdirSync(path.dirname(destination), { recursive: true });
++ fs.writeFileSync(destination, bytes);
++ manifest[name] = target;
++ }
++ }
++ visit(source);
++ fs.mkdirSync(output, { recursive: true });
++ fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
++ return manifest;
++}
++
++if (require.main === module) {
++ if (process.argv.length !== 4) {
++ console.error('Usage: openvibe-build-assets <source-dir> <output-dir>');
++ process.exitCode = 1;
++ } else {
++ try { build(process.argv[2], process.argv[3]); }
++ catch (err) { console.error(`build-assets: ${err.message}`); process.exitCode = 1; }
++ }
++}
++
++module.exports = { build };
ddiff --git a/serve.js b/serve.js
iindex ad61db9..1535b96 100644
---- a/serve.js
++++ b/serve.js
@@@ -14,13 +14,13 @@
* copies on openvibe.network. Only the files openvibe-shared/files lists are served.
*/
const fs = require('fs');
--const crypto = require('crypto');
const files = require('./files');
const cache = require('./cache-policy');
++const { hashAsset } = require('./assets');
const hashes = new Map();
function hashOf(name) {
-- if (!hashes.has(name)) hashes.set(name, crypto.createHash('sha256').update(fs.readFileSync(files.path(name))).digest('hex').slice(0, 12));
++ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(files.path(name))));
return hashes.get(name);
}
ddiff --git a/test-runner.js b/test-runner.js
iindex fd50d49..5af62c3 100644
---- a/test-runner.js
++++ b/test-runner.js
@@@ -57,7 +57,8 @@ function runOne(file, { dir, cwd, env, timeoutMs, nodeArgs }) {
* opts: dir (required: the test directory), argv (default process.argv.slice(2)), cwd (default dir/..),
* timeoutMs (60000), parallel (min(4, cpus-1)), env (added to process.env; NODE_ENV=test by default),
* hide (a RegExp: output lines of a failed file left out, e.g. /^\[DB\] /), pad (file-name column, 44),
-- * match (which files, default /\.test\.js$/), nodeArgs ([]), log (console.log).
++ * match (which files, default /\.test\.js$/), serial (files to run alone after parallel files),
++ * nodeArgs ([]), log (console.log).
*/
async function run(opts = {}) {
const dir = opts.dir;
@@@ -79,7 +80,8 @@ async function run(opts = {}) {
};
const parallel = Math.max(1, opts.parallel || Math.min(4, os.cpus().length - 1));
const pad = opts.pad || 44;
-- const queue = [...files];
++ const queue = [], serial = [];
++ for (const file of files) (opts.serial && opts.serial.test(file) ? serial : queue).push(file);
const results = [];
await Promise.all(Array.from({ length: parallel }, async () => {
while (queue.length) {
@@@ -89,6 +91,12 @@ async function run(opts = {}) {
log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);
}
}));
++ for (const file of serial) {
++ const r = await runOne(file, cfg);
++ results.push(r);
++ const mark = r.state === 'pass' ? '✓' : r.state === 'skip' ? '○' : '✗';
++ log(`${mark} ${r.file.padEnd(pad)} ${String(r.ms).padStart(6)}ms${r.state === 'skip' ? ` ${r.skips.join('; ')}` : ''}`);
++ }
results.sort((a, b) => a.file.localeCompare(b.file));
const failed = results.filter((r) => r.state === 'fail');
const skipped = results.filter((r) => r.state === 'skip');
ddiff --git a/test/assets.test.js b/test/assets.test.js
iindex e376436..b3e114b 100644
---- a/test/assets.test.js
++++ b/test/assets.test.js
@@@ -2,7 +2,14 @@
// openvibe-shared/assets: hash stability, content-addressed URL shape, source/format ordering and fallback behavior.
const assert = require('assert');
const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
const assets = require('../assets');
++const serve = require('../serve');
++const files = require('../files');
++const { isHashed } = require('../cache-policy');
// hash: stable, 12 hex, the estate's sha256 convention, and content-sensitive.
const bytes = Buffer.from([0, 1, 2, 253, 254, 255, 0x89, 0x50]);
@@@ -14,6 +21,8 @@ assert.strictEqual(h, crypto.createHash('sha256').update(bytes).digest('hex').sl
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');
++assert.strictEqual(assets.hashAsset(bytes), h, 'hashAsset and the existing hash alias agree');
++assert.strictEqual(assets.hashAsset(fs.readFileSync(files.path('navbar.js'))), serve.hashOf('navbar.js'), 'serve uses the same 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');
@@@ -66,4 +75,37 @@ assert.ok(single.includes(`<source type="image/webp" media="(min-width: 60em)" s
// 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' }] }));
++
++const generated = assets.picture({
++ src: '/img/hero.jpg', alt: '\"><script>alert(1)</script>&', widths: [400, 800],
++ sizes: '(max-width: 600px) 100vw, 800px', width: 800, height: 450,
++});
++assert.ok(generated.startsWith('<picture>') && generated.endsWith('</picture>'));
++assert.ok(generated.indexOf('type="image/avif"') < generated.indexOf('type="image/webp"'));
++assert.ok(generated.indexOf('type="image/webp"') < generated.indexOf('<img '));
++assert.ok(generated.includes('srcset="/img/hero-400.avif 400w, /img/hero-800.avif 800w"'));
++assert.ok(generated.includes('srcset="/img/hero-400.webp 400w, /img/hero-800.webp 800w"'));
++assert.ok(generated.includes('src="/img/hero.jpg"') && generated.includes('width="800" height="450"'));
++assert.ok(generated.includes('alt="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;&amp;"'));
++assert.ok(generated.includes('sizes="(max-width: 600px) 100vw, 800px"'));
++assert.ok(generated.includes('loading="lazy" decoding="async"'));
++
++const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-assets-'));
++try {
++ const source = path.join(temp, 'source');
++ const output = path.join(temp, 'output');
++ fs.mkdirSync(path.join(source, 'icons'), { recursive: true });
++ const hero = Buffer.from([0, 255, 120, 42]);
++ fs.writeFileSync(path.join(source, 'hero.png'), hero);
++ fs.writeFileSync(path.join(source, 'icons', 'mark.svg'), '<svg/>');
++ const run = spawnSync(process.execPath, [path.join(__dirname, '..', 'scripts', 'build-assets.js'), source, output], { encoding: 'utf8' });
++ assert.strictEqual(run.status, 0, run.stderr);
++ const manifest = JSON.parse(fs.readFileSync(path.join(output, 'manifest.json'), 'utf8'));
++ assert.deepStrictEqual(Object.keys(manifest), ['hero.png', 'icons/mark.svg']);
++ assert.strictEqual(manifest['hero.png'], `hero.${assets.hashAsset(hero)}.png`);
++ for (const [original, hashed] of Object.entries(manifest)) {
++ assert.ok(isHashed(hashed), `${hashed} is immutable by cache policy`);
++ assert.deepStrictEqual(fs.readFileSync(path.join(output, hashed)), fs.readFileSync(path.join(source, original)));
++ }
++} finally { fs.rmSync(temp, { recursive: true, force: true }); }
console.log('assets: all checks passed');
ddiff --git a/test/run.js b/test/run.js
iindex bff9d1f..e7c0aa5 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -13,4 +13,4 @@
* a skipped file is listed with ○ and never counted as passed.
*/
'use strict';
--require('../test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 36 });
++require('../test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 36, serial: /-chrome\.test\.js$/ });