@tileflow/dev 0.1.0-alpha.14 → 0.1.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,203 +1,1839 @@
1
1
  // src/index.ts
2
2
  import { createJiti } from "jiti";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync, readFileSync, realpathSync as realpathSync2 } from "fs";
4
4
  import { mkdir, writeFile } from "fs/promises";
5
- import { dirname, resolve as resolve2 } from "path";
5
+ import { createRequire } from "module";
6
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join, relative as relative3, resolve as resolve3, sep as sep3 } from "path";
6
7
  import {
8
+ compareCodeUnits as compareCodeUnits3,
7
9
  createManifest,
8
- createStyleFromProject,
10
+ createStyleFromProject as createStyleFromProject2,
11
+ normalizeTileflowCaptureScene,
9
12
  validateConfig
10
13
  } from "@tileflow/core";
11
14
 
12
15
  // src/icons.ts
13
- import { existsSync } from "fs";
14
- import { readdir } from "fs/promises";
15
- import { extname, resolve } from "path";
16
+ import { readdir, readFile, realpath, stat } from "fs/promises";
17
+ import { basename, extname, isAbsolute, relative, resolve, sep } from "path";
18
+ import { SaxesParser } from "saxes";
19
+ import {
20
+ compareCodeUnits,
21
+ hashTileflowIconPackageManifest,
22
+ hashTileflowRenderedIconPixels,
23
+ serializeCanonicalJson,
24
+ sha256Hex,
25
+ tileflowHostedIconIdPattern,
26
+ tileflowIconPackageLabelSchema,
27
+ tileflowIconPackageLimits,
28
+ tileflowIconPackageManifestSchema
29
+ } from "@tileflow/core";
30
+ var TileflowIconCompilationError = class extends Error {
31
+ code = "ICON_INVALID";
32
+ issues;
33
+ phase = "icon-compilation";
34
+ constructor(issues) {
35
+ super(
36
+ [
37
+ "Invalid Tileflow icon package",
38
+ ...issues.map((issue) => `- ${issue.path}: ${issue.message}`)
39
+ ].join("\n")
40
+ );
41
+ this.name = "TileflowIconCompilationError";
42
+ this.issues = issues;
43
+ }
44
+ };
16
45
  var iconFileExtensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".jpeg", ".webp"]);
17
46
  var iconSpriteSize = 24;
