@tileflow/dev 0.1.0-alpha.3 → 0.1.0-alpha.31

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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +201 -5
  3. package/THIRD_PARTY_NOTICES.md +87 -0
  4. package/dist/artifacts.d.ts +265 -0
  5. package/dist/artifacts.js +81 -0
  6. package/dist/chunk-2QXKVQ6O.js +3062 -0
  7. package/dist/chunk-7C4F6GAX.js +170 -0
  8. package/dist/chunk-AQUGMUZW.js +324 -0
  9. package/dist/chunk-D4OE4ITL.js +13 -0
  10. package/dist/chunk-IR22ZTQW.js +2392 -0
  11. package/dist/chunk-KDFTE4Q5.js +156 -0
  12. package/dist/chunk-ONYQNLNA.js +1108 -0
  13. package/dist/chunk-UTRO4IBQ.js +671 -0
  14. package/dist/chunk-XHJBCVBB.js +241 -0
  15. package/dist/config.d.ts +33 -0
  16. package/dist/config.js +27 -0
  17. package/dist/fonts.d.ts +47 -0
  18. package/dist/fonts.js +16 -0
  19. package/dist/icons.d.ts +339 -0
  20. package/dist/icons.js +39 -0
  21. package/dist/inspect.d.ts +149 -0
  22. package/dist/inspect.js +805 -0
  23. package/dist/preview-assets-MVUJJ5XR.js +148 -0
  24. package/dist/preview.d.ts +71 -0
  25. package/dist/preview.js +2574 -0
  26. package/dist/server.d.ts +82 -0
  27. package/dist/server.js +2094 -0
  28. package/dist/tilesets.d.ts +89 -0
  29. package/dist/tilesets.js +6 -0
  30. package/dist/validation.d.ts +65 -0
  31. package/dist/validation.js +24 -0
  32. package/docs/artifact-lifecycle.md +152 -0
  33. package/docs/bounded-pmtiles-resource-inspection.md +21 -0
  34. package/docs/bounded-vector-feature-inspection.md +100 -0
  35. package/docs/local-comparison-workbench-and-compiler-inspection.md +227 -0
  36. package/docs/native-artifacts.md +285 -0
  37. package/docs/native-icons.md +129 -0
  38. package/docs/structured-config-inspection-and-diagnostics.md +39 -0
  39. package/package.json +77 -13
  40. package/dist/index.d.ts +0 -54
  41. package/dist/index.js +0 -314
