@tileflow/dev 0.1.0-alpha.2 → 0.1.0-alpha.20

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.
@@ -0,0 +1,844 @@
1
+ import {
2
+ TileflowAssetDirectoryError,
3
+ resolveTileflowAssetDirectories
4
+ } from "./chunk-BL3FK4GO.js";
5
+
6
+ // src/icons.ts
7
+ import { readdir, readFile, realpath, stat } from "fs/promises";
8
+ import { extname, isAbsolute, relative, resolve, sep } from "path";
9
+ import { SaxesParser } from "saxes";
10
+ import {
11
+ compareCodeUnits,
12
+ hashTileflowIconPackageManifest,
13
+ hashTileflowRenderedIconPixels,
14
+ parseResolvedTileflowMap,
15
+ serializeCanonicalJson,
16
+ sha256Hex,
17
+ tileflowIconIdPattern,
18
+ tileflowIconPackageLimits,
19
+ tileflowIconPackageManifestSchema
20
+ } from "@tileflow/core";
21
+ var TileflowIconCompilationError = class extends Error {
22
+ code = "ICON_INVALID";
23
+ issues;
24
+ phase = "icon-compilation";
25
+ constructor(issues) {
26
+ super(
27
+ [
28
+ "Invalid Tileflow icon package",
29
+ ...issues.map((issue) => `- ${issue.path}: ${issue.message}`)
30
+ ].join("\n")
31
+ );
32
+ this.name = "TileflowIconCompilationError";
33
+ this.issues = issues;
34
+ }
35
+ };
36
+ var iconFileExtensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".jpeg", ".webp"]);
37
+ var iconSpriteSize = 24;
38
+ async function compileTileflowIconPackages(project, options) {
39
+ const result = await compileIconSources(project, options);
40
+ const packagesByHash = /* @__PURE__ */ new Map();
41
+ for (const compiled of result.compiledBySequence.values()) {
42
+ packagesByHash.set(compiled.package.contentHash, compiled.package);
43
+ }
44
+ const sourceIdentities = {};
45
+ for (const request of result.mapRequests) {
46
+ const compiled = result.compiledBySequence.get(request.sequenceKey);
47
+ sourceIdentities[request.mapName] = compiled?.icons.map((icon) => ({
48
+ format: icon.input.format,
49
+ id: icon.input.name,
50
+ kind: icon.input.kind,
51
+ sha256: icon.sourceSha256
52
+ })) ?? [];
53
+ }
54
+ const bindings = result.mapRequests.flatMap((request) => {
55
+ if (request.directories.length === 0) return [];
56
+ const compiled = result.compiledBySequence.get(request.sequenceKey);
57
+ if (!compiled) {
58
+ throw new Error(`Missing compiled icon package for map ${request.mapName}`);
59
+ }
60
+ return [
61
+ {
62
+ iconIds: compiled.package.manifest.iconNames,
63
+ label: request.mapName,
64
+ mapName: request.mapName,
65
+ packageHash: compiled.package.contentHash
66
+ }
67
+ ];
68
+ });
69
+ return {
70
+ bindings,
71
+ packages: [...packagesByHash.values()].sort(
72
+ (left, right) => compareCodeUnits(left.contentHash, right.contentHash)
73
+ ),
74
+ sourceIdentities,
75
+ watchPaths: uniqueStrings(
76
+ result.mapRequests.flatMap(
77
+ (request) => request.directories.filter((directory) => directory.watch).map((directory) => directory.realPath)
78
+ )
79
+ ).sort(compareCodeUnits)
80
+ };
81
+ }
82
+ async function inspectTileflowIconCatalogs(project, options) {
83
+ const result = await compileIconSources(project, {
84
+ baseDirectory: options.baseDirectory,
85
+ cwd: options.cwd,
86
+ mapNames: options.mapNames,
87
+ target: "local"
88
+ });
89
+ const maps = result.mapRequests.map((request) => {
90
+ if (request.directories.length === 0) return { name: request.mapName, icons: { kind: "none" } };
91
+ const compiled = result.compiledBySequence.get(request.sequenceKey);
92
+ if (!compiled) throw new Error(`Missing compiled icon catalog for map ${request.mapName}`);
93
+ return {
94
+ name: request.mapName,
95
+ icons: {
96
+ directories: request.directories.map(describeAssetDirectory),
97
+ iconIds: [...compiled.package.manifest.iconNames],
98
+ kind: "directories",
99
+ label: request.mapName,
100
+ packageHash: compiled.package.contentHash
101
+ }
102
+ };
103
+ });
104
+ const requestBySequence = new Map(
105
+ result.mapRequests.filter((request) => request.directories.length > 0).map((request) => [request.sequenceKey, request])
106
+ );
107
+ const realCwd = await realpath(options.cwd);
108
+ const catalogs = [...result.compiledBySequence.entries()].map(([sequenceKey, compiled]) => {
109
+ const request = requestBySequence.get(sequenceKey);
110
+ if (!request) throw new Error(`Missing icon directory sequence ${sequenceKey}`);
111
+ return {
112
+ compiledPackage: compiled.package,
113
+ directories: request.directories.map(describeAssetDirectory),
114
+ icons: compiled.icons.map((icon, index) => {
115
+ const manifestIcon = compiled.package.manifest.renderedIcons[index];
116
+ const oneXAtlas = compiled.layoutOneX.index[icon.input.name];
117
+ const twoXAtlas = compiled.layoutTwoX.index[icon.input.name];
118
+ if (!manifestIcon || manifestIcon.name !== icon.input.name || !oneXAtlas || !twoXAtlas) {
119
+ throw new Error(`Missing compiled catalog metadata for icon ${icon.input.name}`);
120
+ }
121
+ return {
122
+ id: icon.input.name,
123
+ rendered: {
124
+ oneX: {
125
+ atlas: atlasRectangle(oneXAtlas),
126
+ height: icon.oneX.height,
127
+ pixelRatio: 1,
128
+ pixelSha256: manifestIcon.pixelSha256.oneX,
129
+ width: icon.oneX.width
130
+ },
131
+ twoX: {
132
+ atlas: atlasRectangle(twoXAtlas),
133
+ height: icon.twoX.height,
134
+ pixelRatio: 2,
135
+ pixelSha256: manifestIcon.pixelSha256.twoX,
136
+ width: icon.twoX.width
137
+ }
138
+ },
139
+ source: {
140
+ byteLength: icon.input.source.byteLength,
141
+ dimensions: { ...icon.dimensions },
142
+ format: icon.input.format,
143
+ path: icon.input.displayPath
144
+ }
145
+ };
146
+ }),
147
+ insideWorkingTree: request.directories.every(
148
+ (directory) => isPathInside(realCwd, directory.realPath)
149
+ ),
150
+ replacements: [...compiled.sourceReplacements]
151
+ };
152
+ }).sort(
153
+ (left, right) => compareCodeUnits(left.directories.join("\0"), right.directories.join("\0"))
154
+ );
155
+ return { catalogs, maps };
156
+ }
157
+ async function compileIconSources(project, options) {
158
+ const mapRequests = await getMapIconRequests(
159
+ project,
160
+ options.cwd,
161
+ options.baseDirectory ?? options.cwd,
162
+ options.target,
163
+ options.mapNames
164
+ );
165
+ const issues = [];
166
+ const inspectedBySource = /* @__PURE__ */ new Map();
167
+ for (const directory of uniqueDirectories(mapRequests)) {
168
+ const inspected = await inspectIconSource(directory, options.target, issues);
169
+ if (inspected) inspectedBySource.set(directory.realPath, inspected);
170
+ }
171
+ if (issues.length > 0) throw new TileflowIconCompilationError(issues);
172
+ const compiledBySequence = /* @__PURE__ */ new Map();
173
+ for (const request of uniqueSequences(mapRequests)) {
174
+ if (request.directories.length === 0) continue;
175
+ try {
176
+ const composed = composeIconDirectories(request, inspectedBySource);
177
+ compiledBySequence.set(request.sequenceKey, await compileInspectedIconSource(composed));
178
+ } catch (error) {
179
+ issues.push({
180
+ message: error instanceof Error ? error.message : "Icon compilation failed",
181
+ path: `maps.${request.mapName}.icons`
182
+ });
183
+ }
184
+ }
185
+ if (issues.length > 0) throw new TileflowIconCompilationError(issues);
186
+ return { compiledBySequence, mapRequests };
187
+ }
188
+ async function prepareTileflowCatalogIcons(project, options) {
189
+ const compiled = await compileTileflowIconPackages(project, {
190
+ baseDirectory: options.baseDirectory,
191
+ cwd: options.cwd,
192
+ target: "local"
193
+ });
194
+ const packagesByHash = new Map(
195
+ compiled.packages.map((iconPackage) => [iconPackage.contentHash, iconPackage])
196
+ );
197
+ const assets = [];
198
+ const mapAssets = {};
199
+ const mapIconSources = {};
200
+ for (const mapName of Object.keys(project.maps)) mapIconSources[mapName] = [];
201
+ for (const binding of compiled.bindings) {
202
+ const iconPackage = packagesByHash.get(binding.packageHash);
203
+ if (!iconPackage) throw new Error(`Missing compiled icon package for map ${binding.mapName}`);
204
+ const spriteUrl = joinUrl(options.assetBaseUrl, `icons/${binding.mapName}/sprite`);
205
+ mapAssets[binding.mapName] = { icons: { ids: binding.iconIds, sprite: spriteUrl } };
206
+ mapIconSources[binding.mapName] = compiled.sourceIdentities[binding.mapName] ?? [];
207
+ for (const file of iconPackage.files) {
208
+ assets.push({
209
+ contentType: file.contentType === "application/json" ? "application/json; charset=utf-8" : file.contentType,
210
+ fileName: `icons/${binding.mapName}/${file.fileName}`,
211
+ source: file.contentType === "application/json" ? new TextDecoder().decode(file.source) : file.source
212
+ });
213
+ }
214
+ }
215
+ return {
216
+ assets,
217
+ baseDirectory: await realpath(options.baseDirectory ?? options.cwd),
218
+ cwd: await realpath(options.cwd),
219
+ mapAssets,
220
+ mapIconSources,
221
+ project,
222
+ sourceProject: project,
223
+ watchPaths: compiled.watchPaths
224
+ };
225
+ }
226
+ async function getTileflowIconWatchPaths(project, cwd, baseDirectory = cwd) {
227
+ const requests = await getMapIconRequests(project, cwd, baseDirectory, "local");
228
+ return uniqueStrings(
229
+ requests.flatMap(
230
+ (request) => request.directories.filter((directory) => directory.watch).map((directory) => directory.realPath)
231
+ )
232
+ ).sort(compareCodeUnits);
233
+ }
234
+ async function getMapIconRequests(project, cwd, baseDirectory, target, mapNames) {
235
+ const selectedNames = (mapNames === void 0 ? Object.keys(project.maps) : [...new Set(mapNames)]).sort(compareCodeUnits);
236
+ const requests = [];
237
+ for (const mapName of selectedNames) {
238
+ const mapConfig = project.maps[mapName];
239
+ if (!mapConfig) throw new Error(`Unknown Tileflow map: ${mapName}`);
240
+ const resolvedMap = parseResolvedTileflowMap(mapConfig);
241
+ try {
242
+ const directories = await resolveTileflowAssetDirectories(resolvedMap.icons ?? [], {
243
+ baseDirectory,
244
+ configPath: `maps.${mapName}.icons`,
245
+ cwd,
246
+ kind: "icons",
247
+ target
248
+ });
249
+ requests.push({
250
+ directories,
251
+ mapName,
252
+ sequenceKey: directories.map((directory) => directory.realPath).join("\0")
253
+ });
254
+ } catch (error) {
255
+ if (error instanceof TileflowAssetDirectoryError) {
256
+ throw new TileflowIconCompilationError([...error.issues]);
257
+ }
258
+ throw error;
259
+ }
260
+ }
261
+ return requests;
262
+ }
263
+ function uniqueDirectories(requests) {
264
+ return [
265
+ ...new Map(
266
+ requests.flatMap(
267
+ (request) => request.directories.map((directory) => [directory.realPath, directory])
268
+ )
269
+ ).values()
270
+ ].sort((left, right) => compareCodeUnits(left.realPath, right.realPath));
271
+ }
272
+ function uniqueSequences(requests) {
273
+ return [...new Map(requests.map((request) => [request.sequenceKey, request])).values()].sort(
274
+ (left, right) => compareCodeUnits(left.sequenceKey, right.sequenceKey)
275
+ );
276
+ }
277
+ function composeIconDirectories(request, inspectedBySource) {
278
+ const iconsById = /* @__PURE__ */ new Map();
279
+ const idsByCaseFold = /* @__PURE__ */ new Map();
280
+ const replacements = [];
281
+ for (const directory of request.directories) {
282
+ const inspected = inspectedBySource.get(directory.realPath);
283
+ if (!inspected)
284
+ throw new Error(`Missing inspected icon directory ${describeAssetDirectory(directory)}`);
285
+ for (const icon of inspected.icons) {
286
+ const folded = icon.name.toLocaleLowerCase("en-US");
287
+ const existing = idsByCaseFold.get(folded);
288
+ if (existing !== void 0 && existing !== icon.name) {
289
+ throw new Error(`Icon ID "${icon.name}" collides case-insensitively with "${existing}".`);
290
+ }
291
+ idsByCaseFold.set(folded, icon.name);
292
+ const replaced = iconsById.get(icon.name);
293
+ if (replaced) {
294
+ replacements.push({
295
+ id: icon.name,
296
+ replaced: replaced.displayPath,
297
+ winner: icon.displayPath
298
+ });
299
+ }
300
+ iconsById.set(icon.name, icon);
301
+ }
302
+ }
303
+ const icons = [...iconsById.values()].sort(
304
+ (left, right) => compareCodeUnits(left.name, right.name)
305
+ );
306
+ const aggregateBytes = icons.reduce((total, icon) => total + icon.source.byteLength, 0);
307
+ if (icons.length > tileflowIconPackageLimits.maxIconCount) {
308
+ throw new Error(
309
+ `Composed icon set contains more than ${tileflowIconPackageLimits.maxIconCount} icons`
310
+ );
311
+ }
312
+ if (aggregateBytes > tileflowIconPackageLimits.maxSourceBytes) {
313
+ throw new Error(
314
+ `Composed icon set exceeds ${tileflowIconPackageLimits.maxSourceBytes} aggregate bytes`
315
+ );
316
+ }
317
+ return { icons, replacements };
318
+ }
319
+ function describeAssetDirectory(directory) {
320
+ return typeof directory.authoring === "string" ? directory.authoring : `npm:${directory.authoring.package}/${directory.authoring.path}`;
321
+ }
322
+ function describeAssetFile(directory, fileName) {
323
+ return `${describeAssetDirectory(directory).replace(/\/+$/u, "")}/${fileName}`;
324
+ }
325
+ async function inspectIconSource(directory, target, issues) {
326
+ let entries;
327
+ const issueCount = issues.length;
328
+ try {
329
+ entries = await readdir(directory.realPath, { withFileTypes: true });
330
+ } catch (error) {
331
+ issues.push({
332
+ message: describeFileSystemError(error, "Icon source could not be read"),
333
+ path: directory.configPath
334
+ });
335
+ return null;
336
+ }
337
+ entries.sort((left, right) => compareCodeUnits(left.name, right.name));
338
+ const candidates = [];
339
+ const names = /* @__PURE__ */ new Set();
340
+ for (const entry of entries) {
341
+ const entryPath = resolve(directory.realPath, entry.name);
342
+ const path = `${directory.configPath}/${entry.name}`;
343
+ if (entry.isDirectory()) {
344
+ issues.push({ message: "Nested directories are not supported", path });
345
+ continue;
346
+ }
347
+ let entryStat;
348
+ let realEntryPath = entryPath;
349
+ try {
350
+ if (entry.isSymbolicLink()) {
351
+ realEntryPath = await realpath(entryPath);
352
+ if (!isPathInside(directory.containmentRoot, realEntryPath)) {
353
+ issues.push({ message: "Icon symlink escapes its owning directory boundary", path });
354
+ continue;
355
+ }
356
+ }
357
+ entryStat = await stat(realEntryPath);
358
+ } catch (error) {
359
+ issues.push({ message: describeFileSystemError(error, "Icon entry could not be read"), path });
360
+ continue;
361
+ }
362
+ if (!entryStat.isFile()) {
363
+ issues.push({ message: "Icon entries must be regular files", path });
364
+ continue;
365
+ }
366
+ const extension = extname(entry.name).toLowerCase();
367
+ if (!iconFileExtensions.has(extension)) {
368
+ continue;
369
+ }
370
+ const sourceName = entry.name.slice(0, -extname(entry.name).length);
371
+ const kind = sourceName.endsWith(".pattern") ? "pattern" : "icon";
372
+ const name = kind === "pattern" ? sourceName.slice(0, -".pattern".length) : sourceName;
373
+ if (names.has(name)) {
374
+ issues.push({
375
+ message: `Duplicate icon basename "${name}"`,
376
+ path
377
+ });
378
+ continue;
379
+ }
380
+ names.add(name);
381
+ if (!tileflowIconIdPattern.test(name) || name.length > 64) {
382
+ issues.push({
383
+ message: "Icon filenames must produce a lower-kebab ID of at most 64 characters",
384
+ path
385
+ });
386
+ }
387
+ if (entryStat.size > tileflowIconPackageLimits.maxSourceFileBytes) {
388
+ issues.push({
389
+ message: `Icon source exceeds ${tileflowIconPackageLimits.maxSourceFileBytes} bytes`,
390
+ path
391
+ });
392
+ }
393
+ candidates.push({
394
+ displayPath: describeAssetFile(directory, entry.name),
395
+ fileName: entry.name,
396
+ format: iconSourceFormat(extension),
397
+ kind,
398
+ name,
399
+ path: realEntryPath,
400
+ size: entryStat.size
401
+ });
402
+ }
403
+ candidates.sort((left, right) => compareCodeUnits(left.name, right.name));
404
+ if (candidates.length === 0) {
405
+ issues.push({
406
+ message: "No supported .svg, .png, .jpg, .jpeg, or .webp icon files were found",
407
+ path: directory.configPath
408
+ });
409
+ }
410
+ if (issues.length > issueCount) {
411
+ return null;
412
+ }
413
+ const icons = [];
414
+ for (const candidate of candidates) {
415
+ const displayPath = candidate.displayPath;
416
+ try {
417
+ const source = await readFile(candidate.path);
418
+ if (source.byteLength !== candidate.size || source.byteLength > tileflowIconPackageLimits.maxSourceFileBytes) {
419
+ throw new Error("Icon changed while it was being validated");
420
+ }
421
+ if (target === "hosted" && extname(candidate.fileName).toLowerCase() === ".svg") {
422
+ validateHostedSvg(source, displayPath);
423
+ }
424
+ icons.push({
425
+ displayPath,
426
+ fileName: candidate.fileName,
427
+ format: candidate.format,
428
+ kind: candidate.kind,
429
+ name: candidate.name,
430
+ path: candidate.path,
431
+ source
432
+ });
433
+ } catch (error) {
434
+ issues.push({
435
+ message: error instanceof Error ? error.message : "Icon could not be read",
436
+ path: displayPath
437
+ });
438
+ }
439
+ }
440
+ if (issues.length > issueCount) {
441
+ return null;
442
+ }
443
+ return { icons };
444
+ }
445
+ async function compileInspectedIconSource(inspected) {
446
+ const rendered = await mapWithConcurrency(
447
+ inspected.icons,
448
+ tileflowIconPackageLimits.decodeConcurrency,
449
+ async (icon) => {
450
+ const dimensions = await validateDecodedDimensions(icon);
451
+ if (icon.kind === "pattern" && (dimensions.width < 2 || dimensions.width > 512 || (dimensions.width & dimensions.width - 1) !== 0)) {
452
+ throw new Error(
453
+ `${icon.fileName} pattern width must be a power of two from 2 through 512 pixels`
454
+ );
455
+ }
456
+ const oneX = await renderIcon(icon, dimensions, 1);
457
+ const twoX = await renderIcon(icon, dimensions, 2);
458
+ return {
459
+ dimensions,
460
+ input: icon,
461
+ oneX,
462
+ pixelSha256: {
463
+ oneX: await hashTileflowRenderedIconPixels({
464
+ height: oneX.height,
465
+ pixelRatio: 1,
466
+ rgba: oneX.rgba,
467
+ width: oneX.width
468
+ }),
469
+ twoX: await hashTileflowRenderedIconPixels({
470
+ height: twoX.height,
471
+ pixelRatio: 2,
472
+ rgba: twoX.rgba,
473
+ width: twoX.width
474
+ })
475
+ },
476
+ sourceSha256: await sha256Hex(icon.source),
477
+ twoX
478
+ };
479
+ }
480
+ );
481
+ const layoutOneX = createSpriteLayout(
482
+ rendered.map(({ input, oneX }) => ({ ...oneX, name: input.name })),
483
+ 1
484
+ );
485
+ const layoutTwoX = createSpriteLayout(
486
+ rendered.map(({ input, twoX }) => ({ ...twoX, name: input.name })),
487
+ 2
488
+ );
489
+ const [oneXImage, twoXImage] = await Promise.all([
490
+ createSpriteImage(
491
+ rendered.map(({ input, oneX }) => ({ ...oneX, name: input.name })),
492
+ layoutOneX
493
+ ),
494
+ createSpriteImage(
495
+ rendered.map(({ input, twoX }) => ({ ...twoX, name: input.name })),
496
+ layoutTwoX
497
+ )
498
+ ]);
499
+ const oneXJson = new TextEncoder().encode(`${serializeCanonicalJson(layoutOneX.index)}
500
+ `);
501
+ const twoXJson = new TextEncoder().encode(`${serializeCanonicalJson(layoutTwoX.index)}
502
+ `);
503
+ const files = [
504
+ { contentType: "application/json", fileName: "sprite.json", source: oneXJson },
505
+ { contentType: "image/png", fileName: "sprite.png", source: oneXImage },
506
+ { contentType: "application/json", fileName: "sprite@2x.json", source: twoXJson },
507
+ { contentType: "image/png", fileName: "sprite@2x.png", source: twoXImage }
508
+ ];
509
+ assertGeneratedFileLimits(files);
510
+ const fileDigests = await Promise.all(files.map((file) => sha256Hex(file.source)));
511
+ const manifest = tileflowIconPackageManifestSchema.parse({
512
+ files: files.map((file, index) => ({
513
+ byteLength: file.source.byteLength,
514
+ contentType: file.contentType,
515
+ name: file.fileName,
516
+ sha256: fileDigests[index]
517
+ })),
518
+ format: "tileflow-icon-package-v1",
519
+ iconNames: inspected.icons.map((icon) => icon.name),
520
+ renderedIcons: inspected.icons.map((icon, index) => {
521
+ const pixels = rendered[index];
522
+ if (!pixels) {
523
+ throw new Error(`Missing rendered pixels for ${icon.name}`);
524
+ }
525
+ return { name: icon.name, pixelSha256: pixels.pixelSha256 };
526
+ }),
527
+ sprites: {
528
+ oneX: { height: layoutOneX.height, pixelRatio: 1, width: layoutOneX.width },
529
+ twoX: { height: layoutTwoX.height, pixelRatio: 2, width: layoutTwoX.width }
530
+ }
531
+ });
532
+ return {
533
+ icons: rendered,
534
+ layoutOneX,
535
+ layoutTwoX,
536
+ package: {
537
+ contentHash: await hashTileflowIconPackageManifest(manifest),
538
+ files,
539
+ manifest
540
+ },
541
+ sourceReplacements: inspected.replacements ?? []
542
+ };
543
+ }
544
+ function createSpriteLayout(icons, pixelRatio) {
545
+ const columns = Math.ceil(Math.sqrt(icons.length));
546
+ const widest = Math.max(...icons.map((icon) => icon.width));
547
+ const targetWidth = Math.min(tileflowIconPackageLimits.maxAtlasDimension, columns * widest);
548
+ const placements = [];
549
+ let left = 0;
550
+ let rowHeight = 0;
551
+ let top = 0;
552
+ for (const icon of icons) {
553
+ if (icon.width > tileflowIconPackageLimits.maxAtlasDimension) {
554
+ throw new Error(
555
+ `Generated sprite exceeds ${tileflowIconPackageLimits.maxAtlasDimension} pixels per dimension`
556
+ );
557
+ }
558
+ if (left > 0 && left + icon.width > targetWidth) {
559
+ top += rowHeight;
560
+ left = 0;
561
+ rowHeight = 0;
562
+ }
563
+ placements.push({ left, top });
564
+ left += icon.width;
565
+ rowHeight = Math.max(rowHeight, icon.height);
566
+ }
567
+ const width = targetWidth;
568
+ const height = top + rowHeight;
569
+ if (width > tileflowIconPackageLimits.maxAtlasDimension || height > tileflowIconPackageLimits.maxAtlasDimension) {
570
+ throw new Error(
571
+ `Generated sprite exceeds ${tileflowIconPackageLimits.maxAtlasDimension} pixels per dimension`
572
+ );
573
+ }
574
+ const index = Object.fromEntries(
575
+ icons.map((icon, indexNumber) => {
576
+ const placement = placements[indexNumber];
577
+ return [
578
+ icon.name,
579
+ {
580
+ height: icon.height,
581
+ pixelRatio,
582
+ width: icon.width,
583
+ x: placement.left,
584
+ y: placement.top
585
+ }
586
+ ];
587
+ })
588
+ );
589
+ return { height, index, width };
590
+ }
591
+ async function validateDecodedDimensions(icon) {
592
+ const sharp = await loadSharp();
593
+ const metadata = await sharp(icon.source, {
594
+ density: 72,
595
+ failOn: "error",
596
+ limitInputPixels: tileflowIconPackageLimits.maxDecodedPixelsPerIcon
597
+ }).metadata();
598
+ const width = metadata.width ?? 0;
599
+ const height = metadata.height ?? 0;
600
+ if (width <= 0 || height <= 0) {
601
+ throw new Error(`Unable to determine decoded dimensions for ${icon.fileName}`);
602
+ }
603
+ if (width * height > tileflowIconPackageLimits.maxDecodedPixelsPerIcon) {
604
+ throw new Error(
605
+ `${icon.fileName} exceeds ${tileflowIconPackageLimits.maxDecodedPixelsPerIcon} decoded pixels`
606
+ );
607
+ }
608
+ return { height, width };
609
+ }
610
+ async function renderIcon(icon, dimensions, pixelRatio) {
611
+ const sharp = await loadSharp();
612
+ const targetWidth = (icon.kind === "pattern" ? dimensions.width : iconSpriteSize) * pixelRatio;
613
+ const targetHeight = (icon.kind === "pattern" ? dimensions.height : iconSpriteSize) * pixelRatio;
614
+ const { data, info } = await sharp(icon.source, {
615
+ density: 72 * pixelRatio,
616
+ failOn: "error",
617
+ limitInputPixels: tileflowIconPackageLimits.maxDecodedPixelsPerIcon
618
+ }).resize(targetWidth, targetHeight, {
619
+ background: { alpha: 0, b: 0, g: 0, r: 0 },
620
+ fit: "contain"
621
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
622
+ if (info.channels !== 4 || info.width !== targetWidth || info.height !== targetHeight) {
623
+ throw new Error(`Rendered icon ${icon.fileName} did not produce canonical RGBA pixels`);
624
+ }
625
+ const rgba = new Uint8Array(data);
626
+ const png = await sharp(data, {
627
+ raw: { channels: 4, height: info.height, width: info.width }
628
+ }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
629
+ return { height: info.height, png, rgba, width: info.width };
630
+ }
631
+ async function createSpriteImage(icons, layout) {
632
+ const sharp = await loadSharp();
633
+ const atlas = new Uint8Array(layout.width * layout.height * 4);
634
+ for (const icon of icons) {
635
+ const placement = layout.index[icon.name];
636
+ if (!placement || icon.width !== placement.width || icon.height !== placement.height) {
637
+ throw new Error("Rendered icon dimensions do not match the sprite layout");
638
+ }
639
+ const rowBytes = icon.width * 4;
640
+ for (let row = 0; row < icon.height; row += 1) {
641
+ const sourceStart = row * rowBytes;
642
+ const targetStart = ((placement.y + row) * layout.width + placement.x) * 4;
643
+ atlas.set(icon.rgba.subarray(sourceStart, sourceStart + rowBytes), targetStart);
644
+ }
645
+ }
646
+ return sharp(atlas, {
647
+ raw: {
648
+ channels: 4,
649
+ height: layout.height,
650
+ width: layout.width
651
+ }
652
+ }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
653
+ }
654
+ function assertGeneratedFileLimits(files) {
655
+ let totalBytes = 0;
656
+ for (const file of files) {
657
+ if (file.source.byteLength > tileflowIconPackageLimits.maxGeneratedFileBytes) {
658
+ throw new Error(
659
+ `${file.fileName} exceeds ${tileflowIconPackageLimits.maxGeneratedFileBytes} generated bytes`
660
+ );
661
+ }
662
+ totalBytes += file.source.byteLength;
663
+ }
664
+ if (totalBytes > tileflowIconPackageLimits.maxGeneratedPackageBytes) {
665
+ throw new Error(
666
+ `Generated package exceeds ${tileflowIconPackageLimits.maxGeneratedPackageBytes} bytes`
667
+ );
668
+ }
669
+ }
670
+ function validateHostedSvg(source, displayPath) {
671
+ const parser = new SaxesParser({ fileName: displayPath, xmlns: true });
672
+ const decoder = new TextDecoder("utf-8", { fatal: true });
673
+ let styleDepth = 0;
674
+ let styleText = "";
675
+ parser.on("doctype", () => {
676
+ throw new Error("Hosted SVGs cannot contain a document type or entity declarations");
677
+ });
678
+ parser.on("processinginstruction", (instruction) => {
679
+ if (instruction.target.toLowerCase() === "xml-stylesheet") {
680
+ throw new Error("Hosted SVGs cannot load external stylesheets");
681
+ }
682
+ });
683
+ parser.on("opentag", (tag) => {
684
+ const localName = tag.local.toLowerCase();
685
+ if (localName === "script") {
686
+ throw new Error("Hosted SVGs cannot contain scripts");
687
+ }
688
+ if (localName === "style") {
689
+ styleDepth += 1;
690
+ }
691
+ for (const attribute of Object.values(tag.attributes)) {
692
+ const attributeName = attribute.local.toLowerCase();
693
+ const value = attribute.value.trim();
694
+ if (attributeName.startsWith("on")) {
695
+ throw new Error("Hosted SVGs cannot contain script event handlers");
696
+ }
697
+ if (attributeName === "href" && value.length > 0 && !value.startsWith("#")) {
698
+ throw new Error("Hosted SVG resource references must use local fragments");
699
+ }
700
+ if (containsExternalCssUrl(value)) {
701
+ throw new Error("Hosted SVG resource references must use local fragments");
702
+ }
703
+ }
704
+ });
705
+ parser.on("text", (text) => {
706
+ if (styleDepth > 0) {
707
+ styleText += text;
708
+ }
709
+ });
710
+ parser.on("cdata", (text) => {
711
+ if (styleDepth > 0) {
712
+ styleText += text;
713
+ }
714
+ });
715
+ parser.on("closetag", (tag) => {
716
+ if (tag.local.toLowerCase() !== "style") {
717
+ return;
718
+ }
719
+ styleDepth -= 1;
720
+ if (styleDepth === 0) {
721
+ if (styleText.toLowerCase().includes("@import") || containsExternalCssUrl(styleText)) {
722
+ throw new Error("Hosted SVGs cannot load external stylesheets or resources");
723
+ }
724
+ styleText = "";
725
+ }
726
+ });
727
+ parser.on("error", (error) => {
728
+ throw error;
729
+ });
730
+ const chunkBytes = 64 * 1024;
731
+ for (let offset = 0; offset < source.byteLength; offset += chunkBytes) {
732
+ parser.write(decoder.decode(source.subarray(offset, offset + chunkBytes), { stream: true }));
733
+ }
734
+ parser.write(decoder.decode());
735
+ parser.close();
736
+ }
737
+ function containsExternalCssUrl(value) {
738
+ const lowerValue = value.toLowerCase();
739
+ let cursor = 0;
740
+ while (cursor < value.length) {
741
+ const start = lowerValue.indexOf("url(", cursor);
742
+ if (start === -1) {
743
+ return false;
744
+ }
745
+ const end = value.indexOf(")", start + 4);
746
+ if (end === -1) {
747
+ return true;
748
+ }
749
+ const rawTarget = value.slice(start + 4, end).trim();
750
+ const target = stripMatchingQuotes(rawTarget);
751
+ if (!target.startsWith("#")) {
752
+ return true;
753
+ }
754
+ cursor = end + 1;
755
+ }
756
+ return false;
757
+ }
758
+ function stripMatchingQuotes(value) {
759
+ if (value.length < 2) {
760
+ return value;
761
+ }
762
+ const first = value[0];
763
+ const last = value[value.length - 1];
764
+ return first === '"' && last === '"' || first === "'" && last === "'" ? value.slice(1, -1).trim() : value;
765
+ }
766
+ async function mapWithConcurrency(values, concurrency, map) {
767
+ const results = new Array(values.length);
768
+ let nextIndex = 0;
769
+ async function worker() {
770
+ while (nextIndex < values.length) {
771
+ const index = nextIndex;
772
+ nextIndex += 1;
773
+ const value = values[index];
774
+ if (value !== void 0) {
775
+ results[index] = await map(value, index);
776
+ }
777
+ }
778
+ }
779
+ await Promise.all(
780
+ Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
781
+ );
782
+ return results;
783
+ }
784
+ async function loadSharp() {
785
+ try {
786
+ return (await import("sharp")).default;
787
+ } catch (error) {
788
+ throw new Error(
789
+ 'Local icon sprites require the optional "sharp" package. Install sharp or disable local icons.',
790
+ { cause: error }
791
+ );
792
+ }
793
+ }
794
+ function atlasRectangle(entry) {
795
+ return { height: entry.height, width: entry.width, x: entry.x, y: entry.y };
796
+ }
797
+ function iconSourceFormat(extension) {
798
+ switch (extension) {
799
+ case ".svg":
800
+ return "svg";
801
+ case ".png":
802
+ return "png";
803
+ case ".jpg":
804
+ case ".jpeg":
805
+ return "jpeg";
806
+ case ".webp":
807
+ return "webp";
808
+ default:
809
+ throw new Error(`Unsupported icon source format: ${extension}`);
810
+ }
811
+ }
812
+ function isPathInside(root, candidate) {
813
+ const pathFromRoot = relative(root, candidate);
814
+ return pathFromRoot === "" || !isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`);
815
+ }
816
+ function describeFileSystemError(error, fallback) {
817
+ if (error && typeof error === "object" && "code" in error) {
818
+ const code = String(error.code ?? "");
819
+ if (code === "ENOENT") {
820
+ return fallback;
821
+ }
822
+ if (code === "EACCES" || code === "EPERM") {
823
+ return "Icon source is not readable";
824
+ }
825
+ }
826
+ return error instanceof Error && error.message ? error.message : fallback;
827
+ }
828
+ function joinUrl(base, path) {
829
+ const trimmedBase = base.replace(/\/+$/g, "");
830
+ const trimmedPath = path.replace(/^\/+/g, "");
831
+ return trimmedBase ? `${trimmedBase}/${trimmedPath}` : trimmedPath;
832
+ }
833
+ function uniqueStrings(values) {
834
+ return [...new Set(values)];
835
+ }
836
+
837
+ export {
838
+ TileflowIconCompilationError,
839
+ iconFileExtensions,
840
+ compileTileflowIconPackages,
841
+ inspectTileflowIconCatalogs,
842
+ prepareTileflowCatalogIcons,
843
+ getTileflowIconWatchPaths
844
+ };