openvibers/openvibe.shared assets.js
assets.js
| 1 | ; |
| 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 | |
| 34 | const crypto = require('crypto'); |
| 35 | |
| 36 | const HASH_LEN = 12; // ?v= length across the estate (serve.js, product asset helpers) |
| 37 | const FORMAT_RANK = { 'image/avif': 0, 'image/webp': 1 }; |
| 38 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 39 | |
| 40 | /** The content hash of bytes (Buffer|string|Uint8Array): sha256, first `HASH_LEN` hex. */ |
| 41 | function 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 | } |
| 46 | const hash = hashAsset; |
| 47 | |
| 48 | /** Drop an existing `?v=` (and a dangling `?`/`&`) from the path part, keeping its query and fragment. */ |
| 49 | function 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 | */ |
| 62 | function 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. */ |
| 75 | function 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. */ |
| 84 | function 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 | |
| 93 | const 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 | */ |
| 110 | function 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 | |
| 149 | module.exports = { hash, hashAsset, url, srcset, picture }; |
| 150 |