|
5 | 5 | */ |
6 | 6 | import fs from "node:fs"; |
7 | 7 | import path from "node:path"; |
| 8 | +import { svgSignature } from "../src/svgSignature.ts"; |
8 | 9 |
|
9 | 10 | /** |
10 | 11 | * A project's boards: this folder under it, one subfolder per canvas. The same folder under the |
@@ -44,6 +45,126 @@ export const IMAGE_MIME: Record<string, string> = { |
44 | 45 | */ |
45 | 46 | export const THUMB_EDGE = 880; |
46 | 47 |
|
| 48 | +/** |
| 49 | + * FNV-1a 32 over a string's code units, base 36. The inspector's agent runs the same function |
| 50 | + * over the base64 payload of each data: URI inside the board, and joins on `length:hash`. A |
| 51 | + * plain hash rather than SHA because the agent runs in a sandboxed frame with no `crypto.subtle` |
| 52 | + * in every deployment, and this does 3 MB in about 12 ms. |
| 53 | + */ |
| 54 | +function fnv1a(s: string) { |
| 55 | + let h = 0x811c9dc5; |
| 56 | + for (let i = 0; i < s.length; i++) |
| 57 | + h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0; |
| 58 | + return h.toString(36); |
| 59 | +} |
| 60 | + |
| 61 | +interface AssetName { |
| 62 | + /** Path inside the board folder, `assets/art/hero.png`, or `assets.json#key`. */ |
| 63 | + name: string; |
| 64 | + /** Decoded size, i.e. the file's own byte count. */ |
| 65 | + bytes: number; |
| 66 | +} |
| 67 | + |
| 68 | +/** |
| 69 | + * What one asset file, or one assets.json, contributes to the index, remembered by the file's |
| 70 | + * size and mtime. The index is built on every request for it, and this repo's own boards hold |
| 71 | + * 300 MB of assets: hashing them once is a second, a stat each is nothing. |
| 72 | + */ |
| 73 | +const hashed = new Map< |
| 74 | + string, |
| 75 | + { stamp: string; keys: [string, AssetName][] } |
| 76 | +>(); |
| 77 | +function keysOf(file: string, read: (buf: Buffer) => [string, AssetName][]) { |
| 78 | + const stat = fs.statSync(file); |
| 79 | + const stamp = `${stat.size}:${stat.mtimeMs}`; |
| 80 | + let entry = hashed.get(file); |
| 81 | + if (entry?.stamp !== stamp) { |
| 82 | + entry = { stamp, keys: read(fs.readFileSync(file)) }; |
| 83 | + hashed.set(file, entry); |
| 84 | + } |
| 85 | + return entry.keys; |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * `length:hash` of the base64 payload -> the source file, for every image a folder's generator |
| 90 | + * could have inlined: `assets/**`, `assets-dark/**` and the values of `assets.json`. `refs/` is |
| 91 | + * skipped because it holds third-party captures that are never committed. A generator that |
| 92 | + * re-encodes on the way (a PIL resize) produces bytes that match nothing here, and the |
| 93 | + * inspector then falls back to the image's alt text. An `.svg` file is indexed twice: by its |
| 94 | + * bytes like any image, and as `svg:hash` of its geometry, which is how an inline `<svg>` on a |
| 95 | + * board is keyed, since the generator rewrote its root tag on the way in. |
| 96 | + */ |
| 97 | +function assetIndex(folder: string): Record<string, AssetName> { |
| 98 | + const out: Record<string, AssetName> = {}; |
| 99 | + const add = ([key, name]: [string, AssetName]) => { |
| 100 | + if (!(key in out)) out[key] = name; |
| 101 | + }; |
| 102 | + const walk = (dir: string, rel: string) => { |
| 103 | + let entries: fs.Dirent[]; |
| 104 | + try { |
| 105 | + entries = fs.readdirSync(dir, { withFileTypes: true }); |
| 106 | + } catch { |
| 107 | + return; |
| 108 | + } |
| 109 | + for (const e of entries) { |
| 110 | + if (e.name.startsWith(".")) continue; |
| 111 | + const p = path.join(dir, e.name); |
| 112 | + if (e.isDirectory()) { |
| 113 | + if (e.name !== "refs") walk(p, `${rel}${e.name}/`); |
| 114 | + continue; |
| 115 | + } |
| 116 | + if (!(path.extname(e.name).toLowerCase() in IMAGE_MIME)) continue; |
| 117 | + const name = { name: rel + e.name, bytes: 0 }; |
| 118 | + keysOf(p, (buf) => { |
| 119 | + const payload = buf.toString("base64"); |
| 120 | + const named = { ...name, bytes: buf.length }; |
| 121 | + const keys: [string, AssetName][] = [ |
| 122 | + [`${payload.length}:${fnv1a(payload)}`, named], |
| 123 | + ]; |
| 124 | + if (e.name.toLowerCase().endsWith(".svg")) |
| 125 | + keys.push([ |
| 126 | + `svg:${fnv1a(svgSignature(buf.toString("utf8")))}`, |
| 127 | + named, |
| 128 | + ]); |
| 129 | + return keys; |
| 130 | + }).forEach(add); |
| 131 | + } |
| 132 | + }; |
| 133 | + for (const sub of ["assets", "assets-dark"]) |
| 134 | + walk(path.join(folder, sub), `${sub}/`); |
| 135 | + const json = path.join(folder, "assets.json"); |
| 136 | + if (fs.existsSync(json)) { |
| 137 | + keysOf(json, (buf) => { |
| 138 | + const keys: [string, AssetName][] = []; |
| 139 | + try { |
| 140 | + const map: unknown = JSON.parse(buf.toString("utf8")); |
| 141 | + if (map && typeof map === "object") { |
| 142 | + for (const [key, v] of Object.entries(map)) { |
| 143 | + if (typeof v !== "string" || !v.startsWith("data:")) continue; |
| 144 | + const payload = v.slice(v.indexOf(",") + 1); |
| 145 | + const pad = payload.endsWith("==") |
| 146 | + ? 2 |
| 147 | + : payload.endsWith("=") |
| 148 | + ? 1 |
| 149 | + : 0; |
| 150 | + keys.push([ |
| 151 | + `${payload.length}:${fnv1a(payload)}`, |
| 152 | + { |
| 153 | + name: `assets.json#${key}`, |
| 154 | + bytes: Math.floor((payload.length * 3) / 4) - pad, |
| 155 | + }, |
| 156 | + ]); |
| 157 | + } |
| 158 | + } |
| 159 | + } catch { |
| 160 | + // a malformed assets.json names nothing; the boards still render |
| 161 | + } |
| 162 | + return keys; |
| 163 | + }).forEach(add); |
| 164 | + } |
| 165 | + return out; |
| 166 | +} |
| 167 | + |
47 | 168 | /** |
48 | 169 | * `#` and `?` are legal in a filename but are a fragment and a query in a URL, and no encoding |
49 | 170 | * survives the round trip (the request path is decoded with decodeURI, which leaves both alone). |
@@ -178,6 +299,7 @@ export function boardIndex( |
178 | 299 | thumbnail: fs.existsSync(path.join(folder, "thumbnail.png")), |
179 | 300 | brand: brandImages(folder), |
180 | 301 | thumbs: [] as string[], |
| 302 | + assets: assetIndex(folder), |
181 | 303 | comments: readJson(path.join(folder, "comments.json")), |
182 | 304 | // null for a canvas.json that is there but will not parse, after a merge left half |
183 | 305 | // done, say. The page must not take that for no file and write over it |
|
0 commit comments