openvibers/openvibe.shared Commits
Add hashed asset builder and responsive picture variants (#4)
Changes
@@@ -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 fora 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@@@ -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)@@@ -38,11 +38,12 @@ 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) {++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 };@@@ -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"},@@@ -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",nnew file mode 100755@@@ -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 };@@@ -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);}@@@ -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');@@@ -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').slassert.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=""><script>alert(1)</script>&"'));++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');@@@ -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$/ });
@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
- `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
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
`?v=`, replaces an old one and leaves the path unchanged when there is no hash; `srcset(entries)` builds
@@ -112,9 +112,21 @@ renders a plain `<img>`, never an empty `<picture>`; with no `src` it renders no
@@ -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`
`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.
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)
### Metrics and readiness (server)
@@ -38,11 +38,12 @@ const FORMAT_RANK = { 'image/avif': 0, 'image/webp': 1 };
@@ -38,11 +38,12 @@ const FORMAT_RANK = { 'image/avif': 0, 'image/webp': 1 };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
/** The content hash of bytes (Buffer|string|Uint8Array): sha256, first `HASH_LEN` hex. */
/** The content hash of bytes (Buffer|string|Uint8Array): sha256, first `HASH_LEN` hex. */
-function hash(data) {
+function hashAsset(data) {
if (data == null) return '';
if (data == null) return '';
const buf = Buffer.isBuffer(data) || data instanceof Uint8Array ? data : Buffer.from(String(data));
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);
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. */
/** Drop an existing `?v=` (and a dangling `?`/`&`) from the path part, keeping its query and fragment. */
function versionless(src) {
function versionless(src) {
@@ -98,7 +99,8 @@ const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ?
@@ -98,7 +99,8 @@ const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ?
* src, hash the fallback image (?v= added when hash is given)
* src, hash the fallback image (?v= added when hash is given)
* alt alt text (escaped; pass '' only for decorative images)
* alt alt text (escaped; pass '' only for decorative images)
* width, height intrinsic pixels, so the box is reserved before the image loads
* 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? }]
* srcset fallback entries for the <img>: [{ src, hash?, w? | x? }]
* sources [{ type, src? , hash?, entries? | srcset?, media?, sizes? }] — a source with
* sources [{ type, src? , hash?, entries? | srcset?, media?, sizes? }] — a source with
* no srcset is dropped; AVIF sorts before WebP before the rest
* no srcset is dropped; AVIF sorts before WebP before the rest
@@ -108,7 +110,14 @@ const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ?
@@ -108,7 +110,14 @@ const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ?
function picture(o = {}) {
function picture(o = {}) {
const { src, alt = '', width, height, sizes, className, id, loading = 'lazy', decoding = 'async', fetchpriority, srcset: fallbackEntries } = o;
const { src, alt = '', width, height, sizes, className, id, loading = 'lazy', decoding = 'async', fetchpriority, srcset: fallbackEntries } = o;
if (!src) return '';
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 }))
.map((s, i) => ({ s, i }))
.filter(({ s }) => s && s.type && (s.src || s.srcset || s.entries))
.filter(({ s }) => s && s.type && (s.src || s.srcset || s.entries))
.map(({ s, i }) => {
.map(({ s, i }) => {
@@ -128,7 +137,7 @@ function picture(o = {}) {
@@ -128,7 +137,7 @@ function picture(o = {}) {
height != null ? `height="${esc(height)}"` : '',
height != null ? `height="${esc(height)}"` : '',
id ? `id="${esc(id)}"` : '',
id ? `id="${esc(id)}"` : '',
className ? `class="${esc(className)}"` : '',
className ? `class="${esc(className)}"` : '',
- sizes && fallback ? `sizes="${esc(sizes)}"` : '',
+ sizes ? `sizes="${esc(sizes)}"` : '',
loading ? `loading="${esc(loading)}"` : '',
loading ? `loading="${esc(loading)}"` : '',
decoding ? `decoding="${esc(decoding)}"` : '',
decoding ? `decoding="${esc(decoding)}"` : '',
fetchpriority ? `fetchpriority="${esc(fetchpriority)}"` : '',
fetchpriority ? `fetchpriority="${esc(fetchpriority)}"` : '',
@@ -137,4 +146,4 @@ function picture(o = {}) {
@@ -137,4 +146,4 @@ function picture(o = {}) {
return built.length ? `<picture>${built.join('')}${img}</picture>` : img;
return built.length ? `<picture>${built.join('')}${img}</picture>` : img;
}
}
-module.exports = { hash, url, srcset, picture };
+module.exports = { hash, hashAsset, url, srcset, picture };
@@ -8,6 +8,9 @@
@@ -8,6 +8,9 @@
"name": "openvibe-shared",
"name": "openvibe-shared",
"version": "2.4.0",
"version": "2.4.0",
"license": "MIT",
"license": "MIT",
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ },
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
},
},
@@ -3,6 +3,9 @@
@@ -3,6 +3,9 @@
"version": "2.4.0",
"version": "2.4.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
+ "bin": {
+ "openvibe-build-assets": "./scripts/build-assets.js"
+ },
"exports": {
"exports": {
".": "./index.js",
".": "./index.js",
"./account-switcher": "./account-switcher.js",
"./account-switcher": "./account-switcher.js",
new file mode 100755
new file mode 100755
@@ -0,0 +1,50 @@
@@ -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 };
@@ -14,13 +14,13 @@
@@ -14,13 +14,13 @@
* copies on openvibe.network. Only the files openvibe-shared/files lists are served.
* copies on openvibe.network. Only the files openvibe-shared/files lists are served.
*/
*/
const fs = require('fs');
const fs = require('fs');
-const crypto = require('crypto');
const files = require('./files');
const files = require('./files');
const cache = require('./cache-policy');
const cache = require('./cache-policy');
+const { hashAsset } = require('./assets');
const hashes = new Map();
const hashes = new Map();
function hashOf(name) {
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);
return hashes.get(name);
}
}
@@ -57,7 +57,8 @@ function runOne(file, { dir, cwd, env, timeoutMs, nodeArgs }) {
@@ -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/..),
* 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),
* 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),
* 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 = {}) {
async function run(opts = {}) {
const dir = opts.dir;
const dir = opts.dir;
@@ -79,7 +80,8 @@ async function run(opts = {}) {
@@ -79,7 +80,8 @@ async function run(opts = {}) {
};
};
const parallel = Math.max(1, opts.parallel || Math.min(4, os.cpus().length - 1));
const parallel = Math.max(1, opts.parallel || Math.min(4, os.cpus().length - 1));
const pad = opts.pad || 44;
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 = [];
const results = [];
await Promise.all(Array.from({ length: parallel }, async () => {
await Promise.all(Array.from({ length: parallel }, async () => {
while (queue.length) {
while (queue.length) {
@@ -89,6 +91,12 @@ async function run(opts = {}) {
@@ -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('; ')}` : ''}`);
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));
results.sort((a, b) => a.file.localeCompare(b.file));
const failed = results.filter((r) => r.state === 'fail');
const failed = results.filter((r) => r.state === 'fail');
const skipped = results.filter((r) => r.state === 'skip');
const skipped = results.filter((r) => r.state === 'skip');
@@ -2,7 +2,14 @@
@@ -2,7 +2,14 @@
// openvibe-shared/assets: hash stability, content-addressed URL shape, source/format ordering and fallback behavior.
// openvibe-shared/assets: hash stability, content-addressed URL shape, source/format ordering and fallback behavior.
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
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 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.
// 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 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
@@ -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.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(''), crypto.createHash('sha256').update('').digest('hex').slice(0, 12));
assert.strictEqual(assets.hash(null), '', 'nothing to hash is no hash');
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.
// 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');
@@ -66,4 +75,37 @@ assert.ok(single.includes(`<source type="image/webp" media="(min-width: 60em)" s
@@ -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).
// 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' }] }));
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=""><script>alert(1)</script>&"'));
+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');
console.log('assets: all checks passed');
@@ -13,4 +13,4 @@
@@ -13,4 +13,4 @@
* a skipped file is listed with ○ and never counted as passed.
* a skipped file is listed with ○ and never counted as passed.
*/
*/
'use strict';
'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$/ });