@tileflow/dev 0.1.0-alpha.23 → 0.1.0-alpha.24
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/README.md +130 -515
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +2 -2
- package/dist/{chunk-CENQR5Q2.js → chunk-NUHSYLLS.js} +312 -170
- package/dist/{chunk-G5FUSWNN.js → chunk-XS5Q4IS2.js} +1 -1
- package/dist/{chunk-XTRHREXO.js → chunk-XXVI42UZ.js} +1 -1
- package/dist/fonts.d.ts +1 -1
- package/dist/icons-CkLVtaP-.d.ts +164 -0
- package/dist/icons.d.ts +3 -136
- package/dist/icons.js +5 -3
- package/dist/index.d.ts +52 -3
- package/dist/index.js +587 -4
- package/dist/server.d.ts +1 -1
- package/dist/server.js +3 -3
- package/docs/artifact-lifecycle.md +153 -0
- package/docs/bounded-pmtiles-resource-inspection.md +21 -0
- package/docs/bounded-vector-feature-inspection.md +101 -0
- package/docs/local-comparison-workbench-and-compiler-inspection.md +250 -0
- package/docs/structured-config-inspection-and-diagnostics.md +39 -0
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
isTileflowRequestUrl,
|
|
32
32
|
tileflowComparisonSchemaVersion,
|
|
33
33
|
writeTileflowNodeResponse
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-XS5Q4IS2.js";
|
|
35
35
|
import {
|
|
36
36
|
TileflowHostedManifestOverwriteError,
|
|
37
37
|
TileflowLocalTilesetProductionError,
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
tileflowArtifactSessionSchemaVersion,
|
|
57
57
|
writeTileflowArtifactPlan,
|
|
58
58
|
writeTileflowBuildArtifacts
|
|
59
|
-
} from "./chunk-
|
|
59
|
+
} from "./chunk-XXVI42UZ.js";
|
|
60
60
|
import {
|
|
61
61
|
inspectTileflowPmtiles,
|
|
62
62
|
inspectTileflowPmtilesForLocalUse
|
|
@@ -79,8 +79,11 @@ import {
|
|
|
79
79
|
getTileflowIconWatchPaths,
|
|
80
80
|
iconFileExtensions,
|
|
81
81
|
inspectTileflowIconCatalogs,
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
loadSharp,
|
|
83
|
+
packTileflowRenderedIcons,
|
|
84
|
+
prepareTileflowCatalogIcons,
|
|
85
|
+
readTileflowIconDirectory
|
|
86
|
+
} from "./chunk-NUHSYLLS.js";
|
|
84
87
|
import "./chunk-BL3FK4GO.js";
|
|
85
88
|
import {
|
|
86
89
|
TileflowStyleValidationError,
|
|
@@ -102,6 +105,577 @@ import {
|
|
|
102
105
|
loadValidTileflowConfigWithInputs
|
|
103
106
|
} from "./chunk-6MGFMKG3.js";
|
|
104
107
|
import "./chunk-D4OE4ITL.js";
|
|
108
|
+
|
|
109
|
+
// src/icon-composition.ts
|
|
110
|
+
import { resolve as resolve3 } from "path";
|
|
111
|
+
import { tileflowIconsLockfileName as tileflowIconsLockfileName2 } from "@tileflow/core";
|
|
112
|
+
import {
|
|
113
|
+
collectTileflowIconSetReferences,
|
|
114
|
+
compareCodeUnits as compareCodeUnits2,
|
|
115
|
+
parseTileflowIconComposition,
|
|
116
|
+
parseTileflowIconsLockfile as parseTileflowIconsLockfile2,
|
|
117
|
+
TileflowIconSetError as TileflowIconSetError4,
|
|
118
|
+
tileflowIconPackageLimits as tileflowIconPackageLimits2
|
|
119
|
+
} from "@tileflow/core";
|
|
120
|
+
|
|
121
|
+
// src/icon-cache.ts
|
|
122
|
+
import { hashTileflowIconPackageManifest as hashTileflowIconPackageManifest2 } from "@tileflow/core";
|
|
123
|
+
import { constants } from "fs";
|
|
124
|
+
import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm } from "fs/promises";
|
|
125
|
+
import { homedir, platform } from "os";
|
|
126
|
+
import { join, resolve } from "path";
|
|
127
|
+
import {
|
|
128
|
+
TileflowIconSetError as TileflowIconSetError2,
|
|
129
|
+
tileflowIconSetPinSchema
|
|
130
|
+
} from "@tileflow/core";
|
|
131
|
+
|
|
132
|
+
// src/icon-artifact.ts
|
|
133
|
+
import {
|
|
134
|
+
tileflowIconSpriteIndexSchema as indexSchema
|
|
135
|
+
} from "@tileflow/core";
|
|
136
|
+
import {
|
|
137
|
+
compareCodeUnits,
|
|
138
|
+
hashTileflowIconPackageManifest,
|
|
139
|
+
hashTileflowRenderedIconPixels,
|
|
140
|
+
parseTileflowIconJson,
|
|
141
|
+
sha256Hex,
|
|
142
|
+
TileflowIconSetError,
|
|
143
|
+
tileflowIconPackageLimits,
|
|
144
|
+
tileflowIconPackageManifestSchema
|
|
145
|
+
} from "@tileflow/core";
|
|
146
|
+
async function verifyTileflowIconArtifact(input) {
|
|
147
|
+
try {
|
|
148
|
+
const manifest = tileflowIconPackageManifestSchema.parse(input.manifest);
|
|
149
|
+
const contentHash = input.contentHash;
|
|
150
|
+
if (input.files.length !== 4 || new Set(input.files.map((file) => file.fileName)).size !== 4)
|
|
151
|
+
throw new Error("An artifact must contain exactly the four generated files");
|
|
152
|
+
const files = manifest.files.map((expected) => {
|
|
153
|
+
const source = input.files.find((file) => file.fileName === expected.name);
|
|
154
|
+
if (!source || source.contentType !== expected.contentType || source.source.byteLength !== expected.byteLength)
|
|
155
|
+
throw new Error("Generated file identity does not match the manifest");
|
|
156
|
+
return {
|
|
157
|
+
contentType: source.contentType,
|
|
158
|
+
fileName: source.fileName,
|
|
159
|
+
source: new Uint8Array(source.source)
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
if (await hashTileflowIconPackageManifest(manifest) !== contentHash)
|
|
163
|
+
throw new Error("Artifact manifest hash mismatch");
|
|
164
|
+
for (const [index, file] of files.entries()) {
|
|
165
|
+
if (await sha256Hex(file.source) !== manifest.files[index].sha256)
|
|
166
|
+
throw new Error(`Generated ${file.fileName} checksum mismatch`);
|
|
167
|
+
}
|
|
168
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
169
|
+
const oneIndex = indexSchema.parse(
|
|
170
|
+
parseTileflowIconJson(
|
|
171
|
+
decoder.decode(files[0].source),
|
|
172
|
+
tileflowIconPackageLimits.maxGeneratedFileBytes
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
const twoIndex = indexSchema.parse(
|
|
176
|
+
parseTileflowIconJson(
|
|
177
|
+
decoder.decode(files[2].source),
|
|
178
|
+
tileflowIconPackageLimits.maxGeneratedFileBytes
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
for (const index of [oneIndex, twoIndex]) {
|
|
182
|
+
const names = Object.keys(index).sort(compareCodeUnits);
|
|
183
|
+
if (names.length !== manifest.iconNames.length || names.some((name, ordinal) => name !== manifest.iconNames[ordinal]))
|
|
184
|
+
throw new Error("Sprite index does not exactly match the manifest icon names");
|
|
185
|
+
}
|
|
186
|
+
for (const [index, dimensions, ratio] of [
|
|
187
|
+
[oneIndex, manifest.sprites.oneX, 1],
|
|
188
|
+
[twoIndex, manifest.sprites.twoX, 2]
|
|
189
|
+
]) {
|
|
190
|
+
const rectangles = Object.values(index);
|
|
191
|
+
for (const [ordinal, rect] of rectangles.entries()) {
|
|
192
|
+
if (rect.pixelRatio !== ratio || rect.x + rect.width > dimensions.width || rect.y + rect.height > dimensions.height)
|
|
193
|
+
throw new Error("Sprite rectangle is outside the atlas or has the wrong density");
|
|
194
|
+
for (const other of rectangles.slice(0, ordinal)) {
|
|
195
|
+
if (rect.x < other.x + other.width && other.x < rect.x + rect.width && rect.y < other.y + other.height && other.y < rect.y + rect.height)
|
|
196
|
+
throw new Error("Sprite rectangles must not overlap");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const name of manifest.iconNames) {
|
|
201
|
+
const one = oneIndex[name];
|
|
202
|
+
const two = twoIndex[name];
|
|
203
|
+
if (two.x !== one.x * 2 || two.y !== one.y * 2 || two.width !== one.width * 2 || two.height !== one.height * 2)
|
|
204
|
+
throw new Error("Sprite densities must have exactly doubled geometry");
|
|
205
|
+
}
|
|
206
|
+
const oneAtlas = await decodeAtlas(files[1].source, manifest.sprites.oneX);
|
|
207
|
+
const twoAtlas = await decodeAtlas(files[3].source, manifest.sprites.twoX);
|
|
208
|
+
const icons = [];
|
|
209
|
+
for (const [ordinal, id] of manifest.iconNames.entries()) {
|
|
210
|
+
const oneX = extractCell(oneAtlas, oneIndex[id]);
|
|
211
|
+
const twoX = extractCell(twoAtlas, twoIndex[id]);
|
|
212
|
+
const expected = manifest.renderedIcons[ordinal].pixelSha256;
|
|
213
|
+
if (await hashTileflowRenderedIconPixels({ ...oneX, pixelRatio: 1 }) !== expected.oneX || await hashTileflowRenderedIconPixels({ ...twoX, pixelRatio: 2 }) !== expected.twoX)
|
|
214
|
+
throw new Error(`Rendered pixels do not match the manifest for ${id}`);
|
|
215
|
+
icons.push({ id, oneX, twoX });
|
|
216
|
+
}
|
|
217
|
+
return { package: { contentHash, manifest, files }, icons };
|
|
218
|
+
} catch (cause) {
|
|
219
|
+
throw new TileflowIconSetError(
|
|
220
|
+
"ICON_PACKAGE_INTEGRITY",
|
|
221
|
+
"Generated icon artifact verification failed",
|
|
222
|
+
{ cause }
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function decodeAtlas(bytes, dimensions) {
|
|
227
|
+
if ([137, 80, 78, 71, 13, 10, 26, 10].some((byte, index) => bytes[index] !== byte))
|
|
228
|
+
throw new Error("Expected PNG bytes");
|
|
229
|
+
const sharp = await loadSharp();
|
|
230
|
+
const image = sharp(bytes, {
|
|
231
|
+
failOn: "error",
|
|
232
|
+
limitInputPixels: tileflowIconPackageLimits.maxAtlasDimension ** 2
|
|
233
|
+
});
|
|
234
|
+
const metadata = await image.metadata();
|
|
235
|
+
if (metadata.format !== "png" || (metadata.pages ?? 1) !== 1 || metadata.width !== dimensions.width || metadata.height !== dimensions.height)
|
|
236
|
+
throw new Error("PNG geometry does not match the manifest");
|
|
237
|
+
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
238
|
+
if (info.channels !== 4 || info.width !== dimensions.width || info.height !== dimensions.height || data.length !== dimensions.width * dimensions.height * 4)
|
|
239
|
+
throw new Error("Expected bounded RGBA atlas pixels");
|
|
240
|
+
return { ...dimensions, rgba: new Uint8Array(data) };
|
|
241
|
+
}
|
|
242
|
+
function extractCell(atlas, rect) {
|
|
243
|
+
const rgba = new Uint8Array(rect.width * rect.height * 4);
|
|
244
|
+
const rowBytes = rect.width * 4;
|
|
245
|
+
for (let row = 0; row < rect.height; row += 1) {
|
|
246
|
+
const start = ((rect.y + row) * atlas.width + rect.x) * 4;
|
|
247
|
+
rgba.set(atlas.rgba.subarray(start, start + rowBytes), row * rowBytes);
|
|
248
|
+
}
|
|
249
|
+
return { height: rect.height, width: rect.width, rgba };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/icon-cache.ts
|
|
253
|
+
function getTileflowIconCacheDirectory(options = {}) {
|
|
254
|
+
const defaultRoot = platform() === "darwin" ? join(homedir(), "Library", "Caches") : platform() === "win32" ? process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
|
|
255
|
+
return join(
|
|
256
|
+
resolve(options.cacheRoot ?? process.env.TILEFLOW_ICON_CACHE_DIR ?? defaultRoot),
|
|
257
|
+
"tileflow",
|
|
258
|
+
"icons",
|
|
259
|
+
"v1"
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
async function loadTileflowIconSetArtifact(input, options = {}) {
|
|
263
|
+
const pin = tileflowIconSetPinSchema.parse(input);
|
|
264
|
+
if (await hashTileflowIconPackageManifest2(pin.manifest) !== pin.contentHash)
|
|
265
|
+
throw new TileflowIconSetError2("ICON_LOCK_INVALID", "Locked manifest hash mismatch");
|
|
266
|
+
let cacheFailure;
|
|
267
|
+
try {
|
|
268
|
+
return await readCached(pin, options);
|
|
269
|
+
} catch (cause) {
|
|
270
|
+
if (cause instanceof TileflowIconSetError2 && cause.code === "ICON_CACHE_UNSAFE") throw cause;
|
|
271
|
+
cacheFailure = cause;
|
|
272
|
+
}
|
|
273
|
+
if (options.offline) {
|
|
274
|
+
throw new TileflowIconSetError2(
|
|
275
|
+
isMissing(cacheFailure) ? "ICON_CACHE_MISS" : "ICON_PACKAGE_INTEGRITY",
|
|
276
|
+
"The exact verified icon artifact is unavailable offline; hydrate this pin explicitly",
|
|
277
|
+
{ cause: cacheFailure }
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const url = new URL(pin.spriteUrl);
|
|
281
|
+
const allowed = options.deliveryOrigins ?? ["https://api.tileflow.dev"];
|
|
282
|
+
if (!allowed.some((origin) => origin === url.origin && new URL(origin).href === `${origin}/`)) {
|
|
283
|
+
throw new TileflowIconSetError2(
|
|
284
|
+
"ICON_DOWNLOAD_FAILED",
|
|
285
|
+
"Icon artifact delivery origin is not trusted"
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const files = [];
|
|
289
|
+
const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(3e4)]) : AbortSignal.timeout(3e4);
|
|
290
|
+
try {
|
|
291
|
+
for (const expected of pin.manifest.files) {
|
|
292
|
+
signal.throwIfAborted();
|
|
293
|
+
const fileUrl = `${pin.spriteUrl}${expected.name.slice("sprite".length)}`;
|
|
294
|
+
const response = await (options.fetch ?? globalThis.fetch)(fileUrl, {
|
|
295
|
+
method: "GET",
|
|
296
|
+
credentials: "omit",
|
|
297
|
+
redirect: "error",
|
|
298
|
+
headers: { Accept: expected.contentType, "Accept-Encoding": "identity" },
|
|
299
|
+
signal
|
|
300
|
+
});
|
|
301
|
+
if (response.status !== 200 || response.redirected || response.url && response.url !== fileUrl || !response.body)
|
|
302
|
+
throw new Error("Unexpected artifact response");
|
|
303
|
+
const type = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase();
|
|
304
|
+
const length = response.headers.get("content-length");
|
|
305
|
+
const encoding = response.headers.get("content-encoding")?.trim().toLowerCase();
|
|
306
|
+
const isIdentityEncoding = encoding === void 0 || encoding === "" || encoding === "identity";
|
|
307
|
+
if (type !== expected.contentType || length !== null && (!/^\d+$/u.test(length) || isIdentityEncoding && Number(length) !== expected.byteLength)) {
|
|
308
|
+
await response.body.cancel();
|
|
309
|
+
throw new Error("Artifact response headers do not match the manifest");
|
|
310
|
+
}
|
|
311
|
+
const reader = response.body.getReader();
|
|
312
|
+
const source = new Uint8Array(expected.byteLength);
|
|
313
|
+
let offset = 0;
|
|
314
|
+
try {
|
|
315
|
+
while (true) {
|
|
316
|
+
const chunk = await reader.read();
|
|
317
|
+
if (chunk.done) break;
|
|
318
|
+
if (offset + chunk.value.byteLength > source.byteLength)
|
|
319
|
+
throw new Error("Artifact response exceeds its exact byte length");
|
|
320
|
+
source.set(chunk.value, offset);
|
|
321
|
+
offset += chunk.value.byteLength;
|
|
322
|
+
}
|
|
323
|
+
if (offset !== source.byteLength) throw new Error("Artifact response was truncated");
|
|
324
|
+
} catch (cause) {
|
|
325
|
+
await reader.cancel().catch(() => void 0);
|
|
326
|
+
throw cause;
|
|
327
|
+
} finally {
|
|
328
|
+
reader.releaseLock();
|
|
329
|
+
}
|
|
330
|
+
files.push({ fileName: expected.name, contentType: expected.contentType, source });
|
|
331
|
+
}
|
|
332
|
+
} catch (cause) {
|
|
333
|
+
throw new TileflowIconSetError2("ICON_DOWNLOAD_FAILED", "Exact icon artifact download failed", {
|
|
334
|
+
cause
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return storeTileflowIconSetArtifact(
|
|
338
|
+
pin,
|
|
339
|
+
{ contentHash: pin.contentHash, manifest: pin.manifest, files },
|
|
340
|
+
options
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
async function storeTileflowIconSetArtifact(input, artifact, options = {}) {
|
|
344
|
+
const pin = tileflowIconSetPinSchema.parse(input);
|
|
345
|
+
if (await hashTileflowIconPackageManifest2(pin.manifest) !== pin.contentHash)
|
|
346
|
+
throw new TileflowIconSetError2("ICON_LOCK_INVALID", "Locked manifest hash mismatch");
|
|
347
|
+
if (artifact.contentHash !== pin.contentHash)
|
|
348
|
+
throw new TileflowIconSetError2(
|
|
349
|
+
"ICON_PACKAGE_INTEGRITY",
|
|
350
|
+
"Artifact does not match the selected pin"
|
|
351
|
+
);
|
|
352
|
+
const verified = await verifyTileflowIconArtifact({
|
|
353
|
+
contentHash: pin.contentHash,
|
|
354
|
+
manifest: pin.manifest,
|
|
355
|
+
files: artifact.files
|
|
356
|
+
});
|
|
357
|
+
const root = await safeCacheRoot(options, true);
|
|
358
|
+
const target = join(root, pin.contentHash);
|
|
359
|
+
const temporary = await mkdtemp(join(root, `.incoming-${pin.contentHash}-`));
|
|
360
|
+
try {
|
|
361
|
+
for (const file of verified.package.files) {
|
|
362
|
+
const handle = await open(join(temporary, file.fileName), "wx", 384);
|
|
363
|
+
try {
|
|
364
|
+
await handle.writeFile(file.source);
|
|
365
|
+
await handle.sync();
|
|
366
|
+
} finally {
|
|
367
|
+
await handle.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
371
|
+
try {
|
|
372
|
+
await rename(temporary, target);
|
|
373
|
+
return verified;
|
|
374
|
+
} catch (cause) {
|
|
375
|
+
if (!hasCode(cause, "EEXIST") && !hasCode(cause, "ENOTEMPTY") && !hasCode(cause, "EPERM"))
|
|
376
|
+
throw cause;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
return await readCached(pin, options);
|
|
380
|
+
} catch (cause) {
|
|
381
|
+
if (cause instanceof TileflowIconSetError2 && cause.code === "ICON_CACHE_UNSAFE")
|
|
382
|
+
throw cause;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
await assertDirectory(target);
|
|
386
|
+
const retired = await mkdtemp(join(root, `.retired-${pin.contentHash}-`));
|
|
387
|
+
try {
|
|
388
|
+
await rename(target, join(retired, "entry"));
|
|
389
|
+
} catch (cause) {
|
|
390
|
+
if (!isMissing(cause)) throw cause;
|
|
391
|
+
} finally {
|
|
392
|
+
await rm(retired, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
if (!isMissing(cause)) throw cause;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
throw new TileflowIconSetError2(
|
|
399
|
+
"ICON_CACHE_UNSAFE",
|
|
400
|
+
"Concurrent cache repairs did not converge; retry this exact pin"
|
|
401
|
+
);
|
|
402
|
+
} finally {
|
|
403
|
+
await rm(temporary, { recursive: true, force: true });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async function readCached(pin, options) {
|
|
407
|
+
const root = await safeCacheRoot(options, false);
|
|
408
|
+
const directory = join(root, pin.contentHash);
|
|
409
|
+
await assertDirectory(directory);
|
|
410
|
+
const entries = (await readdir(directory)).sort();
|
|
411
|
+
const expectedNames = pin.manifest.files.map((file) => file.name).sort();
|
|
412
|
+
if (entries.length !== 4 || entries.some((name, index) => name !== expectedNames[index]))
|
|
413
|
+
throw new TileflowIconSetError2(
|
|
414
|
+
"ICON_PACKAGE_INTEGRITY",
|
|
415
|
+
"Cache entry does not contain exactly four generated files"
|
|
416
|
+
);
|
|
417
|
+
const files = [];
|
|
418
|
+
for (const expected of pin.manifest.files) {
|
|
419
|
+
files.push({
|
|
420
|
+
fileName: expected.name,
|
|
421
|
+
contentType: expected.contentType,
|
|
422
|
+
source: await readSafeFile(join(directory, expected.name), expected.byteLength)
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return verifyTileflowIconArtifact({ contentHash: pin.contentHash, manifest: pin.manifest, files });
|
|
426
|
+
}
|
|
427
|
+
async function safeCacheRoot(options, create) {
|
|
428
|
+
const root = getTileflowIconCacheDirectory(options);
|
|
429
|
+
const base = resolve(root, "..", "..", "..");
|
|
430
|
+
if (create) await mkdir(base, { recursive: true, mode: 448 });
|
|
431
|
+
await assertDirectory(base);
|
|
432
|
+
let current = await realpath(base);
|
|
433
|
+
for (const segment of ["tileflow", "icons", "v1"]) {
|
|
434
|
+
current = join(current, segment);
|
|
435
|
+
if (create)
|
|
436
|
+
await mkdir(current, { mode: 448 }).catch((cause) => {
|
|
437
|
+
if (!hasCode(cause, "EEXIST")) throw cause;
|
|
438
|
+
});
|
|
439
|
+
await assertDirectory(current);
|
|
440
|
+
}
|
|
441
|
+
return current;
|
|
442
|
+
}
|
|
443
|
+
async function assertDirectory(path) {
|
|
444
|
+
const entry = await lstat(path);
|
|
445
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
446
|
+
throw new TileflowIconSetError2(
|
|
447
|
+
"ICON_CACHE_UNSAFE",
|
|
448
|
+
"Icon cache paths must be directories, not symlinks"
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
async function readSafeFile(path, maximumBytes, exact = true) {
|
|
452
|
+
const entry = await lstat(path);
|
|
453
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
454
|
+
throw new TileflowIconSetError2(
|
|
455
|
+
"ICON_CACHE_UNSAFE",
|
|
456
|
+
"Icon files must be regular files, not symlinks"
|
|
457
|
+
);
|
|
458
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
459
|
+
try {
|
|
460
|
+
const opened = await handle.stat();
|
|
461
|
+
if (!opened.isFile() || opened.ino !== entry.ino || opened.dev !== entry.dev || opened.size > maximumBytes || exact && opened.size !== maximumBytes)
|
|
462
|
+
throw new TileflowIconSetError2(
|
|
463
|
+
"ICON_PACKAGE_INTEGRITY",
|
|
464
|
+
"Icon file size or identity changed"
|
|
465
|
+
);
|
|
466
|
+
const buffer = new Uint8Array(maximumBytes + 1);
|
|
467
|
+
let offset = 0;
|
|
468
|
+
while (offset < buffer.length) {
|
|
469
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null);
|
|
470
|
+
if (bytesRead === 0) break;
|
|
471
|
+
offset += bytesRead;
|
|
472
|
+
}
|
|
473
|
+
if (offset > maximumBytes || exact && offset !== maximumBytes)
|
|
474
|
+
throw new TileflowIconSetError2("ICON_PACKAGE_INTEGRITY", "Icon file byte length changed");
|
|
475
|
+
return buffer.slice(0, offset);
|
|
476
|
+
} finally {
|
|
477
|
+
await handle.close();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function hasCode(error, code) {
|
|
481
|
+
return error !== null && typeof error === "object" && "code" in error && error.code === code;
|
|
482
|
+
}
|
|
483
|
+
function isMissing(error) {
|
|
484
|
+
return hasCode(error, "ENOENT");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/icon-lockfile.ts
|
|
488
|
+
import { lstat as lstat2, mkdir as mkdir2, mkdtemp as mkdtemp2, open as open2, rename as rename2, rm as rm2, rmdir } from "fs/promises";
|
|
489
|
+
import { join as join2, resolve as resolve2 } from "path";
|
|
490
|
+
import {
|
|
491
|
+
parseTileflowIconsLockfile,
|
|
492
|
+
serializeTileflowIconsLockfile,
|
|
493
|
+
TileflowIconSetError as TileflowIconSetError3,
|
|
494
|
+
tileflowIconsLockfileMaximumBytes,
|
|
495
|
+
tileflowIconsLockfileName
|
|
496
|
+
} from "@tileflow/core";
|
|
497
|
+
async function readTileflowIconsLockfile(baseDirectory, sources) {
|
|
498
|
+
const text = await readLockText(join2(resolve2(baseDirectory), tileflowIconsLockfileName));
|
|
499
|
+
if (text === null)
|
|
500
|
+
throw new TileflowIconSetError3(
|
|
501
|
+
"ICON_LOCK_MISSING",
|
|
502
|
+
"Missing tileflow.icons.lock.json; install exact icon set revisions explicitly"
|
|
503
|
+
);
|
|
504
|
+
return parseTileflowIconsLockfile(text, sources);
|
|
505
|
+
}
|
|
506
|
+
async function writeTileflowIconsLockfile(baseDirectory, input, expectedContents) {
|
|
507
|
+
const content = await serializeTileflowIconsLockfile(input);
|
|
508
|
+
const directory = resolve2(baseDirectory);
|
|
509
|
+
const destination = join2(directory, tileflowIconsLockfileName);
|
|
510
|
+
const guard = join2(directory, `.${tileflowIconsLockfileName}.writing`);
|
|
511
|
+
try {
|
|
512
|
+
await mkdir2(guard, { mode: 448 });
|
|
513
|
+
} catch (cause) {
|
|
514
|
+
throw new TileflowIconSetError3(
|
|
515
|
+
"ICON_LOCK_CONFLICT",
|
|
516
|
+
"Another icon lock writer is active; a stale .writing guard may be removed only after confirming its writer has exited",
|
|
517
|
+
{ cause }
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
let temporary;
|
|
521
|
+
try {
|
|
522
|
+
const actual = await readLockText(destination);
|
|
523
|
+
if (actual !== expectedContents)
|
|
524
|
+
throw new TileflowIconSetError3(
|
|
525
|
+
"ICON_LOCK_CONFLICT",
|
|
526
|
+
"Icon lock changed since it was read; reload and retry without discarding other pins"
|
|
527
|
+
);
|
|
528
|
+
if (actual === content) return;
|
|
529
|
+
temporary = await mkdtemp2(join2(directory, ".tileflow-icons-lock-"));
|
|
530
|
+
const file = join2(temporary, "lock");
|
|
531
|
+
const handle = await open2(file, "wx", 384);
|
|
532
|
+
try {
|
|
533
|
+
await handle.writeFile(content, "utf8");
|
|
534
|
+
await handle.sync();
|
|
535
|
+
} finally {
|
|
536
|
+
await handle.close();
|
|
537
|
+
}
|
|
538
|
+
if (await readLockText(destination) !== actual)
|
|
539
|
+
throw new TileflowIconSetError3(
|
|
540
|
+
"ICON_LOCK_CONFLICT",
|
|
541
|
+
"Icon lock changed while preparing an update"
|
|
542
|
+
);
|
|
543
|
+
await rename2(file, destination);
|
|
544
|
+
} finally {
|
|
545
|
+
if (temporary) await rm2(temporary, { recursive: true, force: true });
|
|
546
|
+
await rmdir(guard);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async function readLockText(path) {
|
|
550
|
+
try {
|
|
551
|
+
const entry = await lstat2(path);
|
|
552
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
553
|
+
throw new TileflowIconSetError3(
|
|
554
|
+
"ICON_LOCK_INVALID",
|
|
555
|
+
"The icon lock must be a regular file, not a symlink"
|
|
556
|
+
);
|
|
557
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
558
|
+
await readSafeFile(path, tileflowIconsLockfileMaximumBytes, false)
|
|
559
|
+
);
|
|
560
|
+
} catch (cause) {
|
|
561
|
+
if (cause !== null && typeof cause === "object" && "code" in cause && cause.code === "ENOENT")
|
|
562
|
+
return null;
|
|
563
|
+
throw cause;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/icon-composition.ts
|
|
568
|
+
async function composeTileflowIconSources(input, options) {
|
|
569
|
+
const sources = structuredClone(input);
|
|
570
|
+
const references = collectTileflowIconSetReferences(sources);
|
|
571
|
+
const lock = references.length > 0 ? options.lock === void 0 ? await readTileflowIconsLockfile(options.baseDirectory ?? options.cwd, sources) : await parseTileflowIconsLockfile2(options.lock, sources) : await parseTileflowIconsLockfile2(options.lock ?? { lockfileVersion: 1, sets: {} }, sources);
|
|
572
|
+
const contributors = new Array(sources.length);
|
|
573
|
+
const winners = /* @__PURE__ */ new Map();
|
|
574
|
+
const watchPaths = /* @__PURE__ */ new Set();
|
|
575
|
+
if (references.length > 0 && options.lock === void 0)
|
|
576
|
+
watchPaths.add(resolve3(options.baseDirectory ?? options.cwd, tileflowIconsLockfileName2));
|
|
577
|
+
let reused;
|
|
578
|
+
let pixelBytes = 0;
|
|
579
|
+
let localSourceBytes = 0;
|
|
580
|
+
for (let ordinal = sources.length - 1; ordinal >= 0; ordinal -= 1) {
|
|
581
|
+
const source = sources[ordinal];
|
|
582
|
+
if (typeof source === "object" && source.kind === "icon-set") {
|
|
583
|
+
const pin = lock.sets[source.reference];
|
|
584
|
+
const loaded = await loadTileflowIconSetArtifact(pin, options);
|
|
585
|
+
contributors[ordinal] = {
|
|
586
|
+
kind: "icon-set",
|
|
587
|
+
reference: source.reference,
|
|
588
|
+
teamId: pin.teamId,
|
|
589
|
+
setId: pin.setId,
|
|
590
|
+
versionId: pin.versionId,
|
|
591
|
+
version: pin.version,
|
|
592
|
+
packageId: pin.packageId,
|
|
593
|
+
contentHash: pin.contentHash,
|
|
594
|
+
iconIds: [...pin.manifest.iconNames]
|
|
595
|
+
};
|
|
596
|
+
if (sources.length === 1) reused = loaded.package;
|
|
597
|
+
for (const icon of loaded.icons) {
|
|
598
|
+
if (winners.has(icon.id)) continue;
|
|
599
|
+
const pixels = pin.manifest.renderedIcons.find(
|
|
600
|
+
(entry) => entry.name === icon.id
|
|
601
|
+
).pixelSha256;
|
|
602
|
+
addWinner(ordinal, icon, {
|
|
603
|
+
kind: "rendered-icon",
|
|
604
|
+
id: icon.id,
|
|
605
|
+
width: icon.oneX.width,
|
|
606
|
+
height: icon.oneX.height,
|
|
607
|
+
pixelSha256: { ...pixels }
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
const directory = await readTileflowIconDirectory(source, {
|
|
612
|
+
cwd: options.cwd,
|
|
613
|
+
baseDirectory: options.baseDirectory ?? options.cwd,
|
|
614
|
+
target: options.target ?? "local"
|
|
615
|
+
});
|
|
616
|
+
if (directory.watchPath) watchPaths.add(directory.watchPath);
|
|
617
|
+
contributors[ordinal] = typeof source === "string" ? { kind: "local", iconIds: directory.iconIds } : { kind: "package", package: source.package, iconIds: directory.iconIds };
|
|
618
|
+
const selected = directory.iconIds.filter((id) => !winners.has(id));
|
|
619
|
+
if (winners.size + selected.length > tileflowIconPackageLimits2.maxIconCount)
|
|
620
|
+
throw new TileflowIconSetError4(
|
|
621
|
+
"ICON_COMPOSITION_INVALID",
|
|
622
|
+
"Composed icon set exceeds 256 effective icons"
|
|
623
|
+
);
|
|
624
|
+
for (const item of await directory.render(selected)) {
|
|
625
|
+
localSourceBytes += item.sourceBytes;
|
|
626
|
+
if (localSourceBytes > tileflowIconPackageLimits2.maxSourceBytes)
|
|
627
|
+
throw new TileflowIconSetError4(
|
|
628
|
+
"ICON_COMPOSITION_INVALID",
|
|
629
|
+
"Effective local icon originals exceed the source-byte limit"
|
|
630
|
+
);
|
|
631
|
+
addWinner(ordinal, item.icon, item.identity);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const sorted = [...winners.values()].sort(
|
|
636
|
+
(left, right) => compareCodeUnits2(left.icon.id, right.icon.id)
|
|
637
|
+
);
|
|
638
|
+
const iconPackage = sorted.length ? reused ?? await packTileflowRenderedIcons(sorted.map((winner) => winner.icon)) : null;
|
|
639
|
+
const composition = references.length && iconPackage ? parseTileflowIconComposition({
|
|
640
|
+
format: "tileflow-icon-composition-v1",
|
|
641
|
+
compositionVersion: 1,
|
|
642
|
+
packageHash: iconPackage.contentHash,
|
|
643
|
+
contributors,
|
|
644
|
+
winners: sorted.map(({ ordinal, icon }, index) => ({
|
|
645
|
+
id: icon.id,
|
|
646
|
+
contributor: ordinal,
|
|
647
|
+
width: icon.oneX.width,
|
|
648
|
+
height: icon.oneX.height,
|
|
649
|
+
pixelSha256: iconPackage.manifest.renderedIcons[index].pixelSha256
|
|
650
|
+
}))
|
|
651
|
+
}) : null;
|
|
652
|
+
const previous = /* @__PURE__ */ new Map();
|
|
653
|
+
const replacements = [];
|
|
654
|
+
for (const [ordinal, contributor] of contributors.entries()) {
|
|
655
|
+
for (const id of contributor.iconIds) {
|
|
656
|
+
const replaced = previous.get(id);
|
|
657
|
+
if (replaced !== void 0) replacements.push({ id, replaced, winner: ordinal });
|
|
658
|
+
previous.set(id, ordinal);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
package: iconPackage,
|
|
663
|
+
composition,
|
|
664
|
+
sourceIdentities: sorted.map((winner) => winner.identity),
|
|
665
|
+
replacements,
|
|
666
|
+
watchPaths: [...watchPaths].sort(compareCodeUnits2)
|
|
667
|
+
};
|
|
668
|
+
function addWinner(ordinal, icon, identity) {
|
|
669
|
+
pixelBytes += icon.oneX.rgba.byteLength + icon.twoX.rgba.byteLength;
|
|
670
|
+
const maximumPixelBytes = (tileflowIconPackageLimits2.maxAtlasDimension ** 2 + (tileflowIconPackageLimits2.maxAtlasDimension / 2) ** 2) * 4;
|
|
671
|
+
if (winners.size >= tileflowIconPackageLimits2.maxIconCount || pixelBytes > maximumPixelBytes)
|
|
672
|
+
throw new TileflowIconSetError4(
|
|
673
|
+
"ICON_COMPOSITION_INVALID",
|
|
674
|
+
"Composed rendered cells exceed the final sprite capacity"
|
|
675
|
+
);
|
|
676
|
+
winners.set(icon.id, { ordinal, icon, identity });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
105
679
|
export {
|
|
106
680
|
TileflowConfigInspectionError,
|
|
107
681
|
TileflowFontCompilationError,
|
|
@@ -117,6 +691,7 @@ export {
|
|
|
117
691
|
assertValidTileflowStyle,
|
|
118
692
|
bindTileflowStyleFontBundle,
|
|
119
693
|
compileTileflowIconPackages,
|
|
694
|
+
composeTileflowIconSources,
|
|
120
695
|
createTileflowArtifactDiagnostics,
|
|
121
696
|
createTileflowArtifactPlan,
|
|
122
697
|
createTileflowArtifactSession,
|
|
@@ -141,6 +716,7 @@ export {
|
|
|
141
716
|
getTileflowAssetBasePath,
|
|
142
717
|
getTileflowAssetFileName,
|
|
143
718
|
getTileflowFontWatchPaths,
|
|
719
|
+
getTileflowIconCacheDirectory,
|
|
144
720
|
getTileflowIconWatchPaths,
|
|
145
721
|
getTileflowMapNames,
|
|
146
722
|
getTileflowRequestPath,
|
|
@@ -159,15 +735,19 @@ export {
|
|
|
159
735
|
joinTileflowPublicUrl,
|
|
160
736
|
loadTileflowConfig,
|
|
161
737
|
loadTileflowConfigWithInputs,
|
|
738
|
+
loadTileflowIconSetArtifact,
|
|
162
739
|
loadValidTileflowConfig,
|
|
163
740
|
loadValidTileflowConfigWithInputs,
|
|
164
741
|
normalizeTileflowBasePath,
|
|
165
742
|
normalizeTileflowLandmarkManifest,
|
|
743
|
+
packTileflowRenderedIcons,
|
|
166
744
|
prepareTileflowCatalogIcons,
|
|
167
745
|
prepareTileflowLocalTilesets,
|
|
168
746
|
prepareTileflowStyleFonts,
|
|
169
747
|
readBoundedTileflowJsonResponse,
|
|
170
748
|
readBoundedTileflowResponse,
|
|
749
|
+
readTileflowIconDirectory,
|
|
750
|
+
readTileflowIconsLockfile,
|
|
171
751
|
refreshTileflowArtifactSession,
|
|
172
752
|
renderTileflowPreviewHtml,
|
|
173
753
|
replaceTileflowStyleFontSources,
|
|
@@ -176,13 +756,16 @@ export {
|
|
|
176
756
|
sanitizeDiagnosticSecrets,
|
|
177
757
|
sanitizeTileflowInspectableValue,
|
|
178
758
|
serializeTileflowCommandDocument,
|
|
759
|
+
storeTileflowIconSetArtifact,
|
|
179
760
|
tileflowArtifactInventoryFileName,
|
|
180
761
|
tileflowArtifactPlanSchemaVersion,
|
|
181
762
|
tileflowArtifactSessionSchemaVersion,
|
|
182
763
|
tileflowCommandDocumentSchemaVersion,
|
|
183
764
|
tileflowComparisonSchemaVersion,
|
|
184
765
|
validateTileflowStyle,
|
|
766
|
+
verifyTileflowIconArtifact,
|
|
185
767
|
writeTileflowArtifactPlan,
|
|
186
768
|
writeTileflowBuildArtifacts,
|
|
769
|
+
writeTileflowIconsLockfile,
|
|
187
770
|
writeTileflowNodeResponse
|
|
188
771
|
};
|
package/dist/server.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
4
4
|
import '@tileflow/core';
|
|
5
5
|
import '@tileflow/core/build';
|
|
6
6
|
import '@tileflow/core/manifest';
|
|
7
|
-
import './icons
|
|
7
|
+
import './icons-CkLVtaP-.js';
|
|
8
8
|
import './config.js';
|
|
9
9
|
|
|
10
10
|
declare const tileflowComparisonSchemaVersion: 1;
|
package/dist/server.js
CHANGED
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
isTileflowRequestUrl,
|
|
10
10
|
tileflowComparisonSchemaVersion,
|
|
11
11
|
writeTileflowNodeResponse
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-XS5Q4IS2.js";
|
|
13
13
|
import {
|
|
14
14
|
createTileflowArtifactDiagnostics,
|
|
15
15
|
tileflowArtifactSessionSchemaVersion
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-XXVI42UZ.js";
|
|
17
17
|
import "./chunk-ONYQNLNA.js";
|
|
18
18
|
import {
|
|
19
19
|
getTileflowAssetBasePath,
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
normalizeTileflowBasePath,
|
|
23
23
|
resolveTileflowArtifactPublicUrls
|
|
24
24
|
} from "./chunk-RSBV4L2Y.js";
|
|
25
|
-
import "./chunk-
|
|
25
|
+
import "./chunk-NUHSYLLS.js";
|
|
26
26
|
import "./chunk-BL3FK4GO.js";
|
|
27
27
|
import "./chunk-KDFTE4Q5.js";
|
|
28
28
|
import "./chunk-6MGFMKG3.js";
|