@@ -0,0 +1,2392 @@
1
+ import {
2
+ TileflowAssetDirectoryError,
3
+ resolveTileflowAssetDirectories
4
+ } from "./chunk-7C4F6GAX.js";
5
+
6
+ // src/icons.ts
7
+ import { readdir as readdir3, readFile as readFile2, realpath as realpath3, stat } from "fs/promises";
8
+ import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
9
+ import { SaxesParser } from "saxes";
10
+ import {
11
+ assertTileflowIconLayoutBounds as assertTileflowIconLayoutBounds2,
12
+ collectTileflowIconSetReferences as collectTileflowIconSetReferences2,
13
+ tileflowIconAuthorMetadataSchema,
14
+ tileflowIconMetadataFileName,
15
+ TileflowIconSetError as TileflowIconSetError5,
16
+ tileflowIconsLockfileName as tileflowIconsLockfileName3,
17
+ tileflowIconSpriteIndexSchema
18
+ } from "@tileflow/core";
19
+ import {
20
+ compareCodeUnits as compareCodeUnits4,
21
+ hashTileflowRenderedIconPixels as hashTileflowRenderedIconPixels3,
22
+ parseResolvedTileflowMap,
23
+ parseTileflowIconJson as parseTileflowIconJson2,
24
+ serializeCanonicalJson as serializeCanonicalJson3,
25
+ sha256Hex as sha256Hex3,
26
+ tileflowIconIdPattern,
27
+ tileflowIconPackageLimits as tileflowIconPackageLimits4
28
+ } from "@tileflow/core";
29
+
30
+ // src/icon-composition.ts
31
+ import { realpath as realpath2 } from "fs/promises";
32
+ import { isAbsolute, relative, resolve as resolve3, sep } from "path";
33
+ import { tileflowIconsLockfileName as tileflowIconsLockfileName2 } from "@tileflow/core";
34
+ import {
35
+ collectTileflowIconSetReferences,
36
+ compareCodeUnits as compareCodeUnits3,
37
+ parseTileflowIconComposition,
38
+ parseTileflowIconsLockfile as parseTileflowIconsLockfile2,
39
+ tileflowIconPackageLimits as tileflowIconPackageLimits3,
40
+ TileflowIconSetError as TileflowIconSetError4
41
+ } from "@tileflow/core";
42
+
43
+ // src/icon-cache.ts
44
+ import { hashTileflowIconPackageManifest as hashTileflowIconPackageManifest3 } from "@tileflow/core";
45
+ import { constants } from "fs";
46
+ import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm } from "fs/promises";
47
+ import { homedir, platform } from "os";
48
+ import { join, resolve } from "path";
49
+ import {
50
+ TileflowIconSetError as TileflowIconSetError2,
51
+ tileflowIconSetPinSchema
52
+ } from "@tileflow/core";
53
+
54
+ // src/icon-artifact.ts
55
+ import {
56
+ tileflowIconSpriteIndexSchema as indexSchema
57
+ } from "@tileflow/core";
58
+ import {
59
+ assertTileflowSdfPixels as assertTileflowSdfPixels2,
60
+ compareCodeUnits as compareCodeUnits2,
61
+ hashTileflowIconPackageManifest as hashTileflowIconPackageManifest2,
62
+ hashTileflowRenderedIconPixels as hashTileflowRenderedIconPixels2,
63
+ parseTileflowIconJson,
64
+ readTileflowIconLayout,
65
+ scaleTileflowIconLayout as scaleTileflowIconLayout2,
66
+ serializeCanonicalJson as serializeCanonicalJson2,
67
+ sha256Hex as sha256Hex2,
68
+ tileflowIconPackageLimits as tileflowIconPackageLimits2,
69
+ tileflowIconPackageManifestSchema as tileflowIconPackageManifestSchema2,
70
+ TileflowIconSetError
71
+ } from "@tileflow/core";
72
+
73
+ // src/icon-sprite.ts
74
+ import {
75
+ assertTileflowIconLayoutBounds,
76
+ assertTileflowSdfPixels,
77
+ compareCodeUnits,
78
+ hashTileflowIconPackageManifest,
79
+ hashTileflowRenderedIconPixels,
80
+ scaleTileflowIconLayout,
81
+ serializeCanonicalJson,
82
+ sha256Hex,
83
+ tileflowIconIdSchema,
84
+ tileflowIconPackageLimits,
85
+ tileflowIconPackageManifestSchema
86
+ } from "@tileflow/core";
87
+ async function packTileflowRenderedIcons(input) {
88
+ const icons = [...input].sort((left, right) => compareCodeUnits(left.id, right.id));
89
+ if (icons.length < 1 || icons.length > tileflowIconPackageLimits.maxIconCount || new Set(icons.map((icon) => icon.id)).size !== icons.length)
90
+ throw new Error(
91
+ `Expected 1 through ${tileflowIconPackageLimits.maxIconCount} unique rendered icons`
92
+ );
93
+ const renderedIcons = [];
94
+ for (const icon of icons) {
95
+ tileflowIconIdSchema.parse(icon.id);
96
+ if (icon.layout) assertTileflowIconLayoutBounds(icon.layout, icon.oneX.width, icon.oneX.height);
97
+ if (icon.appearance) {
98
+ assertTileflowSdfPixels({ ...icon.oneX, pixelRatio: 1 });
99
+ assertTileflowSdfPixels({ ...icon.twoX, pixelRatio: 2 });
100
+ }
101
+ if (icon.twoX.width !== icon.oneX.width * 2 || icon.twoX.height !== icon.oneX.height * 2)
102
+ throw new Error("Rendered density geometry must double exactly");
103
+ renderedIcons.push({
104
+ name: icon.id,
105
+ ...icon.appearance ? { appearance: icon.appearance } : {},
106
+ ...icon.layout ? { layout: icon.layout } : {},
107
+ pixelSha256: {
108
+ oneX: await hashTileflowRenderedIconPixels({ ...icon.oneX, pixelRatio: 1 }),
109
+ twoX: await hashTileflowRenderedIconPixels({ ...icon.twoX, pixelRatio: 2 })
110
+ }
111
+ });
112
+ }
113
+ const oneX = icons.map((icon) => ({
114
+ ...icon.oneX,
115
+ name: icon.id,
116
+ appearance: icon.appearance,
117
+ layout: icon.layout
118
+ }));
119
+ const twoX = icons.map((icon) => ({
120
+ ...icon.twoX,
121
+ name: icon.id,
122
+ appearance: icon.appearance,
123
+ layout: icon.layout
124
+ }));
125
+ const layoutOneX = createSpriteLayout(oneX, 1);
126
+ const layoutTwoX = createSpriteLayout(twoX, 2);
127
+ if (layoutTwoX.width !== layoutOneX.width * 2 || layoutTwoX.height !== layoutOneX.height * 2)
128
+ throw new Error("Composed atlas exceeds the paired density geometry limit");
129
+ const files = [
130
+ {
131
+ contentType: "application/json",
132
+ fileName: "sprite.json",
133
+ source: new TextEncoder().encode(`${serializeCanonicalJson(layoutOneX.index)}
134
+ `)
135
+ },
136
+ {
137
+ contentType: "image/png",
138
+ fileName: "sprite.png",
139
+ source: await createSpriteImage(oneX, layoutOneX)
140
+ },
141
+ {
142
+ contentType: "application/json",
143
+ fileName: "sprite@2x.json",
144
+ source: new TextEncoder().encode(`${serializeCanonicalJson(layoutTwoX.index)}
145
+ `)
146
+ },
147
+ {
148
+ contentType: "image/png",
149
+ fileName: "sprite@2x.png",
150
+ source: await createSpriteImage(twoX, layoutTwoX)
151
+ }
152
+ ];
153
+ assertGeneratedFileLimits(files);
154
+ const manifest = tileflowIconPackageManifestSchema.parse({
155
+ format: icons.some((icon) => icon.appearance || icon.layout) ? "tileflow-icon-package-v2" : "tileflow-icon-package-v1",
156
+ files: await Promise.all(
157
+ files.map(async (file) => ({
158
+ name: file.fileName,
159
+ byteLength: file.source.byteLength,
160
+ contentType: file.contentType,
161
+ sha256: await sha256Hex(file.source)
162
+ }))
163
+ ),
164
+ iconNames: icons.map((icon) => icon.id),
165
+ renderedIcons,
166
+ sprites: {
167
+ oneX: { width: layoutOneX.width, height: layoutOneX.height, pixelRatio: 1 },
168
+ twoX: { width: layoutTwoX.width, height: layoutTwoX.height, pixelRatio: 2 }
169
+ }
170
+ });
171
+ return { contentHash: await hashTileflowIconPackageManifest(manifest), manifest, files };
172
+ }
173
+ function createSpriteLayout(icons, pixelRatio) {
174
+ const columns = Math.ceil(Math.sqrt(icons.length));
175
+ const widest = Math.max(...icons.map((icon) => icon.width));
176
+ const maxDimension = tileflowIconPackageLimits.maxAtlasDimension / 2 * pixelRatio;
177
+ const targetWidth = Math.min(maxDimension, columns * widest);
178
+ const placements = [];
179
+ let left = 0;
180
+ let rowHeight = 0;
181
+ let top = 0;
182
+ for (const icon of icons) {
183
+ if (icon.width > maxDimension)
184
+ throw new Error(
185
+ `Generated sprite exceeds ${maxDimension} pixels per dimension at ${pixelRatio}x`
186
+ );
187
+ if (left > 0 && left + icon.width > targetWidth) {
188
+ top += rowHeight;
189
+ left = 0;
190
+ rowHeight = 0;
191
+ }
192
+ placements.push({ left, top });
193
+ left += icon.width;
194
+ rowHeight = Math.max(rowHeight, icon.height);
195
+ }
196
+ const width = targetWidth;
197
+ const height = top + rowHeight;
198
+ if (width > maxDimension || height > maxDimension)
199
+ throw new Error(
200
+ `Generated sprite exceeds ${maxDimension} pixels per dimension at ${pixelRatio}x`
201
+ );
202
+ const index = Object.fromEntries(
203
+ icons.map((icon, ordinal) => {
204
+ const placement = placements[ordinal];
205
+ return [
206
+ icon.name,
207
+ {
208
+ height: icon.height,
209
+ pixelRatio,
210
+ width: icon.width,
211
+ x: placement.left,
212
+ y: placement.top,
213
+ ...icon.appearance ? { sdf: true, tileflow: icon.appearance } : {},
214
+ ...icon.layout ? scaleTileflowIconLayout(icon.layout, pixelRatio) : {}
215
+ }
216
+ ];
217
+ })
218
+ );
219
+ return { height, index, width };
220
+ }
221
+ async function createSpriteImage(icons, layout) {
222
+ const sharp = await loadSharp();
223
+ const atlas = new Uint8Array(layout.width * layout.height * 4);
224
+ for (const icon of icons) {
225
+ const placement = layout.index[icon.name];
226
+ if (!placement || icon.width !== placement.width || icon.height !== placement.height || icon.rgba.byteLength !== icon.width * icon.height * 4)
227
+ throw new Error("Rendered icon dimensions do not match the sprite layout");
228
+ const rowBytes = icon.width * 4;
229
+ for (let row = 0; row < icon.height; row += 1) {
230
+ const sourceStart = row * rowBytes;
231
+ const targetStart = ((placement.y + row) * layout.width + placement.x) * 4;
232
+ atlas.set(icon.rgba.subarray(sourceStart, sourceStart + rowBytes), targetStart);
233
+ }
234
+ }
235
+ return sharp(atlas, { raw: { channels: 4, height: layout.height, width: layout.width } }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
236
+ }
237
+ function assertGeneratedFileLimits(files) {
238
+ let totalBytes = 0;
239
+ for (const file of files) {
240
+ if (file.source.byteLength > tileflowIconPackageLimits.maxGeneratedFileBytes)
241
+ throw new Error(
242
+ `${file.fileName} exceeds ${tileflowIconPackageLimits.maxGeneratedFileBytes} generated bytes`
243
+ );
244
+ totalBytes += file.source.byteLength;
245
+ }
246
+ if (totalBytes > tileflowIconPackageLimits.maxGeneratedPackageBytes)
247
+ throw new Error(
248
+ `Generated package exceeds ${tileflowIconPackageLimits.maxGeneratedPackageBytes} bytes`
249
+ );
250
+ }
251
+ async function loadSharp() {
252
+ try {
253
+ return (await import("sharp")).default;
254
+ } catch (cause) {
255
+ throw new Error(
256
+ 'Local icon sprites require the optional "sharp" package. Install sharp or disable local icons.',
257
+ { cause }
258
+ );
259
+ }
260
+ }
261
+
262
+ // src/icon-artifact.ts
263
+ async function verifyTileflowIconArtifact(input) {
264
+ try {
265
+ const manifest = tileflowIconPackageManifestSchema2.parse(input.manifest);
266
+ const contentHash = input.contentHash;
267
+ if (input.files.length !== 4 || new Set(input.files.map((file) => file.fileName)).size !== 4)
268
+ throw new Error("An artifact must contain exactly the four generated files");
269
+ const files = manifest.files.map((expected) => {
270
+ const source = input.files.find((file) => file.fileName === expected.name);
271
+ if (!source || source.contentType !== expected.contentType || source.source.byteLength !== expected.byteLength)
272
+ throw new Error("Generated file identity does not match the manifest");
273
+ return {
274
+ contentType: source.contentType,
275
+ fileName: source.fileName,
276
+ source: new Uint8Array(source.source)
277
+ };
278
+ });
279
+ if (await hashTileflowIconPackageManifest2(manifest) !== contentHash)
280
+ throw new Error("Artifact manifest hash mismatch");
281
+ for (const [index, file] of files.entries()) {
282
+ if (await sha256Hex2(file.source) !== manifest.files[index].sha256)
283
+ throw new Error(`Generated ${file.fileName} checksum mismatch`);
284
+ }
285
+ const decoder = new TextDecoder("utf-8", { fatal: true });
286
+ const oneIndex = indexSchema.parse(
287
+ parseTileflowIconJson(
288
+ decoder.decode(files[0].source),
289
+ tileflowIconPackageLimits2.maxGeneratedFileBytes
290
+ )
291
+ );
292
+ const twoIndex = indexSchema.parse(
293
+ parseTileflowIconJson(
294
+ decoder.decode(files[2].source),
295
+ tileflowIconPackageLimits2.maxGeneratedFileBytes
296
+ )
297
+ );
298
+ for (const index of [oneIndex, twoIndex]) {
299
+ const names = Object.keys(index).sort(compareCodeUnits2);
300
+ if (names.length !== manifest.iconNames.length || names.some((name, ordinal) => name !== manifest.iconNames[ordinal]))
301
+ throw new Error("Sprite index does not exactly match the manifest icon names");
302
+ }
303
+ for (const [index, dimensions, ratio] of [
304
+ [oneIndex, manifest.sprites.oneX, 1],
305
+ [twoIndex, manifest.sprites.twoX, 2]
306
+ ]) {
307
+ const rectangles = Object.values(index);
308
+ for (const [ordinal, rect] of rectangles.entries()) {
309
+ if (rect.pixelRatio !== ratio || rect.x + rect.width > dimensions.width || rect.y + rect.height > dimensions.height)
310
+ throw new Error("Sprite rectangle is outside the atlas or has the wrong density");
311
+ for (const other of rectangles.slice(0, ordinal)) {
312
+ if (rect.x < other.x + other.width && other.x < rect.x + rect.width && rect.y < other.y + other.height && other.y < rect.y + rect.height)
313
+ throw new Error("Sprite rectangles must not overlap");
314
+ }
315
+ }
316
+ }
317
+ for (const name of manifest.iconNames) {
318
+ const one = oneIndex[name];
319
+ const two = twoIndex[name];
320
+ const declared = manifest.renderedIcons.find((icon) => icon.name === name).appearance;
321
+ const declaredLayout = manifest.renderedIcons.find((icon) => icon.name === name).layout;
322
+ const oneLayout = readTileflowIconLayout(one);
323
+ const twoLayout = readTileflowIconLayout(two);
324
+ if (serializeCanonicalJson2(oneLayout ?? null) !== serializeCanonicalJson2(declaredLayout ?? null) || serializeCanonicalJson2(twoLayout ?? null) !== serializeCanonicalJson2(oneLayout ? scaleTileflowIconLayout2(oneLayout, 2) : null))
325
+ throw new Error(
326
+ "Sprite layout must match the manifest and scale exactly between densities"
327
+ );
328
+ if (serializeCanonicalJson2(one.tileflow ?? null) !== serializeCanonicalJson2(two.tileflow ?? null) || serializeCanonicalJson2(one.tileflow ?? null) !== serializeCanonicalJson2(declared ?? null))
329
+ throw new Error("Sprite appearance must match at both densities and in the manifest");
330
+ if (two.x !== one.x * 2 || two.y !== one.y * 2 || two.width !== one.width * 2 || two.height !== one.height * 2)
331
+ throw new Error("Sprite densities must have exactly doubled geometry");
332
+ }
333
+ const oneAtlas = await decodeAtlas(files[1].source, manifest.sprites.oneX);
334
+ const twoAtlas = await decodeAtlas(files[3].source, manifest.sprites.twoX);
335
+ const icons = [];
336
+ for (const [ordinal, id] of manifest.iconNames.entries()) {
337
+ const oneX = extractCell(oneAtlas, oneIndex[id]);
338
+ const twoX = extractCell(twoAtlas, twoIndex[id]);
339
+ const expected = manifest.renderedIcons[ordinal].pixelSha256;
340
+ if (await hashTileflowRenderedIconPixels2({ ...oneX, pixelRatio: 1 }) !== expected.oneX || await hashTileflowRenderedIconPixels2({ ...twoX, pixelRatio: 2 }) !== expected.twoX)
341
+ throw new Error(`Rendered pixels do not match the manifest for ${id}`);
342
+ const appearance = oneIndex[id].tileflow;
343
+ if (appearance) {
344
+ assertTileflowSdfPixels2({ ...oneX, pixelRatio: 1 });
345
+ assertTileflowSdfPixels2({ ...twoX, pixelRatio: 2 });
346
+ }
347
+ const layout = readTileflowIconLayout(oneIndex[id]);
348
+ icons.push({
349
+ id,
350
+ oneX,
351
+ twoX,
352
+ ...appearance ? { appearance } : {},
353
+ ...layout ? { layout } : {}
354
+ });
355
+ }
356
+ return { package: { contentHash, manifest, files }, icons };
357
+ } catch (cause) {
358
+ throw new TileflowIconSetError(
359
+ "ICON_PACKAGE_INTEGRITY",
360
+ "Generated icon artifact verification failed",
361
+ { cause }
362
+ );
363
+ }
364
+ }
365
+ async function decodeAtlas(bytes, dimensions) {
366
+ if ([137, 80, 78, 71, 13, 10, 26, 10].some((byte, index) => bytes[index] !== byte))
367
+ throw new Error("Expected PNG bytes");
368
+ const sharp = await loadSharp();
369
+ const image = sharp(bytes, {
370
+ failOn: "error",
371
+ limitInputPixels: tileflowIconPackageLimits2.maxAtlasDimension ** 2
372
+ });
373
+ const metadata = await image.metadata();
374
+ if (metadata.format !== "png" || (metadata.pages ?? 1) !== 1 || metadata.width !== dimensions.width || metadata.height !== dimensions.height)
375
+ throw new Error("PNG geometry does not match the manifest");
376
+ const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
377
+ if (info.channels !== 4 || info.width !== dimensions.width || info.height !== dimensions.height || data.length !== dimensions.width * dimensions.height * 4)
378
+ throw new Error("Expected bounded RGBA atlas pixels");
379
+ return { ...dimensions, rgba: new Uint8Array(data) };
380
+ }
381
+ function extractCell(atlas, rect) {
382
+ const rgba = new Uint8Array(rect.width * rect.height * 4);
383
+ const rowBytes = rect.width * 4;
384
+ for (let row = 0; row < rect.height; row += 1) {
385
+ const start = ((rect.y + row) * atlas.width + rect.x) * 4;
386
+ rgba.set(atlas.rgba.subarray(start, start + rowBytes), row * rowBytes);
387
+ }
388
+ return { height: rect.height, width: rect.width, rgba };
389
+ }
390
+
391
+ // src/icon-cache.ts
392
+ function getTileflowIconCacheDirectory(options = {}) {
393
+ const defaultRoot = platform() === "darwin" ? join(homedir(), "Library", "Caches") : platform() === "win32" ? process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
394
+ return join(
395
+ resolve(options.cacheRoot ?? process.env.TILEFLOW_ICON_CACHE_DIR ?? defaultRoot),
396
+ "tileflow",
397
+ "icons",
398
+ "v1"
399
+ );
400
+ }
401
+ async function loadTileflowIconSetArtifact(input, options = {}) {
402
+ const pin = tileflowIconSetPinSchema.parse(input);
403
+ if (await hashTileflowIconPackageManifest3(pin.manifest) !== pin.contentHash)
404
+ throw new TileflowIconSetError2("ICON_LOCK_INVALID", "Locked manifest hash mismatch");
405
+ let cacheFailure;
406
+ try {
407
+ return await readCached(pin, options);
408
+ } catch (cause) {
409
+ if (cause instanceof TileflowIconSetError2 && cause.code === "ICON_CACHE_UNSAFE") throw cause;
410
+ cacheFailure = cause;
411
+ }
412
+ if (options.offline) {
413
+ throw new TileflowIconSetError2(
414
+ isMissing(cacheFailure) ? "ICON_CACHE_MISS" : "ICON_PACKAGE_INTEGRITY",
415
+ "The exact verified icon artifact is unavailable offline; hydrate this pin explicitly",
416
+ { cause: cacheFailure }
417
+ );
418
+ }
419
+ const url = new URL(pin.spriteUrl);
420
+ const allowed = options.deliveryOrigins ?? ["https://api.tileflow.dev"];
421
+ if (!allowed.some((origin) => origin === url.origin && new URL(origin).href === `${origin}/`)) {
422
+ throw new TileflowIconSetError2(
423
+ "ICON_DOWNLOAD_FAILED",
424
+ "Icon artifact delivery origin is not trusted"
425
+ );
426
+ }
427
+ const files = [];
428
+ const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(3e4)]) : AbortSignal.timeout(3e4);
429
+ try {
430
+ for (const expected of pin.manifest.files) {
431
+ signal.throwIfAborted();
432
+ const fileUrl = `${pin.spriteUrl}${expected.name.slice("sprite".length)}`;
433
+ const response = await (options.fetch ?? globalThis.fetch)(fileUrl, {
434
+ method: "GET",
435
+ credentials: "omit",
436
+ redirect: "error",
437
+ headers: { Accept: expected.contentType, "Accept-Encoding": "identity" },
438
+ signal
439
+ });
440
+ if (response.status !== 200 || response.redirected || response.url && response.url !== fileUrl || !response.body)
441
+ throw new Error("Unexpected artifact response");
442
+ const type = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
443
+ const length = response.headers.get("content-length");
444
+ const encoding = response.headers.get("content-encoding")?.trim().toLowerCase();
445
+ const isIdentityEncoding = encoding === void 0 || encoding === "" || encoding === "identity";
446
+ if (type !== expected.contentType || length !== null && (!/^\d+$/u.test(length) || isIdentityEncoding && Number(length) !== expected.byteLength)) {
447
+ await response.body.cancel();
448
+ throw new Error("Artifact response headers do not match the manifest");
449
+ }
450
+ const reader = response.body.getReader();
451
+ const source = new Uint8Array(expected.byteLength);
452
+ let offset = 0;
453
+ try {
454
+ while (true) {
455
+ const chunk = await reader.read();
456
+ if (chunk.done) break;
457
+ if (offset + chunk.value.byteLength > source.byteLength)
458
+ throw new Error("Artifact response exceeds its exact byte length");
459
+ source.set(chunk.value, offset);
460
+ offset += chunk.value.byteLength;
461
+ }
462
+ if (offset !== source.byteLength) throw new Error("Artifact response was truncated");
463
+ } catch (cause) {
464
+ await reader.cancel().catch(() => void 0);
465
+ throw cause;
466
+ } finally {
467
+ reader.releaseLock();
468
+ }
469
+ files.push({ fileName: expected.name, contentType: expected.contentType, source });
470
+ }
471
+ } catch (cause) {
472
+ throw new TileflowIconSetError2("ICON_DOWNLOAD_FAILED", "Exact icon artifact download failed", {
473
+ cause
474
+ });
475
+ }
476
+ return storeTileflowIconSetArtifact(
477
+ pin,
478
+ { contentHash: pin.contentHash, manifest: pin.manifest, files },
479
+ options
480
+ );
481
+ }
482
+ async function storeTileflowIconSetArtifact(input, artifact, options = {}) {
483
+ const pin = tileflowIconSetPinSchema.parse(input);
484
+ if (await hashTileflowIconPackageManifest3(pin.manifest) !== pin.contentHash)
485
+ throw new TileflowIconSetError2("ICON_LOCK_INVALID", "Locked manifest hash mismatch");
486
+ if (artifact.contentHash !== pin.contentHash)
487
+ throw new TileflowIconSetError2(
488
+ "ICON_PACKAGE_INTEGRITY",
489
+ "Artifact does not match the selected pin"
490
+ );
491
+ const verified = await verifyTileflowIconArtifact({
492
+ contentHash: pin.contentHash,
493
+ manifest: pin.manifest,
494
+ files: artifact.files
495
+ });
496
+ const root = await safeCacheRoot(options, true);
497
+ const target = join(root, pin.contentHash);
498
+ const temporary = await mkdtemp(join(root, `.incoming-${pin.contentHash}-`));
499
+ try {
500
+ for (const file of verified.package.files) {
501
+ const handle = await open(join(temporary, file.fileName), "wx", 384);
502
+ try {
503
+ await handle.writeFile(file.source);
504
+ await handle.sync();
505
+ } finally {
506
+ await handle.close();
507
+ }
508
+ }
509
+ for (let attempt = 0; attempt < 8; attempt += 1) {
510
+ try {
511
+ await rename(temporary, target);
512
+ return verified;
513
+ } catch (cause) {
514
+ if (!hasCode(cause, "EEXIST") && !hasCode(cause, "ENOTEMPTY") && !hasCode(cause, "EPERM"))
515
+ throw cause;
516
+ }
517
+ try {
518
+ return await readCached(pin, options);
519
+ } catch (cause) {
520
+ if (cause instanceof TileflowIconSetError2 && cause.code === "ICON_CACHE_UNSAFE")
521
+ throw cause;
522
+ }
523
+ try {
524
+ await assertDirectory(target);
525
+ const retired = await mkdtemp(join(root, `.retired-${pin.contentHash}-`));
526
+ try {
527
+ await rename(target, join(retired, "entry"));
528
+ } catch (cause) {
529
+ if (!isMissing(cause)) throw cause;
530
+ } finally {
531
+ await rm(retired, { recursive: true, force: true });
532
+ }
533
+ } catch (cause) {
534
+ if (!isMissing(cause)) throw cause;
535
+ }
536
+ }
537
+ throw new TileflowIconSetError2(
538
+ "ICON_CACHE_UNSAFE",
539
+ "Concurrent cache repairs did not converge; retry this exact pin"
540
+ );
541
+ } finally {
542
+ await rm(temporary, { recursive: true, force: true });
543
+ }
544
+ }
545
+ async function readCached(pin, options) {
546
+ const root = await safeCacheRoot(options, false);
547
+ const directory = join(root, pin.contentHash);
548
+ await assertDirectory(directory);
549
+ const entries = (await readdir(directory)).sort();
550
+ const expectedNames = pin.manifest.files.map((file) => file.name).sort();
551
+ if (entries.length !== 4 || entries.some((name, index) => name !== expectedNames[index]))
552
+ throw new TileflowIconSetError2(
553
+ "ICON_PACKAGE_INTEGRITY",
554
+ "Cache entry does not contain exactly four generated files"
555
+ );
556
+ const files = [];
557
+ for (const expected of pin.manifest.files) {
558
+ files.push({
559
+ fileName: expected.name,
560
+ contentType: expected.contentType,
561
+ source: await readSafeFile(join(directory, expected.name), expected.byteLength)
562
+ });
563
+ }
564
+ return verifyTileflowIconArtifact({ contentHash: pin.contentHash, manifest: pin.manifest, files });
565
+ }
566
+ async function safeCacheRoot(options, create) {
567
+ const root = getTileflowIconCacheDirectory(options);
568
+ const base = resolve(root, "..", "..", "..");
569
+ if (create) await mkdir(base, { recursive: true, mode: 448 });
570
+ await assertDirectory(base);
571
+ let current = await realpath(base);
572
+ for (const segment of ["tileflow", "icons", "v1"]) {
573
+ current = join(current, segment);
574
+ if (create)
575
+ await mkdir(current, { mode: 448 }).catch((cause) => {
576
+ if (!hasCode(cause, "EEXIST")) throw cause;
577
+ });
578
+ await assertDirectory(current);
579
+ }
580
+ return current;
581
+ }
582
+ async function assertDirectory(path) {
583
+ const entry = await lstat(path);
584
+ if (!entry.isDirectory() || entry.isSymbolicLink())
585
+ throw new TileflowIconSetError2(
586
+ "ICON_CACHE_UNSAFE",
587
+ "Icon cache paths must be directories, not symlinks"
588
+ );
589
+ }
590
+ async function readSafeFile(path, maximumBytes, exact = true) {
591
+ const entry = await lstat(path);
592
+ if (!entry.isFile() || entry.isSymbolicLink())
593
+ throw new TileflowIconSetError2(
594
+ "ICON_CACHE_UNSAFE",
595
+ "Icon files must be regular files, not symlinks"
596
+ );
597
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
598
+ try {
599
+ const opened = await handle.stat();
600
+ if (!opened.isFile() || opened.ino !== entry.ino || opened.dev !== entry.dev || opened.size > maximumBytes || exact && opened.size !== maximumBytes)
601
+ throw new TileflowIconSetError2(
602
+ "ICON_PACKAGE_INTEGRITY",
603
+ "Icon file size or identity changed"
604
+ );
605
+ const buffer = new Uint8Array(maximumBytes + 1);
606
+ let offset = 0;
607
+ while (offset < buffer.length) {
608
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
609
+ if (bytesRead === 0) break;
610
+ offset += bytesRead;
611
+ }
612
+ if (offset > maximumBytes || exact && offset !== maximumBytes)
613
+ throw new TileflowIconSetError2("ICON_PACKAGE_INTEGRITY", "Icon file byte length changed");
614
+ return buffer.slice(0, offset);
615
+ } finally {
616
+ await handle.close();
617
+ }
618
+ }
619
+ function hasCode(error, code) {
620
+ return error !== null && typeof error === "object" && "code" in error && error.code === code;
621
+ }
622
+ function isMissing(error) {
623
+ return hasCode(error, "ENOENT");
624
+ }
625
+
626
+ // src/icon-lockfile.ts
627
+ import { lstat as lstat2, mkdir as mkdir2, mkdtemp as mkdtemp2, open as open2, rename as rename2, rm as rm2, rmdir } from "fs/promises";
628
+ import { join as join2, resolve as resolve2 } from "path";
629
+ import {
630
+ parseTileflowIconsLockfile,
631
+ serializeTileflowIconsLockfile,
632
+ TileflowIconSetError as TileflowIconSetError3,
633
+ tileflowIconsLockfileMaximumBytes,
634
+ tileflowIconsLockfileName
635
+ } from "@tileflow/core";
636
+ async function readTileflowIconsLockfileText(baseDirectory) {
637
+ return readLockText(join2(resolve2(baseDirectory), tileflowIconsLockfileName));
638
+ }
639
+ async function readTileflowIconsLockfile(baseDirectory, sources) {
640
+ const text = await readLockText(join2(resolve2(baseDirectory), tileflowIconsLockfileName));
641
+ if (text === null)
642
+ throw new TileflowIconSetError3(
643
+ "ICON_LOCK_MISSING",
644
+ "Missing tileflow.icons.lock.json; install exact icon set revisions explicitly"
645
+ );
646
+ return parseTileflowIconsLockfile(text, sources);
647
+ }
648
+ async function writeTileflowIconsLockfile(baseDirectory, input, expectedContents) {
649
+ const content = await serializeTileflowIconsLockfile(input);
650
+ const directory = resolve2(baseDirectory);
651
+ const destination = join2(directory, tileflowIconsLockfileName);
652
+ const guard = join2(directory, `.${tileflowIconsLockfileName}.writing`);
653
+ try {
654
+ await mkdir2(guard, { mode: 448 });
655
+ } catch (cause) {
656
+ throw new TileflowIconSetError3(
657
+ "ICON_LOCK_CONFLICT",
658
+ "Another icon lock writer is active; a stale .writing guard may be removed only after confirming its writer has exited",
659
+ { cause }
660
+ );
661
+ }
662
+ let temporary;
663
+ try {
664
+ const actual = await readLockText(destination);
665
+ if (actual !== expectedContents)
666
+ throw new TileflowIconSetError3(
667
+ "ICON_LOCK_CONFLICT",
668
+ "Icon lock changed since it was read; reload and retry without discarding other pins"
669
+ );
670
+ if (actual === content) return;
671
+ temporary = await mkdtemp2(join2(directory, ".tileflow-icons-lock-"));
672
+ const file = join2(temporary, "lock");
673
+ const handle = await open2(file, "wx", 384);
674
+ try {
675
+ await handle.writeFile(content, "utf8");
676
+ await handle.sync();
677
+ } finally {
678
+ await handle.close();
679
+ }
680
+ if (await readLockText(destination) !== actual)
681
+ throw new TileflowIconSetError3(
682
+ "ICON_LOCK_CONFLICT",
683
+ "Icon lock changed while preparing an update"
684
+ );
685
+ await rename2(file, destination);
686
+ } finally {
687
+ if (temporary) await rm2(temporary, { recursive: true, force: true });
688
+ await rmdir(guard);
689
+ }
690
+ }
691
+ async function readLockText(path) {
692
+ try {
693
+ const entry = await lstat2(path);
694
+ if (!entry.isFile() || entry.isSymbolicLink())
695
+ throw new TileflowIconSetError3(
696
+ "ICON_LOCK_INVALID",
697
+ "The icon lock must be a regular file, not a symlink"
698
+ );
699
+ return new TextDecoder("utf-8", { fatal: true }).decode(
700
+ await readSafeFile(path, tileflowIconsLockfileMaximumBytes, false)
701
+ );
702
+ } catch (cause) {
703
+ if (cause !== null && typeof cause === "object" && "code" in cause && cause.code === "ENOENT")
704
+ return null;
705
+ throw cause;
706
+ }
707
+ }
708
+
709
+ // src/icon-composition.ts
710
+ async function composeTileflowIconSources(input, options) {
711
+ const sources = structuredClone(input);
712
+ const references = collectTileflowIconSetReferences(sources);
713
+ let realCwd;
714
+ const lock = references.length > 0 ? options.lock === void 0 ? await readTileflowIconsLockfile(options.baseDirectory ?? options.cwd, sources) : await parseTileflowIconsLockfile2(options.lock, sources) : await parseTileflowIconsLockfile2(options.lock ?? { lockfileVersion: 1, sets: {} }, sources);
715
+ const contributors = new Array(sources.length);
716
+ const details = new Array(sources.length);
717
+ const winners = /* @__PURE__ */ new Map();
718
+ const watchPaths = /* @__PURE__ */ new Set();
719
+ const watchFiles = /* @__PURE__ */ new Set();
720
+ if (references.length > 0 && options.lock === void 0)
721
+ watchFiles.add(resolve3(options.baseDirectory ?? options.cwd, tileflowIconsLockfileName2));
722
+ let reused;
723
+ let pixelBytes = 0;
724
+ let localSourceBytes = 0;
725
+ for (let ordinal = sources.length - 1; ordinal >= 0; ordinal -= 1) {
726
+ const source = sources[ordinal];
727
+ if (typeof source === "object" && source.kind === "icon-set") {
728
+ const pin = lock.sets[source.reference];
729
+ const loaded = await loadTileflowIconSetArtifact(pin, options);
730
+ contributors[ordinal] = {
731
+ kind: "icon-set",
732
+ reference: source.reference,
733
+ teamId: pin.teamId,
734
+ setId: pin.setId,
735
+ versionId: pin.versionId,
736
+ version: pin.version,
737
+ packageId: pin.packageId,
738
+ contentHash: pin.contentHash,
739
+ iconIds: [...pin.manifest.iconNames]
740
+ };
741
+ details[ordinal] = {
742
+ iconIds: [...pin.manifest.iconNames],
743
+ kind: "icon-set",
744
+ label: source.reference,
745
+ packageId: pin.packageId,
746
+ reference: source.reference,
747
+ setId: pin.setId,
748
+ version: pin.version,
749
+ versionId: pin.versionId
750
+ };
751
+ if (sources.length === 1) reused = loaded.package;
752
+ for (const icon of loaded.icons) {
753
+ if (winners.has(icon.id)) continue;
754
+ const pixels = pin.manifest.renderedIcons.find(
755
+ (entry) => entry.name === icon.id
756
+ ).pixelSha256;
757
+ addWinner(
758
+ ordinal,
759
+ icon,
760
+ {
761
+ kind: "rendered-icon",
762
+ id: icon.id,
763
+ width: icon.oneX.width,
764
+ height: icon.oneX.height,
765
+ pixelSha256: { ...pixels },
766
+ ...icon.appearance ? { appearance: icon.appearance } : {},
767
+ ...icon.layout ? { layout: icon.layout } : {}
768
+ },
769
+ null
770
+ );
771
+ }
772
+ } else {
773
+ const directory = await readTileflowIconDirectory(source, {
774
+ cwd: options.cwd,
775
+ baseDirectory: options.baseDirectory ?? options.cwd,
776
+ ...options.configPath === void 0 ? {} : { configPath: options.configPath },
777
+ ordinal,
778
+ target: options.target ?? "local"
779
+ });
780
+ if (directory.watchPath) watchPaths.add(directory.watchPath);
781
+ contributors[ordinal] = typeof source === "string" ? { kind: "local", iconIds: directory.iconIds } : { kind: "package", package: source.package, iconIds: directory.iconIds };
782
+ details[ordinal] = {
783
+ entries: directory.entries,
784
+ iconIds: directory.iconIds,
785
+ insideWorkingTree: isPathInside(
786
+ realCwd ??= await realpath2(options.cwd),
787
+ directory.realPath
788
+ ),
789
+ kind: typeof source === "string" ? "local" : "package",
790
+ label: directory.label
791
+ };
792
+ const selected = directory.iconIds.filter((id) => !winners.has(id));
793
+ if (winners.size + selected.length > tileflowIconPackageLimits3.maxIconCount)
794
+ throw new TileflowIconSetError4(
795
+ "ICON_COMPOSITION_INVALID",
796
+ `Composed icon set exceeds ${tileflowIconPackageLimits3.maxIconCount} effective icons`
797
+ );
798
+ for (const item of await directory.render(selected)) {
799
+ localSourceBytes += item.sourceBytes;
800
+ if (localSourceBytes > tileflowIconPackageLimits3.maxSourceBytes)
801
+ throw new TileflowIconSetError4(
802
+ "ICON_COMPOSITION_INVALID",
803
+ "Effective local icon originals exceed the source-byte limit"
804
+ );
805
+ addWinner(ordinal, item.icon, item.identity, item.source);
806
+ }
807
+ }
808
+ }
809
+ const sorted = [...winners.values()].sort(
810
+ (left, right) => compareCodeUnits3(left.icon.id, right.icon.id)
811
+ );
812
+ const iconPackage = sorted.length ? reused ?? await packTileflowRenderedIcons(sorted.map((winner) => winner.icon)) : null;
813
+ const composition = references.length && iconPackage ? parseTileflowIconComposition({
814
+ format: sorted.some((winner) => winner.icon.appearance || winner.icon.layout) ? "tileflow-icon-composition-v2" : "tileflow-icon-composition-v1",
815
+ compositionVersion: sorted.some((winner) => winner.icon.appearance || winner.icon.layout) ? 2 : 1,
816
+ packageHash: iconPackage.contentHash,
817
+ contributors,
818
+ winners: sorted.map(({ ordinal, icon }, index) => ({
819
+ id: icon.id,
820
+ contributor: ordinal,
821
+ width: icon.oneX.width,
822
+ height: icon.oneX.height,
823
+ pixelSha256: iconPackage.manifest.renderedIcons[index].pixelSha256,
824
+ ...icon.appearance ? { appearance: icon.appearance } : {},
825
+ ...icon.layout ? { layout: icon.layout } : {}
826
+ }))
827
+ }) : null;
828
+ const previous = /* @__PURE__ */ new Map();
829
+ const replacements = [];
830
+ for (const [ordinal, contributor] of contributors.entries()) {
831
+ for (const id of contributor.iconIds) {
832
+ const replaced = previous.get(id);
833
+ if (replaced !== void 0) replacements.push({ id, replaced, winner: ordinal });
834
+ previous.set(id, ordinal);
835
+ }
836
+ }
837
+ return {
838
+ package: iconPackage,
839
+ composition,
840
+ contributors: details,
841
+ sourceIdentities: sorted.map((winner) => winner.identity),
842
+ replacements,
843
+ watchFiles: [...watchFiles].sort(compareCodeUnits3),
844
+ watchPaths: [...watchPaths].sort(compareCodeUnits3),
845
+ winners: sorted.map((winner) => ({
846
+ contributor: winner.ordinal,
847
+ id: winner.icon.id,
848
+ identity: winner.identity,
849
+ source: winner.source
850
+ }))
851
+ };
852
+ function addWinner(ordinal, icon, identity2, source) {
853
+ pixelBytes += icon.oneX.rgba.byteLength + icon.twoX.rgba.byteLength;
854
+ const maximumPixelBytes = (tileflowIconPackageLimits3.maxAtlasDimension ** 2 + (tileflowIconPackageLimits3.maxAtlasDimension / 2) ** 2) * 4;
855
+ if (winners.size >= tileflowIconPackageLimits3.maxIconCount || pixelBytes > maximumPixelBytes)
856
+ throw new TileflowIconSetError4(
857
+ "ICON_COMPOSITION_INVALID",
858
+ "Composed rendered cells exceed the final sprite capacity"
859
+ );
860
+ winners.set(icon.id, { ordinal, icon, identity: identity2, source });
861
+ }
862
+ }
863
+ function isPathInside(root, candidate) {
864
+ const pathFromRoot = relative(root, candidate);
865
+ return pathFromRoot === "" || !isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`);
866
+ }
867
+
868
+ // src/icon-distance.ts
869
+ var infinity = 1e20;
870
+ function createIconDistanceField(rgba, width, height, ratio) {
871
+ if (width !== 24 * ratio || height !== 24 * ratio || rgba.length !== width * height * 4)
872
+ throw new Error("SDF icons require a 24px logical cell at each density.");
873
+ const outer = new Float64Array(width * height);
874
+ const inner = new Float64Array(width * height);
875
+ let occupied = false;
876
+ for (let y = 0; y < height; y++) {
877
+ for (let x = 0; x < width; x++) {
878
+ const index = y * width + x;
879
+ const alpha = rgba[index * 4 + 3] / 255;
880
+ if (alpha && (x < 6 * ratio || y < 6 * ratio || x >= width - 6 * ratio || y >= height - 6 * ratio))
881
+ throw new Error(
882
+ "SDF icons require six transparent pixels of padding inside the 24px cell. Add a transparent border to the source."
883
+ );
884
+ occupied ||= alpha > 0;
885
+ outer[index] = alpha === 1 ? 0 : alpha === 0 ? infinity : Math.max(0, 0.5 - alpha) ** 2;
886
+ inner[index] = alpha === 1 ? infinity : alpha === 0 ? 0 : Math.max(0, alpha - 0.5) ** 2;
887
+ }
888
+ }
889
+ if (!occupied) throw new Error("An SDF silhouette must contain visible pixels.");
890
+ transform(outer, width, height);
891
+ transform(inner, width, height);
892
+ const result = new Uint8Array(rgba.length);
893
+ for (let i = 0; i < outer.length; i++) {
894
+ const distance = Math.sqrt(outer[i]) - Math.sqrt(inner[i]);
895
+ const alpha = Math.round(255 * Math.max(0, Math.min(1, 0.75 - distance / (8 * ratio))));
896
+ result.set([255, 255, 255, alpha], i * 4);
897
+ }
898
+ return result;
899
+ }
900
+ function transform(grid, width, height) {
901
+ const size = Math.max(width, height);
902
+ const samples = new Float64Array(size);
903
+ const result = new Float64Array(size);
904
+ const sites = new Uint32Array(size);
905
+ const bounds = new Float64Array(size + 1);
906
+ const line = (length) => {
907
+ let count = 0;
908
+ sites[0] = 0;
909
+ bounds[0] = -infinity;
910
+ bounds[1] = infinity;
911
+ for (let position = 1; position < length; position++) {
912
+ let intersection;
913
+ do {
914
+ const site = sites[count];
915
+ intersection = (samples[position] + position ** 2 - samples[site] - site ** 2) / (2 * (position - site));
916
+ if (intersection > bounds[count]) break;
917
+ count--;
918
+ } while (count >= 0);
919
+ count++;
920
+ sites[count] = position;
921
+ bounds[count] = intersection;
922
+ bounds[count + 1] = infinity;
923
+ }
924
+ count = 0;
925
+ for (let position = 0; position < length; position++) {
926
+ while (bounds[count + 1] < position) count++;
927
+ result[position] = (position - sites[count]) ** 2 + samples[sites[count]];
928
+ }
929
+ };
930
+ for (let x = 0; x < width; x++) {
931
+ for (let y = 0; y < height; y++) samples[y] = grid[y * width + x];
932
+ line(height);
933
+ for (let y = 0; y < height; y++) grid[y * width + x] = result[y];
934
+ }
935
+ for (let y = 0; y < height; y++) {
936
+ samples.set(grid.subarray(y * width, (y + 1) * width));
937
+ line(width);
938
+ grid.set(result.subarray(0, width), y * width);
939
+ }
940
+ }
941
+
942
+ // src/tree-models.ts
943
+ import { createHash } from "crypto";
944
+ import { lstat as lstat3, readdir as readdir2, readFile } from "fs/promises";
945
+ import { join as join3 } from "path";
946
+ import {
947
+ encodeTileflowTreeModel,
948
+ tileflowTreeModelLimits as tileflowTreeModelLimits2
949
+ } from "@tileflow/core";
950
+
951
+ // src/gltf-reader.ts
952
+ var TileflowGltfError = class extends Error {
953
+ name = "TileflowGltfError";
954
+ };
955
+ var glbMagic = 1179937895;
956
+ var jsonChunk = 1313821514;
957
+ var binChunk = 5130562;
958
+ var triangles = 4;
959
+ var compressions = /* @__PURE__ */ new Set([
960
+ "KHR_draco_mesh_compression",
961
+ "EXT_meshopt_compression",
962
+ "KHR_meshopt_compression",
963
+ "KHR_mesh_quantization"
964
+ ]);
965
+ var componentBytes = {
966
+ 5120: 1,
967
+ 5121: 1,
968
+ 5122: 2,
969
+ 5123: 2,
970
+ 5125: 4,
971
+ 5126: 4
972
+ };
973
+ var typeSizes = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
974
+ function readTileflowGltfTriangles(bytes) {
975
+ const { json, bin } = chunks(bytes);
976
+ const required = array(json.extensionsRequired).filter((name) => typeof name === "string");
977
+ const compressed = required.find((name) => compressions.has(name));
978
+ if (compressed)
979
+ throw new TileflowGltfError(
980
+ `uses compressed geometry (${String(compressed)}); export it without compression`
981
+ );
982
+ for (const buffer of array(json.buffers))
983
+ if (record(buffer).uri !== void 0)
984
+ throw new TileflowGltfError("keeps its geometry in another file; export a single .glb");
985
+ const nodes = array(json.nodes).map(record);
986
+ const scenes = array(json.scenes).map(record);
987
+ const scene = scenes[typeof json.scene === "number" ? json.scene : 0];
988
+ const roots = scene ? array(scene.nodes) : nodes.map((_, index) => index).filter((index) => !nodes.some((node) => array(node.children).includes(index)));
989
+ const out = [];
990
+ const visit = (index, parent, depth) => {
991
+ if (typeof index !== "number" || !nodes[index])
992
+ throw new TileflowGltfError("names a node it does not hold");
993
+ if (depth > 64) throw new TileflowGltfError("nests its nodes more than 64 deep");
994
+ const node = nodes[index];
995
+ const world = multiply(parent, local(node));
996
+ if (typeof node.mesh === "number") out.push(...meshOf(json, bin, node.mesh, world));
997
+ for (const child of array(node.children)) visit(child, world, depth + 1);
998
+ };
999
+ for (const root of roots) visit(root, identity(), 0);
1000
+ if (out.length === 0) throw new TileflowGltfError("holds no triangles in its scene");
1001
+ return out;
1002
+ }
1003
+ function chunks(bytes) {
1004
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1005
+ if (bytes.byteLength < 20 || view.getUint32(0, true) !== glbMagic)
1006
+ throw new TileflowGltfError("is not a glTF binary; export the model as .glb");
1007
+ if (view.getUint32(4, true) !== 2) throw new TileflowGltfError("is not glTF 2.0");
1008
+ if (view.getUint32(8, true) !== bytes.byteLength)
1009
+ throw new TileflowGltfError("is cut short or has trailing bytes");
1010
+ let at = 12;
1011
+ let json;
1012
+ let bin;
1013
+ while (at + 8 <= bytes.byteLength) {
1014
+ const length = view.getUint32(at, true);
1015
+ const type = view.getUint32(at + 4, true);
1016
+ const start = at + 8;
1017
+ if (start + length > bytes.byteLength) throw new TileflowGltfError("has a chunk past its end");
1018
+ const body = bytes.subarray(start, start + length);
1019
+ if (type === jsonChunk && json === void 0) {
1020
+ try {
1021
+ json = record(JSON.parse(new TextDecoder().decode(body)));
1022
+ } catch {
1023
+ throw new TileflowGltfError("has an unreadable JSON chunk");
1024
+ }
1025
+ } else if (type === binChunk && bin === void 0) bin = body;
1026
+ at = start + length + (4 - length % 4) % 4;
1027
+ }
1028
+ if (!json) throw new TileflowGltfError("has no JSON chunk");
1029
+ return { json, bin };
1030
+ }
1031
+ function meshOf(json, bin, index, world) {
1032
+ const mesh = record(array(json.meshes)[index]);
1033
+ const normalMatrix = inverseTranspose(world);
1034
+ return array(mesh.primitives).map((value) => {
1035
+ const primitive = record(value);
1036
+ if ((primitive.mode ?? triangles) !== triangles)
1037
+ throw new TileflowGltfError("draws points or lines; a tree model draws triangles");
1038
+ const attributes = record(primitive.attributes);
1039
+ if (typeof attributes.POSITION !== "number")
1040
+ throw new TileflowGltfError("has a primitive without positions");
1041
+ const raw = accessor(json, bin, attributes.POSITION, 3);
1042
+ const count = raw.length / 3;
1043
+ const positions = [];
1044
+ for (let vertex = 0; vertex < count; vertex += 1) {
1045
+ const [x, y, z] = transform2(
1046
+ world,
1047
+ raw[vertex * 3],
1048
+ raw[vertex * 3 + 1],
1049
+ raw[vertex * 3 + 2],
1050
+ 1
1051
+ );
1052
+ positions.push(x, -z, y);
1053
+ }
1054
+ let normals;
1055
+ if (typeof attributes.NORMAL === "number") {
1056
+ const rawNormals = accessor(json, bin, attributes.NORMAL, 3);
1057
+ normals = [];
1058
+ for (let vertex = 0; vertex < count; vertex += 1) {
1059
+ const [x, y, z] = transform2(
1060
+ normalMatrix,
1061
+ rawNormals[vertex * 3],
1062
+ rawNormals[vertex * 3 + 1],
1063
+ rawNormals[vertex * 3 + 2],
1064
+ 0
1065
+ );
1066
+ const size = Math.hypot(x, y, z) || 1;
1067
+ normals.push(x / size, -z / size, y / size);
1068
+ }
1069
+ }
1070
+ const material = typeof primitive.material === "number" ? record(array(json.materials)[primitive.material]) : void 0;
1071
+ const base = array(record(material?.pbrMetallicRoughness).baseColorFactor).map(Number);
1072
+ const factor = base.length >= 3 && base.every(Number.isFinite) ? base : [1, 1, 1];
1073
+ const colours = [];
1074
+ const vertexColours = typeof attributes.COLOR_0 === "number" ? accessorWithSize(json, bin, attributes.COLOR_0) : void 0;
1075
+ for (let vertex = 0; vertex < count; vertex += 1)
1076
+ for (let channel = 0; channel < 3; channel += 1)
1077
+ colours.push(
1078
+ factor[channel] * (vertexColours ? vertexColours.values[vertex * vertexColours.size + channel] : 1)
1079
+ );
1080
+ const indices = typeof primitive.indices === "number" ? accessor(json, bin, primitive.indices, 1).map((value2) => Math.round(value2)) : Array.from({ length: count }, (_, vertex) => vertex);
1081
+ if (indices.length % 3 !== 0 || indices.some((vertex) => vertex < 0 || vertex >= count))
1082
+ throw new TileflowGltfError("has triangles that name missing vertices");
1083
+ if (determinant(world) < 0)
1084
+ for (let at = 0; at + 2 < indices.length; at += 3)
1085
+ [indices[at + 1], indices[at + 2]] = [indices[at + 2], indices[at + 1]];
1086
+ return {
1087
+ material: typeof material?.name === "string" ? material.name : void 0,
1088
+ positions,
1089
+ normals,
1090
+ colours,
1091
+ indices
1092
+ };
1093
+ });
1094
+ }
1095
+ function accessor(json, bin, index, size) {
1096
+ const read = accessorWithSize(json, bin, index);
1097
+ if (read.size !== size)
1098
+ throw new TileflowGltfError(`has an accessor of ${read.size} components where ${size} belong`);
1099
+ return read.values;
1100
+ }
1101
+ function accessorWithSize(json, bin, index) {
1102
+ const accessor2 = record(array(json.accessors)[index]);
1103
+ if (accessor2.sparse !== void 0)
1104
+ throw new TileflowGltfError("uses sparse accessors; export without them");
1105
+ const size = typeSizes[String(accessor2.type)];
1106
+ const component = Number(accessor2.componentType);
1107
+ const bytesEach = componentBytes[component];
1108
+ const count = Number(accessor2.count);
1109
+ if (!size || !bytesEach || !Number.isInteger(count) || count < 0 || count > 1e6)
1110
+ throw new TileflowGltfError("has an accessor Tileflow cannot read");
1111
+ if (typeof accessor2.bufferView !== "number")
1112
+ return { values: new Array(count * size).fill(0), size };
1113
+ const view = record(array(json.bufferViews)[accessor2.bufferView]);
1114
+ if (!bin || Number(view.buffer ?? 0) !== 0)
1115
+ throw new TileflowGltfError("has geometry outside its binary chunk");
1116
+ const stride = Number(view.byteStride ?? 0) || size * bytesEach;
1117
+ const start = Number(view.byteOffset ?? 0) + Number(accessor2.byteOffset ?? 0);
1118
+ const end = start + (count - 1) * stride + size * bytesEach;
1119
+ if (count > 0 && (start < 0 || end > Number(view.byteOffset ?? 0) + Number(view.byteLength) || end > bin.byteLength))
1120
+ throw new TileflowGltfError("has an accessor past the end of its buffer");
1121
+ const data = new DataView(bin.buffer, bin.byteOffset, bin.byteLength);
1122
+ const normalized = accessor2.normalized === true;
1123
+ const values = [];
1124
+ for (let item = 0; item < count; item += 1)
1125
+ for (let part = 0; part < size; part += 1) {
1126
+ const at = start + item * stride + part * bytesEach;
1127
+ let value;
1128
+ switch (component) {
1129
+ case 5126:
1130
+ value = data.getFloat32(at, true);
1131
+ break;
1132
+ case 5125:
1133
+ value = data.getUint32(at, true);
1134
+ break;
1135
+ case 5123:
1136
+ value = data.getUint16(at, true);
1137
+ if (normalized) value /= 65535;
1138
+ break;
1139
+ case 5122:
1140
+ value = data.getInt16(at, true);
1141
+ if (normalized) value = Math.max(value / 32767, -1);
1142
+ break;
1143
+ case 5121:
1144
+ value = data.getUint8(at);
1145
+ if (normalized) value /= 255;
1146
+ break;
1147
+ default:
1148
+ value = data.getInt8(at);
1149
+ if (normalized) value = Math.max(value / 127, -1);
1150
+ }
1151
+ if (!Number.isFinite(value)) throw new TileflowGltfError("has a value that is not a number");
1152
+ values.push(value);
1153
+ }
1154
+ return { values, size };
1155
+ }
1156
+ function identity() {
1157
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
1158
+ }
1159
+ function local(node) {
1160
+ const matrix = array(node.matrix).map(Number);
1161
+ if (matrix.length === 16 && matrix.every(Number.isFinite)) return matrix;
1162
+ const [tx, ty, tz] = vector(node.translation, [0, 0, 0]);
1163
+ const [qx, qy, qz, qw] = vector(node.rotation, [0, 0, 0, 1]);
1164
+ const [sx, sy, sz] = vector(node.scale, [1, 1, 1]);
1165
+ const [xx, yy, zz] = [qx * qx, qy * qy, qz * qz];
1166
+ const [xy, xz, yz, wx, wy, wz] = [qx * qy, qx * qz, qy * qz, qw * qx, qw * qy, qw * qz];
1167
+ return [
1168
+ (1 - 2 * (yy + zz)) * sx,
1169
+ 2 * (xy + wz) * sx,
1170
+ 2 * (xz - wy) * sx,
1171
+ 0,
1172
+ 2 * (xy - wz) * sy,
1173
+ (1 - 2 * (xx + zz)) * sy,
1174
+ 2 * (yz + wx) * sy,
1175
+ 0,
1176
+ 2 * (xz + wy) * sz,
1177
+ 2 * (yz - wx) * sz,
1178
+ (1 - 2 * (xx + yy)) * sz,
1179
+ 0,
1180
+ tx,
1181
+ ty,
1182
+ tz,
1183
+ 1
1184
+ ];
1185
+ }
1186
+ function multiply(a, b) {
1187
+ const out = new Array(16).fill(0);
1188
+ for (let column = 0; column < 4; column += 1)
1189
+ for (let row = 0; row < 4; row += 1)
1190
+ for (let k = 0; k < 4; k += 1) out[column * 4 + row] += a[k * 4 + row] * b[column * 4 + k];
1191
+ return out;
1192
+ }
1193
+ function transform2(m, x, y, z, w) {
1194
+ return [
1195
+ m[0] * x + m[4] * y + m[8] * z + m[12] * w,
1196
+ m[1] * x + m[5] * y + m[9] * z + m[13] * w,
1197
+ m[2] * x + m[6] * y + m[10] * z + m[14] * w
1198
+ ];
1199
+ }
1200
+ function determinant(m) {
1201
+ return m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2]) + m[8] * (m[1] * m[6] - m[5] * m[2]);
1202
+ }
1203
+ function inverseTranspose(m) {
1204
+ const [a, b, c, d, e, f, g, h, i] = [
1205
+ m[0],
1206
+ m[1],
1207
+ m[2],
1208
+ m[4],
1209
+ m[5],
1210
+ m[6],
1211
+ m[8],
1212
+ m[9],
1213
+ m[10]
1214
+ ];
1215
+ const det = determinant(m) || 1;
1216
+ return [
1217
+ (e * i - h * f) / det,
1218
+ (g * f - d * i) / det,
1219
+ (d * h - g * e) / det,
1220
+ 0,
1221
+ (h * c - b * i) / det,
1222
+ (a * i - g * c) / det,
1223
+ (g * b - a * h) / det,
1224
+ 0,
1225
+ (b * f - e * c) / det,
1226
+ (d * c - a * f) / det,
1227
+ (a * e - d * b) / det,
1228
+ 0,
1229
+ 0,
1230
+ 0,
1231
+ 0,
1232
+ 1
1233
+ ];
1234
+ }
1235
+ function record(value) {
1236
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
1237
+ }
1238
+ function array(value) {
1239
+ return Array.isArray(value) ? value : [];
1240
+ }
1241
+ function vector(value, fallback) {
1242
+ const list = array(value).map(Number);
1243
+ return list.length === fallback.length && list.every(Number.isFinite) ? list : fallback;
1244
+ }
1245
+
1246
+ // src/tree-model-mesh.ts
1247
+ import {
1248
+ tileflowTreeModelLimits,
1249
+ tileflowTreeParts
1250
+ } from "@tileflow/core";
1251
+ var tileflowTreeModelPartNames = ["bark", "leaves", "fronds", "stem"];
1252
+ function prepareTileflowTreeModelMesh(bytes, level) {
1253
+ const primitives = readTileflowGltfTriangles(bytes);
1254
+ const positions = [];
1255
+ const normals = [];
1256
+ const parts = [];
1257
+ const luminance = [];
1258
+ const indices = [];
1259
+ for (const primitive of primitives) {
1260
+ const part = partOf(primitive.material);
1261
+ const offset = positions.length / 3;
1262
+ const count2 = primitive.positions.length / 3;
1263
+ positions.push(...primitive.positions);
1264
+ for (let vertex = 0; vertex < count2; vertex += 1) {
1265
+ parts.push(part);
1266
+ normals.push(primitive.normals?.slice(vertex * 3, vertex * 3 + 3));
1267
+ const [r, g, b] = primitive.colours.slice(vertex * 3, vertex * 3 + 3);
1268
+ luminance.push(0.2126 * r + 0.7152 * g + 0.0722 * b);
1269
+ }
1270
+ for (const index of primitive.indices) indices.push(offset + index);
1271
+ }
1272
+ const count = positions.length / 3;
1273
+ const triangleCount = indices.length / 3;
1274
+ const limit = level === "near" ? tileflowTreeModelLimits.nearTriangles : tileflowTreeModelLimits.farTriangles;
1275
+ if (triangleCount > limit)
1276
+ throw new TileflowGltfError(
1277
+ `has ${triangleCount.toLocaleString("en")} triangles; a ${level} tree model takes at most ${limit.toLocaleString("en")}. Simplify it (for example with Blender's Decimate modifier) and export it again`
1278
+ );
1279
+ if (count > tileflowTreeModelLimits.vertices)
1280
+ throw new TileflowGltfError(
1281
+ `has ${count.toLocaleString("en")} vertices; a tree model takes at most ${tileflowTreeModelLimits.vertices.toLocaleString("en")}. Merge vertices by distance and export it again`
1282
+ );
1283
+ let height = 0;
1284
+ let reach = 0;
1285
+ const crowned = parts.some((part) => isCrown(part));
1286
+ for (let vertex = 0; vertex < count; vertex += 1) {
1287
+ height = Math.max(height, positions[vertex * 3 + 2]);
1288
+ if (!crowned || isCrown(parts[vertex]))
1289
+ reach = Math.max(reach, Math.hypot(positions[vertex * 3], positions[vertex * 3 + 1]));
1290
+ }
1291
+ if (!(height > 0))
1292
+ throw new TileflowGltfError(
1293
+ "stands below its origin; put the origin where the trunk meets the ground, with the tree above it"
1294
+ );
1295
+ if (!(reach > 0)) throw new TileflowGltfError("has no width round its origin");
1296
+ const across = 2 * reach;
1297
+ const unit = [];
1298
+ for (let vertex = 0; vertex < count; vertex += 1)
1299
+ unit.push(
1300
+ positions[vertex * 3] / across,
1301
+ positions[vertex * 3 + 1] / across,
1302
+ positions[vertex * 3 + 2] / height
1303
+ );
1304
+ const faceSums = new Array(count * 3).fill(0);
1305
+ for (let at = 0; at + 2 < indices.length; at += 3) {
1306
+ const [a, b, c] = [indices[at], indices[at + 1], indices[at + 2]];
1307
+ const n = cross(sub(point(unit, b), point(unit, a)), sub(point(unit, c), point(unit, a)));
1308
+ for (const vertex of [a, b, c])
1309
+ for (let axis = 0; axis < 3; axis += 1) faceSums[vertex * 3 + axis] += n[axis];
1310
+ }
1311
+ const outNormals = [];
1312
+ for (let vertex = 0; vertex < count; vertex += 1) {
1313
+ const own = normals[vertex];
1314
+ const n = own ? [own[0] * across, own[1] * across, own[2] * height] : faceSums.slice(vertex * 3, vertex * 3 + 3);
1315
+ const size = Math.hypot(n[0], n[1], n[2]) || 1;
1316
+ outNormals.push(n[0] / size, n[1] / size, n[2] / size);
1317
+ }
1318
+ const shades = new Array(count).fill(0);
1319
+ for (const part of new Set(parts)) {
1320
+ const members = parts.flatMap((value, vertex) => value === part ? [vertex] : []);
1321
+ const mean = members.reduce((sum, vertex) => sum + luminance[vertex], 0) / members.length;
1322
+ const spread = Math.max(0.06, mean * 0.6);
1323
+ for (const vertex of members)
1324
+ shades[vertex] = clamp((luminance[vertex] - mean) / spread, -1, 1);
1325
+ }
1326
+ let crownLow = 1;
1327
+ let crownHigh = 0;
1328
+ for (let vertex = 0; vertex < count; vertex += 1)
1329
+ if (isCrown(parts[vertex])) {
1330
+ crownLow = Math.min(crownLow, unit[vertex * 3 + 2]);
1331
+ crownHigh = Math.max(crownHigh, unit[vertex * 3 + 2]);
1332
+ }
1333
+ const occlusion = [];
1334
+ for (let vertex = 0; vertex < count; vertex += 1) {
1335
+ const z = unit[vertex * 3 + 2];
1336
+ if (!isCrown(parts[vertex])) {
1337
+ occlusion.push(z > crownLow ? 0.55 : 0.8);
1338
+ continue;
1339
+ }
1340
+ const up = crownHigh > crownLow ? (z - crownLow) / (crownHigh - crownLow) : 1;
1341
+ const out = Math.min(1, Math.hypot(unit[vertex * 3], unit[vertex * 3 + 1]) / 0.5);
1342
+ occlusion.push(clamp(0.4 + 0.35 * up + 0.3 * out, 0.35, 1));
1343
+ }
1344
+ return {
1345
+ // The author's proportions: the trees it stands keep them around the sizes the data gives.
1346
+ proportion: Math.min(3, Math.max(0.1, across / height)),
1347
+ positions: unit,
1348
+ normals: outNormals,
1349
+ parts,
1350
+ shades,
1351
+ occlusion,
1352
+ indices
1353
+ };
1354
+ }
1355
+ function partOf(material) {
1356
+ const name = material?.trim().toLowerCase().replace(/\.\d+$/u, "");
1357
+ const part = tileflowTreeModelPartNames.find((candidate) => candidate === name);
1358
+ if (part) return tileflowTreeParts[part];
1359
+ throw new TileflowGltfError(
1360
+ material === void 0 ? "has a mesh without a material; name each material bark, leaves, fronds or stem" : `has a material "${material}" that names no part of a tree; name each material bark, leaves, fronds or stem`
1361
+ );
1362
+ }
1363
+ function isCrown(part) {
1364
+ return part === tileflowTreeParts.leaves || part === tileflowTreeParts.fronds;
1365
+ }
1366
+ function point(list, vertex) {
1367
+ return [list[vertex * 3], list[vertex * 3 + 1], list[vertex * 3 + 2]];
1368
+ }
1369
+ function sub(a, b) {
1370
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1371
+ }
1372
+ function cross(a, b) {
1373
+ return [
1374
+ a[1] * b[2] - a[2] * b[1],
1375
+ a[2] * b[0] - a[0] * b[2],
1376
+ a[0] * b[1] - a[1] * b[0]
1377
+ ];
1378
+ }
1379
+ function clamp(value, low, high) {
1380
+ return Math.min(high, Math.max(low, value));
1381
+ }
1382
+
1383
+ // src/tree-models.ts
1384
+ var TileflowTreeModelError = class extends Error {
1385
+ code = "TREE_MODEL_INVALID";
1386
+ issues;
1387
+ phase = "tree-model-preparation";
1388
+ constructor(issues) {
1389
+ super(
1390
+ [
1391
+ "Invalid Tileflow tree models",
1392
+ ...issues.map((issue) => `- ${issue.path}: ${issue.message}`)
1393
+ ].join("\n")
1394
+ );
1395
+ this.name = "TileflowTreeModelError";
1396
+ this.issues = issues;
1397
+ }
1398
+ };
1399
+ var fileLimit = 8 * 1024 * 1024;
1400
+ var modelId = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
1401
+ async function prepareTileflowMapTreeModels(directories, options) {
1402
+ const resolved = await resolveTileflowAssetDirectories(directories, {
1403
+ baseDirectory: options.baseDirectory,
1404
+ configPath: options.configPath,
1405
+ cwd: options.cwd,
1406
+ kind: "models",
1407
+ target: options.target
1408
+ });
1409
+ const issues = [];
1410
+ const files = /* @__PURE__ */ new Map();
1411
+ for (const directory of resolved) {
1412
+ let names;
1413
+ try {
1414
+ names = (await readdir2(directory.realPath)).sort();
1415
+ } catch {
1416
+ issues.push({ message: "is not a readable directory", path: directory.configPath });
1417
+ continue;
1418
+ }
1419
+ for (const name of names) {
1420
+ const display = `${directory.configPath}/${name}`;
1421
+ const lower = name.toLowerCase();
1422
+ if (lower.endsWith(".gltf")) {
1423
+ issues.push({
1424
+ message: "is a text glTF; export the model as a glTF binary (.glb)",
1425
+ path: display
1426
+ });
1427
+ continue;
1428
+ }
1429
+ if (!lower.endsWith(".glb")) continue;
1430
+ const far = name.endsWith(".far.glb");
1431
+ const id = name.slice(0, name.length - (far ? 8 : 4));
1432
+ if (!modelId.test(id) || id.length > 64) {
1433
+ issues.push({
1434
+ message: "a model's file name is its id: lowercase letters, digits and hyphens, up to 64, such as royal-palm.glb",
1435
+ path: display
1436
+ });
1437
+ continue;
1438
+ }
1439
+ const path = join3(directory.realPath, name);
1440
+ const info = await lstat3(path).catch(() => void 0);
1441
+ if (!info?.isFile()) {
1442
+ issues.push({ message: "is not a regular file", path: display });
1443
+ continue;
1444
+ }
1445
+ if (info.size > fileLimit) {
1446
+ issues.push({ message: "is larger than 8 MiB", path: display });
1447
+ continue;
1448
+ }
1449
+ const entry = files.get(id) ?? {};
1450
+ entry[far ? "far" : "near"] = { path, display };
1451
+ files.set(id, entry);
1452
+ }
1453
+ }
1454
+ const models = {};
1455
+ const identities = [];
1456
+ for (const [id, entry] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
1457
+ if (!entry.near) {
1458
+ issues.push({ message: `has no ${id}.glb beside it`, path: entry.far.display });
1459
+ continue;
1460
+ }
1461
+ const prepared = {};
1462
+ for (const level of ["near", "far"]) {
1463
+ const file = entry[level];
1464
+ if (!file) continue;
1465
+ try {
1466
+ const bytes = new Uint8Array(await readFile(file.path));
1467
+ prepared[level] = encodeTileflowTreeModel(
1468
+ prepareTileflowTreeModelMesh(bytes, level),
1469
+ level
1470
+ );
1471
+ identities.push({ id, level, sha256: createHash("sha256").update(bytes).digest("hex") });
1472
+ } catch (error) {
1473
+ issues.push({
1474
+ message: error instanceof TileflowGltfError || error instanceof RangeError ? error.message : "cannot be read",
1475
+ path: file.display
1476
+ });
1477
+ }
1478
+ }
1479
+ if (prepared.near)
1480
+ models[id] = { near: prepared.near, ...prepared.far ? { far: prepared.far } : {} };
1481
+ }
1482
+ if (Object.keys(models).length > tileflowTreeModelLimits2.models)
1483
+ issues.push({
1484
+ message: `holds ${Object.keys(models).length} models; a map takes at most ${tileflowTreeModelLimits2.models}`,
1485
+ path: options.configPath
1486
+ });
1487
+ if (issues.length > 0) throw new TileflowTreeModelError(issues);
1488
+ return {
1489
+ models,
1490
+ identities,
1491
+ watchPaths: resolved.filter((directory) => directory.watch).map((directory) => directory.realPath)
1492
+ };
1493
+ }
1494
+
1495
+ // src/icon-preview.ts
1496
+ import { renderTileflowIconAtlasPixels } from "@tileflow/core";
1497
+ async function renderTileflowIconAtlasPreview(source, index) {
1498
+ if (!Object.values(index).some((entry) => entry.sdf)) return source;
1499
+ const sharp = await loadSharp();
1500
+ const { data, info } = await sharp(source, { limitInputPixels: 2048 * 2048 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
1501
+ const rendered = renderTileflowIconAtlasPixels(data, info.width, info.height, index);
1502
+ return sharp(rendered, { raw: { width: info.width, height: info.height, channels: 4 } }).png().toBuffer();
1503
+ }
1504
+
1505
+ // src/icons.ts
1506
+ var TileflowIconCompilationError = class extends Error {
1507
+ code = "ICON_INVALID";
1508
+ issues;
1509
+ phase = "icon-compilation";
1510
+ constructor(issues) {
1511
+ super(
1512
+ [
1513
+ "Invalid Tileflow icon package",
1514
+ ...issues.map((issue) => `- ${issue.path}: ${issue.message}`)
1515
+ ].join("\n")
1516
+ );
1517
+ this.name = "TileflowIconCompilationError";
1518
+ this.issues = issues;
1519
+ }
1520
+ };
1521
+ var iconFileExtensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".jpeg", ".webp"]);
1522
+ var iconSpriteSize = 24;
1523
+ async function compileTileflowIconPackages(project, options) {
1524
+ const result = await composeMapIconSources(project, options);
1525
+ const packagesByHash = /* @__PURE__ */ new Map();
1526
+ for (const composed of result.composedBySequence.values()) {
1527
+ if (composed.package) packagesByHash.set(composed.package.contentHash, composed.package);
1528
+ }
1529
+ const compositions = {};
1530
+ const sourceIdentities = {};
1531
+ for (const request of result.mapRequests) {
1532
+ const composed = result.composedBySequence.get(request.sequenceKey);
1533
+ sourceIdentities[request.mapName] = composed?.sourceIdentities ?? [];
1534
+ if (composed?.composition) compositions[request.mapName] = composed.composition;
1535
+ }
1536
+ const bindings = result.mapRequests.flatMap((request) => {
1537
+ if (request.sources.length === 0) return [];
1538
+ const composed = result.composedBySequence.get(request.sequenceKey);
1539
+ if (!composed?.package) {
1540
+ throw new Error(`Missing compiled icon package for map ${request.mapName}`);
1541
+ }
1542
+ const appearances = Object.fromEntries(
1543
+ composed.package.manifest.renderedIcons.flatMap(
1544
+ (icon) => icon.appearance ? [[icon.name, icon.appearance]] : []
1545
+ )
1546
+ );
1547
+ return [
1548
+ {
1549
+ iconIds: composed.package.manifest.iconNames,
1550
+ ...Object.keys(appearances).length ? { appearances } : {},
1551
+ label: request.mapName,
1552
+ mapName: request.mapName,
1553
+ packageHash: composed.package.contentHash
1554
+ }
1555
+ ];
1556
+ });
1557
+ return {
1558
+ bindings,
1559
+ compositions,
1560
+ packages: [...packagesByHash.values()].sort(
1561
+ (left, right) => compareCodeUnits4(left.contentHash, right.contentHash)
1562
+ ),
1563
+ sourceIdentities,
1564
+ watchFiles: uniqueStrings(
1565
+ result.mapRequests.flatMap(
1566
+ (request) => result.composedBySequence.get(request.sequenceKey)?.watchFiles ?? []
1567
+ )
1568
+ ).sort(compareCodeUnits4),
1569
+ watchPaths: uniqueStrings(
1570
+ result.mapRequests.flatMap(
1571
+ (request) => result.composedBySequence.get(request.sequenceKey)?.watchPaths ?? []
1572
+ )
1573
+ ).sort(compareCodeUnits4)
1574
+ };
1575
+ }
1576
+ async function inspectTileflowIconCatalogs(project, options) {
1577
+ const result = await composeMapIconSources(project, {
1578
+ ...options,
1579
+ target: "local"
1580
+ });
1581
+ const maps = result.mapRequests.map((request) => {
1582
+ if (request.sources.length === 0) return { name: request.mapName, icons: { kind: "none" } };
1583
+ const composed = result.composedBySequence.get(request.sequenceKey);
1584
+ if (!composed?.package)
1585
+ throw new Error(`Missing compiled icon catalog for map ${request.mapName}`);
1586
+ return {
1587
+ name: request.mapName,
1588
+ icons: {
1589
+ composition: composed.composition,
1590
+ contributors: composed.contributors.map(describeComposedContributor),
1591
+ iconIds: [...composed.package.manifest.iconNames],
1592
+ kind: "sources",
1593
+ label: request.mapName,
1594
+ packageHash: composed.package.contentHash
1595
+ }
1596
+ };
1597
+ });
1598
+ const requestBySequence = new Map(
1599
+ result.mapRequests.filter((request) => request.sources.length > 0).map((request) => [request.sequenceKey, request])
1600
+ );
1601
+ const catalogs = await Promise.all(
1602
+ [...result.composedBySequence.entries()].map(
1603
+ async ([sequenceKey, composed]) => {
1604
+ const request = requestBySequence.get(sequenceKey);
1605
+ if (!request || !composed.package)
1606
+ throw new Error(`Missing icon contributor sequence ${sequenceKey}`);
1607
+ const atlas = await readComposedAtlasIndexes(composed.package);
1608
+ return {
1609
+ composition: composed.composition,
1610
+ compiledPackage: composed.package,
1611
+ contributors: composed.contributors.map(describeComposedContributor),
1612
+ icons: composed.winners.map((winner, index) => {
1613
+ const manifestIcon = composed.package.manifest.renderedIcons[index];
1614
+ const oneXAtlas = atlas.oneX[winner.id];
1615
+ const twoXAtlas = atlas.twoX[winner.id];
1616
+ const contributor = composed.contributors[winner.contributor];
1617
+ if (!manifestIcon || manifestIcon.name !== winner.id || !oneXAtlas || !twoXAtlas || !contributor) {
1618
+ throw new Error(`Missing compiled catalog metadata for icon ${winner.id}`);
1619
+ }
1620
+ return {
1621
+ id: winner.id,
1622
+ ...manifestIcon.appearance ? { appearance: manifestIcon.appearance } : {},
1623
+ ...manifestIcon.layout ? { layout: manifestIcon.layout } : {},
1624
+ rendered: {
1625
+ oneX: {
1626
+ atlas: atlasRectangle(oneXAtlas),
1627
+ height: oneXAtlas.height,
1628
+ pixelRatio: 1,
1629
+ pixelSha256: manifestIcon.pixelSha256.oneX,
1630
+ width: oneXAtlas.width
1631
+ },
1632
+ twoX: {
1633
+ atlas: atlasRectangle(twoXAtlas),
1634
+ height: twoXAtlas.height,
1635
+ pixelRatio: 2,
1636
+ pixelSha256: manifestIcon.pixelSha256.twoX,
1637
+ width: twoXAtlas.width
1638
+ }
1639
+ },
1640
+ source: winner.source === null ? {
1641
+ contributor: winner.contributor,
1642
+ kind: "icon-set",
1643
+ reference: contributor.kind === "icon-set" ? contributor.reference : "",
1644
+ version: contributor.kind === "icon-set" ? contributor.version : 0
1645
+ } : {
1646
+ byteLength: winner.source.byteLength,
1647
+ contributor: winner.contributor,
1648
+ dimensions: { ...winner.source.dimensions },
1649
+ format: winner.source.format,
1650
+ kind: "file",
1651
+ path: winner.source.path
1652
+ }
1653
+ };
1654
+ }),
1655
+ insideWorkingTree: composed.contributors.every(
1656
+ (contributor) => contributor.kind === "icon-set" || contributor.insideWorkingTree
1657
+ ),
1658
+ replacements: composed.replacements.map(
1659
+ (replacement) => describeComposedReplacement(replacement, composed.contributors)
1660
+ )
1661
+ };
1662
+ }
1663
+ )
1664
+ );
1665
+ catalogs.sort(
1666
+ (left, right) => compareCodeUnits4(
1667
+ left.contributors.map((contributor) => contributor.label).join("\0"),
1668
+ right.contributors.map((contributor) => contributor.label).join("\0")
1669
+ )
1670
+ );
1671
+ return { catalogs, maps };
1672
+ }
1673
+ async function composeMapIconSources(project, options) {
1674
+ const mapRequests = getMapIconRequests(project, options.mapNames);
1675
+ const workspaceRequests = options.mapNames === void 0 ? mapRequests : getMapIconRequests(project);
1676
+ const baseDirectory = options.baseDirectory ?? options.cwd;
1677
+ const workspaceLock = await readWorkspaceIconSetLock(workspaceRequests, baseDirectory);
1678
+ const lockPath = workspaceLock ? resolvePath(baseDirectory, tileflowIconsLockfileName3) : void 0;
1679
+ const issues = [];
1680
+ const composedBySequence = /* @__PURE__ */ new Map();
1681
+ for (const request of uniqueSequences(mapRequests)) {
1682
+ if (request.sources.length === 0) continue;
1683
+ try {
1684
+ const lock = workspaceLock ? selectMapIconSetLock(workspaceLock, request.sources) : void 0;
1685
+ const composed = await composeTileflowIconSources(request.sources, {
1686
+ ...options.icons,
1687
+ baseDirectory,
1688
+ configPath: `maps.${request.mapName}.icons`,
1689
+ cwd: options.cwd,
1690
+ ...lock ? { lock } : {},
1691
+ target: options.target
1692
+ });
1693
+ composedBySequence.set(
1694
+ request.sequenceKey,
1695
+ lockPath && lock ? { ...composed, watchFiles: uniqueStrings([...composed.watchFiles, lockPath]) } : composed
1696
+ );
1697
+ } catch (error) {
1698
+ if (error instanceof TileflowIconSetError5) throw error;
1699
+ if (error instanceof TileflowIconCompilationError) issues.push(...error.issues);
1700
+ else if (error instanceof TileflowAssetDirectoryError) issues.push(...error.issues);
1701
+ else
1702
+ issues.push({
1703
+ message: error instanceof Error ? error.message : "Icon compilation failed",
1704
+ path: `maps.${request.mapName}.icons`
1705
+ });
1706
+ }
1707
+ }
1708
+ if (issues.length > 0) throw new TileflowIconCompilationError(issues);
1709
+ return { composedBySequence, mapRequests };
1710
+ }
1711
+ async function readWorkspaceIconSetLock(requests, baseDirectory) {
1712
+ const references = /* @__PURE__ */ new Set();
1713
+ for (const request of requests) {
1714
+ for (const reference of collectTileflowIconSetReferences2(request.sources))
1715
+ references.add(reference);
1716
+ }
1717
+ if (references.size === 0) return void 0;
1718
+ const sources = [...references].sort(compareCodeUnits4).map((reference) => ({ kind: "icon-set", reference }));
1719
+ return readTileflowIconsLockfile(baseDirectory, sources);
1720
+ }
1721
+ function selectMapIconSetLock(lock, sources) {
1722
+ const references = collectTileflowIconSetReferences2(sources);
1723
+ if (references.length === 0) return void 0;
1724
+ return {
1725
+ lockfileVersion: 1,
1726
+ sets: Object.fromEntries(references.map((reference) => [reference, lock.sets[reference]]))
1727
+ };
1728
+ }
1729
+ async function prepareTileflowCatalogIcons(project, options) {
1730
+ const compiled = await compileTileflowIconPackages(project, {
1731
+ baseDirectory: options.baseDirectory,
1732
+ cwd: options.cwd,
1733
+ ...options.icons ? { icons: options.icons } : {},
1734
+ target: "local"
1735
+ });
1736
+ const packagesByHash = new Map(
1737
+ compiled.packages.map((iconPackage) => [iconPackage.contentHash, iconPackage])
1738
+ );
1739
+ const assets = [];
1740
+ const mapAssets = {};
1741
+ const mapIconCompositions = {};
1742
+ const mapIconSources = {};
1743
+ for (const mapName of Object.keys(project.maps)) mapIconSources[mapName] = [];
1744
+ for (const binding of compiled.bindings) {
1745
+ const iconPackage = packagesByHash.get(binding.packageHash);
1746
+ if (!iconPackage) throw new Error(`Missing compiled icon package for map ${binding.mapName}`);
1747
+ const spriteUrl = joinUrl(options.assetBaseUrl, `icons/${binding.mapName}/sprite`);
1748
+ mapAssets[binding.mapName] = {
1749
+ icons: {
1750
+ ids: binding.iconIds,
1751
+ sprite: spriteUrl,
1752
+ ...binding.appearances ? { appearances: binding.appearances } : {}
1753
+ }
1754
+ };
1755
+ mapIconSources[binding.mapName] = compiled.sourceIdentities[binding.mapName] ?? [];
1756
+ const composition = compiled.compositions[binding.mapName];
1757
+ if (composition) mapIconCompositions[binding.mapName] = composition;
1758
+ for (const file of iconPackage.files) {
1759
+ assets.push({
1760
+ contentType: file.contentType === "application/json" ? "application/json; charset=utf-8" : file.contentType,
1761
+ fileName: `icons/${binding.mapName}/${file.fileName}`,
1762
+ source: file.contentType === "application/json" ? new TextDecoder().decode(file.source) : file.source
1763
+ });
1764
+ }
1765
+ }
1766
+ const mapModelSources = {};
1767
+ const modelWatchPaths = [];
1768
+ for (const [mapName, map] of Object.entries(project.maps)) {
1769
+ if (!map.models?.length) continue;
1770
+ const models = await prepareTileflowMapTreeModels(map.models, {
1771
+ baseDirectory: options.baseDirectory ?? options.cwd,
1772
+ configPath: `maps.${mapName}.models`,
1773
+ cwd: options.cwd,
1774
+ target: "local"
1775
+ });
1776
+ mapAssets[mapName] = { ...mapAssets[mapName], models: models.models };
1777
+ mapModelSources[mapName] = models.identities;
1778
+ modelWatchPaths.push(...models.watchPaths);
1779
+ }
1780
+ return {
1781
+ assets,
1782
+ baseDirectory: await realpath3(options.baseDirectory ?? options.cwd),
1783
+ cwd: await realpath3(options.cwd),
1784
+ mapAssets,
1785
+ mapIconCompositions,
1786
+ mapIconSources,
1787
+ ...Object.keys(mapModelSources).length > 0 ? { mapModelSources } : {},
1788
+ project,
1789
+ sourceProject: project,
1790
+ watchFiles: compiled.watchFiles,
1791
+ watchPaths: [...compiled.watchPaths, ...modelWatchPaths]
1792
+ };
1793
+ }
1794
+ async function getTileflowIconWatchPaths(project, cwd, baseDirectory = cwd) {
1795
+ const requests = getMapIconRequests(project);
1796
+ const paths = /* @__PURE__ */ new Set();
1797
+ let realBaseDirectory;
1798
+ for (const request of uniqueSequences(requests)) {
1799
+ for (const source of request.sources) {
1800
+ if (typeof source === "object" && source.kind === "icon-set") {
1801
+ realBaseDirectory ??= await realpath3(baseDirectory);
1802
+ paths.add(resolvePath(realBaseDirectory, tileflowIconsLockfileName3));
1803
+ continue;
1804
+ }
1805
+ try {
1806
+ const directory = await readTileflowIconDirectory(source, {
1807
+ baseDirectory,
1808
+ configPath: `maps.${request.mapName}.icons`,
1809
+ cwd,
1810
+ target: "local"
1811
+ });
1812
+ if (directory.watchPath) paths.add(directory.watchPath);
1813
+ } catch {
1814
+ }
1815
+ }
1816
+ }
1817
+ return [...paths].sort(compareCodeUnits4);
1818
+ }
1819
+ function getMapIconRequests(project, mapNames) {
1820
+ const selectedNames = (mapNames === void 0 ? Object.keys(project.maps) : [...new Set(mapNames)]).sort(compareCodeUnits4);
1821
+ return selectedNames.map((mapName) => {
1822
+ const mapConfig = project.maps[mapName];
1823
+ if (!mapConfig) throw new Error(`Unknown Tileflow map: ${mapName}`);
1824
+ const sources = parseResolvedTileflowMap(mapConfig).icons ?? [];
1825
+ return { mapName, sequenceKey: serializeCanonicalJson3(sources), sources };
1826
+ });
1827
+ }
1828
+ function uniqueSequences(requests) {
1829
+ return [...new Map(requests.map((request) => [request.sequenceKey, request])).values()].sort(
1830
+ (left, right) => compareCodeUnits4(left.sequenceKey, right.sequenceKey)
1831
+ );
1832
+ }
1833
+ function describeComposedContributor(contributor) {
1834
+ return contributor.kind === "icon-set" ? {
1835
+ iconIds: [...contributor.iconIds],
1836
+ kind: "icon-set",
1837
+ label: contributor.label,
1838
+ reference: contributor.reference,
1839
+ version: contributor.version
1840
+ } : {
1841
+ iconIds: [...contributor.iconIds],
1842
+ insideWorkingTree: contributor.insideWorkingTree,
1843
+ kind: contributor.kind,
1844
+ label: contributor.label
1845
+ };
1846
+ }
1847
+ function describeComposedReplacement(replacement, contributors) {
1848
+ return {
1849
+ id: replacement.id,
1850
+ replaced: describeContributorExport(contributors[replacement.replaced], replacement.id),
1851
+ winner: describeContributorExport(contributors[replacement.winner], replacement.id)
1852
+ };
1853
+ }
1854
+ function describeContributorExport(contributor, id) {
1855
+ if (!contributor) return id;
1856
+ if (contributor.kind === "icon-set") return contributor.label;
1857
+ return contributor.entries.find((entry) => entry.id === id)?.path ?? contributor.label;
1858
+ }
1859
+ async function readComposedAtlasIndexes(iconPackage) {
1860
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1861
+ const read = (fileName) => {
1862
+ const file = iconPackage.files.find((candidate) => candidate.fileName === fileName);
1863
+ if (!file) throw new Error(`Missing generated ${fileName}`);
1864
+ return tileflowIconSpriteIndexSchema.parse(
1865
+ parseTileflowIconJson2(
1866
+ decoder.decode(file.source),
1867
+ tileflowIconPackageLimits4.maxGeneratedFileBytes
1868
+ )
1869
+ );
1870
+ };
1871
+ return { oneX: read("sprite.json"), twoX: read("sprite@2x.json") };
1872
+ }
1873
+ function describeAssetDirectory(directory) {
1874
+ return typeof directory.authoring === "string" ? directory.authoring : `npm:${directory.authoring.package}/${directory.authoring.path}`;
1875
+ }
1876
+ function describeAssetFile(directory, fileName) {
1877
+ return `${describeAssetDirectory(directory).replace(/\/+$/u, "")}/${fileName}`;
1878
+ }
1879
+ async function inspectIconSource(directory, target, issues) {
1880
+ let entries;
1881
+ const issueCount = issues.length;
1882
+ try {
1883
+ entries = await readdir3(directory.realPath, { withFileTypes: true });
1884
+ } catch (error) {
1885
+ issues.push({
1886
+ message: describeFileSystemError(error, "Icon source could not be read"),
1887
+ path: directory.configPath
1888
+ });
1889
+ return null;
1890
+ }
1891
+ entries.sort((left, right) => compareCodeUnits4(left.name, right.name));
1892
+ const candidates = [];
1893
+ const names = /* @__PURE__ */ new Set();
1894
+ let metadata = {
1895
+ schemaVersion: 1,
1896
+ icons: {}
1897
+ };
1898
+ for (const entry of entries) {
1899
+ const entryPath = resolvePath(directory.realPath, entry.name);
1900
+ const path = `${directory.configPath}/${entry.name}`;
1901
+ if (entry.isDirectory()) {
1902
+ issues.push({ message: "Nested directories are not supported", path });
1903
+ continue;
1904
+ }
1905
+ let entryStat;
1906
+ let realEntryPath = entryPath;
1907
+ try {
1908
+ if (entry.isSymbolicLink()) {
1909
+ realEntryPath = await realpath3(entryPath);
1910
+ if (!isPathInside2(directory.containmentRoot, realEntryPath)) {
1911
+ issues.push({ message: "Icon symlink escapes its owning directory boundary", path });
1912
+ continue;
1913
+ }
1914
+ }
1915
+ entryStat = await stat(realEntryPath);
1916
+ } catch (error) {
1917
+ issues.push({ message: describeFileSystemError(error, "Icon entry could not be read"), path });
1918
+ continue;
1919
+ }
1920
+ if (!entryStat.isFile()) {
1921
+ issues.push({ message: "Icon entries must be regular files", path });
1922
+ continue;
1923
+ }
1924
+ const extension = extname(entry.name).toLowerCase();
1925
+ if (entry.name === tileflowIconMetadataFileName) {
1926
+ try {
1927
+ if (entryStat.size > tileflowIconPackageLimits4.maxSourceFileBytes)
1928
+ throw new Error("Icon metadata exceeds its byte limit");
1929
+ metadata = tileflowIconAuthorMetadataSchema.parse(
1930
+ parseTileflowIconJson2(
1931
+ await readFile2(realEntryPath, "utf8"),
1932
+ tileflowIconPackageLimits4.maxSourceFileBytes
1933
+ )
1934
+ );
1935
+ } catch (error) {
1936
+ issues.push({
1937
+ path,
1938
+ message: `Invalid icon metadata: ${error instanceof Error ? error.message : String(error)}`
1939
+ });
1940
+ }
1941
+ continue;
1942
+ }
1943
+ if (!iconFileExtensions.has(extension)) {
1944
+ continue;
1945
+ }
1946
+ const sourceName = entry.name.slice(0, -extname(entry.name).length);
1947
+ const kind = sourceName.endsWith(".pattern") ? "pattern" : "icon";
1948
+ const name = kind === "pattern" ? sourceName.slice(0, -".pattern".length) : sourceName;
1949
+ if (names.has(name)) {
1950
+ issues.push({
1951
+ message: `Duplicate icon basename "${name}"`,
1952
+ path
1953
+ });
1954
+ continue;
1955
+ }
1956
+ names.add(name);
1957
+ if (!tileflowIconIdPattern.test(name) || name.length > 64) {
1958
+ issues.push({
1959
+ message: "Icon filenames must produce a lower-kebab ID of at most 64 characters",
1960
+ path
1961
+ });
1962
+ }
1963
+ if (entryStat.size > tileflowIconPackageLimits4.maxSourceFileBytes) {
1964
+ issues.push({
1965
+ message: `Icon source exceeds ${tileflowIconPackageLimits4.maxSourceFileBytes} bytes`,
1966
+ path
1967
+ });
1968
+ }
1969
+ candidates.push({
1970
+ displayPath: describeAssetFile(directory, entry.name),
1971
+ fileName: entry.name,
1972
+ format: iconSourceFormat(extension),
1973
+ kind,
1974
+ name,
1975
+ path: realEntryPath,
1976
+ size: entryStat.size
1977
+ });
1978
+ }
1979
+ candidates.sort((left, right) => compareCodeUnits4(left.name, right.name));
1980
+ for (const id of Object.keys(metadata.icons)) {
1981
+ if (!names.has(id))
1982
+ issues.push({
1983
+ path: `${directory.configPath}/${tileflowIconMetadataFileName}`,
1984
+ message: `Icon metadata references unknown ID "${id}"`
1985
+ });
1986
+ }
1987
+ if (candidates.length === 0) {
1988
+ issues.push({
1989
+ message: "No supported .svg, .png, .jpg, .jpeg, or .webp icon files were found",
1990
+ path: directory.configPath
1991
+ });
1992
+ }
1993
+ if (issues.length > issueCount) {
1994
+ return null;
1995
+ }
1996
+ const icons = [];
1997
+ for (const candidate of candidates) {
1998
+ const displayPath = candidate.displayPath;
1999
+ try {
2000
+ const source = await readFile2(candidate.path);
2001
+ if (source.byteLength !== candidate.size || source.byteLength > tileflowIconPackageLimits4.maxSourceFileBytes) {
2002
+ throw new Error("Icon changed while it was being validated");
2003
+ }
2004
+ if (target === "hosted" && extname(candidate.fileName).toLowerCase() === ".svg") {
2005
+ validateHostedSvg(source, displayPath);
2006
+ }
2007
+ const declared = metadata.icons[candidate.name];
2008
+ icons.push({
2009
+ ...declared?.representation === "sdf" ? { appearance: { representation: declared.representation, defaults: declared.defaults } } : {},
2010
+ ...declared?.layout ? { layout: declared.layout } : {},
2011
+ displayPath,
2012
+ fileName: candidate.fileName,
2013
+ format: candidate.format,
2014
+ kind: candidate.kind,
2015
+ name: candidate.name,
2016
+ path: candidate.path,
2017
+ source
2018
+ });
2019
+ } catch (error) {
2020
+ issues.push({
2021
+ message: error instanceof Error ? error.message : "Icon could not be read",
2022
+ path: displayPath
2023
+ });
2024
+ }
2025
+ }
2026
+ if (issues.length > issueCount) {
2027
+ return null;
2028
+ }
2029
+ return { icons };
2030
+ }
2031
+ async function validateDecodedDimensions(icon) {
2032
+ const sharp = await loadSharp();
2033
+ const metadata = await sharp(icon.source, {
2034
+ density: 72,
2035
+ failOn: "error",
2036
+ limitInputPixels: tileflowIconPackageLimits4.maxDecodedPixelsPerIcon
2037
+ }).metadata();
2038
+ const width = metadata.width ?? 0;
2039
+ const height = metadata.height ?? 0;
2040
+ if (width <= 0 || height <= 0) {
2041
+ throw new Error(`Unable to determine decoded dimensions for ${icon.fileName}`);
2042
+ }
2043
+ if (width * height > tileflowIconPackageLimits4.maxDecodedPixelsPerIcon) {
2044
+ throw new Error(
2045
+ `${icon.fileName} exceeds ${tileflowIconPackageLimits4.maxDecodedPixelsPerIcon} decoded pixels`
2046
+ );
2047
+ }
2048
+ return { height, width };
2049
+ }
2050
+ async function renderIcon(icon, dimensions, pixelRatio) {
2051
+ const sharp = await loadSharp();
2052
+ const targetWidth = (icon.kind === "pattern" ? dimensions.width : iconSpriteSize) * pixelRatio;
2053
+ const targetHeight = (icon.kind === "pattern" ? dimensions.height : iconSpriteSize) * pixelRatio;
2054
+ const { data, info } = await sharp(icon.source, {
2055
+ density: 72 * pixelRatio,
2056
+ failOn: "error",
2057
+ limitInputPixels: tileflowIconPackageLimits4.maxDecodedPixelsPerIcon
2058
+ }).resize(targetWidth, targetHeight, {
2059
+ background: { alpha: 0, b: 0, g: 0, r: 0 },
2060
+ fit: "contain"
2061
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
2062
+ if (info.channels !== 4 || info.width !== targetWidth || info.height !== targetHeight) {
2063
+ throw new Error(`Rendered icon ${icon.fileName} did not produce canonical RGBA pixels`);
2064
+ }
2065
+ let rgba;
2066
+ try {
2067
+ if (icon.appearance && icon.kind === "pattern")
2068
+ throw new Error("SDF metadata is only supported for icons, not patterns.");
2069
+ if (icon.layout) {
2070
+ if (icon.kind === "pattern")
2071
+ throw new Error("Text-fitting metadata is only supported for icons, not patterns.");
2072
+ assertTileflowIconLayoutBounds2(
2073
+ icon.layout,
2074
+ info.width / pixelRatio,
2075
+ info.height / pixelRatio
2076
+ );
2077
+ }
2078
+ rgba = icon.appearance ? createIconDistanceField(new Uint8Array(data), info.width, info.height, pixelRatio) : new Uint8Array(data);
2079
+ } catch (error) {
2080
+ throw new Error(
2081
+ `${icon.displayPath}: ${error instanceof Error ? error.message : "Invalid icon capabilities"}`,
2082
+ { cause: error }
2083
+ );
2084
+ }
2085
+ const png = await sharp(rgba, {
2086
+ raw: { channels: 4, height: info.height, width: info.width }
2087
+ }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
2088
+ return { height: info.height, png, rgba, width: info.width };
2089
+ }
2090
+ function validateHostedSvg(source, displayPath) {
2091
+ const parser = new SaxesParser({ fileName: displayPath, xmlns: true });
2092
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2093
+ let styleDepth = 0;
2094
+ let styleText = "";
2095
+ parser.on("doctype", () => {
2096
+ throw new Error("Hosted SVGs cannot contain a document type or entity declarations");
2097
+ });
2098
+ parser.on("processinginstruction", (instruction) => {
2099
+ if (instruction.target.toLowerCase() === "xml-stylesheet") {
2100
+ throw new Error("Hosted SVGs cannot load external stylesheets");
2101
+ }
2102
+ });
2103
+ parser.on("opentag", (tag) => {
2104
+ const localName = tag.local.toLowerCase();
2105
+ if (localName === "script") {
2106
+ throw new Error("Hosted SVGs cannot contain scripts");
2107
+ }
2108
+ if (localName === "style") {
2109
+ styleDepth += 1;
2110
+ }
2111
+ for (const attribute of Object.values(tag.attributes)) {
2112
+ const attributeName = attribute.local.toLowerCase();
2113
+ const value = attribute.value.trim();
2114
+ if (attributeName.startsWith("on")) {
2115
+ throw new Error("Hosted SVGs cannot contain script event handlers");
2116
+ }
2117
+ if (attributeName === "href" && value.length > 0 && !value.startsWith("#")) {
2118
+ throw new Error("Hosted SVG resource references must use local fragments");
2119
+ }
2120
+ if (containsExternalCssUrl(value)) {
2121
+ throw new Error("Hosted SVG resource references must use local fragments");
2122
+ }
2123
+ }
2124
+ });
2125
+ parser.on("text", (text) => {
2126
+ if (styleDepth > 0) {
2127
+ styleText += text;
2128
+ }
2129
+ });
2130
+ parser.on("cdata", (text) => {
2131
+ if (styleDepth > 0) {
2132
+ styleText += text;
2133
+ }
2134
+ });
2135
+ parser.on("closetag", (tag) => {
2136
+ if (tag.local.toLowerCase() !== "style") {
2137
+ return;
2138
+ }
2139
+ styleDepth -= 1;
2140
+ if (styleDepth === 0) {
2141
+ if (styleText.toLowerCase().includes("@import") || containsExternalCssUrl(styleText)) {
2142
+ throw new Error("Hosted SVGs cannot load external stylesheets or resources");
2143
+ }
2144
+ styleText = "";
2145
+ }
2146
+ });
2147
+ parser.on("error", (error) => {
2148
+ throw error;
2149
+ });
2150
+ const chunkBytes = 64 * 1024;
2151
+ for (let offset = 0; offset < source.byteLength; offset += chunkBytes) {
2152
+ parser.write(decoder.decode(source.subarray(offset, offset + chunkBytes), { stream: true }));
2153
+ }
2154
+ parser.write(decoder.decode());
2155
+ parser.close();
2156
+ }
2157
+ function containsExternalCssUrl(value) {
2158
+ const lowerValue = value.toLowerCase();
2159
+ let cursor = 0;
2160
+ while (cursor < value.length) {
2161
+ const start = lowerValue.indexOf("url(", cursor);
2162
+ if (start === -1) {
2163
+ return false;
2164
+ }
2165
+ const end = value.indexOf(")", start + 4);
2166
+ if (end === -1) {
2167
+ return true;
2168
+ }
2169
+ const rawTarget = value.slice(start + 4, end).trim();
2170
+ const target = stripMatchingQuotes(rawTarget);
2171
+ if (!target.startsWith("#")) {
2172
+ return true;
2173
+ }
2174
+ cursor = end + 1;
2175
+ }
2176
+ return false;
2177
+ }
2178
+ function stripMatchingQuotes(value) {
2179
+ if (value.length < 2) {
2180
+ return value;
2181
+ }
2182
+ const first = value[0];
2183
+ const last = value[value.length - 1];
2184
+ return first === '"' && last === '"' || first === "'" && last === "'" ? value.slice(1, -1).trim() : value;
2185
+ }
2186
+ async function mapWithConcurrency(values, concurrency, map) {
2187
+ const results = new Array(values.length);
2188
+ let nextIndex = 0;
2189
+ async function worker() {
2190
+ while (nextIndex < values.length) {
2191
+ const index = nextIndex;
2192
+ nextIndex += 1;
2193
+ const value = values[index];
2194
+ if (value !== void 0) {
2195
+ results[index] = await map(value, index);
2196
+ }
2197
+ }
2198
+ }
2199
+ await Promise.all(
2200
+ Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
2201
+ );
2202
+ return results;
2203
+ }
2204
+ function atlasRectangle(entry) {
2205
+ return { height: entry.height, width: entry.width, x: entry.x, y: entry.y };
2206
+ }
2207
+ function iconSourceFormat(extension) {
2208
+ switch (extension) {
2209
+ case ".svg":
2210
+ return "svg";
2211
+ case ".png":
2212
+ return "png";
2213
+ case ".jpg":
2214
+ case ".jpeg":
2215
+ return "jpeg";
2216
+ case ".webp":
2217
+ return "webp";
2218
+ default:
2219
+ throw new Error(`Unsupported icon source format: ${extension}`);
2220
+ }
2221
+ }
2222
+ function isPathInside2(root, candidate) {
2223
+ const pathFromRoot = relative2(root, candidate);
2224
+ return pathFromRoot === "" || !isAbsolute2(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep2}`);
2225
+ }
2226
+ function describeFileSystemError(error, fallback) {
2227
+ if (error && typeof error === "object" && "code" in error) {
2228
+ const code = String(error.code ?? "");
2229
+ if (code === "ENOENT") {
2230
+ return fallback;
2231
+ }
2232
+ if (code === "EACCES" || code === "EPERM") {
2233
+ return "Icon source is not readable";
2234
+ }
2235
+ }
2236
+ return error instanceof Error && error.message ? error.message : fallback;
2237
+ }
2238
+ function joinUrl(base, path) {
2239
+ const trimmedBase = base.replace(/\/+$/g, "");
2240
+ const trimmedPath = path.replace(/^\/+/g, "");
2241
+ return trimmedBase ? `${trimmedBase}/${trimmedPath}` : trimmedPath;
2242
+ }
2243
+ function uniqueStrings(values) {
2244
+ return [...new Set(values)];
2245
+ }
2246
+ async function renderIconInputs(inputs) {
2247
+ let renderedOneXPixels = 0;
2248
+ return mapWithConcurrency(inputs, tileflowIconPackageLimits4.decodeConcurrency, async (icon) => {
2249
+ const dimensions = await validateDecodedDimensions(icon);
2250
+ const oneXWidth = icon.kind === "pattern" ? dimensions.width : iconSpriteSize;
2251
+ const oneXHeight = icon.kind === "pattern" ? dimensions.height : iconSpriteSize;
2252
+ const maximumOneXDimension = tileflowIconPackageLimits4.maxAtlasDimension / 2;
2253
+ renderedOneXPixels += oneXWidth * oneXHeight;
2254
+ if (oneXWidth > maximumOneXDimension || oneXHeight > maximumOneXDimension || renderedOneXPixels > maximumOneXDimension ** 2) {
2255
+ throw new Error(
2256
+ "Rendered icon inputs exceed the paired sprite capacity before rasterization"
2257
+ );
2258
+ }
2259
+ if (icon.kind === "pattern" && (dimensions.width < 2 || dimensions.width > 512 || (dimensions.width & dimensions.width - 1) !== 0)) {
2260
+ throw new Error(
2261
+ `${icon.fileName} pattern width must be a power of two from 2 through 512 pixels`
2262
+ );
2263
+ }
2264
+ const oneX = await renderIcon(icon, dimensions, 1);
2265
+ const twoX = await renderIcon(icon, dimensions, 2);
2266
+ return {
2267
+ dimensions,
2268
+ input: icon,
2269
+ oneX,
2270
+ pixelSha256: {
2271
+ oneX: await hashTileflowRenderedIconPixels3({
2272
+ height: oneX.height,
2273
+ pixelRatio: 1,
2274
+ rgba: oneX.rgba,
2275
+ width: oneX.width
2276
+ }),
2277
+ twoX: await hashTileflowRenderedIconPixels3({
2278
+ height: twoX.height,
2279
+ pixelRatio: 2,
2280
+ rgba: twoX.rgba,
2281
+ width: twoX.width
2282
+ })
2283
+ },
2284
+ sourceSha256: icon.appearance || icon.layout ? await sha256Hex3(
2285
+ `tileflow-icon-source-v2\0${await sha256Hex3(icon.source)}\0${serializeCanonicalJson3({ ...icon.appearance ? { appearance: icon.appearance } : {}, ...icon.layout ? { layout: icon.layout } : {} })}`
2286
+ ) : await sha256Hex3(icon.source),
2287
+ twoX
2288
+ };
2289
+ });
2290
+ }
2291
+ async function readTileflowIconDirectory(source, options) {
2292
+ const configPath = options.configPath ?? "icons";
2293
+ const directory = (await resolveTileflowAssetDirectories([source], {
2294
+ baseDirectory: options.baseDirectory,
2295
+ configPath,
2296
+ cwd: options.cwd,
2297
+ kind: "icons",
2298
+ ordinalOffset: options.ordinal ?? 0,
2299
+ target: options.target
2300
+ }))[0];
2301
+ if (!directory)
2302
+ throw new TileflowIconCompilationError([{ path: configPath, message: "Missing icon directory" }]);
2303
+ const issues = [];
2304
+ const inspected = await inspectIconSource(directory, options.target, issues);
2305
+ if (!inspected || issues.length) throw new TileflowIconCompilationError(issues);
2306
+ if (inspected.icons.length > tileflowIconPackageLimits4.maxIconCount)
2307
+ throw new TileflowIconCompilationError([
2308
+ {
2309
+ path: configPath,
2310
+ message: `One icon contributor supports at most ${tileflowIconPackageLimits4.maxIconCount} exports`
2311
+ }
2312
+ ]);
2313
+ const entries = inspected.icons.map((icon) => ({
2314
+ byteLength: icon.source.byteLength,
2315
+ format: icon.format,
2316
+ id: icon.name,
2317
+ kind: icon.kind,
2318
+ path: icon.displayPath
2319
+ }));
2320
+ return {
2321
+ containmentRoot: directory.containmentRoot,
2322
+ entries,
2323
+ iconIds: entries.map((entry) => entry.id),
2324
+ label: describeAssetDirectory(directory),
2325
+ realPath: directory.realPath,
2326
+ ...directory.watch ? { watchPath: directory.realPath } : {},
2327
+ render: async (ids) => {
2328
+ const selected = new Set(ids);
2329
+ const inputs = inspected.icons.filter((icon) => selected.has(icon.name));
2330
+ if (inputs.length !== ids.length || inputs.reduce((sum, icon) => sum + icon.source.byteLength, 0) > tileflowIconPackageLimits4.maxSourceBytes)
2331
+ throw new TileflowIconCompilationError([
2332
+ {
2333
+ path: configPath,
2334
+ message: "Selected icon inputs exceed their source budget or have invalid IDs"
2335
+ }
2336
+ ]);
2337
+ return (await renderIconInputs(inputs)).map((rendered) => ({
2338
+ icon: {
2339
+ id: rendered.input.name,
2340
+ ...rendered.input.appearance ? { appearance: rendered.input.appearance } : {},
2341
+ ...rendered.input.layout ? { layout: rendered.input.layout } : {},
2342
+ oneX: {
2343
+ height: rendered.oneX.height,
2344
+ width: rendered.oneX.width,
2345
+ rgba: rendered.oneX.rgba
2346
+ },
2347
+ twoX: {
2348
+ height: rendered.twoX.height,
2349
+ width: rendered.twoX.width,
2350
+ rgba: rendered.twoX.rgba
2351
+ }
2352
+ },
2353
+ // Canonical key order keeps the in-memory build manifest byte-identical to its artifact.
2354
+ identity: {
2355
+ format: rendered.input.format,
2356
+ id: rendered.input.name,
2357
+ kind: rendered.input.kind,
2358
+ sha256: rendered.sourceSha256
2359
+ },
2360
+ source: {
2361
+ byteLength: rendered.input.source.byteLength,
2362
+ dimensions: { ...rendered.dimensions },
2363
+ format: rendered.input.format,
2364
+ id: rendered.input.name,
2365
+ kind: rendered.input.kind,
2366
+ path: rendered.input.displayPath
2367
+ },
2368
+ sourceBytes: rendered.input.source.byteLength
2369
+ }));
2370
+ }
2371
+ };
2372
+ }
2373
+
2374
+ export {
2375
+ packTileflowRenderedIcons,
2376
+ verifyTileflowIconArtifact,
2377
+ getTileflowIconCacheDirectory,
2378
+ loadTileflowIconSetArtifact,
2379
+ storeTileflowIconSetArtifact,
2380
+ readTileflowIconsLockfileText,
2381
+ readTileflowIconsLockfile,
2382
+ writeTileflowIconsLockfile,
2383
+ composeTileflowIconSources,
2384
+ renderTileflowIconAtlasPreview,
2385
+ TileflowIconCompilationError,
2386
+ iconFileExtensions,
2387
+ compileTileflowIconPackages,
2388
+ inspectTileflowIconCatalogs,
2389
+ prepareTileflowCatalogIcons,
2390
+ getTileflowIconWatchPaths,
2391
+ readTileflowIconDirectory
2392
+ };