47
+ async function compileTileflowIconPackages(project, options) {
48
+ const result = await compileIconSources(project, options);
49
+ const packagesByHash = /* @__PURE__ */ new Map();
50
+ for (const compiled of result.compiledBySource.values()) {
51
+ packagesByHash.set(compiled.package.contentHash, compiled.package);
52
+ }
53
+ const bindings = result.resolvedRequests.map((request) => {
54
+ const compiled = result.compiledBySource.get(request.realSourceDir);
55
+ if (!compiled) {
56
+ throw new Error(`Missing compiled icon package for map ${request.mapName}`);
57
+ }
58
+ return {
59
+ label: request.label,
60
+ mapName: request.mapName,
61
+ ...request.mapping ? { mapping: request.mapping } : {},
62
+ packageHash: compiled.package.contentHash
63
+ };
64
+ });
65
+ return {
66
+ bindings,
67
+ packages: [...packagesByHash.values()].sort(
68
+ (left, right) => compareCodeUnits(left.contentHash, right.contentHash)
69
+ ),
70
+ watchPaths: [...result.compiledBySource.keys()].sort(compareCodeUnits)
71
+ };
72
+ }
73
+ async function inspectTileflowIconCatalogs(project, options) {
74
+ const result = await compileIconSources(project, {
75
+ cwd: options.cwd,
76
+ mapNames: options.mapNames,
77
+ target: "local"
78
+ });
79
+ const resolvedByMap = new Map(
80
+ result.resolvedRequests.map((request) => [request.mapName, request])
81
+ );
82
+ const mappedFromBySource = /* @__PURE__ */ new Map();
83
+ const maps = result.mapRequests.map((request) => {
84
+ if (request.kind !== "local") {
85
+ return {
86
+ name: request.mapName,
87
+ icons: {
88
+ inspectable: false,
89
+ kind: request.kind,
90
+ mappings: createCatalogMappings(request.mapping, "unknown")
91
+ }
92
+ };
93
+ }
94
+ const resolved = resolvedByMap.get(request.mapName);
95
+ if (!resolved || !result.realCwd) {
96
+ throw new Error(`Missing resolved icon source for map ${request.mapName}`);
97
+ }
98
+ const compiled = result.compiledBySource.get(resolved.realSourceDir);
99
+ if (!compiled) {
100
+ throw new Error(`Missing compiled icon catalog for map ${request.mapName}`);
101
+ }
102
+ const iconNames = new Set(compiled.package.manifest.iconNames);
103
+ const mappings = createCatalogMappings(
104
+ request.mapping,
105
+ (iconId) => iconNames.has(iconId) ? "present" : "missing"
106
+ );
107
+ const mappedFromByIcon = mappedFromBySource.get(resolved.realSourceDir) ?? /* @__PURE__ */ new Map();
108
+ for (const mapping of mappings) {
109
+ if (mapping.targetStatus !== "present") {
110
+ continue;
111
+ }
112
+ const mappedFrom = mappedFromByIcon.get(mapping.iconId) ?? [];
113
+ mappedFrom.push({ map: request.mapName, semantic: mapping.semantic });
114
+ mappedFromByIcon.set(mapping.iconId, mappedFrom);
115
+ }
116
+ mappedFromBySource.set(resolved.realSourceDir, mappedFromByIcon);
117
+ return {
118
+ name: request.mapName,
119
+ icons: {
120
+ catalogSourcePath: relativePortablePath(result.realCwd, resolved.realSourceDir),
121
+ kind: "local",
122
+ label: request.label,
123
+ mappings,
124
+ packageHash: compiled.package.contentHash
125
+ }
126
+ };
127
+ });
128
+ if (!result.realCwd) {
129
+ return { catalogs: [], maps };
130
+ }
131
+ const realCwd = result.realCwd;
132
+ const catalogs = [...result.compiledBySource.entries()].map(([realSourceDir, compiled]) => {
133
+ const sourcePath2 = relativePortablePath(realCwd, realSourceDir);
134
+ const mappedFromByIcon = mappedFromBySource.get(realSourceDir) ?? /* @__PURE__ */ new Map();
135
+ return {
136
+ compiledPackage: compiled.package,
137
+ icons: compiled.icons.map((icon, index) => {
138
+ const manifestIcon = compiled.package.manifest.renderedIcons[index];
139
+ const oneXAtlas = compiled.layoutOneX.index[icon.input.name];
140
+ const twoXAtlas = compiled.layoutTwoX.index[icon.input.name];
141
+ if (!manifestIcon || manifestIcon.name !== icon.input.name || !oneXAtlas || !twoXAtlas) {
142
+ throw new Error(`Missing compiled catalog metadata for icon ${icon.input.name}`);
143
+ }
144
+ return {
145
+ id: icon.input.name,
146
+ mappedFrom: [...mappedFromByIcon.get(icon.input.name) ?? []].sort(compareMappedFrom),
147
+ rendered: {
148
+ oneX: {
149
+ atlas: atlasRectangle(oneXAtlas),
150
+ height: icon.oneX.height,
151
+ pixelRatio: 1,
152
+ pixelSha256: manifestIcon.pixelSha256.oneX,
153
+ width: icon.oneX.width
154
+ },
155
+ twoX: {
156
+ atlas: atlasRectangle(twoXAtlas),
157
+ height: icon.twoX.height,
158
+ pixelRatio: 2,
159
+ pixelSha256: manifestIcon.pixelSha256.twoX,
160
+ width: icon.twoX.width
161
+ }
162
+ },
163
+ source: {
164
+ byteLength: icon.input.source.byteLength,
165
+ dimensions: { ...icon.dimensions },
166
+ format: icon.input.format,
167
+ path: relativePortablePath(realCwd, icon.input.path)
168
+ }
169
+ };
170
+ }),
171
+ insideWorkingTree: isPathInside(realCwd, realSourceDir),
172
+ sourcePath: sourcePath2
173
+ };
174
+ }).sort((left, right) => compareCodeUnits(left.sourcePath, right.sourcePath));
175
+ return { catalogs, maps };
176
+ }
177
+ async function compileIconSources(project, options) {
178
+ const mapRequests = getMapIconRequests(project, options.cwd, options.mapNames);
179
+ const requests = mapRequests.filter(
180
+ (request) => request.kind === "local"
181
+ );
182
+ if (requests.length === 0) {
183
+ return {
184
+ compiledBySource: /* @__PURE__ */ new Map(),
185
+ mapRequests,
186
+ realCwd: null,
187
+ resolvedRequests: []
188
+ };
189
+ }
190
+ const issues = [];
191
+ const realCwd = await resolveWorkingDirectory(options.cwd, issues);
192
+ const resolvedRequests = [];
193
+ for (const request of requests) {
194
+ try {
195
+ const realSourceDir = await realpath(request.sourceDir);
196
+ if (options.target === "hosted" && realCwd && !isPathInside(realCwd, realSourceDir)) {
197
+ issues.push({
198
+ message: "Hosted icon sources must remain inside the selected working tree",
199
+ path: sourcePath(request.mapName)
200
+ });
201
+ continue;
202
+ }
203
+ const sourceStat = await stat(realSourceDir);
204
+ if (!sourceStat.isDirectory()) {
205
+ issues.push({
206
+ message: "Icon source must be a directory",
207
+ path: sourcePath(request.mapName)
208
+ });
209
+ continue;
210
+ }
211
+ resolvedRequests.push({ ...request, realSourceDir });
212
+ } catch (error) {
213
+ issues.push({
214
+ message: describeFileSystemError(error, "Icon source was not found"),
215
+ path: sourcePath(request.mapName)
216
+ });
217
+ }
218
+ }
219
+ const inspectedBySource = /* @__PURE__ */ new Map();
220
+ const requestsBySource = groupRequestsBySource(resolvedRequests);
221
+ for (const [realSourceDir, sourceRequests] of requestsBySource) {
222
+ const representative = sourceRequests[0];
223
+ if (!representative) {
224
+ continue;
225
+ }
226
+ const inspected = await inspectIconSource(
227
+ representative,
228
+ realSourceDir,
229
+ realCwd,
230
+ options.target,
231
+ issues
232
+ );
233
+ if (inspected) {
234
+ inspectedBySource.set(realSourceDir, inspected);
235
+ }
236
+ }
237
+ if (issues.length > 0) {
238
+ throw new TileflowIconCompilationError(issues);
239
+ }
240
+ const compiledBySource = /* @__PURE__ */ new Map();
241
+ for (const [realSourceDir, inspected] of inspectedBySource) {
242
+ const representative = requestsBySource.get(realSourceDir)?.[0];
243
+ if (!representative) {
244
+ continue;
245
+ }
246
+ try {
247
+ compiledBySource.set(realSourceDir, await compileInspectedIconSource(inspected));
248
+ } catch (error) {
249
+ issues.push({
250
+ message: error instanceof Error ? error.message : "Icon compilation failed",
251
+ path: sourcePath(representative.mapName)
252
+ });
253
+ }
254
+ }
255
+ if (issues.length > 0) {
256
+ throw new TileflowIconCompilationError(issues);
257
+ }
258
+ return { compiledBySource, mapRequests, realCwd, resolvedRequests };
259
+ }
18
260
  async function prepareTileflowProjectIcons(project, options) {
19
261
  const nextProject = cloneProject(project);
20
- const localSources = getLocalIconSources(project, options.cwd);
21
- for (const localSource of localSources) {
22
- const mapConfig = nextProject.maps[localSource.mapName];
262
+ const compiled = await compileTileflowIconPackages(project, {
263
+ cwd: options.cwd,
264
+ target: "local"
265
+ });
266
+ const packagesByHash = new Map(
267
+ compiled.packages.map((iconPackage) => [iconPackage.contentHash, iconPackage])
268
+ );
269
+ const assets = [];
270
+ for (const binding of compiled.bindings) {
271
+ const mapConfig = nextProject.maps[binding.mapName];
272
+ const iconPackage = packagesByHash.get(binding.packageHash);
273
+ if (!mapConfig || !iconPackage) {
274
+ continue;
275
+ }
276
+ const spriteUrl = joinUrl(options.assetBaseUrl, `icons/${binding.mapName}/sprite`);
277
+ mapConfig.icons = {
278
+ ...binding.mapping ? { mapping: binding.mapping } : {},
279
+ sprite: spriteUrl
280
+ };
281
+ for (const file of iconPackage.files) {
282
+ assets.push({
283
+ contentType: file.contentType === "application/json" ? "application/json; charset=utf-8" : file.contentType,
284
+ fileName: `icons/${binding.mapName}/${file.fileName}`,
285
+ source: file.contentType === "application/json" ? new TextDecoder().decode(file.source) : file.source
286
+ });
287
+ }
288
+ }
289
+ return {
290
+ assets,
291
+ project: nextProject,
292
+ watchPaths: compiled.watchPaths
293
+ };
294
+ }
295
+ function getTileflowIconWatchPaths(project, cwd) {
296
+ return uniqueStrings(getLocalIconRequests(project, cwd).map((request) => request.sourceDir)).sort(
297
+ compareCodeUnits
298
+ );
299
+ }
300
+ function getLocalIconRequests(project, cwd) {
301
+ return getMapIconRequests(project, cwd).filter(
302
+ (request) => request.kind === "local"
303
+ );
304
+ }
305
+ function getMapIconRequests(project, cwd, mapNames) {
306
+ const selectedNames = (mapNames === void 0 ? Object.keys(project.maps) : [...new Set(mapNames)]).sort(compareCodeUnits);
307
+ return selectedNames.map((mapName) => {
308
+ const mapConfig = project.maps[mapName];
23
309
  if (!mapConfig) {
310
+ throw new Error(`Unknown Tileflow map: ${mapName}`);
311
+ }
312
+ const iconSet = resolveIconSet(mapConfig.icons, project.icons);
313
+ const mapping = sortMapping(iconSet?.mapping);
314
+ if (iconSet?.source) {
315
+ return {
316
+ kind: "local",
317
+ label: resolvePackageLabel(mapConfig.icons, iconSet.source, project.icons, mapName),
318
+ mapName,
319
+ ...mapping ? { mapping } : {},
320
+ source: iconSet.source,
321
+ sourceDir: resolve(cwd, iconSet.source)
322
+ };
323
+ }
324
+ return {
325
+ kind: iconSet?.sprite ? "external" : "none",
326
+ mapName,
327
+ ...mapping ? { mapping } : {}
328
+ };
329
+ });
330
+ }
331
+ function resolveIconSet(icons, iconSets, path = []) {
332
+ if (!icons) {
333
+ return void 0;
334
+ }
335
+ if (typeof icons === "string") {
336
+ const referenced = iconSets?.[icons];
337
+ if (referenced) {
338
+ if (path.includes(icons)) {
339
+ throw new Error(`Circular Tileflow icon set extends: ${[...path, icons].join(" -> ")}`);
340
+ }
341
+ return resolveIconSet(referenced, iconSets, [...path, icons]);
342
+ }
343
+ return isRemoteSpriteReference(icons) ? { sprite: icons } : { source: icons };
344
+ }
345
+ const extended = icons.extends ? resolveIconSet(icons.extends, iconSets, path) : void 0;
346
+ return {
347
+ ...extended,
348
+ ...icons,
349
+ mapping: {
350
+ ...extended?.mapping,
351
+ ...icons.mapping
352
+ }
353
+ };
354
+ }
355
+ async function resolveWorkingDirectory(cwd, issues) {
356
+ try {
357
+ const resolved = await realpath(cwd);
358
+ if (!(await stat(resolved)).isDirectory()) {
359
+ throw new Error("Working tree is not a directory");
360
+ }
361
+ return resolved;
362
+ } catch (error) {
363
+ issues.push({
364
+ message: describeFileSystemError(error, "Selected working tree was not found"),
365
+ path: "cwd"
366
+ });
367
+ return null;
368
+ }
369
+ }
370
+ function groupRequestsBySource(requests) {
371
+ const result = /* @__PURE__ */ new Map();
372
+ for (const request of requests) {
373
+ const existing = result.get(request.realSourceDir) ?? [];
374
+ existing.push(request);
375
+ result.set(request.realSourceDir, existing);
376
+ }
377
+ return result;
378
+ }
379
+ async function inspectIconSource(request, realSourceDir, realCwd, target, issues) {
380
+ let entries;
381
+ try {
382
+ entries = await readdir(realSourceDir, { withFileTypes: true });
383
+ } catch (error) {
384
+ issues.push({
385
+ message: describeFileSystemError(error, "Icon source could not be read"),
386
+ path: sourcePath(request.mapName)
387
+ });
388
+ return null;
389
+ }
390
+ entries.sort((left, right) => compareCodeUnits(left.name, right.name));
391
+ const candidates = [];
392
+ const names = /* @__PURE__ */ new Set();
393
+ let aggregateBytes = 0;
394
+ for (const entry of entries) {
395
+ const entryPath = resolve(realSourceDir, entry.name);
396
+ const path = iconPath(request.mapName, entry.name);
397
+ if (entry.isDirectory()) {
398
+ if (target === "hosted") {
399
+ issues.push({ message: "Nested directories are not supported", path });
400
+ }
401
+ continue;
402
+ }
403
+ let entryStat;
404
+ let realEntryPath = entryPath;
405
+ try {
406
+ if (entry.isSymbolicLink()) {
407
+ if (target === "local") {
408
+ continue;
409
+ }
410
+ realEntryPath = await realpath(entryPath);
411
+ if (!realCwd || !isPathInside(realCwd, realEntryPath)) {
412
+ issues.push({ message: "Icon symlink escapes the selected working tree", path });
413
+ continue;
414
+ }
415
+ }
416
+ entryStat = await stat(realEntryPath);
417
+ } catch (error) {
418
+ if (target === "hosted") {
419
+ issues.push({
420
+ message: describeFileSystemError(error, "Icon entry could not be read"),
421
+ path
422
+ });
423
+ }
424
+ continue;
425
+ }
426
+ if (!entryStat.isFile()) {
427
+ if (target === "hosted") {
428
+ issues.push({ message: "Icon entries must be regular files", path });
429
+ }
430
+ continue;
431
+ }
432
+ const extension = extname(entry.name).toLowerCase();
433
+ if (!iconFileExtensions.has(extension)) {
24
434
  continue;
25
435
  }
26
- const iconSet = resolveIconSet(mapConfig.icons, nextProject.icons);
27
- if (!iconSet) {
28
- continue;
436
+ const name = entry.name.slice(0, -extname(entry.name).length);
437
+ if (names.has(name)) {
438
+ issues.push({
439
+ message: `Duplicate icon basename "${name}"`,
440
+ path
441
+ });
442
+ continue;
443
+ }
444
+ names.add(name);
445
+ if (target === "hosted" && !tileflowHostedIconIdPattern.test(name)) {
446
+ issues.push({
447
+ message: "Hosted icon IDs must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$",
448
+ path
449
+ });
450
+ }
451
+ if (entryStat.size > tileflowIconPackageLimits.maxSourceFileBytes) {
452
+ issues.push({
453
+ message: `Icon source exceeds ${tileflowIconPackageLimits.maxSourceFileBytes} bytes`,
454
+ path
455
+ });
456
+ }
457
+ aggregateBytes += entryStat.size;
458
+ candidates.push({
459
+ fileName: entry.name,
460
+ format: iconSourceFormat(extension),
461
+ name,
462
+ path: realEntryPath,
463
+ size: entryStat.size
464
+ });
465
+ }
466
+ candidates.sort((left, right) => compareCodeUnits(left.name, right.name));
467
+ if (candidates.length === 0) {
468
+ issues.push({
469
+ message: "No supported .svg, .png, .jpg, .jpeg, or .webp icon files were found",
470
+ path: sourcePath(request.mapName)
471
+ });
472
+ }
473
+ if (candidates.length > tileflowIconPackageLimits.maxIconCount) {
474
+ issues.push({
475
+ message: `Icon source contains more than ${tileflowIconPackageLimits.maxIconCount} icons`,
476
+ path: sourcePath(request.mapName)
477
+ });
478
+ }
479
+ if (aggregateBytes > tileflowIconPackageLimits.maxSourceBytes) {
480
+ issues.push({
481
+ message: `Icon source exceeds ${tileflowIconPackageLimits.maxSourceBytes} aggregate bytes`,
482
+ path: sourcePath(request.mapName)
483
+ });
484
+ }
485
+ if (issues.length > 0) {
486
+ return null;
487
+ }
488
+ const icons = [];
489
+ for (const candidate of candidates) {
490
+ const displayPath = iconPath(request.mapName, candidate.fileName);
491
+ try {
492
+ const source = await readFile(candidate.path);
493
+ if (source.byteLength !== candidate.size || source.byteLength > tileflowIconPackageLimits.maxSourceFileBytes) {
494
+ throw new Error("Icon changed while it was being validated");
495
+ }
496
+ if (target === "hosted" && extname(candidate.fileName).toLowerCase() === ".svg") {
497
+ validateHostedSvg(source, displayPath);
498
+ }
499
+ icons.push({
500
+ fileName: candidate.fileName,
501
+ format: candidate.format,
502
+ name: candidate.name,
503
+ path: candidate.path,
504
+ source
505
+ });
506
+ } catch (error) {
507
+ issues.push({
508
+ message: error instanceof Error ? error.message : "Icon could not be read",
509
+ path: displayPath
510
+ });
511
+ }
512
+ }
513
+ if (issues.length > 0) {
514
+ return null;
515
+ }
516
+ return { icons, realSourceDir };
517
+ }
518
+ async function compileInspectedIconSource(inspected) {
519
+ const layoutOneX = createSpriteLayout(inspected.icons, 1);
520
+ const layoutTwoX = createSpriteLayout(inspected.icons, 2);
521
+ const rendered = await mapWithConcurrency(
522
+ inspected.icons,
523
+ tileflowIconPackageLimits.decodeConcurrency,
524
+ async (icon) => {
525
+ const dimensions = await validateDecodedDimensions(icon);
526
+ const oneX = await renderIcon(icon, 1);
527
+ const twoX = await renderIcon(icon, 2);
528
+ return {
529
+ dimensions,
530
+ input: icon,
531
+ oneX,
532
+ pixelSha256: {
533
+ oneX: await hashTileflowRenderedIconPixels({
534
+ height: oneX.height,
535
+ pixelRatio: 1,
536
+ rgba: oneX.rgba,
537
+ width: oneX.width
538
+ }),
539
+ twoX: await hashTileflowRenderedIconPixels({
540
+ height: twoX.height,
541
+ pixelRatio: 2,
542
+ rgba: twoX.rgba,
543
+ width: twoX.width
544
+ })
545
+ },
546
+ twoX
547
+ };
548
+ }
549
+ );
550
+ const [oneXImage, twoXImage] = await Promise.all([
551
+ createSpriteImage(
552
+ rendered.map((item) => item.oneX),
553
+ layoutOneX
554
+ ),
555
+ createSpriteImage(
556
+ rendered.map((item) => item.twoX),
557
+ layoutTwoX
558
+ )
559
+ ]);
560
+ const oneXJson = new TextEncoder().encode(`${serializeCanonicalJson(layoutOneX.index)}
561
+ `);
562
+ const twoXJson = new TextEncoder().encode(`${serializeCanonicalJson(layoutTwoX.index)}
563
+ `);
564
+ const files = [
565
+ { contentType: "application/json", fileName: "sprite.json", source: oneXJson },
566
+ { contentType: "image/png", fileName: "sprite.png", source: oneXImage },
567
+ { contentType: "application/json", fileName: "sprite@2x.json", source: twoXJson },
568
+ { contentType: "image/png", fileName: "sprite@2x.png", source: twoXImage }
569
+ ];
570
+ assertGeneratedFileLimits(files);
571
+ const fileDigests = await Promise.all(files.map((file) => sha256Hex(file.source)));
572
+ const manifest = tileflowIconPackageManifestSchema.parse({
573
+ files: files.map((file, index) => ({
574
+ byteLength: file.source.byteLength,
575
+ contentType: file.contentType,
576
+ name: file.fileName,
577
+ sha256: fileDigests[index]
578
+ })),
579
+ format: "tileflow-icon-package-v1",
580
+ iconNames: inspected.icons.map((icon) => icon.name),
581
+ renderedIcons: inspected.icons.map((icon, index) => {
582
+ const pixels = rendered[index];
583
+ if (!pixels) {
584
+ throw new Error(`Missing rendered pixels for ${icon.name}`);
585
+ }
586
+ return { name: icon.name, pixelSha256: pixels.pixelSha256 };
587
+ }),
588
+ sprites: {
589
+ oneX: { height: layoutOneX.height, pixelRatio: 1, width: layoutOneX.width },
590
+ twoX: { height: layoutTwoX.height, pixelRatio: 2, width: layoutTwoX.width }
591
+ }
592
+ });
593
+ return {
594
+ icons: rendered,
595
+ layoutOneX,
596
+ layoutTwoX,
597
+ package: {
598
+ contentHash: await hashTileflowIconPackageManifest(manifest),
599
+ files,
600
+ manifest
601
+ }
602
+ };
603
+ }
604
+ function createSpriteLayout(icons, pixelRatio) {
605
+ const cellSize = iconSpriteSize * pixelRatio;
606
+ const columns = Math.ceil(Math.sqrt(icons.length));
607
+ const rows = Math.ceil(icons.length / columns);
608
+ const width = columns * cellSize;
609
+ const height = rows * cellSize;
610
+ if (width > tileflowIconPackageLimits.maxAtlasDimension || height > tileflowIconPackageLimits.maxAtlasDimension) {
611
+ throw new Error(
612
+ `Generated sprite exceeds ${tileflowIconPackageLimits.maxAtlasDimension} pixels per dimension`
613
+ );
614
+ }
615
+ const index = Object.fromEntries(
616
+ icons.map((icon, indexNumber) => {
617
+ const column = indexNumber % columns;
618
+ const row = Math.floor(indexNumber / columns);
619
+ return [
620
+ icon.name,
621
+ {
622
+ height: cellSize,
623
+ pixelRatio,
624
+ width: cellSize,
625
+ x: column * cellSize,
626
+ y: row * cellSize
627
+ }
628
+ ];
629
+ })
630
+ );
631
+ return { cellSize, columns, height, index, width };
632
+ }
633
+ async function validateDecodedDimensions(icon) {
634
+ const sharp = await loadSharp();
635
+ const metadata = await sharp(icon.source, {
636
+ density: 72,
637
+ failOn: "error",
638
+ limitInputPixels: tileflowIconPackageLimits.maxDecodedPixelsPerIcon
639
+ }).metadata();
640
+ const width = metadata.width ?? 0;
641
+ const height = metadata.height ?? 0;
642
+ if (width <= 0 || height <= 0) {
643
+ throw new Error(`Unable to determine decoded dimensions for ${icon.fileName}`);
644
+ }
645
+ if (width * height > tileflowIconPackageLimits.maxDecodedPixelsPerIcon) {
646
+ throw new Error(
647
+ `${icon.fileName} exceeds ${tileflowIconPackageLimits.maxDecodedPixelsPerIcon} decoded pixels`
648
+ );
649
+ }
650
+ return { height, width };
651
+ }
652
+ async function renderIcon(icon, pixelRatio) {
653
+ const sharp = await loadSharp();
654
+ const cellSize = iconSpriteSize * pixelRatio;
655
+ const { data, info } = await sharp(icon.source, {
656
+ density: 72 * pixelRatio,
657
+ failOn: "error",
658
+ limitInputPixels: tileflowIconPackageLimits.maxDecodedPixelsPerIcon
659
+ }).resize(cellSize, cellSize, {
660
+ background: { alpha: 0, b: 0, g: 0, r: 0 },
661
+ fit: "contain"
662
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
663
+ if (info.channels !== 4 || info.width !== cellSize || info.height !== cellSize) {
664
+ throw new Error(`Rendered icon ${icon.fileName} did not produce canonical RGBA pixels`);
665
+ }
666
+ const rgba = new Uint8Array(data);
667
+ const png = await sharp(data, {
668
+ raw: { channels: 4, height: info.height, width: info.width }
669
+ }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
670
+ return { height: info.height, png, rgba, width: info.width };
671
+ }
672
+ async function createSpriteImage(icons, layout) {
673
+ const sharp = await loadSharp();
674
+ const atlas = new Uint8Array(layout.width * layout.height * 4);
675
+ for (const [indexNumber, icon] of icons.entries()) {
676
+ if (icon.width !== layout.cellSize || icon.height !== layout.cellSize) {
677
+ throw new Error("Rendered icon dimensions do not match the sprite layout");
678
+ }
679
+ const left = indexNumber % layout.columns * layout.cellSize;
680
+ const top = Math.floor(indexNumber / layout.columns) * layout.cellSize;
681
+ const rowBytes = icon.width * 4;
682
+ for (let row = 0; row < icon.height; row += 1) {
683
+ const sourceStart = row * rowBytes;
684
+ const targetStart = ((top + row) * layout.width + left) * 4;
685
+ atlas.set(icon.rgba.subarray(sourceStart, sourceStart + rowBytes), targetStart);
686
+ }
687
+ }
688
+ return sharp(atlas, {
689
+ raw: {
690
+ channels: 4,
691
+ height: layout.height,
692
+ width: layout.width
693
+ }
694
+ }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
695
+ }
696
+ function assertGeneratedFileLimits(files) {
697
+ let totalBytes = 0;
698
+ for (const file of files) {
699
+ if (file.source.byteLength > tileflowIconPackageLimits.maxGeneratedFileBytes) {
700
+ throw new Error(
701
+ `${file.fileName} exceeds ${tileflowIconPackageLimits.maxGeneratedFileBytes} generated bytes`
702
+ );
703
+ }
704
+ totalBytes += file.source.byteLength;
705
+ }
706
+ if (totalBytes > tileflowIconPackageLimits.maxGeneratedPackageBytes) {
707
+ throw new Error(
708
+ `Generated package exceeds ${tileflowIconPackageLimits.maxGeneratedPackageBytes} bytes`
709
+ );
710
+ }
711
+ }
712
+ function validateHostedSvg(source, displayPath) {
713
+ const parser = new SaxesParser({ fileName: displayPath, xmlns: true });
714
+ const decoder = new TextDecoder("utf-8", { fatal: true });
715
+ let styleDepth = 0;
716
+ let styleText = "";
717
+ parser.on("doctype", () => {
718
+ throw new Error("Hosted SVGs cannot contain a document type or entity declarations");
719
+ });
720
+ parser.on("processinginstruction", (instruction) => {
721
+ if (instruction.target.toLowerCase() === "xml-stylesheet") {
722
+ throw new Error("Hosted SVGs cannot load external stylesheets");
723
+ }
724
+ });
725
+ parser.on("opentag", (tag) => {
726
+ const localName = tag.local.toLowerCase();
727
+ if (localName === "script") {
728
+ throw new Error("Hosted SVGs cannot contain scripts");
729
+ }
730
+ if (localName === "style") {
731
+ styleDepth += 1;
732
+ }
733
+ for (const attribute of Object.values(tag.attributes)) {
734
+ const attributeName = attribute.local.toLowerCase();
735
+ const value = attribute.value.trim();
736
+ if (attributeName.startsWith("on")) {
737
+ throw new Error("Hosted SVGs cannot contain script event handlers");
738
+ }
739
+ if (attributeName === "href" && value.length > 0 && !value.startsWith("#")) {
740
+ throw new Error("Hosted SVG resource references must use local fragments");
741
+ }
742
+ if (containsExternalCssUrl(value)) {
743
+ throw new Error("Hosted SVG resource references must use local fragments");
744
+ }
745
+ }
746
+ });
747
+ parser.on("text", (text) => {
748
+ if (styleDepth > 0) {
749
+ styleText += text;
750
+ }
751
+ });
752
+ parser.on("cdata", (text) => {
753
+ if (styleDepth > 0) {
754
+ styleText += text;
755
+ }
756
+ });
757
+ parser.on("closetag", (tag) => {
758
+ if (tag.local.toLowerCase() !== "style") {
759
+ return;
760
+ }
761
+ styleDepth -= 1;
762
+ if (styleDepth === 0) {
763
+ if (styleText.toLowerCase().includes("@import") || containsExternalCssUrl(styleText)) {
764
+ throw new Error("Hosted SVGs cannot load external stylesheets or resources");
765
+ }
766
+ styleText = "";
767
+ }
768
+ });
769
+ parser.on("error", (error) => {
770
+ throw error;
771
+ });
772
+ const chunkBytes = 64 * 1024;
773
+ for (let offset = 0; offset < source.byteLength; offset += chunkBytes) {
774
+ parser.write(decoder.decode(source.subarray(offset, offset + chunkBytes), { stream: true }));
775
+ }
776
+ parser.write(decoder.decode());
777
+ parser.close();
778
+ }
779
+ function containsExternalCssUrl(value) {
780
+ const lowerValue = value.toLowerCase();
781
+ let cursor = 0;
782
+ while (cursor < value.length) {
783
+ const start = lowerValue.indexOf("url(", cursor);
784
+ if (start === -1) {
785
+ return false;
786
+ }
787
+ const end = value.indexOf(")", start + 4);
788
+ if (end === -1) {
789
+ return true;
790
+ }
791
+ const rawTarget = value.slice(start + 4, end).trim();
792
+ const target = stripMatchingQuotes(rawTarget);
793
+ if (!target.startsWith("#")) {
794
+ return true;
795
+ }
796
+ cursor = end + 1;
797
+ }
798
+ return false;
799
+ }
800
+ function stripMatchingQuotes(value) {
801
+ if (value.length < 2) {
802
+ return value;
803
+ }
804
+ const first = value[0];
805
+ const last = value[value.length - 1];
806
+ return first === '"' && last === '"' || first === "'" && last === "'" ? value.slice(1, -1).trim() : value;
807
+ }
808
+ async function mapWithConcurrency(values, concurrency, map) {
809
+ const results = new Array(values.length);
810
+ let nextIndex = 0;
811
+ async function worker() {
812
+ while (nextIndex < values.length) {
813
+ const index = nextIndex;
814
+ nextIndex += 1;
815
+ const value = values[index];
816
+ if (value !== void 0) {
817
+ results[index] = await map(value, index);
818
+ }
819
+ }
820
+ }
821
+ await Promise.all(
822
+ Array.from({ length: Math.min(concurrency, values.length) }, async () => worker())
823
+ );
824
+ return results;
825
+ }
826
+ async function loadSharp() {
827
+ try {
828
+ return (await import("sharp")).default;
829
+ } catch (error) {
830
+ throw new Error(
831
+ 'Local icon sprites require the optional "sharp" package. Install sharp or disable local icons.',
832
+ { cause: error }
833
+ );
834
+ }
835
+ }
836
+ function resolvePackageLabel(icons, source, iconSets, mapName) {
837
+ let candidate;
838
+ if (typeof icons === "string" && iconSets?.[icons]) {
839
+ candidate = icons;
840
+ } else if (icons && typeof icons === "object" && icons.extends && iconSets?.[icons.extends]) {
841
+ candidate = icons.extends;
842
+ }
843
+ candidate ??= basename(source.replace(/[\\/]+$/u, "")) || mapName || "Icons";
844
+ const printable = Array.from(candidate.normalize("NFC")).map((character) => {
845
+ const codePoint = character.codePointAt(0) ?? 0;
846
+ return codePoint < 32 || codePoint >= 127 && codePoint <= 159 ? "" : character;
847
+ }).join("").trim();
848
+ const bounded = Array.from(printable || "Icons").slice(0, 64).join("");
849
+ return tileflowIconPackageLabelSchema.parse(bounded);
850
+ }
851
+ function sortMapping(mapping) {
852
+ if (!mapping) {
853
+ return void 0;
854
+ }
855
+ return Object.fromEntries(
856
+ Object.entries(mapping).sort(([left], [right]) => compareCodeUnits(left, right))
857
+ );
858
+ }
859
+ function createCatalogMappings(mapping, targetStatus) {
860
+ return Object.entries(mapping ?? {}).sort(([left], [right]) => compareCodeUnits(left, right)).map(([semantic, iconId]) => ({
861
+ iconId,
862
+ semantic,
863
+ targetStatus: typeof targetStatus === "function" ? targetStatus(iconId) : targetStatus
864
+ }));
865
+ }
866
+ function compareMappedFrom(left, right) {
867
+ return compareCodeUnits(left.map, right.map) || compareCodeUnits(left.semantic, right.semantic);
868
+ }
869
+ function atlasRectangle(entry) {
870
+ return { height: entry.height, width: entry.width, x: entry.x, y: entry.y };
871
+ }
872
+ function relativePortablePath(root, candidate) {
873
+ const pathFromRoot = relative(root, candidate);
874
+ if (pathFromRoot === "") {
875
+ return ".";
876
+ }
877
+ if (isAbsolute(pathFromRoot)) {
878
+ throw new Error("Icon catalog path cannot be represented relative to the working tree");
879
+ }
880
+ return pathFromRoot.split(sep).join("/");
881
+ }
882
+ function iconSourceFormat(extension) {
883
+ switch (extension) {
884
+ case ".svg":
885
+ return "svg";
886
+ case ".png":
887
+ return "png";
888
+ case ".jpg":
889
+ case ".jpeg":
890
+ return "jpeg";
891
+ case ".webp":
892
+ return "webp";
893
+ default:
894
+ throw new Error(`Unsupported icon source format: ${extension}`);
895
+ }
896
+ }
897
+ function sourcePath(mapName) {
898
+ return `maps.${mapName}.icons.source`;
899
+ }
900
+ function iconPath(mapName, fileName) {
901
+ return `${sourcePath(mapName)}/${fileName}`;
902
+ }
903
+ function isPathInside(root, candidate) {
904
+ const pathFromRoot = relative(root, candidate);
905
+ return pathFromRoot === "" || !isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`);
906
+ }
907
+ function describeFileSystemError(error, fallback) {
908
+ if (error && typeof error === "object" && "code" in error) {
909
+ const code = String(error.code ?? "");
910
+ if (code === "ENOENT") {
911
+ return fallback;
912
+ }
913
+ if (code === "EACCES" || code === "EPERM") {
914
+ return "Icon source is not readable";
915
+ }
916
+ }
917
+ return error instanceof Error && error.message ? error.message : fallback;
918
+ }
919
+ function cloneProject(project) {
920
+ return JSON.parse(JSON.stringify(project));
921
+ }
922
+ function isRemoteSpriteReference(value) {
923
+ return value.startsWith("http://") || value.startsWith("https://") || value.startsWith("/") || value.startsWith("data:");
924
+ }
925
+ function joinUrl(base, path) {
926
+ const trimmedBase = base.replace(/\/+$/g, "");
927
+ const trimmedPath = path.replace(/^\/+/g, "");
928
+ return trimmedBase ? `${trimmedBase}/${trimmedPath}` : trimmedPath;
929
+ }
930
+ function uniqueStrings(values) {
931
+ return [...new Set(values)];
932
+ }
933
+
934
+ // src/session.ts
935
+ import { watch } from "chokidar";
936
+ import { realpathSync } from "fs";
937
+ import { dirname, extname as extname2, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve2, sep as sep2, win32 } from "path";
938
+
939
+ // src/diagnostic-sanitization.ts
940
+ function sanitizeDiagnosticSecrets(value) {
941
+ return value.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[redacted]@").replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]").replace(/\btf_(?:live|cli|test)_[A-Za-z0-9_-]{8,}\b/gi, "tf_[redacted]").replace(/\b(?:sk_(?:live|test)|rk_(?:live|test)|whsec)_[A-Za-z0-9_-]+\b/g, "[redacted]").replace(
942
+ /([?&](?:access_token|api_key|key|password|secret|signature|token)=)[^&#\s]+/gi,
943
+ "$1[redacted]"
944
+ );
945
+ }
946
+
947
+ // src/session.ts
948
+ var tileflowArtifactSessionSchemaVersion = 1;
949
+ var configExtensions = /* @__PURE__ */ new Set([".cjs", ".cts", ".js", ".json", ".mjs", ".mts", ".ts", ".tsx"]);
950
+ var iconExtensions = /* @__PURE__ */ new Set([".jpeg", ".jpg", ".png", ".svg", ".webp"]);
951
+ var excludedDirectories = /* @__PURE__ */ new Set([
952
+ ".cache",
953
+ ".git",
954
+ ".hg",
955
+ ".next",
956
+ ".nuxt",
957
+ ".svn",
958
+ ".svelte-kit",
959
+ ".tileflow",
960
+ ".turbo",
961
+ "build",
962
+ "coverage",
963
+ "dist",
964
+ "node_modules"
965
+ ]);
966
+ async function createTileflowArtifactSessionWithBuilder(options, buildArtifacts) {
967
+ const session = new TileflowArtifactSessionImpl(options, buildArtifacts);
968
+ await session.start();
969
+ return session;
970
+ }
971
+ var TileflowArtifactSessionImpl = class {
972
+ #buildArtifacts;
973
+ #buildOptions;
974
+ #configPath;
975
+ #cwd;
976
+ #debounceMs;
977
+ #listeners = /* @__PURE__ */ new Set();
978
+ #ignoredPaths;
979
+ #watchEnabled;
980
+ #closed = false;
981
+ #debounceTimer;
982
+ #generation = 0;
983
+ #lastGoodArtifacts;
984
+ #lastGoodGeneration;
985
+ #refreshToken = 0;
986
+ #state = { generation: 0, status: "building" };
987
+ #watcher;
988
+ #watchedArtifactPaths = /* @__PURE__ */ new Set();
989
+ constructor(options, buildArtifacts) {
990
+ this.#cwd = resolve2(options.cwd ?? process.cwd());
991
+ this.#configPath = resolve2(this.#cwd, options.config ?? "tileflow.config.ts");
992
+ this.#debounceMs = Math.max(0, Math.min(options.debounceMs ?? 75, 1e3));
993
+ this.#watchEnabled = options.watch ?? false;
994
+ this.#ignoredPaths = (options.ignoredPaths ?? []).map((path) => resolve2(this.#cwd, path));
995
+ this.#buildArtifacts = buildArtifacts;
996
+ this.#buildOptions = {
997
+ assetBaseUrl: options.assetBaseUrl,
998
+ config: options.config,
999
+ cwd: this.#cwd,
1000
+ styleBaseUrl: options.styleBaseUrl,
1001
+ tileBaseUrl: options.tileBaseUrl
1002
+ };
1003
+ }
1004
+ async start() {
1005
+ if (this.#watchEnabled) {
1006
+ this.#watcher = watch(dirname(this.#configPath), {
1007
+ awaitWriteFinish: { pollInterval: 20, stabilityThreshold: 50 },
1008
+ followSymlinks: false,
1009
+ ignoreInitial: true,
1010
+ ignored: (path, stats) => this.#ignoreWatchPath(path, stats?.isDirectory())
1011
+ });
1012
+ const watcherReady = new Promise((resolveReady, rejectReady) => {
1013
+ const watcher = this.#watcher;
1014
+ const cleanup = () => {
1015
+ watcher.off("ready", onReady);
1016
+ watcher.off("error", onError);
1017
+ };
1018
+ const onReady = () => {
1019
+ cleanup();
1020
+ resolveReady();
1021
+ };
1022
+ const onError = (error) => {
1023
+ cleanup();
1024
+ rejectReady(error);
1025
+ };
1026
+ watcher.once("ready", onReady);
1027
+ watcher.once("error", onError);
1028
+ });
1029
+ this.#watcher.on("all", (event, path) => {
1030
+ if (event === "addDir" || event === "unlinkDir") return;
1031
+ if (!this.#shouldRefreshForWatchEvent(path)) return;
1032
+ this.#scheduleRefresh(path);
1033
+ });
1034
+ this.#watcher.on("error", (error) => {
1035
+ if (this.#closed) return;
1036
+ this.#refreshToken += 1;
1037
+ this.#publishInvalid(this.#generation + 1, error);
1038
+ });
1039
+ await watcherReady;
1040
+ }
1041
+ await this.refresh("initial");
1042
+ }
1043
+ getState() {
1044
+ return this.#state;
1045
+ }
1046
+ getLastGoodArtifacts() {
1047
+ return this.#lastGoodArtifacts;
1048
+ }
1049
+ subscribe(listener) {
1050
+ if (this.#closed) return () => void 0;
1051
+ this.#listeners.add(listener);
1052
+ return () => this.#listeners.delete(listener);
1053
+ }
1054
+ async refresh(_reason = "manual") {
1055
+ if (this.#closed) return;
1056
+ const generation = ++this.#generation;
1057
+ const token = ++this.#refreshToken;
1058
+ this.#publish({
1059
+ generation,
1060
+ status: "building",
1061
+ ...this.#lastGoodGeneration === void 0 ? {} : { lastGoodGeneration: this.#lastGoodGeneration }
1062
+ });
1063
+ try {
1064
+ const artifacts = await this.#buildArtifacts(this.#buildOptions);
1065
+ if (this.#closed || token !== this.#refreshToken) return;
1066
+ this.#lastGoodArtifacts = artifacts;
1067
+ this.#lastGoodGeneration = generation;
1068
+ this.#updateArtifactWatchPaths(artifacts.watchPaths);
1069
+ this.#publish({
1070
+ artifacts,
1071
+ generation,
1072
+ lastGoodGeneration: generation,
1073
+ status: "ready"
1074
+ });
1075
+ } catch (error) {
1076
+ if (this.#closed || token !== this.#refreshToken) return;
1077
+ this.#publishInvalid(generation, error);
1078
+ }
1079
+ }
1080
+ async close() {
1081
+ if (this.#closed) return;
1082
+ this.#closed = true;
1083
+ this.#refreshToken += 1;
1084
+ if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
1085
+ this.#listeners.clear();
1086
+ await this.#watcher?.close();
1087
+ }
1088
+ #ignoreWatchPath(path, isDirectory) {
1089
+ const resolvedPath = resolve2(path);
1090
+ const relativePath = relative2(dirname(this.#configPath), resolvedPath);
1091
+ const segments = relativePath.split(sep2).filter(Boolean);
1092
+ if (this.#ignoredPaths.some((path2) => isSameOrInside(path2, resolvedPath))) return true;
1093
+ if (segments.some((segment) => excludedDirectories.has(segment))) return true;
1094
+ if (isDirectory !== false) return false;
1095
+ if (resolvedPath === this.#configPath || this.#watchedArtifactPaths.has(resolvedPath))
1096
+ return false;
1097
+ const extension = extname2(resolvedPath).toLowerCase();
1098
+ if (configExtensions.has(extension)) return false;
1099
+ if (iconExtensions.has(extension)) return false;
1100
+ return true;
1101
+ }
1102
+ #shouldRefreshForWatchEvent(path) {
1103
+ const resolvedPath = resolve2(path);
1104
+ if (!iconExtensions.has(extname2(resolvedPath).toLowerCase())) return true;
1105
+ return [...this.#watchedArtifactPaths].some((path2) => isSameOrInside(path2, resolvedPath));
1106
+ }
1107
+ #publish(state) {
1108
+ this.#state = state;
1109
+ for (const listener of this.#listeners) listener(state);
1110
+ }
1111
+ #publishInvalid(generation, error) {
1112
+ this.#generation = Math.max(this.#generation, generation);
1113
+ this.#publish({
1114
+ diagnostics: createTileflowArtifactDiagnostics(error, this.#cwd),
1115
+ generation,
1116
+ status: "invalid",
1117
+ ...this.#lastGoodGeneration === void 0 ? {} : { lastGoodGeneration: this.#lastGoodGeneration }
1118
+ });
1119
+ }
1120
+ #scheduleRefresh(_path) {
1121
+ if (this.#closed) return;
1122
+ if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
1123
+ this.#debounceTimer = setTimeout(() => {
1124
+ this.#debounceTimer = void 0;
1125
+ void this.refresh("filesystem");
1126
+ }, this.#debounceMs);
1127
+ }
1128
+ #updateArtifactWatchPaths(paths) {
1129
+ if (!this.#watcher) return;
1130
+ const next = new Set(paths.map((path) => resolve2(this.#cwd, path)));
1131
+ const previous = this.#watchedArtifactPaths;
1132
+ this.#watchedArtifactPaths = next;
1133
+ for (const path of previous) {
1134
+ if (!next.has(path) && !isSameOrInside(dirname(this.#configPath), path)) {
1135
+ void this.#watcher.unwatch(path);
1136
+ }
1137
+ }
1138
+ for (const path of next) {
1139
+ if (!previous.has(path) && !isSameOrInside(dirname(this.#configPath), path)) {
1140
+ this.#watcher.add(path);
1141
+ }
1142
+ }
1143
+ }
1144
+ };
1145
+ function isSameOrInside(root, candidate) {
1146
+ const path = relative2(resolve2(root), resolve2(candidate));
1147
+ return path === "" || !isAbsolute2(path) && path !== ".." && !path.startsWith(`..${sep2}`);
1148
+ }
1149
+ function createTileflowArtifactDiagnostics(error, cwd) {
1150
+ const inheritedCode = optionalDiagnosticField(error, "code");
1151
+ const inheritedPhase = optionalDiagnosticField(error, "phase") ?? optionalNestedDiagnosticField(error, "phase");
1152
+ const issueList = getIssueList(error);
1153
+ if (issueList.length > 0) {
1154
+ return normalizeDiagnostics(
1155
+ issueList.map((issue) => ({
1156
+ ...issue.code ?? inheritedCode ? { code: issue.code ?? inheritedCode } : {},
1157
+ message: sanitizeMessage(issue.message, cwd),
1158
+ path: sanitizePath(issue.path, cwd),
1159
+ ...issue.phase ?? inheritedPhase ? { phase: issue.phase ?? inheritedPhase } : {}
1160
+ }))
1161
+ );
1162
+ }
1163
+ return normalizeDiagnostics([
1164
+ {
1165
+ ...inheritedCode ? { code: inheritedCode } : {},
1166
+ message: sanitizeMessage(error instanceof Error ? error.message : "Unknown build error", cwd),
1167
+ path: "",
1168
+ ...inheritedPhase ? { phase: inheritedPhase } : {}
1169
+ }
1170
+ ]);
1171
+ }
1172
+ function getIssueList(error) {
1173
+ if (!error || typeof error !== "object") return [];
1174
+ const containers = [];
1175
+ for (const key of ["messages", "issues"]) {
1176
+ const value = error[key];
1177
+ if (Array.isArray(value)) containers.push(value);
1178
+ }
1179
+ const details = error.details;
1180
+ if (details && typeof details === "object") {
1181
+ const diagnostics = details.diagnostics;
1182
+ if (Array.isArray(diagnostics)) containers.push(diagnostics);
1183
+ }
1184
+ return containers.flatMap(
1185
+ (value) => value.flatMap((entry) => {
1186
+ if (!entry || typeof entry !== "object") return [];
1187
+ const message = entry.message;
1188
+ const path = entry.path;
1189
+ const code = optionalDiagnosticField(entry, "code");
1190
+ const phase = optionalDiagnosticField(entry, "phase");
1191
+ return typeof message === "string" ? [
1192
+ {
1193
+ ...code ? { code } : {},
1194
+ message,
1195
+ path: typeof path === "string" ? path : "",
1196
+ ...phase ? { phase } : {}
1197
+ }
1198
+ ] : [];
1199
+ })
1200
+ );
1201
+ }
1202
+ function sanitizeMessage(value, cwd) {
1203
+ let sanitized = value;
1204
+ for (const root of localPathAliases(cwd)) sanitized = sanitized.replaceAll(root, ".");
1205
+ sanitized = sanitized.replace(/[\r\n]+/g, " ").trim();
1206
+ sanitized = sanitized.replace(/https?:\/\/[^\s'")<>]+/gi, (url) => {
1207
+ try {
1208
+ return new URL(url.replace(/[),.;]+$/, "")).origin;
1209
+ } catch {
1210
+ return "(resource URL)";
1211
+ }
1212
+ });
1213
+ sanitized = sanitized.replace(/(^|[\s'"(])[A-Za-z]:[\\/][^\s'")]+/g, "$1(external path)");
1214
+ sanitized = sanitized.replace(/(^|[\s'"(])(?:\\\\|\/\/)[^\s'")]+/g, "$1(external path)");
1215
+ sanitized = sanitized.replace(/(^|[\s'"(])\/[^\s'")]+/g, "$1(external path)");
1216
+ return sanitizeDiagnosticSecrets(sanitized);
1217
+ }
1218
+ function sanitizePath(value, cwd) {
1219
+ if (!value) return "";
1220
+ if (win32.isAbsolute(value) && !isAbsolute2(value)) return "(external)";
1221
+ if (!isAbsolute2(value)) return value.replaceAll("\\", "/");
1222
+ const local = relative2(canonicalPath(cwd), canonicalPath(value)).replaceAll(sep2, "/");
1223
+ return local.startsWith("../") || local === ".." ? "(external)" : local || ".";
1224
+ }
1225
+ function localPathAliases(path) {
1226
+ return [.../* @__PURE__ */ new Set([resolve2(path), canonicalPath(path)])].sort(
1227
+ (left, right) => right.length - left.length
1228
+ );
1229
+ }
1230
+ function canonicalPath(path) {
1231
+ try {
1232
+ return realpathSync.native(resolve2(path));
1233
+ } catch {
1234
+ return resolve2(path);
1235
+ }
1236
+ }
1237
+ function normalizeDiagnostics(diagnostics) {
1238
+ const bounded = diagnostics.map((diagnostic) => ({
1239
+ ...diagnostic.code ? { code: boundDiagnosticField(diagnostic.code) } : {},
1240
+ message: boundDiagnosticText(diagnostic.message, 300),
1241
+ path: boundDiagnosticText(diagnostic.path, 300),
1242
+ ...diagnostic.phase ? { phase: boundDiagnosticField(diagnostic.phase) } : {}
1243
+ }));
1244
+ bounded.sort((left, right) => {
1245
+ const leftKey = `${left.path}\0${left.message}\0${left.code ?? ""}\0${left.phase ?? ""}`;
1246
+ const rightKey = `${right.path}\0${right.message}\0${right.code ?? ""}\0${right.phase ?? ""}`;
1247
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
1248
+ });
1249
+ return bounded.filter(
1250
+ (diagnostic, index) => index === 0 || JSON.stringify(diagnostic) !== JSON.stringify(bounded[index - 1])
1251
+ ).slice(0, 32);
1252
+ }
1253
+ function optionalDiagnosticField(value, key) {
1254
+ if (!value || typeof value !== "object") return void 0;
1255
+ const field = value[key];
1256
+ return typeof field === "string" && field.trim() ? boundDiagnosticField(field) : void 0;
1257
+ }
1258
+ function optionalNestedDiagnosticField(value, key) {
1259
+ if (!value || typeof value !== "object") return void 0;
1260
+ return optionalDiagnosticField(value.details, key);
1261
+ }
1262
+ function boundDiagnosticField(value) {
1263
+ return boundDiagnosticText(value.replace(/[^A-Za-z0-9_-]/g, ""), 64);
1264
+ }
1265
+ function boundDiagnosticText(value, limit) {
1266
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}\u2026`;
1267
+ }
1268
+
1269
+ // src/style-validation.ts
1270
+ import { validateStyleMin } from "@maplibre/maplibre-gl-style-spec";
1271
+ var TileflowStyleValidationError = class extends Error {
1272
+ code = "STYLE_INVALID";
1273
+ issues;
1274
+ phase = "style-validation";
1275
+ constructor(issues) {
1276
+ super("Compiled Tileflow styles are not valid MapLibre styles.");
1277
+ this.name = "TileflowStyleValidationError";
1278
+ this.issues = normalizeIssues(issues);
1279
+ }
1280
+ };
1281
+ var maxIssues = 32;
1282
+ var maxMessageLength = 300;
1283
+ var maxPathLength = 300;
1284
+ function assertValidTileflowStyle(style, map) {
1285
+ const issues = validateTileflowStyle(style, map);
1286
+ if (issues.length > 0) throw new TileflowStyleValidationError(issues);
1287
+ }
1288
+ function validateTileflowStyle(style, map) {
1289
+ const jsonIssues = [];
1290
+ collectJsonIssues(
1291
+ style,
1292
+ `maps.${sanitizePathSegment(map)}.style`,
1293
+ map,
1294
+ jsonIssues,
1295
+ /* @__PURE__ */ new Set()
1296
+ );
1297
+ if (jsonIssues.length > 0) return normalizeIssues(jsonIssues);
1298
+ const semanticIssues = validateStyleMin(style).map(
1299
+ (issue) => createSemanticIssue(style, map, issue.message)
1300
+ );
1301
+ return normalizeIssues(semanticIssues);
1302
+ }
1303
+ function normalizeTileflowStyleValidationIssues(issues) {
1304
+ return normalizeIssues(issues);
1305
+ }
1306
+ function createSemanticIssue(style, map, rawMessage) {
1307
+ const normalized = rawMessage.replace(/[\r\n]+/g, " ").trim();
1308
+ const separator = normalized.indexOf(":");
1309
+ const rawPath = separator >= 0 ? normalized.slice(0, separator).trim() : "";
1310
+ const detail = separator >= 0 ? normalized.slice(separator + 1).trim() : normalized;
1311
+ return {
1312
+ map,
1313
+ message: boundText(sanitizeDiagnosticSecrets(sanitizeUrls(detail)), maxMessageLength),
1314
+ path: boundText(stableStylePath(style, map, rawPath), maxPathLength)
1315
+ };
1316
+ }
1317
+ function stableStylePath(style, map, rawPath) {
1318
+ const fallback = `maps.${sanitizePathSegment(map)}.style`;
1319
+ if (!rawPath) return fallback;
1320
+ const path = rawPath.replace(/layers\[(\d+)]/g, (_match, index) => {
1321
+ const layer = style.layers[Number(index)];
1322
+ const id = typeof layer?.id === "string" && layer.id ? layer.id : index;
1323
+ return `layers.${sanitizePathSegment(id)}`;
1324
+ });
1325
+ return `${fallback}.${path}`;
1326
+ }
1327
+ function sanitizePathSegment(value) {
1328
+ let sanitized = "";
1329
+ let replacing = false;
1330
+ for (const character of value) {
1331
+ const codePoint = character.codePointAt(0) ?? 0;
1332
+ const isAsciiLetter = codePoint >= 65 && codePoint <= 90 || codePoint >= 97 && codePoint <= 122;
1333
+ const isDigit = codePoint >= 48 && codePoint <= 57;
1334
+ const shouldReplace = !isAsciiLetter && !isDigit && character !== "-" && character !== "_";
1335
+ if (shouldReplace) {
1336
+ if (!replacing) sanitized += "-";
1337
+ replacing = true;
1338
+ } else {
1339
+ sanitized += character;
1340
+ replacing = false;
1341
+ }
1342
+ }
1343
+ return sanitized.slice(0, 128) || "(unknown)";
1344
+ }
1345
+ function collectJsonIssues(value, path, map, issues, ancestors) {
1346
+ if (issues.length >= maxIssues) return;
1347
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
1348
+ if (typeof value === "number") {
1349
+ if (!Number.isFinite(value)) addJsonIssue(map, path, "non-finite number", issues);
1350
+ return;
1351
+ }
1352
+ if (typeof value !== "object") {
1353
+ addJsonIssue(map, path, typeof value, issues);
1354
+ return;
1355
+ }
1356
+ if (ancestors.has(value)) {
1357
+ addJsonIssue(map, path, "cyclic object", issues);
1358
+ return;
1359
+ }
1360
+ ancestors.add(value);
1361
+ if (Array.isArray(value)) {
1362
+ value.forEach((item, index) => {
1363
+ const layerId = path.endsWith(".layers") && item && typeof item === "object" && !Array.isArray(item) ? item.id : void 0;
1364
+ const childPath = typeof layerId === "string" && layerId ? `${path}.${sanitizePathSegment(layerId)}` : `${path}[${index}]`;
1365
+ collectJsonIssues(item, childPath, map, issues, ancestors);
1366
+ });
1367
+ } else {
1368
+ for (const [key, item] of Object.entries(value)) {
1369
+ collectJsonIssues(item, `${path}.${sanitizePathSegment(key)}`, map, issues, ancestors);
1370
+ }
1371
+ }
1372
+ ancestors.delete(value);
1373
+ }
1374
+ function addJsonIssue(map, path, type, issues) {
1375
+ issues.push({
1376
+ map,
1377
+ message: `Expected a recursive JSON value; found ${type}.`,
1378
+ path: boundText(path, maxPathLength)
1379
+ });
1380
+ }
1381
+ function normalizeIssues(issues) {
1382
+ const normalized = issues.map((issue) => ({
1383
+ map: boundText(sanitizePathSegment(issue.map), 128),
1384
+ message: boundText(
1385
+ sanitizeDiagnosticSecrets(issue.message.replace(/[\r\n]+/g, " ").trim()),
1386
+ maxMessageLength
1387
+ ),
1388
+ path: boundText(issue.path.replaceAll("\\", "/"), maxPathLength)
1389
+ }));
1390
+ normalized.sort(
1391
+ (left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : left.message < right.message ? -1 : left.message > right.message ? 1 : 0
1392
+ );
1393
+ return normalized.filter(
1394
+ (issue, index) => index === 0 || issue.path !== normalized[index - 1]?.path || issue.message !== normalized[index - 1]?.message
1395
+ ).slice(0, maxIssues);
1396
+ }
1397
+ function sanitizeUrls(message) {
1398
+ return message.replace(/https?:\/\/[^\s'"<>]+/gi, (value) => {
1399
+ try {
1400
+ return new URL(value.replace(/[),.;]+$/, "")).origin;
1401
+ } catch {
1402
+ return "(resource URL)";
29
1403
  }
30
- const spriteUrl = joinUrl(options.assetBaseUrl, `icons/${localSource.mapName}/sprite`);
31
- mapConfig.icons = {
32
- ...iconSet,
33
- sprite: spriteUrl
34
- };
1404
+ });
1405
+ }
1406
+ function boundText(value, limit) {
1407
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}\u2026`;
1408
+ }
1409
+
1410
+ // src/feature-inspection.ts
1411
+ import { VectorTile } from "@mapbox/vector-tile";
1412
+ import Pbf from "pbf";
1413
+ import {
1414
+ compareCodeUnits as compareCodeUnits2,
1415
+ createStyleFromProject
1416
+ } from "@tileflow/core";
1417
+ var mercatorLatitudeLimit = 85.0511287798066;
1418
+ var maxTileJsonBytes = 1024 * 1024;
1419
+ var maxTileBytes = 6 * 1024 * 1024;
1420
+ var maxTotalTileBytes = 24 * 1024 * 1024;
1421
+ var maxTiles = 32;
1422
+ var maxScannedFeatures = 2e4;
1423
+ var maxStringLength = 256;
1424
+ async function inspectTileflowFeatures(project, mapName, input) {
1425
+ const options = validateInspectionOptions(input);
1426
+ if (!Object.hasOwn(project.maps, mapName)) {
1427
+ const available = Object.keys(project.maps).sort(compareCodeUnits2);
1428
+ throw new Error(
1429
+ `Unknown map "${mapName}". Available maps: ${available.join(", ") || "(none)"}`
1430
+ );
35
1431
  }
36
- if (localSources.length === 0) {
37
- return {
38
- assets: [],
39
- project: nextProject,
40
- watchPaths: []
41
- };
1432
+ const style = createStyleFromProject(project, mapName, { tileBaseUrl: options.tileBaseUrl });
1433
+ assertValidTileflowStyle(style, mapName);
1434
+ const source = resolveVectorSource(style, options.sourceLayers);
1435
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
1436
+ const remoteOrigins = /* @__PURE__ */ new Set();
1437
+ const tileSource = await resolveTileSource(
1438
+ source.definition,
1439
+ fetchImplementation,
1440
+ options,
1441
+ remoteOrigins
1442
+ );
1443
+ const bounds = viewportBounds(options.center, options.zoom, options.width, options.height);
1444
+ const tileZoom = resolveTileZoom(options.zoom, tileSource.minzoom, tileSource.maxzoom);
1445
+ const tileCoordinates = tileCover(bounds, tileZoom);
1446
+ if (tileCoordinates.length > maxTiles) {
1447
+ throw new Error(
1448
+ `Feature inspection would read ${tileCoordinates.length} tiles; narrow the viewport or increase zoom (maximum ${maxTiles}).`
1449
+ );
1450
+ }
1451
+ const features = [];
1452
+ let scannedFeatures = 0;
1453
+ let totalTileBytes = 0;
1454
+ let scanTruncated = false;
1455
+ for (const coordinate of tileCoordinates) {
1456
+ const tileUrl = resolveTileUrl(tileSource.template, coordinate, tileSource.scheme);
1457
+ const bytes = await fetchBytes(tileUrl, {
1458
+ fetchImplementation,
1459
+ label: "vector tile",
1460
+ limit: maxTileBytes,
1461
+ signal: options.signal,
1462
+ timeoutMs: options.timeoutMs
1463
+ });
1464
+ remoteOrigins.add(safeOrigin(bytes.finalUrl));
1465
+ totalTileBytes += bytes.body.byteLength;
1466
+ if (totalTileBytes > maxTotalTileBytes) {
1467
+ throw new Error(
1468
+ `Feature inspection exceeded the ${maxTotalTileBytes}-byte total vector-tile limit.`
1469
+ );
1470
+ }
1471
+ const tile = new VectorTile(new Pbf(bytes.body));
1472
+ for (const sourceLayer of options.sourceLayers) {
1473
+ const layer = tile.layers[sourceLayer];
1474
+ if (!layer) continue;
1475
+ for (let index = 0; index < layer.length; index += 1) {
1476
+ scannedFeatures += 1;
1477
+ if (scannedFeatures > maxScannedFeatures) {
1478
+ scanTruncated = true;
1479
+ break;
1480
+ }
1481
+ const vectorFeature = layer.feature(index);
1482
+ const geoJsonFeature = vectorFeature.toGeoJSON(coordinate.x, coordinate.y, coordinate.z);
1483
+ const geometry = summarizeGeometry(geoJsonFeature.geometry);
1484
+ if (!geometry || !boundsIntersect(bounds, geometry.bounds)) continue;
1485
+ features.push({
1486
+ sourceLayer,
1487
+ id: typeof vectorFeature.id === "number" && Number.isSafeInteger(vectorFeature.id) ? vectorFeature.id : null,
1488
+ geometry,
1489
+ properties: projectProperties(vectorFeature.properties, options.properties)
1490
+ });
1491
+ }
1492
+ if (scanTruncated) break;
1493
+ }
1494
+ if (scanTruncated) break;
42
1495
  }
43
- const assets = (await Promise.all(
44
- localSources.map(async (source) => buildSpriteAssets(source.sourceDir, source.mapName))
45
- )).flat();
1496
+ const uniqueFeatures = deduplicateFeatures(features).sort(compareInspectedFeatures);
1497
+ const limitedFeatures = uniqueFeatures.slice(0, options.limit);
1498
+ const metadataVersion = style.metadata?.["tileflow:tilesetVersion"];
46
1499
  return {
47
- assets,
48
- project: nextProject,
49
- watchPaths: localSources.map((source) => source.sourceDir)
1500
+ schemaVersion: 1,
1501
+ map: mapName,
1502
+ camera: {
1503
+ center: [...options.center],
1504
+ zoom: options.zoom,
1505
+ viewport: { width: options.width, height: options.height }
1506
+ },
1507
+ source: {
1508
+ id: source.id,
1509
+ origin: tileSource.origin,
1510
+ tileJsonPath: tileSource.tileJsonPath,
1511
+ tileOrigins: [...remoteOrigins].sort(compareCodeUnits2),
1512
+ version: typeof metadataVersion === "string" ? metadataVersion : null
1513
+ },
1514
+ sourceLayers: options.sourceLayers,
1515
+ properties: options.properties,
1516
+ tileZoom,
1517
+ tilesRead: tileCoordinates.length,
1518
+ scannedFeatures: Math.min(scannedFeatures, maxScannedFeatures),
1519
+ truncated: scanTruncated || uniqueFeatures.length > options.limit,
1520
+ features: limitedFeatures
50
1521
  };
51
1522
  }
52
- function getTileflowIconWatchPaths(project, cwd) {
53
- return uniqueStrings(getLocalIconSources(project, cwd).map((source) => source.sourceDir));
54
- }
55
- function getLocalIconSources(project, cwd) {
56
- return Object.entries(project.maps).flatMap(([mapName, mapConfig]) => {
57
- const iconSet = resolveIconSet(mapConfig.icons, project.icons);
58
- const sourceDir = resolveLocalIconSource(iconSet, cwd);
59
- return sourceDir ? [{ mapName, sourceDir }] : [];
60
- });
1523
+ function validateInspectionOptions(input) {
1524
+ const center = input.center;
1525
+ if (!Array.isArray(center) || center.length !== 2 || !Number.isFinite(center[0]) || !Number.isFinite(center[1]) || center[0] < -180 || center[0] > 180 || center[1] < -mercatorLatitudeLimit || center[1] > mercatorLatitudeLimit) {
1526
+ throw new Error(
1527
+ `Feature inspection center must be [longitude, latitude] inside Web Mercator bounds.`
1528
+ );
1529
+ }
1530
+ if (!Number.isFinite(input.zoom) || input.zoom < 0 || input.zoom > 24) {
1531
+ throw new Error("Feature inspection zoom must be between 0 and 24.");
1532
+ }
1533
+ const width = boundedInteger(input.width ?? 512, 64, 2048, "width");
1534
+ const height = boundedInteger(input.height ?? 512, 64, 2048, "height");
1535
+ const limit = boundedInteger(input.limit ?? 200, 1, 500, "limit");
1536
+ const timeoutMs = boundedInteger(input.timeoutMs ?? 1e4, 100, 6e4, "timeout");
1537
+ const sourceLayers = normalizeNames(input.sourceLayers, 1, 12, "source layer");
1538
+ const properties = normalizeNames(input.properties ?? [], 0, 16, "property");
1539
+ return {
1540
+ ...input,
1541
+ center: [center[0], center[1]],
1542
+ height,
1543
+ limit,
1544
+ properties,
1545
+ sourceLayers,
1546
+ timeoutMs,
1547
+ width
1548
+ };
61
1549
  }
62
- function resolveLocalIconSource(iconSet, cwd) {
63
- if (!iconSet?.source) {
64
- return null;
1550
+ function boundedInteger(value, minimum, maximum, label) {
1551
+ if (!Number.isInteger(value) || value < minimum || value > maximum) {
1552
+ throw new Error(
1553
+ `Feature inspection ${label} must be an integer from ${minimum} to ${maximum}.`
1554
+ );
65
1555
  }
66
- return resolve(cwd, iconSet.source);
1556
+ return value;
67
1557
  }
68
- function resolveIconSet(icons, iconSets, path = []) {
69
- if (!icons) {
70
- return void 0;
1558
+ function normalizeNames(values, minimum, maximum, label) {
1559
+ if (!Array.isArray(values) || values.length < minimum || values.length > maximum) {
1560
+ throw new Error(
1561
+ `Feature inspection requires ${minimum}-${maximum} ${label}${maximum === 1 ? "" : "s"}.`
1562
+ );
71
1563
  }
72
- if (typeof icons === "string") {
73
- const referenced = iconSets?.[icons];
74
- if (referenced) {
75
- if (path.includes(icons)) {
76
- throw new Error(`Circular Tileflow icon set extends: ${[...path, icons].join(" -> ")}`);
77
- }
78
- return resolveIconSet(referenced, iconSets, [...path, icons]);
1564
+ const normalized = [...new Set(values)].sort(compareCodeUnits2);
1565
+ for (const value of normalized) {
1566
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(value)) {
1567
+ throw new Error(`Invalid feature inspection ${label} "${value}".`);
79
1568
  }
80
- return isRemoteSpriteReference(icons) ? { sprite: icons } : { source: icons };
81
1569
  }
82
- const extended = icons.extends ? resolveIconSet(icons.extends, iconSets, path) : void 0;
1570
+ return normalized;
1571
+ }
1572
+ function resolveVectorSource(style, sourceLayers) {
1573
+ const requested = new Set(sourceLayers);
1574
+ const referencedSourceIds = new Set(
1575
+ style.layers.filter((layer) => requested.has(String(layer["source-layer"] ?? ""))).map((layer) => layer.source).filter((sourceId) => typeof sourceId === "string")
1576
+ );
1577
+ const vectorSources = Object.entries(style.sources).filter(
1578
+ ([, definition2]) => definition2.type === "vector"
1579
+ );
1580
+ const candidates = referencedSourceIds.size > 0 ? vectorSources.filter(([sourceId]) => referencedSourceIds.has(sourceId)) : vectorSources;
1581
+ if (candidates.length !== 1) {
1582
+ throw new Error(
1583
+ `Feature inspection requires exactly one vector source for the selected layers; found ${candidates.length}.`
1584
+ );
1585
+ }
1586
+ const [id, definition] = candidates[0];
1587
+ return { id, definition };
1588
+ }
1589
+ async function resolveTileSource(definition, fetchImplementation, options, origins) {
1590
+ if (Array.isArray(definition.tiles) && typeof definition.tiles[0] === "string") {
1591
+ const template2 = safeHttpUrl(definition.tiles[0], "vector tile template");
1592
+ origins.add(template2.origin);
1593
+ return {
1594
+ maxzoom: finiteZoom(definition.maxzoom, 22),
1595
+ minzoom: finiteZoom(definition.minzoom, 0),
1596
+ origin: template2.origin,
1597
+ scheme: definition.scheme === "tms" ? "tms" : "xyz",
1598
+ template: template2,
1599
+ tileJsonPath: null
1600
+ };
1601
+ }
1602
+ if (typeof definition.url !== "string") {
1603
+ throw new Error("Feature inspection vector source must define an HTTP(S) TileJSON URL.");
1604
+ }
1605
+ const tileJsonUrl = safeHttpUrl(definition.url, "TileJSON URL");
1606
+ const response = await fetchBytes(tileJsonUrl, {
1607
+ fetchImplementation,
1608
+ label: "TileJSON",
1609
+ limit: maxTileJsonBytes,
1610
+ signal: options.signal,
1611
+ timeoutMs: options.timeoutMs
1612
+ });
1613
+ const finalTileJsonUrl = safeHttpUrl(response.finalUrl, "TileJSON response URL");
1614
+ origins.add(finalTileJsonUrl.origin);
1615
+ let parsed;
1616
+ try {
1617
+ parsed = JSON.parse(new TextDecoder().decode(response.body));
1618
+ } catch {
1619
+ throw new Error("Feature inspection received invalid TileJSON JSON.");
1620
+ }
1621
+ const tileJson = parseTileJson(parsed);
1622
+ const template = safeHttpUrl(
1623
+ new URL(tileJson.tiles[0], finalTileJsonUrl).toString(),
1624
+ "vector tile template"
1625
+ );
1626
+ origins.add(template.origin);
83
1627
  return {
84
- ...extended,
85
- ...icons,
86
- mapping: {
87
- ...extended?.mapping,
88
- ...icons.mapping
89
- }
1628
+ maxzoom: finiteZoom(tileJson.maxzoom, 22),
1629
+ minzoom: finiteZoom(tileJson.minzoom, 0),
1630
+ origin: finalTileJsonUrl.origin,
1631
+ scheme: tileJson.scheme === "tms" ? "tms" : "xyz",
1632
+ template,
1633
+ tileJsonPath: finalTileJsonUrl.pathname
90
1634
  };
91
1635
  }
92
- async function buildSpriteAssets(sourceDir, mapName) {
93
- const icons = await readIconFiles(sourceDir);
94
- if (icons.length === 0) {
95
- throw new Error(`No SVG or PNG icons found in ${sourceDir}.`);
1636
+ function parseTileJson(value) {
1637
+ if (!isRecord(value) || !Array.isArray(value.tiles) || value.tiles.length === 0) {
1638
+ throw new Error("Feature inspection TileJSON must contain a non-empty tiles array.");
96
1639
  }
97
- const [sprite, sprite2x] = await Promise.all([
98
- buildSprite(sourceDir, icons, 1),
99
- buildSprite(sourceDir, icons, 2)
100
- ]);
1640
+ const tiles = value.tiles.filter((entry) => typeof entry === "string");
1641
+ if (tiles.length !== value.tiles.length || tiles.length > 8) {
1642
+ throw new Error("Feature inspection TileJSON tiles must contain 1-8 URL templates.");
1643
+ }
1644
+ return {
1645
+ tiles,
1646
+ ...typeof value.maxzoom === "number" ? { maxzoom: value.maxzoom } : {},
1647
+ ...typeof value.minzoom === "number" ? { minzoom: value.minzoom } : {},
1648
+ ...typeof value.scheme === "string" ? { scheme: value.scheme } : {}
1649
+ };
1650
+ }
1651
+ function finiteZoom(value, fallback) {
1652
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 24 ? value : fallback;
1653
+ }
1654
+ function resolveTileZoom(zoom, minzoom, maxzoom) {
1655
+ if (minzoom > maxzoom) throw new Error("Feature inspection source minzoom exceeds maxzoom.");
1656
+ return Math.max(minzoom, Math.min(maxzoom, Math.floor(zoom)));
1657
+ }
1658
+ function viewportBounds(center, zoom, width, height) {
1659
+ const centerX = longitudeToWorldX(center[0]);
1660
+ const centerY = latitudeToWorldY(center[1]);
1661
+ const scale = 512 * 2 ** zoom;
1662
+ const westX = centerX - width / 2 / scale;
1663
+ const eastX = centerX + width / 2 / scale;
1664
+ if (westX < 0 || eastX > 1) {
1665
+ throw new Error("Feature inspection viewports that cross the antimeridian are not supported.");
1666
+ }
1667
+ const northY = Math.max(0, centerY - height / 2 / scale);
1668
+ const southY = Math.min(1, centerY + height / 2 / scale);
101
1669
  return [
102
- jsonAsset(`icons/${mapName}/sprite.json`, sprite.index),
103
- binaryAsset(`icons/${mapName}/sprite.png`, sprite.image, "image/png"),
104
- jsonAsset(`icons/${mapName}/sprite@2x.json`, sprite2x.index),
105
- binaryAsset(`icons/${mapName}/sprite@2x.png`, sprite2x.image, "image/png")
1670
+ worldXToLongitude(westX),
1671
+ worldYToLatitude(southY),
1672
+ worldXToLongitude(eastX),
1673
+ worldYToLatitude(northY)
106
1674
  ];
107
1675
  }
108
- async function readIconFiles(sourceDir) {
109
- if (!existsSync(sourceDir)) {
110
- throw new Error(`Icon source not found: ${sourceDir}`);
1676
+ function tileCover(bounds, zoom) {
1677
+ const count = 2 ** zoom;
1678
+ const minimumX = clampTile(Math.floor(longitudeToWorldX(bounds[0]) * count), count);
1679
+ const maximumX = clampTile(Math.floor(longitudeToWorldX(bounds[2]) * count), count);
1680
+ const minimumY = clampTile(Math.floor(latitudeToWorldY(bounds[3]) * count), count);
1681
+ const maximumY = clampTile(Math.floor(latitudeToWorldY(bounds[1]) * count), count);
1682
+ const coordinates = [];
1683
+ for (let y = minimumY; y <= maximumY; y += 1) {
1684
+ for (let x = minimumX; x <= maximumX; x += 1) coordinates.push({ x, y, z: zoom });
111
1685
  }
112
- const entries = await readdir(sourceDir, { withFileTypes: true });
113
- return entries.filter((entry) => entry.isFile() && iconFileExtensions.has(extname(entry.name).toLowerCase())).map((entry) => ({
114
- name: entry.name.slice(0, -extname(entry.name).length),
115
- path: resolve(sourceDir, entry.name)
116
- })).sort((left, right) => left.name.localeCompare(right.name));
1686
+ return coordinates;
117
1687
  }
118
- async function buildSprite(sourceDir, icons, pixelRatio) {
119
- const sharp = await loadSharp();
120
- const cellSize = iconSpriteSize * pixelRatio;
121
- const columns = Math.ceil(Math.sqrt(icons.length));
122
- const rows = Math.ceil(icons.length / columns);
123
- const width = columns * cellSize;
124
- const height = rows * cellSize;
125
- const index = {};
126
- const composite = await Promise.all(
127
- icons.map(async (icon, indexNumber) => {
128
- const column = indexNumber % columns;
129
- const row = Math.floor(indexNumber / columns);
130
- const left = column * cellSize;
131
- const top = row * cellSize;
132
- index[icon.name] = {
133
- height: cellSize,
134
- pixelRatio,
135
- width: cellSize,
136
- x: left,
137
- y: top
138
- };
139
- return {
140
- input: await sharp(icon.path, { density: 72 * pixelRatio }).resize(cellSize, cellSize, {
141
- background: { alpha: 0, b: 0, g: 0, r: 0 },
142
- fit: "contain"
143
- }).png().toBuffer(),
144
- left,
145
- top
146
- };
147
- })
148
- );
149
- const image = await sharp({
150
- create: {
151
- background: { alpha: 0, b: 0, g: 0, r: 0 },
152
- channels: 4,
153
- height,
154
- width
1688
+ function clampTile(value, count) {
1689
+ return Math.max(0, Math.min(count - 1, value));
1690
+ }
1691
+ function resolveTileUrl(template, coordinate, scheme) {
1692
+ const y = scheme === "tms" ? 2 ** coordinate.z - 1 - coordinate.y : coordinate.y;
1693
+ const replacements = {
1694
+ z: coordinate.z,
1695
+ x: coordinate.x,
1696
+ y,
1697
+ "-y": 2 ** coordinate.z - 1 - coordinate.y
1698
+ };
1699
+ let value = template.toString();
1700
+ for (const [token, replacement] of Object.entries(replacements)) {
1701
+ value = value.replace(
1702
+ new RegExp(`(?:\\{${token}\\}|%7B${token}%7D)`, "giu"),
1703
+ String(replacement)
1704
+ );
1705
+ }
1706
+ if (/(?:\{(?:z|x|-?y)\}|%7B(?:z|x|-?y)%7D)/iu.test(value)) {
1707
+ throw new Error("Feature inspection tile template contains unresolved coordinates.");
1708
+ }
1709
+ return new URL(value);
1710
+ }
1711
+ async function fetchBytes(url, options) {
1712
+ const timeout = AbortSignal.timeout(options.timeoutMs);
1713
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
1714
+ const response = await options.fetchImplementation(url, { signal });
1715
+ if (!response.ok) {
1716
+ throw new Error(`${options.label} request failed with HTTP ${response.status}.`);
1717
+ }
1718
+ const declaredLength = Number(response.headers.get("content-length"));
1719
+ if (Number.isFinite(declaredLength) && declaredLength > options.limit) {
1720
+ throw new Error(`${options.label} exceeds the ${options.limit}-byte limit.`);
1721
+ }
1722
+ if (!response.body) throw new Error(`${options.label} response had no body.`);
1723
+ const chunks = [];
1724
+ let length = 0;
1725
+ const reader = response.body.getReader();
1726
+ while (true) {
1727
+ const result = await reader.read();
1728
+ if (result.done) break;
1729
+ length += result.value.byteLength;
1730
+ if (length > options.limit) {
1731
+ await reader.cancel();
1732
+ throw new Error(`${options.label} exceeds the ${options.limit}-byte limit.`);
155
1733
  }
156
- }).composite(composite).png().toBuffer();
1734
+ chunks.push(result.value);
1735
+ }
1736
+ const body = new Uint8Array(length);
1737
+ let offset = 0;
1738
+ for (const chunk of chunks) {
1739
+ body.set(chunk, offset);
1740
+ offset += chunk.byteLength;
1741
+ }
1742
+ return { body, finalUrl: response.url || url.toString() };
1743
+ }
1744
+ function summarizeGeometry(value) {
1745
+ if (!isRecord(value) || typeof value.type !== "string" || !("coordinates" in value)) return;
1746
+ const points = [];
1747
+ collectCoordinates(value.coordinates, points);
1748
+ if (points.length === 0) return;
1749
+ let west = Infinity;
1750
+ let south = Infinity;
1751
+ let east = -Infinity;
1752
+ let north = -Infinity;
1753
+ for (const [longitude, latitude] of points) {
1754
+ west = Math.min(west, longitude);
1755
+ south = Math.min(south, latitude);
1756
+ east = Math.max(east, longitude);
1757
+ north = Math.max(north, latitude);
1758
+ }
157
1759
  return {
158
- image,
159
- index,
160
- sourceDir
1760
+ type: value.type,
1761
+ center: [roundCoordinate((west + east) / 2), roundCoordinate((south + north) / 2)],
1762
+ bounds: [
1763
+ roundCoordinate(west),
1764
+ roundCoordinate(south),
1765
+ roundCoordinate(east),
1766
+ roundCoordinate(north)
1767
+ ]
161
1768
  };
162
1769
  }
163
- async function loadSharp() {
1770
+ function collectCoordinates(value, points) {
1771
+ if (!Array.isArray(value)) return;
1772
+ if (value.length >= 2 && typeof value[0] === "number" && Number.isFinite(value[0]) && typeof value[1] === "number" && Number.isFinite(value[1])) {
1773
+ points.push([value[0], value[1]]);
1774
+ return;
1775
+ }
1776
+ for (const child of value) collectCoordinates(child, points);
1777
+ }
1778
+ function boundsIntersect(left, right) {
1779
+ return left[0] <= right[2] && left[2] >= right[0] && left[1] <= right[3] && left[3] >= right[1];
1780
+ }
1781
+ function projectProperties(source, properties) {
1782
+ const projected = {};
1783
+ for (const property of properties) {
1784
+ const value = source[property];
1785
+ if (typeof value === "string") projected[property] = value.slice(0, maxStringLength);
1786
+ else if (typeof value === "boolean") projected[property] = value;
1787
+ else if (typeof value === "number" && Number.isFinite(value)) projected[property] = value;
1788
+ }
1789
+ return projected;
1790
+ }
1791
+ function deduplicateFeatures(features) {
1792
+ const unique = /* @__PURE__ */ new Map();
1793
+ for (const feature of features) {
1794
+ const key = feature.id === null ? `${feature.sourceLayer}|${JSON.stringify(feature.geometry)}|${JSON.stringify(feature.properties)}` : `${feature.sourceLayer}|id:${feature.id}`;
1795
+ if (!unique.has(key)) unique.set(key, feature);
1796
+ }
1797
+ return [...unique.values()];
1798
+ }
1799
+ function compareInspectedFeatures(left, right) {
1800
+ const leftName = typeof left.properties.name === "string" ? left.properties.name : "";
1801
+ const rightName = typeof right.properties.name === "string" ? right.properties.name : "";
1802
+ return compareCodeUnits2(left.sourceLayer, right.sourceLayer) || Number(rightName.length > 0) - Number(leftName.length > 0) || compareCodeUnits2(leftName, rightName) || (left.id ?? Number.MAX_SAFE_INTEGER) - (right.id ?? Number.MAX_SAFE_INTEGER) || compareCodeUnits2(JSON.stringify(left), JSON.stringify(right));
1803
+ }
1804
+ function safeHttpUrl(value, label) {
1805
+ let parsed;
164
1806
  try {
165
- return (await import("sharp")).default;
166
- } catch (error) {
167
- throw new Error(
168
- 'Local icon sprites require the optional "sharp" package. Install sharp or disable local icons.',
169
- { cause: error }
170
- );
1807
+ parsed = new URL(value);
1808
+ } catch {
1809
+ throw new Error(`Feature inspection ${label} is not a valid URL.`);
171
1810
  }
1811
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1812
+ throw new Error(`Feature inspection ${label} must use HTTP or HTTPS.`);
1813
+ }
1814
+ return parsed;
172
1815
  }
173
- function jsonAsset(fileName, value) {
174
- return {
175
- contentType: "application/json; charset=utf-8",
176
- fileName,
177
- source: `${JSON.stringify(value, null, 2)}
178
- `
179
- };
1816
+ function safeOrigin(value) {
1817
+ return safeHttpUrl(value, "response URL").origin;
180
1818
  }
181
- function binaryAsset(fileName, source, contentType) {
182
- return {
183
- contentType,
184
- fileName,
185
- source
186
- };
1819
+ function longitudeToWorldX(longitude) {
1820
+ return (longitude + 180) / 360;
187
1821
  }
188
- function cloneProject(project) {
189
- return JSON.parse(JSON.stringify(project));
1822
+ function worldXToLongitude(x) {
1823
+ return x * 360 - 180;
190
1824
  }
191
- function isRemoteSpriteReference(value) {
192
- return value.startsWith("http://") || value.startsWith("https://") || value.startsWith("/") || value.startsWith("data:");
1825
+ function latitudeToWorldY(latitude) {
1826
+ const radians = latitude * Math.PI / 180;
1827
+ return (1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2;
193
1828
  }
194
- function joinUrl(base, path) {
195
- const trimmedBase = base.replace(/\/+$/g, "");
196
- const trimmedPath = path.replace(/^\/+/g, "");
197
- return trimmedBase ? `${trimmedBase}/${trimmedPath}` : trimmedPath;
1829
+ function worldYToLatitude(y) {
1830
+ return Math.atan(Math.sinh(Math.PI * (1 - 2 * y))) * 180 / Math.PI;
198
1831
  }
199
- function uniqueStrings(values) {
200
- return [...new Set(values)];
1832
+ function roundCoordinate(value) {
1833
+ return Math.round(value * 1e7) / 1e7;
1834
+ }
1835
+ function isRecord(value) {
1836
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
201
1837
  }
202
1838
 
203
1839
  // src/index.ts
@@ -205,8 +1841,17 @@ var defaultTileflowApiUrl = "https://api.tileflow.dev";
205
1841
  var defaultTileflowConfigPath = "tileflow.config.ts";
206
1842
  var defaultTileflowManifestPath = "public/tileflow/manifest.json";
207
1843
  var defaultTileflowTileset = "world";
1844
+ var TileflowPreviewSelectionError = class extends Error {
1845
+ code = "PREVIEW_SELECTION_INVALID";
1846
+ constructor(message) {
1847
+ super(message);
1848
+ this.name = "TileflowPreviewSelectionError";
1849
+ }
1850
+ };
208
1851
  var TileflowValidationError = class extends Error {
1852
+ code = "CONFIG_INVALID";
209
1853
  messages;
1854
+ phase = "config-validation";
210
1855
  constructor(messages) {
211
1856
  super("Invalid Tileflow config");
212
1857
  this.name = "TileflowValidationError";
@@ -215,10 +1860,13 @@ var TileflowValidationError = class extends Error {
215
1860
  };
216
1861
  async function loadTileflowConfig(configPath = defaultTileflowConfigPath, options = {}) {
217
1862
  const cwd = options.cwd ?? process.cwd();
218
- const resolvedPath = resolve2(cwd, configPath);
219
- if (!existsSync2(resolvedPath)) {
1863
+ const resolvedPath = resolve3(cwd, configPath);
1864
+ if (!existsSync(resolvedPath)) {
220
1865
  throw new Error(`Config not found: ${configPath}`);
221
1866
  }
1867
+ if (options.fresh) {
1868
+ clearTileflowLocalModuleCache(resolvedPath, cwd);
1869
+ }
222
1870
  const jiti = createJiti(import.meta.url, {
223
1871
  fsCache: false,
224
1872
  moduleCache: !options.fresh
@@ -263,6 +1911,52 @@ function getFirstTileflowMapName(project) {
263
1911
  }
264
1912
  return mapName;
265
1913
  }
1914
+ function resolveTileflowPreview(project, selection = {}) {
1915
+ if (selection.map !== void 0 && selection.scene !== void 0) {
1916
+ throw new TileflowPreviewSelectionError(
1917
+ "Choose either a Tileflow preview map or scene, not both."
1918
+ );
1919
+ }
1920
+ if (selection.scene !== void 0) {
1921
+ const scene = Object.hasOwn(project.scenes ?? {}, selection.scene) ? project.scenes?.[selection.scene] : void 0;
1922
+ if (!scene) {
1923
+ throw new TileflowPreviewSelectionError(`Unknown Tileflow scene: ${selection.scene}`);
1924
+ }
1925
+ const normalized = normalizeTileflowCaptureScene(scene);
1926
+ if (normalized.target.kind === "application") {
1927
+ throw new TileflowPreviewSelectionError(
1928
+ `Tileflow scene "${selection.scene}" targets an application. Preview it through the application's development server.`
1929
+ );
1930
+ }
1931
+ if (!Object.hasOwn(project.maps, normalized.map)) {
1932
+ throw new TileflowPreviewSelectionError(
1933
+ `Tileflow scene "${selection.scene}" references an unknown map: ${normalized.map}`
1934
+ );
1935
+ }
1936
+ return {
1937
+ camera: normalized.camera,
1938
+ label: `${normalized.map} / ${selection.scene} \xB7 ${normalized.viewport.width}\xD7${normalized.viewport.height}`,
1939
+ mapName: normalized.map,
1940
+ viewport: normalized.viewport
1941
+ };
1942
+ }
1943
+ const mapName = selection.map ?? getFirstTileflowMapName(project);
1944
+ const map = Object.hasOwn(project.maps, mapName) ? project.maps[mapName] : void 0;
1945
+ if (!map) {
1946
+ throw new TileflowPreviewSelectionError(`Unknown Tileflow map: ${mapName}`);
1947
+ }
1948
+ return {
1949
+ camera: {
1950
+ type: "center",
1951
+ center: map.view?.center ? [map.view.center[0], map.view.center[1]] : [0, 0],
1952
+ zoom: map.view?.zoom ?? 0,
1953
+ bearing: map.view?.bearing ?? 0,
1954
+ pitch: 0
1955
+ },
1956
+ label: mapName,
1957
+ mapName
1958
+ };
1959
+ }
266
1960
  function getTileflowStyleMapName(path) {
267
1961
  const fileName = path.split("/").pop() ?? "";
268
1962
  return fileName.replace(/\.json$/, "");
@@ -271,7 +1965,22 @@ function createTileflowManifest(project, options = {}) {
271
1965
  return createManifest(project, options);
272
1966
  }
273
1967
  function createTileflowStyle(project, mapName, options = {}) {
274
- return createStyleFromProject(project, mapName, options);
1968
+ const style = createStyleFromProject2(project, mapName, options);
1969
+ assertValidTileflowStyle(style, mapName);
1970
+ return style;
1971
+ }
1972
+ function createTileflowStyles(project, options = {}) {
1973
+ const styleEntries = [];
1974
+ const issues = [];
1975
+ for (const mapName of getTileflowMapNames(project).sort(compareCodeUnits3)) {
1976
+ const style = createStyleFromProject2(project, mapName, options);
1977
+ styleEntries.push([mapName, style]);
1978
+ issues.push(...validateTileflowStyle(style, mapName));
1979
+ }
1980
+ if (issues.length > 0) {
1981
+ throw new TileflowStyleValidationError(normalizeTileflowStyleValidationIssues(issues));
1982
+ }
1983
+ return Object.fromEntries(styleEntries);
275
1984
  }
276
1985
  async function createTileflowBuildArtifacts(options = {}) {
277
1986
  const project = await loadValidTileflowConfig(options.config ?? defaultTileflowConfigPath, {
@@ -283,22 +1992,21 @@ async function createTileflowBuildArtifacts(options = {}) {
283
1992
  cwd: options.cwd ?? process.cwd()
284
1993
  });
285
1994
  const styleOptions = { tileBaseUrl: options.tileBaseUrl };
286
- const styles = Object.fromEntries(
287
- getTileflowMapNames(prepared.project).map((mapName) => [
288
- mapName,
289
- createStyleFromProject(prepared.project, mapName, styleOptions)
290
- ])
291
- );
1995
+ const styles = createTileflowStyles(prepared.project, styleOptions);
292
1996
  return {
293
1997
  assets: prepared.assets,
294
1998
  manifest: createManifest(prepared.project, {
295
1999
  styleBaseUrl: options.styleBaseUrl,
296
2000
  tileBaseUrl: options.tileBaseUrl
297
2001
  }),
2002
+ project: prepared.project,
298
2003
  styles,
299
2004
  watchPaths: prepared.watchPaths
300
2005
  };
301
2006
  }
2007
+ function createTileflowArtifactSession(options = {}) {
2008
+ return createTileflowArtifactSessionWithBuilder(options, createTileflowBuildArtifacts);
2009
+ }
302
2010
  async function getTileflowWatchPaths(options = {}) {
303
2011
  const cwd = options.cwd ?? process.cwd();
304
2012
  const project = await loadValidTileflowConfig(options.config ?? defaultTileflowConfigPath, {
@@ -309,25 +2017,25 @@ async function getTileflowWatchPaths(options = {}) {
309
2017
  }
310
2018
  async function writeTileflowBuildArtifacts(options) {
311
2019
  const cwd = options.cwd ?? process.cwd();
312
- const outDir = resolve2(cwd, options.outDir);
313
- const stylesDir = resolve2(outDir, "styles");
2020
+ const outDir = resolve3(cwd, options.outDir);
2021
+ const stylesDir = resolve3(outDir, "styles");
314
2022
  const artifacts = await createTileflowBuildArtifacts(options);
315
2023
  await mkdir(stylesDir, { recursive: true });
316
2024
  for (const [mapName, style] of Object.entries(artifacts.styles)) {
317
2025
  await writeFile(
318
- resolve2(stylesDir, `${mapName}.json`),
2026
+ resolve3(stylesDir, `${mapName}.json`),
319
2027
  `${JSON.stringify(style, null, 2)}
320
2028
  `,
321
2029
  "utf8"
322
2030
  );
323
2031
  }
324
2032
  for (const asset of artifacts.assets) {
325
- const outputPath = resolve2(outDir, asset.fileName);
326
- await mkdir(dirname(outputPath), { recursive: true });
2033
+ const outputPath = resolve3(outDir, asset.fileName);
2034
+ await mkdir(dirname2(outputPath), { recursive: true });
327
2035
  await writeFile(outputPath, asset.source);
328
2036
  }
329
2037
  await writeFile(
330
- resolve2(outDir, "manifest.json"),
2038
+ resolve3(outDir, "manifest.json"),
331
2039
  `${JSON.stringify(artifacts.manifest, null, 2)}
332
2040
  `,
333
2041
  "utf8"
@@ -353,7 +2061,25 @@ async function writeTileflowNodeResponse(response, tileflowResponse) {
353
2061
  tileflowResponse.headers.forEach((value, key) => {
354
2062
  response.setHeader(key, value);
355
2063
  });
356
- response.end(Buffer.from(await tileflowResponse.arrayBuffer()));
2064
+ if (!tileflowResponse.body) {
2065
+ response.end();
2066
+ return;
2067
+ }
2068
+ const reader = tileflowResponse.body.getReader();
2069
+ try {
2070
+ while (true) {
2071
+ const { done, value } = await reader.read();
2072
+ if (done) break;
2073
+ if (response.destroyed) {
2074
+ await reader.cancel();
2075
+ return;
2076
+ }
2077
+ response.write(Buffer.from(value));
2078
+ }
2079
+ response.end();
2080
+ } finally {
2081
+ reader.releaseLock();
2082
+ }
357
2083
  }
358
2084
  function joinTileflowPublicUrl(publicBase, basePath) {
359
2085
  const trimmedBase = publicBase.replace(/\/+$/g, "");
@@ -383,64 +2109,63 @@ function createTileflowDevRequestHandler(options = {}) {
383
2109
  }
384
2110
  const url = new URL(request.url);
385
2111
  const path = stripTileflowBasePath(url.pathname, basePath);
386
- const loadPreparedProject = async () => {
387
- const project = await loadValidTileflowConfig(configPath, {
388
- cwd,
389
- fresh: true
390
- });
391
- return prepareTileflowProjectIcons(project, {
392
- assetBaseUrl: `${url.origin}${basePath}`,
393
- cwd
394
- });
395
- };
396
2112
  if (path === null) {
397
2113
  return jsonResponse({ error: "Not found" }, 404);
398
2114
  }
399
2115
  try {
400
- if (path === "/" || path === "") {
401
- const project = await loadValidTileflowConfig(configPath, {
2116
+ if (path === "/__events") {
2117
+ return options.session ? eventStreamResponse(options.session) : jsonResponse({ error: "Live events require a development session." }, 404);
2118
+ }
2119
+ if (path === "/__runtime/maplibre-gl.js") {
2120
+ return textAssetResponse(getLocalMapLibreAsset("js"), "text/javascript; charset=utf-8");
2121
+ }
2122
+ if (path === "/__runtime/maplibre-gl.css") {
2123
+ return textAssetResponse(getLocalMapLibreAsset("css"), "text/css; charset=utf-8");
2124
+ }
2125
+ let artifacts;
2126
+ let state;
2127
+ if (options.session) {
2128
+ artifacts = getSessionArtifacts(options.session);
2129
+ state = options.session.getState();
2130
+ } else {
2131
+ artifacts = await createTileflowBuildArtifacts({
2132
+ assetBaseUrl: `${url.origin}${basePath}`,
2133
+ config: configPath,
402
2134
  cwd,
403
- fresh: true
2135
+ styleBaseUrl: options.styleBaseUrl ?? `${url.origin}${basePath}`,
2136
+ tileBaseUrl
404
2137
  });
405
- return htmlResponse(previewHtml(getFirstTileflowMapName(project), basePath));
2138
+ state = {
2139
+ artifacts,
2140
+ generation: 1,
2141
+ lastGoodGeneration: 1,
2142
+ status: "ready"
2143
+ };
2144
+ }
2145
+ if (path === "/__status") {
2146
+ return jsonResponse(createTileflowArtifactStatus(state));
2147
+ }
2148
+ if (path === "/" || path === "") {
2149
+ const preview = artifacts ? resolveTileflowPreview(artifacts.project, { map: options.map, scene: options.scene }) : void 0;
2150
+ return htmlResponse(previewHtml(preview, basePath, state));
406
2151
  }
2152
+ if (!artifacts) return unavailableArtifactsResponse(state);
407
2153
  if (path === "/manifest.json") {
408
- const project = await loadValidTileflowConfig(configPath, {
409
- cwd,
410
- fresh: true
411
- });
412
- const styleBaseUrl = options.styleBaseUrl ?? `${url.origin}${basePath}`;
413
- return jsonResponse(
414
- createManifest(project, {
415
- styleBaseUrl,
416
- tileBaseUrl
417
- })
418
- );
2154
+ return jsonResponse(artifacts.manifest);
419
2155
  }
420
2156
  if (path === "/style.json") {
421
- const prepared = await loadPreparedProject();
422
- return jsonResponse(
423
- createStyleFromProject(prepared.project, getFirstTileflowMapName(prepared.project), {
424
- tileBaseUrl
425
- })
426
- );
2157
+ return jsonResponse(artifacts.styles[getFirstTileflowMapName(artifacts.project)]);
427
2158
  }
428
2159
  if (path.startsWith("/styles/")) {
429
- const prepared = await loadPreparedProject();
430
2160
  const mapName = getTileflowStyleMapName(path);
431
- if (!prepared.project.maps[mapName]) {
2161
+ if (!Object.hasOwn(artifacts.styles, mapName)) {
432
2162
  return jsonResponse({ error: `Unknown map: ${mapName}` }, 404);
433
2163
  }
434
- return jsonResponse(
435
- createStyleFromProject(prepared.project, mapName, {
436
- tileBaseUrl
437
- })
438
- );
2164
+ return jsonResponse(artifacts.styles[mapName]);
439
2165
  }
440
2166
  if (path.startsWith("/icons/")) {
441
- const prepared = await loadPreparedProject();
442
2167
  const assetName = path.replace(/^\/+/, "");
443
- const asset = prepared.assets.find((candidate) => candidate.fileName === assetName);
2168
+ const asset = artifacts.assets.find((candidate) => candidate.fileName === assetName);
444
2169
  if (!asset) {
445
2170
  return jsonResponse({ error: `Unknown icon asset: ${assetName}` }, 404);
446
2171
  }
@@ -453,6 +2178,60 @@ function createTileflowDevRequestHandler(options = {}) {
453
2178
  }
454
2179
  };
455
2180
  }
2181
+ function createTileflowArtifactStatus(state) {
2182
+ return {
2183
+ schemaVersion: tileflowArtifactSessionSchemaVersion,
2184
+ generation: state.generation,
2185
+ status: state.status,
2186
+ ..."lastGoodGeneration" in state && state.lastGoodGeneration !== void 0 ? { lastGoodGeneration: state.lastGoodGeneration } : {},
2187
+ ...state.status === "invalid" ? { diagnostics: state.diagnostics } : {}
2188
+ };
2189
+ }
2190
+ function getSessionArtifacts(session) {
2191
+ const state = session.getState();
2192
+ return state.status === "ready" ? state.artifacts : session.getLastGoodArtifacts();
2193
+ }
2194
+ function unavailableArtifactsResponse(state) {
2195
+ return jsonResponse(
2196
+ {
2197
+ error: "No valid Tileflow artifact generation is available.",
2198
+ ...createTileflowArtifactStatus(state)
2199
+ },
2200
+ 409
2201
+ );
2202
+ }
2203
+ function eventStreamResponse(session) {
2204
+ const encoder = new TextEncoder();
2205
+ let unsubscribe = () => {
2206
+ };
2207
+ const stream = new ReadableStream({
2208
+ start(controller) {
2209
+ const enqueue = (state) => {
2210
+ const status = createTileflowArtifactStatus(state);
2211
+ controller.enqueue(
2212
+ encoder.encode(`event: ${status.status}
2213
+ data: ${JSON.stringify(status)}
2214
+
2215
+ `)
2216
+ );
2217
+ };
2218
+ enqueue(session.getState());
2219
+ unsubscribe = session.subscribe(enqueue);
2220
+ },
2221
+ cancel() {
2222
+ unsubscribe();
2223
+ }
2224
+ });
2225
+ return new Response(stream, {
2226
+ headers: {
2227
+ "Access-Control-Allow-Origin": "*",
2228
+ "Cache-Control": "no-cache, no-transform",
2229
+ Connection: "keep-alive",
2230
+ "Content-Type": "text/event-stream; charset=utf-8",
2231
+ "X-Accel-Buffering": "no"
2232
+ }
2233
+ });
2234
+ }
456
2235
  function getNodeHeader(request, name) {
457
2236
  const value = request.headers[name];
458
2237
  return Array.isArray(value) ? value[0] : value;
@@ -474,6 +2253,18 @@ function stripTileflowBasePath(pathname, basePath) {
474
2253
  return null;
475
2254
  }
476
2255
  function tileflowErrorResponse(error) {
2256
+ if (error instanceof TileflowPreviewSelectionError) {
2257
+ return jsonResponse({ error: error.message }, 400);
2258
+ }
2259
+ if (error instanceof TileflowStyleValidationError) {
2260
+ return jsonResponse(
2261
+ {
2262
+ error: error.message,
2263
+ issues: error.issues
2264
+ },
2265
+ 400
2266
+ );
2267
+ }
477
2268
  if (error instanceof TileflowValidationError) {
478
2269
  return jsonResponse(
479
2270
  {
@@ -529,21 +2320,33 @@ function resolveAssetBaseUrl(options) {
529
2320
  function isRelativePublicUrl(value) {
530
2321
  return !(value.startsWith("http://") || value.startsWith("https://") || value.startsWith("/") || value.startsWith("data:"));
531
2322
  }
532
- function previewHtml(mapName, basePath) {
533
- const styleUrl = `${basePath}/styles/${mapName}.json`;
2323
+ function previewHtml(preview, basePath, state) {
2324
+ const styleUrl = preview ? `${basePath}/styles/${preview.mapName}.json` : void 0;
2325
+ const initialStatus = createTileflowArtifactStatus(state);
2326
+ const mapOptions = preview ? previewMapOptions(preview) : void 0;
2327
+ const viewportCss = preview?.viewport ? `
2328
+ html, body { min-height: 100%; margin: 0; }
2329
+ body {
2330
+ display: grid;
2331
+ place-items: center;
2332
+ overflow: auto;
2333
+ background: #E8E5DE;
2334
+ }
2335
+ #map {
2336
+ width: ${preview.viewport.width}px;
2337
+ height: ${preview.viewport.height}px;
2338
+ box-shadow: 0 24px 80px rgba(37, 34, 29, 0.18);
2339
+ }` : "html, body, #map { height: 100%; margin: 0; }";
534
2340
  return `<!doctype html>
535
2341
  <html lang="en">
536
2342
  <head>
537
2343
  <meta charset="utf-8" />
538
2344
  <meta name="viewport" content="width=device-width, initial-scale=1" />
539
2345
  <title>Tileflow Preview</title>
540
- <link rel="preconnect" href="https://fonts.googleapis.com" />
541
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
542
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet" />
543
- <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css" />
2346
+ <link rel="stylesheet" href="${basePath}/__runtime/maplibre-gl.css" />
544
2347
  <style>
545
- html, body, #map { height: 100%; margin: 0; }
546
- body { font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
2348
+ ${viewportCss}
2349
+ body { font-family: ui-sans-serif, system-ui, sans-serif; }
547
2350
  .badge {
548
2351
  position: fixed;
549
2352
  left: 16px;
@@ -556,35 +2359,153 @@ function previewHtml(mapName, basePath) {
556
2359
  padding: 10px 12px;
557
2360
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
558
2361
  }
2362
+ .status {
2363
+ position: fixed;
2364
+ left: 16px;
2365
+ right: 16px;
2366
+ bottom: 16px;
2367
+ z-index: 2;
2368
+ display: none;
2369
+ max-width: 720px;
2370
+ border: 1px solid rgba(160, 46, 46, 0.35);
2371
+ border-radius: 8px;
2372
+ background: rgba(255, 244, 244, 0.96);
2373
+ color: #702020;
2374
+ padding: 12px;
2375
+ white-space: pre-wrap;
2376
+ }
559
2377
  </style>
560
2378
  </head>
561
2379
  <body>
562
- <div class="badge">Tileflow preview</div>
2380
+ <div class="badge" id="badge">Tileflow preview</div>
2381
+ <div class="status" id="status" role="status"></div>
563
2382
  <div id="map"></div>
564
- <script type="module">
565
- import maplibregl from "https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js";
2383
+ <script src="${basePath}/__runtime/maplibre-gl.js"></script>
2384
+ <script>
2385
+ const initialStatus = ${JSON.stringify(initialStatus)};
2386
+ const initialGeneration = initialStatus.generation;
2387
+ const badge = document.getElementById("badge");
2388
+ const status = document.getElementById("status");
2389
+ const previewLabel = ${JSON.stringify(preview?.label)};
2390
+ const styleUrl = ${JSON.stringify(styleUrl)};
2391
+ const previewMapOptions = ${JSON.stringify(mapOptions)};
566
2392
 
567
- const map = new maplibregl.Map({
568
- container: "map",
569
- style: "${styleUrl}",
570
- center: [-3.7038, 40.4168],
571
- zoom: 12
572
- });
2393
+ if (styleUrl) {
2394
+ const map = new maplibregl.Map({
2395
+ container: "map",
2396
+ style: styleUrl,
2397
+ ...previewMapOptions
2398
+ });
2399
+ map.addControl(new maplibregl.NavigationControl(), "top-right");
2400
+ }
2401
+
2402
+ function applyStatus(next) {
2403
+ badge.textContent = ["Tileflow preview", previewLabel, next.status].filter(Boolean).join(" \xB7 ");
2404
+ if (next.status === "invalid") {
2405
+ const diagnostics = next.diagnostics || [];
2406
+ status.textContent = diagnostics.map((item) =>
2407
+ (item.path ? item.path + ": " : "") + item.message
2408
+ ).join("\\n") || "Tileflow config is invalid.";
2409
+ status.style.display = "block";
2410
+ return;
2411
+ }
2412
+ status.style.display = "none";
2413
+ if (next.status === "ready" && next.generation > initialGeneration) {
2414
+ location.reload();
2415
+ }
2416
+ }
573
2417
 
574
- map.addControl(new maplibregl.NavigationControl(), "top-right");
2418
+ applyStatus(initialStatus);
2419
+ const events = new EventSource(${JSON.stringify(`${basePath}/__events`)});
2420
+ for (const eventName of ["building", "ready", "invalid"]) {
2421
+ events.addEventListener(eventName, (event) => applyStatus(JSON.parse(event.data)));
2422
+ }
575
2423
  </script>
576
2424
  </body>
577
2425
  </html>`;
578
2426
  }
2427
+ function previewMapOptions(preview) {
2428
+ if (preview.camera.type === "center") {
2429
+ return {
2430
+ bearing: preview.camera.bearing,
2431
+ center: preview.camera.center,
2432
+ pitch: preview.camera.pitch,
2433
+ zoom: preview.camera.zoom
2434
+ };
2435
+ }
2436
+ const [west, south, east, north] = preview.camera.bounds;
2437
+ return {
2438
+ bearing: preview.camera.bearing,
2439
+ bounds: [
2440
+ [west, south],
2441
+ [east, north]
2442
+ ],
2443
+ fitBoundsOptions: { padding: preview.camera.padding },
2444
+ pitch: preview.camera.pitch
2445
+ };
2446
+ }
2447
+ var localRequire = createRequire(import.meta.url);
2448
+ var localMapLibreJavaScript;
2449
+ var localMapLibreStylesheet;
2450
+ function getLocalMapLibreAsset(kind) {
2451
+ if (kind === "js" && localMapLibreJavaScript !== void 0) return localMapLibreJavaScript;
2452
+ if (kind === "css" && localMapLibreStylesheet !== void 0) return localMapLibreStylesheet;
2453
+ const packagePath = localRequire.resolve("maplibre-gl/package.json");
2454
+ const source = readFileSync(
2455
+ join(dirname2(packagePath), "dist", kind === "js" ? "maplibre-gl.js" : "maplibre-gl.css"),
2456
+ "utf8"
2457
+ );
2458
+ if (kind === "js") localMapLibreJavaScript = source;
2459
+ else localMapLibreStylesheet = source;
2460
+ return source;
2461
+ }
2462
+ function textAssetResponse(body, contentType) {
2463
+ return new Response(body, {
2464
+ headers: {
2465
+ "Access-Control-Allow-Origin": "*",
2466
+ "Cache-Control": "no-store",
2467
+ "Content-Type": contentType
2468
+ }
2469
+ });
2470
+ }
2471
+ function clearTileflowLocalModuleCache(configPath, cwd) {
2472
+ const roots = [canonicalPath2(cwd), canonicalPath2(dirname2(resolve3(configPath)))];
2473
+ for (const cachePath of Object.keys(localRequire.cache)) {
2474
+ if (cachePath.split(sep3).includes("node_modules")) continue;
2475
+ if (roots.some((root) => isPathWithin(root, canonicalPath2(cachePath)))) {
2476
+ delete localRequire.cache[cachePath];
2477
+ }
2478
+ }
2479
+ }
2480
+ function canonicalPath2(path) {
2481
+ try {
2482
+ return realpathSync2.native(resolve3(path));
2483
+ } catch {
2484
+ return resolve3(path);
2485
+ }
2486
+ }
2487
+ function isPathWithin(root, candidate) {
2488
+ const path = relative3(resolve3(root), resolve3(candidate));
2489
+ return path === "" || !isAbsolute3(path) && path !== ".." && !path.startsWith(`..${sep3}`);
2490
+ }
579
2491
  export {
2492
+ TileflowIconCompilationError,
2493
+ TileflowPreviewSelectionError,
2494
+ TileflowStyleValidationError,
580
2495
  TileflowValidationError,
581
2496
  asTileflowProjectConfig,
582
2497
  assertValidTileflowConfig,
2498
+ assertValidTileflowStyle,
2499
+ compileTileflowIconPackages,
2500
+ createTileflowArtifactDiagnostics,
2501
+ createTileflowArtifactSession,
2502
+ createTileflowArtifactStatus,
583
2503
  createTileflowBuildArtifacts,
584
2504
  createTileflowDevRequestHandler,
585
2505
  createTileflowManifest,
586
2506
  createTileflowNodeRequest,
587
2507
  createTileflowStyle,
2508
+ createTileflowStyles,
588
2509
  defaultTileflowApiUrl,
589
2510
  defaultTileflowConfigPath,
590
2511
  defaultTileflowManifestPath,
@@ -595,11 +2516,18 @@ export {
595
2516
  getTileflowMapNames,
596
2517
  getTileflowStyleMapName,
597
2518
  getTileflowWatchPaths,
2519
+ iconFileExtensions,
2520
+ inspectTileflowFeatures,
2521
+ inspectTileflowIconCatalogs,
598
2522
  isTileflowRequestUrl,
599
2523
  joinTileflowPublicUrl,
600
2524
  loadTileflowConfig,
601
2525
  loadValidTileflowConfig,
602
2526
  normalizeTileflowBasePath,
2527
+ prepareTileflowProjectIcons,
2528
+ resolveTileflowPreview,
2529
+ tileflowArtifactSessionSchemaVersion,
2530
+ validateTileflowStyle,
603
2531
  writeTileflowBuildArtifacts,
604
2532
  writeTileflowNodeResponse
605
2533
  };