openvibers/openvibe.shared assets.js

assets.js

main 7.6 KB · 150 lines · javascript

1'use strict';
2/**
3 * openvibe-shared/assets — content-addressed image/asset URLs and responsive <picture> markup, so a
4 * page that shows an image gets an immutable URL, a srcset, the right format order and a fallback
5 * without hand-writing any of it. The estate's convention is `?v=<first 12 hex of sha256(file)>`
6 * (serve.js, and the product asset helpers); this module speaks the same URL shape, so
7 * cache-policy.assetHeaders and every CDN already treat the result as immutable.
8 *
9 * const assets = require('openvibe-shared/assets');
10 * const hash = assets.hash(fs.readFileSync('public/img/hero.png')); // 12 hex
11 * assets.url('/img/hero.png', hash); // /img/hero.png?v=<hash>
12 * assets.picture({
13 * src: '/img/hero.png', hash,
14 * alt: 'The dashboard', width: 1600, height: 900,
15 * sizes: '(max-width: 800px) 100vw, 800px',
16 * sources: [
17 * { type: 'image/webp', entries: [{ src: '/img/hero-800.webp', hash: h8, w: 800 }, { src: '/img/hero-1600.webp', hash: h16, w: 1600 }] },
18 * { type: 'image/avif', entries: [{ src: '/img/hero-800.avif', hash: a8, w: 800 }, { src: '/img/hero-1600.avif', hash: a16, w: 1600 }] },
19 * ],
20 * });
21 *
22 * Rules it keeps for the page:
23 * - No hash → the path is returned exactly as given, so a site that has no build step keeps its
24 * existing URLs; no `?v=` is invented.
25 * - <source> order is AVIF, then WebP, then anything else (first match wins in a browser), and the
26 * <img> is always last. The caller's order inside a format is kept.
27 * - No usable source → a plain <img>, never an empty <picture>.
28 * - Every value is escaped; attribute order is fixed, so the same input renders the same bytes.
29 *
30 * Formats are the caller's: this package does not transcode images. Pass the AVIF/WebP variants your
31 * own build produced; when only the original exists, `sources` is left empty and you get the <img>.
32 */
33
34const crypto = require('crypto');
35
36const HASH_LEN = 12; // ?v= length across the estate (serve.js, product asset helpers)
37const FORMAT_RANK = { 'image/avif': 0, 'image/webp': 1 };
38const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
39
40/** The content hash of bytes (Buffer|string|Uint8Array): sha256, first `HASH_LEN` hex. */
41function hashAsset(data) {
42 if (data == null) return '';
43 const buf = Buffer.isBuffer(data) || data instanceof Uint8Array ? data : Buffer.from(String(data));
44 return crypto.createHash('sha256').update(buf).digest('hex').slice(0, HASH_LEN);
45}
46const hash = hashAsset;
47
48/** Drop an existing `?v=` (and a dangling `?`/`&`) from the path part, keeping its query and fragment. */
49function versionless(src) {
50 const at = src.indexOf('#');
51 const head = at < 0 ? src : src.slice(0, at);
52 const frag = at < 0 ? '' : src.slice(at);
53 const clean = head.replace(/([?&])v=[^&#]*/g, '$1')
54 .replace(/\?&/g, '?').replace(/&&+/g, '&').replace(/[?&]+$/, '');
55 return clean + frag;
56}
57
58/**
59 * The content-addressed URL of an asset. `hash` comes from assets.hash (or a build manifest). With no
60 * hash the path is returned unchanged; a replaced `?v=` never stacks.
61 */
62function url(src, hashValue) {
63 const s = String(src == null ? '' : src);
64 if (!s) return '';
65 const clean = versionless(s);
66 const h = hashValue == null ? '' : String(hashValue).trim();
67 if (!h) return clean;
68 const at = clean.indexOf('#');
69 const head = at < 0 ? clean : clean.slice(0, at);
70 const frag = at < 0 ? '' : clean.slice(at);
71 return `${head}${head.includes('?') ? '&' : '?'}v=${encodeURIComponent(h)}${frag}`;
72}
73
74/** ` 800w` for a width, ` 2x` for a density, nothing when neither is a finite number. */
75function descriptor(e) {
76 const w = e.w != null ? e.w : e.width;
77 if (Number.isFinite(Number(w))) return ` ${Number(w)}w`;
78 const x = e.x != null ? e.x : (e.d != null ? e.d : e.density);
79 if (Number.isFinite(Number(x))) return ` ${Number(x)}x`;
80 return '';
81}
82
83/** A srcset attribute value from [{ src, hash?, w? | x? }] (a bare string works too). '' when empty. */
84function srcset(entries) {
85 if (!Array.isArray(entries)) return '';
86 return entries
87 .map((e) => (typeof e === 'string' ? { src: e } : (e || {})))
88 .filter((e) => e.src)
89 .map((e) => `${url(e.src, e.hash)}${descriptor(e)}`)
90 .join(', ');
91}
92
93const formatRank = (type) => (FORMAT_RANK[String(type).toLowerCase()] != null ? FORMAT_RANK[String(type).toLowerCase()] : 2);
94
95/**
96 * Responsive <picture> HTML (or a plain <img> when there is nothing to choose between).
97 *
98 * @param {object} o
99 * src, hash the fallback image (?v= added when hash is given)
100 * alt alt text (escaped; pass '' only for decorative images)
101 * width, height intrinsic pixels, so the box is reserved before the image loads
102 * sizes sizes attribute on generated sources and the fallback <img>
103 * widths widths of existing <base>-<width>.avif and .webp variants
104 * srcset fallback entries for the <img>: [{ src, hash?, w? | x? }]
105 * sources [{ type, src? , hash?, entries? | srcset?, media?, sizes? }] — a source with
106 * no srcset is dropped; AVIF sorts before WebP before the rest
107 * className, id, loading, decoding, fetchpriority pass-through attributes
108 * @returns {string} escaped HTML; '' when there is no src
109 */
110function picture(o = {}) {
111 const { src, alt = '', width, height, sizes, className, id, loading = 'lazy', decoding = 'async', fetchpriority, srcset: fallbackEntries } = o;
112 if (!src) return '';
113 const variantWidths = Array.isArray(o.widths) ? [...new Set(o.widths.map(Number))]
114 .filter((w) => Number.isSafeInteger(w) && w > 0) : [];
115 const base = String(src).split(/[?#]/)[0].replace(/\.[^./]+$/, '');
116 const generatedSources = variantWidths.length ? ['avif', 'webp'].map((format) => ({
117 type: `image/${format}`,
118 entries: variantWidths.map((w) => ({ src: `${base}-${w}.${format}`, w })),
119 })) : [];
120 const built = [...generatedSources, ...(Array.isArray(o.sources) ? o.sources : [])]
121 .map((s, i) => ({ s, i }))
122 .filter(({ s }) => s && s.type && (s.src || s.srcset || s.entries))
123 .map(({ s, i }) => {
124 const set = s.srcset != null ? (typeof s.srcset === 'string' ? s.srcset : srcset(s.srcset))
125 : s.src ? url(s.src, s.hash) : srcset(s.entries);
126 return { s, i, set };
127 })
128 .filter(({ set }) => set)
129 .sort((a, b) => formatRank(a.s.type) - formatRank(b.s.type) || a.i - b.i)
130 .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)}"` : ''}>`);
131 const fallback = fallbackEntries ? srcset(fallbackEntries) : '';
132 const attrs = [
133 `src="${esc(url(src, o.hash))}"`,
134 fallback ? `srcset="${esc(fallback)}"` : '',
135 `alt="${esc(alt)}"`,
136 width != null ? `width="${esc(width)}"` : '',
137 height != null ? `height="${esc(height)}"` : '',
138 id ? `id="${esc(id)}"` : '',
139 className ? `class="${esc(className)}"` : '',
140 sizes ? `sizes="${esc(sizes)}"` : '',
141 loading ? `loading="${esc(loading)}"` : '',
142 decoding ? `decoding="${esc(decoding)}"` : '',
143 fetchpriority ? `fetchpriority="${esc(fetchpriority)}"` : '',
144 ].filter(Boolean).join(' ');
145 const img = `<img ${attrs}>`;
146 return built.length ? `<picture>${built.join('')}${img}</picture>` : img;
147}
148
149module.exports = { hash, hashAsset, url, srcset, picture };
150