@three-flatland/bake 0.1.0-alpha.1 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,162 +1,6 @@
1
- /**
2
- * Contract every baker must satisfy.
3
- *
4
- * Bakers are registered by package.json via a `flatland.bake` field:
5
- *
6
- * ```json
7
- * {
8
- * "flatland": {
9
- * "bake": [
10
- * { "name": "font", "description": "Bake SlugFont", "entry": "./dist/cli.js" }
11
- * ]
12
- * }
13
- * }
14
- * ```
15
- *
16
- * Entry modules must default-export a `Baker`. The legacy `flatland.bakers`
17
- * shape is still accepted for one release with a deprecation warning.
18
- */
19
- interface Baker {
20
- /** Subcommand name used on the CLI: `flatland-bake <name> ...` */
21
- name: string;
22
- /** One-line description shown by `flatland-bake --list`. */
23
- description: string;
24
- /**
25
- * Run the baker with the CLI args that follow the subcommand.
26
- * Resolves to an exit code (0 = success).
27
- */
28
- run(args: string[]): Promise<number>;
29
- /** Optional multiline usage string for `flatland-bake <name> --help`. */
30
- usage?(): string;
31
- }
32
- interface BakerRegistration {
33
- name: string;
34
- description: string;
35
- entry: string;
36
- /** Package that declared the baker — used in diagnostics. */
37
- packageName: string;
38
- /** Absolute path on disk the `entry` resolves to. */
39
- resolvedEntry: string;
40
- }
41
- interface FlatlandManifestEntry {
42
- name: string;
43
- description: string;
44
- entry: string;
45
- }
46
- interface FlatlandManifest {
47
- /** Current registration shape. */
48
- bake?: FlatlandManifestEntry[];
49
- /** @deprecated Legacy shape; use `bake` instead. Accepted for one release. */
50
- bakers?: FlatlandManifestEntry[];
51
- }
52
- /**
53
- * Shared option interface for every loader that speaks the
54
- * "try baked sibling first → fall back to in-memory generation" pattern.
55
- *
56
- * Loaders extend this with their asset-specific options:
57
- *
58
- * ```ts
59
- * interface MyLoaderOptions extends BakedAssetLoaderOptions {
60
- * // asset-specific fields
61
- * }
62
- * ```
63
- */
64
- interface BakedAssetLoaderOptions {
65
- /**
66
- * Generate this asset's derived data in the browser on every load
67
- * instead of loading a pre-baked sidecar. The runtime generator
68
- * becomes the canonical source — no sidecar probe, no devtime "no
69
- * baked sibling" warning, just a fresh generate on every load.
70
- *
71
- * If you ask for the data (e.g. `normals: true`), you always get it.
72
- * `forceRuntime` chooses *where* the generation happens — browser vs
73
- * CI — it does not choose whether you get the data. The default path
74
- * still produces the data on every miss; this flag just commits to
75
- * "the browser is always where it's produced for this asset."
76
- *
77
- * Use when runtime really is the right home for the generation:
78
- * procedurally varied content, throwaway prototypes, asset bundles
79
- * where shipping the sidecar isn't worth the bytes. Not a dev-
80
- * iteration knob — the default path (probe → generate on miss + warn
81
- * pointing at `flatland-bake`) already handles iteration.
82
- *
83
- * Default `false`. Mirrors `SlugFontLoader.forceRuntime` — one flag
84
- * across every baked-asset loader in the codebase.
85
- */
86
- forceRuntime?: boolean;
87
- }
88
- /**
89
- * Metadata stamped into a baked PNG's `tEXt` chunk under the key
90
- * `flatland`. Read back by `probeBakedSibling` to validate the baked
91
- * file still matches the descriptor a consumer is about to use.
92
- */
93
- interface BakedSidecarMetadata {
94
- /** Content hash of the descriptor that produced this file. */
95
- hash: string;
96
- /** Schema version of the metadata format itself. */
97
- v: 1;
98
- }
99
-
100
- /**
101
- * Derive the sibling URL for a baked asset.
102
- *
103
- * @example
104
- * bakedSiblingURL('/sprites/knight.png', '.normal.png')
105
- * // → '/sprites/knight.normal.png'
106
- *
107
- * Query strings and fragments are preserved:
108
- * bakedSiblingURL('/a.png?v=2', '.normal.png')
109
- * // → '/a.normal.png?v=2'
110
- */
111
- declare function bakedSiblingURL(sourceURL: string, suffix: string): string;
112
- /**
113
- * Stable content hash of any JSON-serializable value. FNV-1a 64-bit over
114
- * a canonical stringification (sorted keys). Deterministic across
115
- * browser and node, no deps, sync.
116
- *
117
- * Intended for cache invalidation of baked sidecars — not for any
118
- * cryptographic purpose.
119
- */
120
- declare function hashDescriptor(value: unknown): string;
121
- /**
122
- * HEAD-probe a baked sibling URL and, when `expectedHash` is provided,
123
- * range-fetch the PNG header to verify the tEXt stamp matches.
124
- *
125
- * Returns `{ ok: false }` when the sibling is missing or unreachable.
126
- * Returns `{ ok: true, hashMatches }` otherwise; callers decide whether
127
- * to use the baked file or re-generate in-memory.
128
- */
129
- declare function probeBakedSibling(url: string, opts?: {
130
- expectedHash?: string;
131
- }): Promise<{
132
- ok: true;
133
- hashMatches: boolean;
134
- url: string;
135
- } | {
136
- ok: false;
137
- }>;
138
- /**
139
- * Minimal PNG `tEXt` chunk reader. Walks chunks after the 8-byte
140
- * signature, stopping when it finds one whose keyword matches `key`.
141
- *
142
- * Returns the chunk's Latin-1 text value, or `null` when the keyword is
143
- * absent / the buffer is not a valid PNG.
144
- */
145
- declare function readPngTextChunk(buffer: ArrayBuffer, key: string): string | null;
146
-
147
- /**
148
- * Emit a console warning gated on `NODE_ENV !== 'production'`. Deduped
149
- * per `(category, url)` — the same warning never fires twice.
150
- *
151
- * Shared by every sidecar-using loader so warnings surface uniformly
152
- * across the ecosystem (normals, fonts, atlases, …).
153
- *
154
- * @param category short tag identifying what system warned (e.g. 'normal')
155
- * @param url absolute URL or path of the asset
156
- * @param message the message shown to the user
157
- */
158
- declare function devtimeWarn(category: string, url: string, message: string): void;
159
- /** Clear the devtime-warning dedupe cache. Intended for tests. */
160
- declare function _resetDevtimeWarnings(): void;
161
-
162
- export { type BakedAssetLoaderOptions, type BakedSidecarMetadata, type Baker, type BakerRegistration, type FlatlandManifest, type FlatlandManifestEntry, _resetDevtimeWarnings, bakedSiblingURL, devtimeWarn, hashDescriptor, probeBakedSibling, readPngTextChunk };
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { _resetDevtimeWarnings, devtimeWarn } from "./devtimeWarn.js";
4
+ import { BakedAssetLoaderOptions, BakedSidecarMetadata, Baker, BakerRegistration, FlatlandManifest, FlatlandManifestEntry } from "./types.js";
5
+ import { bakedSiblingURL, hashDescriptor, probeBakedSibling, readPngTextChunk } from "./sidecar.js";
6
+ export { type BakedAssetLoaderOptions, type BakedSidecarMetadata, type Baker, type BakerRegistration, type FlatlandManifest, type FlatlandManifestEntry, _resetDevtimeWarnings, bakedSiblingURL, devtimeWarn, hashDescriptor, probeBakedSibling, readPngTextChunk };
package/dist/index.js CHANGED
@@ -1,16 +1,6 @@
1
- import {
2
- bakedSiblingURL,
3
- probeBakedSibling,
4
- readPngTextChunk,
5
- hashDescriptor
6
- } from "./sidecar.js";
7
- import { devtimeWarn, _resetDevtimeWarnings } from "./devtimeWarn.js";
8
- export {
9
- _resetDevtimeWarnings,
10
- bakedSiblingURL,
11
- devtimeWarn,
12
- hashDescriptor,
13
- probeBakedSibling,
14
- readPngTextChunk
15
- };
16
- //# sourceMappingURL=index.js.map
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { _resetDevtimeWarnings, devtimeWarn } from "./devtimeWarn.js";
5
+ import { bakedSiblingURL, hashDescriptor, probeBakedSibling, readPngTextChunk } from "./sidecar.js";
6
+ export { _resetDevtimeWarnings, bakedSiblingURL, devtimeWarn, hashDescriptor, probeBakedSibling, readPngTextChunk };
package/dist/node.d.ts CHANGED
@@ -1,35 +1,8 @@
1
- import { BakerRegistration, BakedSidecarMetadata } from './index.js';
2
- export { BakedAssetLoaderOptions, Baker, FlatlandManifest, FlatlandManifestEntry, _resetDevtimeWarnings, bakedSiblingURL, devtimeWarn, hashDescriptor, probeBakedSibling, readPngTextChunk } from './index.js';
3
-
4
- /**
5
- * Discover registered bakers by walking `node_modules` near the current
6
- * working directory. Supports both flat and pnpm-symlinked layouts: every
7
- * `package.json` that declares a `flatland.bake` (or legacy
8
- * `flatland.bakers`) field is picked up.
9
- *
10
- * Conflicts (multiple packages registering the same baker name) are reported;
11
- * the first match wins and the rest are returned as warnings so the caller
12
- * can decide whether to fail or log.
13
- */
14
- declare function discoverBakers(cwd?: string): {
15
- bakers: BakerRegistration[];
16
- conflicts: string[];
17
- };
18
-
19
- /**
20
- * Write a PNG with a flatland metadata `tEXt` chunk stamped in.
21
- *
22
- * The stamp lives under keyword `flatland` and carries the descriptor
23
- * hash so downstream `probeBakedSibling` calls can invalidate the
24
- * baked file when the descriptor changes.
25
- */
26
- declare function writeSidecarPng(outputPath: string, pixels: Uint8Array, width: number, height: number, metadata: BakedSidecarMetadata): void;
27
- /**
28
- * Write a sidecar descriptor JSON file adjacent to the source asset.
29
- *
30
- * Same file format `flatland-bake <subcommand> --descriptor` consumes,
31
- * so the runtime and CLI agree on one shape.
32
- */
33
- declare function writeSidecarJson(outputPath: string, descriptor: unknown): void;
34
-
35
- export { BakedSidecarMetadata, BakerRegistration, discoverBakers, writeSidecarJson, writeSidecarPng };
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { _resetDevtimeWarnings, devtimeWarn } from "./devtimeWarn.js";
4
+ import { BakedAssetLoaderOptions, BakedSidecarMetadata, Baker, BakerRegistration, FlatlandManifest, FlatlandManifestEntry } from "./types.js";
5
+ import { discoverBakers } from "./discovery.js";
6
+ import { bakedSiblingURL, hashDescriptor, probeBakedSibling, readPngTextChunk } from "./sidecar.js";
7
+ import { writeSidecarJson, writeSidecarPng } from "./writeSidecar.js";
8
+ export { type BakedAssetLoaderOptions, type BakedSidecarMetadata, type Baker, type BakerRegistration, type FlatlandManifest, type FlatlandManifestEntry, _resetDevtimeWarnings, bakedSiblingURL, devtimeWarn, discoverBakers, hashDescriptor, probeBakedSibling, readPngTextChunk, writeSidecarJson, writeSidecarPng };
package/dist/node.js CHANGED
@@ -1,9 +1,8 @@
1
- export * from "./index.js";
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
2
4
  import { discoverBakers } from "./discovery.js";
3
- import { writeSidecarPng, writeSidecarJson } from "./writeSidecar.js";
4
- export {
5
- discoverBakers,
6
- writeSidecarJson,
7
- writeSidecarPng
8
- };
9
- //# sourceMappingURL=node.js.map
5
+ import { _resetDevtimeWarnings, devtimeWarn } from "./devtimeWarn.js";
6
+ import { bakedSiblingURL, hashDescriptor, probeBakedSibling, readPngTextChunk } from "./sidecar.js";
7
+ import { writeSidecarJson, writeSidecarPng } from "./writeSidecar.js";
8
+ export { _resetDevtimeWarnings, bakedSiblingURL, devtimeWarn, discoverBakers, hashDescriptor, probeBakedSibling, readPngTextChunk, writeSidecarJson, writeSidecarPng };
@@ -0,0 +1,52 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ //#region src/sidecar.d.ts
4
+ /**
5
+ * Derive the sibling URL for a baked asset.
6
+ *
7
+ * @example
8
+ * bakedSiblingURL('/sprites/knight.png', '.normal.png')
9
+ * // → '/sprites/knight.normal.png'
10
+ *
11
+ * Query strings and fragments are preserved:
12
+ * bakedSiblingURL('/a.png?v=2', '.normal.png')
13
+ * // → '/a.normal.png?v=2'
14
+ */
15
+ declare function bakedSiblingURL(sourceURL: string, suffix: string): string;
16
+ /**
17
+ * Stable content hash of any JSON-serializable value. FNV-1a 64-bit over
18
+ * a canonical stringification (sorted keys). Deterministic across
19
+ * browser and node, no deps, sync.
20
+ *
21
+ * Intended for cache invalidation of baked sidecars — not for any
22
+ * cryptographic purpose.
23
+ */
24
+ declare function hashDescriptor(value: unknown): string;
25
+ /**
26
+ * HEAD-probe a baked sibling URL and, when `expectedHash` is provided,
27
+ * range-fetch the PNG header to verify the tEXt stamp matches.
28
+ *
29
+ * Returns `{ ok: false }` when the sibling is missing or unreachable.
30
+ * Returns `{ ok: true, hashMatches }` otherwise; callers decide whether
31
+ * to use the baked file or re-generate in-memory.
32
+ */
33
+ declare function probeBakedSibling(url: string, opts?: {
34
+ expectedHash?: string;
35
+ }): Promise<{
36
+ ok: true;
37
+ hashMatches: boolean;
38
+ url: string;
39
+ } | {
40
+ ok: false;
41
+ }>;
42
+ /**
43
+ * Minimal PNG `tEXt` chunk reader. Walks chunks after the 8-byte
44
+ * signature, stopping when it finds one whose keyword matches `key`.
45
+ *
46
+ * Returns the chunk's Latin-1 text value, or `null` when the keyword is
47
+ * absent / the buffer is not a valid PNG.
48
+ */
49
+ declare function readPngTextChunk(buffer: ArrayBuffer, key: string): string | null;
50
+ //#endregion
51
+ export { bakedSiblingURL, hashDescriptor, probeBakedSibling, readPngTextChunk };
52
+ //# sourceMappingURL=sidecar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidecar.d.ts","names":[],"sources":["../src/sidecar.ts"],"mappings":";;;;;;;;;;;;;;iBAagB,gBAAgB,mBAAmB;;;;;;;;;iBAYnC,eAAe;;;;;;;;;iBAkCT,kBACpB,aACA;EAAS;IACR;EAAU;EAAU;EAAsB;;EAAkB;;;;;;;;;iBA2C/C,iBAAiB,QAAQ,aAAa"}
package/dist/sidecar.js CHANGED
@@ -1,109 +1,154 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/sidecar.ts
5
+ /**
6
+ * Derive the sibling URL for a baked asset.
7
+ *
8
+ * @example
9
+ * bakedSiblingURL('/sprites/knight.png', '.normal.png')
10
+ * // → '/sprites/knight.normal.png'
11
+ *
12
+ * Query strings and fragments are preserved:
13
+ * bakedSiblingURL('/a.png?v=2', '.normal.png')
14
+ * // → '/a.normal.png?v=2'
15
+ */
1
16
  function bakedSiblingURL(sourceURL, suffix) {
2
- return sourceURL.replace(/\.(\w+)($|[?#])/i, `${suffix}$2`);
17
+ return sourceURL.replace(/\.(\w+)($|[?#])/i, `${suffix}$2`);
3
18
  }
19
+ /**
20
+ * Stable content hash of any JSON-serializable value. FNV-1a 64-bit over
21
+ * a canonical stringification (sorted keys). Deterministic across
22
+ * browser and node, no deps, sync.
23
+ *
24
+ * Intended for cache invalidation of baked sidecars — not for any
25
+ * cryptographic purpose.
26
+ */
4
27
  function hashDescriptor(value) {
5
- const canonical = stableStringify(value);
6
- return fnv1a64(canonical);
28
+ return fnv1a64(stableStringify(value));
7
29
  }
8
30
  function stableStringify(value) {
9
- if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
10
- if (Array.isArray(value)) {
11
- return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
12
- }
13
- const obj = value;
14
- const keys = Object.keys(obj).sort();
15
- return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
31
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
32
+ if (Array.isArray(value)) return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
33
+ const obj = value;
34
+ return "{" + Object.keys(obj).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
16
35
  }
17
36
  function fnv1a64(str) {
18
- let hash = 0xcbf29ce484222325n;
19
- const prime = 0x100000001b3n;
20
- const mask = 0xffffffffffffffffn;
21
- for (let i = 0; i < str.length; i++) {
22
- hash = hash ^ BigInt(str.charCodeAt(i));
23
- hash = hash * prime & mask;
24
- }
25
- return hash.toString(16).padStart(16, "0");
37
+ let hash = 14695981039346656037n;
38
+ const prime = 1099511628211n;
39
+ const mask = 18446744073709551615n;
40
+ for (let i = 0; i < str.length; i++) {
41
+ hash = hash ^ BigInt(str.charCodeAt(i));
42
+ hash = hash * prime & mask;
43
+ }
44
+ return hash.toString(16).padStart(16, "0");
26
45
  }
46
+ /**
47
+ * HEAD-probe a baked sibling URL and, when `expectedHash` is provided,
48
+ * range-fetch the PNG header to verify the tEXt stamp matches.
49
+ *
50
+ * Returns `{ ok: false }` when the sibling is missing or unreachable.
51
+ * Returns `{ ok: true, hashMatches }` otherwise; callers decide whether
52
+ * to use the baked file or re-generate in-memory.
53
+ */
27
54
  async function probeBakedSibling(url, opts) {
28
- let head;
29
- try {
30
- head = await fetch(url, { method: "HEAD" });
31
- } catch {
32
- return { ok: false };
33
- }
34
- if (!head.ok) return { ok: false };
35
- if (opts?.expectedHash === void 0) {
36
- return { ok: true, hashMatches: true, url };
37
- }
38
- let header;
39
- try {
40
- header = await fetch(url, { headers: { Range: "bytes=0-4095" } });
41
- } catch {
42
- return { ok: true, hashMatches: false, url };
43
- }
44
- if (!header.ok) {
45
- return { ok: true, hashMatches: false, url };
46
- }
47
- const buf = await header.arrayBuffer();
48
- const metaJSON = readPngTextChunk(buf, "flatland");
49
- if (!metaJSON) return { ok: true, hashMatches: false, url };
50
- try {
51
- const meta = JSON.parse(metaJSON);
52
- return { ok: true, hashMatches: meta.hash === opts.expectedHash, url };
53
- } catch {
54
- return { ok: true, hashMatches: false, url };
55
- }
55
+ let head;
56
+ try {
57
+ head = await fetch(url, { method: "HEAD" });
58
+ } catch {
59
+ return { ok: false };
60
+ }
61
+ if (!head.ok) return { ok: false };
62
+ if (opts?.expectedHash === void 0) return {
63
+ ok: true,
64
+ hashMatches: true,
65
+ url
66
+ };
67
+ let header;
68
+ try {
69
+ header = await fetch(url, { headers: { Range: "bytes=0-4095" } });
70
+ } catch {
71
+ return {
72
+ ok: true,
73
+ hashMatches: false,
74
+ url
75
+ };
76
+ }
77
+ if (!header.ok) return {
78
+ ok: true,
79
+ hashMatches: false,
80
+ url
81
+ };
82
+ const metaJSON = readPngTextChunk(await header.arrayBuffer(), "flatland");
83
+ if (!metaJSON) return {
84
+ ok: true,
85
+ hashMatches: false,
86
+ url
87
+ };
88
+ try {
89
+ return {
90
+ ok: true,
91
+ hashMatches: JSON.parse(metaJSON).hash === opts.expectedHash,
92
+ url
93
+ };
94
+ } catch {
95
+ return {
96
+ ok: true,
97
+ hashMatches: false,
98
+ url
99
+ };
100
+ }
56
101
  }
102
+ /**
103
+ * Minimal PNG `tEXt` chunk reader. Walks chunks after the 8-byte
104
+ * signature, stopping when it finds one whose keyword matches `key`.
105
+ *
106
+ * Returns the chunk's Latin-1 text value, or `null` when the keyword is
107
+ * absent / the buffer is not a valid PNG.
108
+ */
57
109
  function readPngTextChunk(buffer, key) {
58
- if (buffer.byteLength < 8) return null;
59
- const view = new DataView(buffer);
60
- const expectedSig = [137, 80, 78, 71, 13, 10, 26, 10];
61
- for (let i = 0; i < 8; i++) {
62
- if (view.getUint8(i) !== expectedSig[i]) return null;
63
- }
64
- let offset = 8;
65
- while (offset + 8 <= buffer.byteLength) {
66
- const length = view.getUint32(offset);
67
- const type = String.fromCharCode(
68
- view.getUint8(offset + 4),
69
- view.getUint8(offset + 5),
70
- view.getUint8(offset + 6),
71
- view.getUint8(offset + 7)
72
- );
73
- const dataStart = offset + 8;
74
- const dataEnd = dataStart + length;
75
- if (dataEnd + 4 > buffer.byteLength) return null;
76
- if (type === "tEXt") {
77
- let sepIdx = -1;
78
- for (let i = dataStart; i < dataEnd; i++) {
79
- if (view.getUint8(i) === 0) {
80
- sepIdx = i;
81
- break;
82
- }
83
- }
84
- if (sepIdx > dataStart) {
85
- let keyword = "";
86
- for (let i = dataStart; i < sepIdx; i++) {
87
- keyword += String.fromCharCode(view.getUint8(i));
88
- }
89
- if (keyword === key) {
90
- let value = "";
91
- for (let i = sepIdx + 1; i < dataEnd; i++) {
92
- value += String.fromCharCode(view.getUint8(i));
93
- }
94
- return value;
95
- }
96
- }
97
- }
98
- if (type === "IEND") return null;
99
- offset = dataEnd + 4;
100
- }
101
- return null;
110
+ if (buffer.byteLength < 8) return null;
111
+ const view = new DataView(buffer);
112
+ const expectedSig = [
113
+ 137,
114
+ 80,
115
+ 78,
116
+ 71,
117
+ 13,
118
+ 10,
119
+ 26,
120
+ 10
121
+ ];
122
+ for (let i = 0; i < 8; i++) if (view.getUint8(i) !== expectedSig[i]) return null;
123
+ let offset = 8;
124
+ while (offset + 8 <= buffer.byteLength) {
125
+ const length = view.getUint32(offset);
126
+ const type = String.fromCharCode(view.getUint8(offset + 4), view.getUint8(offset + 5), view.getUint8(offset + 6), view.getUint8(offset + 7));
127
+ const dataStart = offset + 8;
128
+ const dataEnd = dataStart + length;
129
+ if (dataEnd + 4 > buffer.byteLength) return null;
130
+ if (type === "tEXt") {
131
+ let sepIdx = -1;
132
+ for (let i = dataStart; i < dataEnd; i++) if (view.getUint8(i) === 0) {
133
+ sepIdx = i;
134
+ break;
135
+ }
136
+ if (sepIdx > dataStart) {
137
+ let keyword = "";
138
+ for (let i = dataStart; i < sepIdx; i++) keyword += String.fromCharCode(view.getUint8(i));
139
+ if (keyword === key) {
140
+ let value = "";
141
+ for (let i = sepIdx + 1; i < dataEnd; i++) value += String.fromCharCode(view.getUint8(i));
142
+ return value;
143
+ }
144
+ }
145
+ }
146
+ if (type === "IEND") return null;
147
+ offset = dataEnd + 4;
148
+ }
149
+ return null;
102
150
  }
103
- export {
104
- bakedSiblingURL,
105
- hashDescriptor,
106
- probeBakedSibling,
107
- readPngTextChunk
108
- };
151
+ //#endregion
152
+ export { bakedSiblingURL, hashDescriptor, probeBakedSibling, readPngTextChunk };
153
+
109
154
  //# sourceMappingURL=sidecar.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sidecar.ts"],"sourcesContent":["import type { BakedSidecarMetadata } from './types.js'\n\n/**\n * Derive the sibling URL for a baked asset.\n *\n * @example\n * bakedSiblingURL('/sprites/knight.png', '.normal.png')\n * // → '/sprites/knight.normal.png'\n *\n * Query strings and fragments are preserved:\n * bakedSiblingURL('/a.png?v=2', '.normal.png')\n * // → '/a.normal.png?v=2'\n */\nexport function bakedSiblingURL(sourceURL: string, suffix: string): string {\n return sourceURL.replace(/\\.(\\w+)($|[?#])/i, `${suffix}$2`)\n}\n\n/**\n * Stable content hash of any JSON-serializable value. FNV-1a 64-bit over\n * a canonical stringification (sorted keys). Deterministic across\n * browser and node, no deps, sync.\n *\n * Intended for cache invalidation of baked sidecars — not for any\n * cryptographic purpose.\n */\nexport function hashDescriptor(value: unknown): string {\n const canonical = stableStringify(value)\n return fnv1a64(canonical)\n}\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'\n if (Array.isArray(value)) {\n return '[' + value.map((v) => stableStringify(v)).join(',') + ']'\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj).sort()\n return (\n '{' +\n keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') +\n '}'\n )\n}\n\nfunction fnv1a64(str: string): string {\n let hash = 0xcbf29ce484222325n\n const prime = 0x100000001b3n\n const mask = 0xffffffffffffffffn\n for (let i = 0; i < str.length; i++) {\n hash = hash ^ BigInt(str.charCodeAt(i))\n hash = (hash * prime) & mask\n }\n return hash.toString(16).padStart(16, '0')\n}\n\n/**\n * HEAD-probe a baked sibling URL and, when `expectedHash` is provided,\n * range-fetch the PNG header to verify the tEXt stamp matches.\n *\n * Returns `{ ok: false }` when the sibling is missing or unreachable.\n * Returns `{ ok: true, hashMatches }` otherwise; callers decide whether\n * to use the baked file or re-generate in-memory.\n */\nexport async function probeBakedSibling(\n url: string,\n opts?: { expectedHash?: string }\n): Promise<{ ok: true; hashMatches: boolean; url: string } | { ok: false }> {\n let head: Response\n try {\n head = await fetch(url, { method: 'HEAD' })\n } catch {\n return { ok: false }\n }\n if (!head.ok) return { ok: false }\n\n if (opts?.expectedHash === undefined) {\n return { ok: true, hashMatches: true, url }\n }\n\n // Range-fetch the PNG header to read the tEXt stamp. We don't need the\n // full image; the first ~4 KB comfortably covers signature + IHDR +\n // metadata chunks for every baker we emit.\n let header: Response\n try {\n header = await fetch(url, { headers: { Range: 'bytes=0-4095' } })\n } catch {\n return { ok: true, hashMatches: false, url }\n }\n if (!header.ok) {\n return { ok: true, hashMatches: false, url }\n }\n const buf = await header.arrayBuffer()\n const metaJSON = readPngTextChunk(buf, 'flatland')\n if (!metaJSON) return { ok: true, hashMatches: false, url }\n try {\n const meta = JSON.parse(metaJSON) as BakedSidecarMetadata\n return { ok: true, hashMatches: meta.hash === opts.expectedHash, url }\n } catch {\n return { ok: true, hashMatches: false, url }\n }\n}\n\n/**\n * Minimal PNG `tEXt` chunk reader. Walks chunks after the 8-byte\n * signature, stopping when it finds one whose keyword matches `key`.\n *\n * Returns the chunk's Latin-1 text value, or `null` when the keyword is\n * absent / the buffer is not a valid PNG.\n */\nexport function readPngTextChunk(buffer: ArrayBuffer, key: string): string | null {\n if (buffer.byteLength < 8) return null\n const view = new DataView(buffer)\n const expectedSig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]\n for (let i = 0; i < 8; i++) {\n if (view.getUint8(i) !== expectedSig[i]) return null\n }\n\n let offset = 8\n while (offset + 8 <= buffer.byteLength) {\n const length = view.getUint32(offset)\n const type = String.fromCharCode(\n view.getUint8(offset + 4),\n view.getUint8(offset + 5),\n view.getUint8(offset + 6),\n view.getUint8(offset + 7)\n )\n const dataStart = offset + 8\n const dataEnd = dataStart + length\n if (dataEnd + 4 > buffer.byteLength) return null\n\n if (type === 'tEXt') {\n let sepIdx = -1\n for (let i = dataStart; i < dataEnd; i++) {\n if (view.getUint8(i) === 0) {\n sepIdx = i\n break\n }\n }\n if (sepIdx > dataStart) {\n let keyword = ''\n for (let i = dataStart; i < sepIdx; i++) {\n keyword += String.fromCharCode(view.getUint8(i))\n }\n if (keyword === key) {\n let value = ''\n for (let i = sepIdx + 1; i < dataEnd; i++) {\n value += String.fromCharCode(view.getUint8(i))\n }\n return value\n }\n }\n }\n\n if (type === 'IEND') return null\n offset = dataEnd + 4\n }\n return null\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"file":"sidecar.js","names":[],"sources":["../src/sidecar.ts"],"sourcesContent":["import type { BakedSidecarMetadata } from './types.js'\n\n/**\n * Derive the sibling URL for a baked asset.\n *\n * @example\n * bakedSiblingURL('/sprites/knight.png', '.normal.png')\n * // → '/sprites/knight.normal.png'\n *\n * Query strings and fragments are preserved:\n * bakedSiblingURL('/a.png?v=2', '.normal.png')\n * // → '/a.normal.png?v=2'\n */\nexport function bakedSiblingURL(sourceURL: string, suffix: string): string {\n return sourceURL.replace(/\\.(\\w+)($|[?#])/i, `${suffix}$2`)\n}\n\n/**\n * Stable content hash of any JSON-serializable value. FNV-1a 64-bit over\n * a canonical stringification (sorted keys). Deterministic across\n * browser and node, no deps, sync.\n *\n * Intended for cache invalidation of baked sidecars — not for any\n * cryptographic purpose.\n */\nexport function hashDescriptor(value: unknown): string {\n const canonical = stableStringify(value)\n return fnv1a64(canonical)\n}\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'\n if (Array.isArray(value)) {\n return '[' + value.map((v) => stableStringify(v)).join(',') + ']'\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj).sort()\n return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}'\n}\n\nfunction fnv1a64(str: string): string {\n let hash = 0xcbf29ce484222325n\n const prime = 0x100000001b3n\n const mask = 0xffffffffffffffffn\n for (let i = 0; i < str.length; i++) {\n hash = hash ^ BigInt(str.charCodeAt(i))\n hash = (hash * prime) & mask\n }\n return hash.toString(16).padStart(16, '0')\n}\n\n/**\n * HEAD-probe a baked sibling URL and, when `expectedHash` is provided,\n * range-fetch the PNG header to verify the tEXt stamp matches.\n *\n * Returns `{ ok: false }` when the sibling is missing or unreachable.\n * Returns `{ ok: true, hashMatches }` otherwise; callers decide whether\n * to use the baked file or re-generate in-memory.\n */\nexport async function probeBakedSibling(\n url: string,\n opts?: { expectedHash?: string }\n): Promise<{ ok: true; hashMatches: boolean; url: string } | { ok: false }> {\n let head: Response\n try {\n head = await fetch(url, { method: 'HEAD' })\n } catch {\n return { ok: false }\n }\n if (!head.ok) return { ok: false }\n\n if (opts?.expectedHash === undefined) {\n return { ok: true, hashMatches: true, url }\n }\n\n // Range-fetch the PNG header to read the tEXt stamp. We don't need the\n // full image; the first ~4 KB comfortably covers signature + IHDR +\n // metadata chunks for every baker we emit.\n let header: Response\n try {\n header = await fetch(url, { headers: { Range: 'bytes=0-4095' } })\n } catch {\n return { ok: true, hashMatches: false, url }\n }\n if (!header.ok) {\n return { ok: true, hashMatches: false, url }\n }\n const buf = await header.arrayBuffer()\n const metaJSON = readPngTextChunk(buf, 'flatland')\n if (!metaJSON) return { ok: true, hashMatches: false, url }\n try {\n const meta = JSON.parse(metaJSON) as BakedSidecarMetadata\n return { ok: true, hashMatches: meta.hash === opts.expectedHash, url }\n } catch {\n return { ok: true, hashMatches: false, url }\n }\n}\n\n/**\n * Minimal PNG `tEXt` chunk reader. Walks chunks after the 8-byte\n * signature, stopping when it finds one whose keyword matches `key`.\n *\n * Returns the chunk's Latin-1 text value, or `null` when the keyword is\n * absent / the buffer is not a valid PNG.\n */\nexport function readPngTextChunk(buffer: ArrayBuffer, key: string): string | null {\n if (buffer.byteLength < 8) return null\n const view = new DataView(buffer)\n const expectedSig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]\n for (let i = 0; i < 8; i++) {\n if (view.getUint8(i) !== expectedSig[i]) return null\n }\n\n let offset = 8\n while (offset + 8 <= buffer.byteLength) {\n const length = view.getUint32(offset)\n const type = String.fromCharCode(\n view.getUint8(offset + 4),\n view.getUint8(offset + 5),\n view.getUint8(offset + 6),\n view.getUint8(offset + 7)\n )\n const dataStart = offset + 8\n const dataEnd = dataStart + length\n if (dataEnd + 4 > buffer.byteLength) return null\n\n if (type === 'tEXt') {\n let sepIdx = -1\n for (let i = dataStart; i < dataEnd; i++) {\n if (view.getUint8(i) === 0) {\n sepIdx = i\n break\n }\n }\n if (sepIdx > dataStart) {\n let keyword = ''\n for (let i = dataStart; i < sepIdx; i++) {\n keyword += String.fromCharCode(view.getUint8(i))\n }\n if (keyword === key) {\n let value = ''\n for (let i = sepIdx + 1; i < dataEnd; i++) {\n value += String.fromCharCode(view.getUint8(i))\n }\n return value\n }\n }\n }\n\n if (type === 'IEND') return null\n offset = dataEnd + 4\n }\n return null\n}\n"],"mappings":"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"}