@tileflow/dev 0.1.0-alpha.2 → 0.1.0-alpha.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GENERATED_OUTPUT_LICENSE.md +21 -0
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +542 -2
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/TRADEMARKS.md +10 -0
- package/dist/artifacts.d.ts +191 -0
- package/dist/artifacts.js +71 -0
- package/dist/chunk-6MGFMKG3.js +284 -0
- package/dist/chunk-BL3FK4GO.js +170 -0
- package/dist/chunk-CENQR5Q2.js +844 -0
- package/dist/chunk-D4OE4ITL.js +13 -0
- package/dist/chunk-KDFTE4Q5.js +156 -0
- package/dist/chunk-KH7CMHT3.js +2069 -0
- package/dist/chunk-LNCTVFUD.js +236 -0
- package/dist/chunk-MIGBHCXX.js +4222 -0
- package/dist/chunk-MKPSQSMX.js +805 -0
- package/dist/chunk-ONYQNLNA.js +1108 -0
- package/dist/chunk-RSBV4L2Y.js +671 -0
- package/dist/chunk-XTRHREXO.js +1688 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.js +27 -0
- package/dist/fonts.d.ts +47 -0
- package/dist/fonts.js +16 -0
- package/dist/icons.d.ts +136 -0
- package/dist/icons.js +17 -0
- package/dist/index.d.ts +13 -54
- package/dist/index.js +171 -297
- package/dist/inspect.d.ts +149 -0
- package/dist/inspect.js +18 -0
- package/dist/preview-assets-6LYL7GP7.js +145 -0
- package/dist/preview.d.ts +69 -0
- package/dist/preview.js +18 -0
- package/dist/server.d.ts +81 -0
- package/dist/server.js +48 -0
- package/dist/tilesets-C0tGeenP.d.ts +94 -0
- package/dist/tilesets.d.ts +1 -0
- package/dist/tilesets.js +6 -0
- package/dist/validation.d.ts +63 -0
- package/dist/validation.js +24 -0
- package/package.json +80 -7
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TileflowAssetDirectoryError,
|
|
3
|
+
resolveTileflowAssetDirectories
|
|
4
|
+
} from "./chunk-BL3FK4GO.js";
|
|
5
|
+
import {
|
|
6
|
+
isPathWithin
|
|
7
|
+
} from "./chunk-D4OE4ITL.js";
|
|
8
|
+
|
|
9
|
+
// src/font-pipeline.ts
|
|
10
|
+
import { createHash } from "crypto";
|
|
11
|
+
import { lstat, readdir, readFile, realpath, stat } from "fs/promises";
|
|
12
|
+
import { createRequire } from "module";
|
|
13
|
+
import { extname, join } from "path";
|
|
14
|
+
import {
|
|
15
|
+
compareCodeUnits,
|
|
16
|
+
getTileflowStyleFontFaces,
|
|
17
|
+
hashTileflowFontBundleManifest,
|
|
18
|
+
parseResolvedTileflowMap,
|
|
19
|
+
tileflowMapIdSchema,
|
|
20
|
+
tileflowStyleFontFaceLimits,
|
|
21
|
+
tileflowStyleFontFacesMetadataKey,
|
|
22
|
+
tileflowThemeNameSchema
|
|
23
|
+
} from "@tileflow/core";
|
|
24
|
+
|
|
25
|
+
// src/public-paths.ts
|
|
26
|
+
function hasUnsafeUrlCharacter(value) {
|
|
27
|
+
for (const character of value) {
|
|
28
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
29
|
+
if (codePoint <= 31 || codePoint === 127 || "\\?#".includes(character)) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
function normalizeTileflowBasePath(value = "") {
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (!trimmed || trimmed === "/") return "";
|
|
36
|
+
if (hasUnsafeUrlCharacter(trimmed)) throw invalidBasePath(value);
|
|
37
|
+
const withoutEdges = trimmed.replace(/^\/+|\/+$/gu, "");
|
|
38
|
+
if (!withoutEdges) return "";
|
|
39
|
+
const segments = withoutEdges.split("/");
|
|
40
|
+
if (segments.some((segment) => !isSafeRouteSegment(segment))) throw invalidBasePath(value);
|
|
41
|
+
return `/${segments.join("/")}`;
|
|
42
|
+
}
|
|
43
|
+
function joinTileflowPublicUrl(publicBase, basePath) {
|
|
44
|
+
const normalizedBase = normalizePublicBase(publicBase);
|
|
45
|
+
const trimmedPath = normalizeTileflowBasePath(basePath).replace(/^\//u, "");
|
|
46
|
+
if (!trimmedPath) return publicBaseWithoutResource(normalizedBase);
|
|
47
|
+
if (!normalizedBase || normalizedBase === "/") return `/${trimmedPath}`;
|
|
48
|
+
if (normalizedBase === ".") return `./${trimmedPath}`;
|
|
49
|
+
if (normalizedBase === "..") return `../${trimmedPath}`;
|
|
50
|
+
return `${normalizedBase}/${trimmedPath}`;
|
|
51
|
+
}
|
|
52
|
+
function resolveTileflowArtifactPublicUrls(publicBase, basePath) {
|
|
53
|
+
const publicBaseUrl = joinTileflowPublicUrl(publicBase, basePath);
|
|
54
|
+
if (publicBaseUrl.startsWith("./") || publicBaseUrl.startsWith("../")) {
|
|
55
|
+
return { publicBaseUrl, styleBaseUrl: "." };
|
|
56
|
+
}
|
|
57
|
+
return { assetBaseUrl: publicBaseUrl, publicBaseUrl, styleBaseUrl: publicBaseUrl };
|
|
58
|
+
}
|
|
59
|
+
function getTileflowAssetBasePath(basePath) {
|
|
60
|
+
return normalizeTileflowBasePath(basePath).replace(/^\//u, "");
|
|
61
|
+
}
|
|
62
|
+
function getTileflowAssetFileName(basePath, fileName) {
|
|
63
|
+
const assetBasePath = getTileflowAssetBasePath(basePath);
|
|
64
|
+
assertSafeAssetFileName(fileName);
|
|
65
|
+
return assetBasePath ? `${assetBasePath}/${fileName}` : fileName;
|
|
66
|
+
}
|
|
67
|
+
function normalizePublicBase(value) {
|
|
68
|
+
if (value !== value.trim() || hasUnsafeUrlCharacter(value)) throw invalidPublicBase(value);
|
|
69
|
+
if (!value) return "";
|
|
70
|
+
if (value.startsWith("//")) throw invalidPublicBase(value);
|
|
71
|
+
if (/^[A-Za-z][A-Za-z\d+.-]*:/u.test(value)) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(value);
|
|
75
|
+
} catch {
|
|
76
|
+
throw invalidPublicBase(value);
|
|
77
|
+
}
|
|
78
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
79
|
+
throw invalidPublicBase(value);
|
|
80
|
+
}
|
|
81
|
+
return value.replace(/\/+$/gu, "");
|
|
82
|
+
}
|
|
83
|
+
if (value === "/") return "/";
|
|
84
|
+
const withoutTrailingSlash = value.replace(/\/+$/gu, "");
|
|
85
|
+
if (!withoutTrailingSlash) return "/";
|
|
86
|
+
if (withoutTrailingSlash.startsWith("/") || withoutTrailingSlash === "." || withoutTrailingSlash === ".." || withoutTrailingSlash.startsWith("./") || withoutTrailingSlash.startsWith("../")) {
|
|
87
|
+
return withoutTrailingSlash;
|
|
88
|
+
}
|
|
89
|
+
return `./${withoutTrailingSlash}`;
|
|
90
|
+
}
|
|
91
|
+
function publicBaseWithoutResource(value) {
|
|
92
|
+
if (value === ".") return "./";
|
|
93
|
+
if (value === "..") return "../";
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function isSafeRouteSegment(segment) {
|
|
97
|
+
if (!segment || segment === "." || segment === "..") return false;
|
|
98
|
+
let decoded;
|
|
99
|
+
try {
|
|
100
|
+
decoded = decodeURIComponent(segment);
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return decoded !== "." && decoded !== ".." && !decoded.includes("/") && !decoded.includes("\\") && !hasUnsafeUrlCharacter(decoded);
|
|
105
|
+
}
|
|
106
|
+
function assertSafeAssetFileName(fileName) {
|
|
107
|
+
if (!fileName || fileName.startsWith("/") || fileName.endsWith("/") || fileName.includes("\\") || fileName.includes("\0") || fileName.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
|
|
108
|
+
throw new Error(`Unsafe Tileflow asset file name: ${fileName}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function invalidBasePath(value) {
|
|
112
|
+
return new TypeError(`Invalid Tileflow base path: ${JSON.stringify(value)}`);
|
|
113
|
+
}
|
|
114
|
+
function invalidPublicBase(value) {
|
|
115
|
+
return new TypeError(`Invalid Tileflow public base: ${JSON.stringify(value)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/font-pipeline.ts
|
|
119
|
+
var TileflowFontCompilationError = class extends Error {
|
|
120
|
+
code = "FONT_INVALID";
|
|
121
|
+
issues;
|
|
122
|
+
phase = "font-compilation";
|
|
123
|
+
constructor(issues) {
|
|
124
|
+
const normalized = [...issues].sort(
|
|
125
|
+
(left, right) => compareCodeUnits(left.path, right.path) || compareCodeUnits(left.message, right.message)
|
|
126
|
+
);
|
|
127
|
+
super(
|
|
128
|
+
[
|
|
129
|
+
"Invalid Tileflow font directories",
|
|
130
|
+
...normalized.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
131
|
+
].join("\n")
|
|
132
|
+
);
|
|
133
|
+
this.name = "TileflowFontCompilationError";
|
|
134
|
+
this.issues = normalized;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
async function getTileflowFontWatchPaths(project, cwd, baseDirectory = cwd) {
|
|
138
|
+
const watchPaths = /* @__PURE__ */ new Set();
|
|
139
|
+
const issues = [];
|
|
140
|
+
for (const mapName of Object.keys(project.maps).sort(compareCodeUnits)) {
|
|
141
|
+
const resolvedMap = project.maps[mapName];
|
|
142
|
+
if (!resolvedMap) continue;
|
|
143
|
+
const fonts = parseResolvedTileflowMap(resolvedMap).fonts;
|
|
144
|
+
if (fonts === void 0) continue;
|
|
145
|
+
try {
|
|
146
|
+
const directories = await resolveTileflowAssetDirectories(fonts, {
|
|
147
|
+
baseDirectory,
|
|
148
|
+
configPath: `maps.${mapName}.fonts`,
|
|
149
|
+
cwd,
|
|
150
|
+
kind: "fonts",
|
|
151
|
+
target: "local"
|
|
152
|
+
});
|
|
153
|
+
for (const directory of directories) {
|
|
154
|
+
if (directory.watch) watchPaths.add(directory.realPath);
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!(error instanceof TileflowAssetDirectoryError)) throw error;
|
|
158
|
+
issues.push(...error.issues);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (issues.length > 0) throw new TileflowFontCompilationError(issues);
|
|
162
|
+
return [...watchPaths].sort(compareCodeUnits);
|
|
163
|
+
}
|
|
164
|
+
var localRequire = createRequire(import.meta.url);
|
|
165
|
+
var fontkit = localRequire("fontkit");
|
|
166
|
+
var maximumFontFileBytes = 1024 * 1024;
|
|
167
|
+
var maximumFontFilesPerDirectory = 64;
|
|
168
|
+
var maximumLicenseBytes = 2 * 1024 * 1024;
|
|
169
|
+
var supportedExtensions = /* @__PURE__ */ new Set([".otf", ".ttf", ".woff2"]);
|
|
170
|
+
var expressionOperators = /* @__PURE__ */ new Set([
|
|
171
|
+
"at",
|
|
172
|
+
"case",
|
|
173
|
+
"coalesce",
|
|
174
|
+
"concat",
|
|
175
|
+
"format",
|
|
176
|
+
"get",
|
|
177
|
+
"interpolate",
|
|
178
|
+
"let",
|
|
179
|
+
"literal",
|
|
180
|
+
"match",
|
|
181
|
+
"step",
|
|
182
|
+
"to-string",
|
|
183
|
+
"var"
|
|
184
|
+
]);
|
|
185
|
+
async function prepareTileflowStyleFonts(project, inputStyles, options) {
|
|
186
|
+
const projectMapNames = Object.keys(project.maps).sort(compareCodeUnits);
|
|
187
|
+
const styleMapNames = Object.keys(inputStyles).sort(compareCodeUnits);
|
|
188
|
+
const issues = [];
|
|
189
|
+
for (const mapName of projectMapNames) {
|
|
190
|
+
if (!tileflowMapIdSchema.safeParse(mapName).success) {
|
|
191
|
+
issues.push({ message: "Expected a portable map key", path: `maps.${mapName}` });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (const mapName of styleMapNames) {
|
|
195
|
+
if (!tileflowMapIdSchema.safeParse(mapName).success) {
|
|
196
|
+
issues.push({ message: "Expected a portable map key", path: `styles.${mapName}` });
|
|
197
|
+
}
|
|
198
|
+
for (const themeName of Object.keys(inputStyles[mapName]).sort(compareCodeUnits)) {
|
|
199
|
+
if (!tileflowThemeNameSchema.safeParse(themeName).success) {
|
|
200
|
+
issues.push({
|
|
201
|
+
message: 'Expected a concrete portable theme key; "system" is browser-only',
|
|
202
|
+
path: `styles.${mapName}.${themeName}`
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (issues.length > 0) throw new TileflowFontCompilationError(issues);
|
|
208
|
+
const parsedMaps = new Map(
|
|
209
|
+
projectMapNames.map(
|
|
210
|
+
(mapName) => [mapName, parseResolvedTileflowMap(project.maps[mapName])]
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
if (projectMapNames.length !== styleMapNames.length || projectMapNames.some((mapName, index) => mapName !== styleMapNames[index])) {
|
|
214
|
+
throw new TileflowFontCompilationError([
|
|
215
|
+
{ message: "Expected one compiled theme family for every project map", path: "styles" }
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
const assets = /* @__PURE__ */ new Map();
|
|
219
|
+
const bundles = {};
|
|
220
|
+
const sourceIdentities = {};
|
|
221
|
+
const styles = Object.fromEntries(
|
|
222
|
+
Object.entries(inputStyles).map(([mapName, themes]) => [mapName, { ...themes }])
|
|
223
|
+
);
|
|
224
|
+
const watchPaths = /* @__PURE__ */ new Set();
|
|
225
|
+
for (const mapName of projectMapNames) {
|
|
226
|
+
const map = parsedMaps.get(mapName);
|
|
227
|
+
const themeStyles = inputStyles[mapName];
|
|
228
|
+
if (!map || !themeStyles) continue;
|
|
229
|
+
const declaredThemeNames = Object.keys(map.themes).sort(compareCodeUnits);
|
|
230
|
+
const compiledThemeNames = Object.keys(themeStyles).sort(compareCodeUnits);
|
|
231
|
+
if (declaredThemeNames.length !== compiledThemeNames.length || declaredThemeNames.some((themeName, index) => themeName !== compiledThemeNames[index])) {
|
|
232
|
+
issues.push({
|
|
233
|
+
message: "Compiled styles must exactly match the map's declared themes",
|
|
234
|
+
path: `styles.${mapName}`
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (map.fonts === void 0) continue;
|
|
239
|
+
const configPath = `maps.${mapName}.fonts`;
|
|
240
|
+
let directories;
|
|
241
|
+
try {
|
|
242
|
+
directories = await resolveTileflowAssetDirectories(map.fonts, {
|
|
243
|
+
baseDirectory: options.baseDirectory,
|
|
244
|
+
configPath,
|
|
245
|
+
cwd: options.cwd,
|
|
246
|
+
kind: "fonts",
|
|
247
|
+
target: options.target
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!(error instanceof TileflowAssetDirectoryError)) throw error;
|
|
251
|
+
issues.push(...error.issues);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
for (const directory of directories) {
|
|
255
|
+
if (directory.watch) watchPaths.add(directory.realPath);
|
|
256
|
+
}
|
|
257
|
+
const selectedFaces = /* @__PURE__ */ new Map();
|
|
258
|
+
const namesByCaseFold = /* @__PURE__ */ new Map();
|
|
259
|
+
for (const directory of directories) {
|
|
260
|
+
const inspected = await inspectFontDirectory(directory, issues);
|
|
261
|
+
for (const face of inspected.faces) {
|
|
262
|
+
const folded = face.canonicalName.toLowerCase();
|
|
263
|
+
const previousSpelling = namesByCaseFold.get(folded);
|
|
264
|
+
if (previousSpelling && previousSpelling !== face.canonicalName) {
|
|
265
|
+
issues.push({
|
|
266
|
+
message: `Canonical font name differs from already declared "${previousSpelling}" only by case`,
|
|
267
|
+
path: `${directory.configPath}.${face.fileName}`
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
namesByCaseFold.set(folded, face.canonicalName);
|
|
272
|
+
selectedFaces.set(face.canonicalName, face);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const requiredNamesByTheme = /* @__PURE__ */ new Map();
|
|
276
|
+
const requiredNames = /* @__PURE__ */ new Set();
|
|
277
|
+
for (const themeName of compiledThemeNames) {
|
|
278
|
+
const style = themeStyles[themeName];
|
|
279
|
+
const themeRequiredNames = collectPrimaryTextFonts(style, `${mapName}.${themeName}`, issues);
|
|
280
|
+
requiredNamesByTheme.set(themeName, themeRequiredNames);
|
|
281
|
+
for (const name of themeRequiredNames) requiredNames.add(name);
|
|
282
|
+
}
|
|
283
|
+
const requiredFaces = [];
|
|
284
|
+
for (const canonicalName of [...requiredNames].sort(compareCodeUnits)) {
|
|
285
|
+
const face = selectedFaces.get(canonicalName);
|
|
286
|
+
if (face) {
|
|
287
|
+
requiredFaces.push(face);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const caseVariant = namesByCaseFold.get(canonicalName.toLowerCase());
|
|
291
|
+
const available = [...selectedFaces.keys()].sort(compareCodeUnits);
|
|
292
|
+
issues.push({
|
|
293
|
+
message: caseVariant ? `Expected exact canonical font name "${caseVariant}"` : `No declared font directory provides canonical face "${canonicalName}"${available.length > 0 ? `. Available: ${available.join(", ")}` : ""}`,
|
|
294
|
+
path: `styles.${mapName}.*.text-font`
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (requiredFaces.length > tileflowStyleFontFaceLimits.maximumCount) {
|
|
298
|
+
issues.push({
|
|
299
|
+
message: `At most ${tileflowStyleFontFaceLimits.maximumCount} local font faces may be used by one map`,
|
|
300
|
+
path: `styles.${mapName}.*.text-font`
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const fontFaces = [];
|
|
305
|
+
const mapAssets = /* @__PURE__ */ new Map();
|
|
306
|
+
const bundleFaces = [];
|
|
307
|
+
for (const face of requiredFaces) {
|
|
308
|
+
const digest = sha256(face.source);
|
|
309
|
+
const licenseDigest = sha256(face.license);
|
|
310
|
+
const slug = slugifyFontName(face.canonicalName);
|
|
311
|
+
const fileName = `fonts/${slug}-${digest}${face.extension}`;
|
|
312
|
+
const fontAsset = {
|
|
313
|
+
contentType: contentTypeForExtension(face.extension),
|
|
314
|
+
fileName,
|
|
315
|
+
source: face.source
|
|
316
|
+
};
|
|
317
|
+
const licenseFileName = `fonts/licenses/license-${licenseDigest}.txt`;
|
|
318
|
+
const licenseAsset = {
|
|
319
|
+
contentType: "text/plain; charset=utf-8",
|
|
320
|
+
fileName: licenseFileName,
|
|
321
|
+
source: face.license
|
|
322
|
+
};
|
|
323
|
+
addAsset(assets, fontAsset);
|
|
324
|
+
addAsset(assets, licenseAsset);
|
|
325
|
+
addAsset(mapAssets, fontAsset);
|
|
326
|
+
addAsset(mapAssets, licenseAsset);
|
|
327
|
+
fontFaces.push({
|
|
328
|
+
...face.fontFace,
|
|
329
|
+
source: joinTileflowPublicUrl(options.assetBaseUrl, fileName)
|
|
330
|
+
});
|
|
331
|
+
bundleFaces.push({
|
|
332
|
+
family: face.fontFace.family,
|
|
333
|
+
file: fileName,
|
|
334
|
+
licenseFile: licenseFileName,
|
|
335
|
+
style: face.fontFace.style ?? "normal",
|
|
336
|
+
weight: face.fontFace.weight ?? "400"
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const sortedBundleFaces = [...bundleFaces].sort(
|
|
340
|
+
(left, right) => compareCodeUnits(fontFaceIdentity(left), fontFaceIdentity(right))
|
|
341
|
+
);
|
|
342
|
+
const fontFaceByIdentity = new Map(
|
|
343
|
+
fontFaces.map((face) => [fontFaceIdentity(face), face])
|
|
344
|
+
);
|
|
345
|
+
const sourceFaceByFamily = new Map(
|
|
346
|
+
requiredFaces.map((face) => [face.canonicalName, face.fontFace])
|
|
347
|
+
);
|
|
348
|
+
const preparedThemes = {};
|
|
349
|
+
for (const themeName of compiledThemeNames) {
|
|
350
|
+
const style = themeStyles[themeName];
|
|
351
|
+
const existingFontFaces = getTileflowStyleFontFaces(style);
|
|
352
|
+
if (existingFontFaces.length > 0) {
|
|
353
|
+
issues.push({
|
|
354
|
+
message: `Compiled styles may not predeclare ${tileflowStyleFontFacesMetadataKey}; the font pipeline owns it`,
|
|
355
|
+
path: `styles.${mapName}.${themeName}.metadata.${tileflowStyleFontFacesMetadataKey}`
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const themeFontIdentities = new Set(
|
|
359
|
+
[...requiredNamesByTheme.get(themeName) ?? []].map((family) => {
|
|
360
|
+
const face = sourceFaceByFamily.get(family);
|
|
361
|
+
return face ? fontFaceIdentity(face) : "";
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
const themeFontFaces = sortedBundleFaces.filter((face) => themeFontIdentities.has(fontFaceIdentity(face))).map((face) => fontFaceByIdentity.get(fontFaceIdentity(face)));
|
|
365
|
+
preparedThemes[themeName] = withFontFaces(style, themeFontFaces);
|
|
366
|
+
}
|
|
367
|
+
styles[mapName] = preparedThemes;
|
|
368
|
+
sourceIdentities[mapName] = sortedBundleFaces.map((face) => ({
|
|
369
|
+
family: face.family,
|
|
370
|
+
sha256: sha256(toBytes(mapAssets.get(face.file).source)),
|
|
371
|
+
style: face.style,
|
|
372
|
+
weight: face.weight
|
|
373
|
+
}));
|
|
374
|
+
if (bundleFaces.length > 0) {
|
|
375
|
+
const files = [...mapAssets.values()].sort(
|
|
376
|
+
(left, right) => compareCodeUnits(left.fileName, right.fileName)
|
|
377
|
+
);
|
|
378
|
+
const manifest = {
|
|
379
|
+
files: files.map((file) => ({
|
|
380
|
+
byteLength: toBytes(file.source).byteLength,
|
|
381
|
+
contentType: file.contentType,
|
|
382
|
+
kind: file.fileName.startsWith("fonts/licenses/") ? "license" : "font",
|
|
383
|
+
name: file.fileName,
|
|
384
|
+
sha256: sha256(toBytes(file.source))
|
|
385
|
+
})),
|
|
386
|
+
fontFaces: sortedBundleFaces,
|
|
387
|
+
format: "tileflow-font-bundle-v1"
|
|
388
|
+
};
|
|
389
|
+
bundles[mapName] = {
|
|
390
|
+
contentHash: await hashTileflowFontBundleManifest(manifest),
|
|
391
|
+
files,
|
|
392
|
+
manifest
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (issues.length > 0) throw new TileflowFontCompilationError(issues);
|
|
397
|
+
return {
|
|
398
|
+
assets: [...assets.values()].sort(
|
|
399
|
+
(left, right) => compareCodeUnits(left.fileName, right.fileName)
|
|
400
|
+
),
|
|
401
|
+
bundles,
|
|
402
|
+
sourceIdentities,
|
|
403
|
+
styles,
|
|
404
|
+
watchPaths: [...watchPaths].sort(compareCodeUnits)
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function bindTileflowStyleFontBundle(style, bundle, publicBaseUrl) {
|
|
408
|
+
const current = getTileflowStyleFontFaces(style);
|
|
409
|
+
const bundleFaceByIdentity = new Map(
|
|
410
|
+
bundle.manifest.fontFaces.map((face) => [fontFaceIdentity(face), face])
|
|
411
|
+
);
|
|
412
|
+
const resolved = current.map((face) => {
|
|
413
|
+
const wanted = bundleFaceByIdentity.get(fontFaceIdentity(face));
|
|
414
|
+
if (!wanted || !(face.source === wanted.file || face.source.endsWith(`/${wanted.file}`))) {
|
|
415
|
+
return void 0;
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
family: wanted.family,
|
|
419
|
+
source: joinTileflowPublicUrl(publicBaseUrl, wanted.file),
|
|
420
|
+
style: wanted.style,
|
|
421
|
+
weight: wanted.weight
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
if (resolved.some((face) => face === void 0)) {
|
|
425
|
+
throw new Error("Compiled style font faces do not match their canonical font bundle.");
|
|
426
|
+
}
|
|
427
|
+
return withFontFaces(style, resolved);
|
|
428
|
+
}
|
|
429
|
+
function replaceTileflowStyleFontSources(style, replace) {
|
|
430
|
+
const fontFaces = getTileflowStyleFontFaces(style);
|
|
431
|
+
if (fontFaces.length === 0) return style;
|
|
432
|
+
return withFontFaces(
|
|
433
|
+
style,
|
|
434
|
+
fontFaces.map((fontFace) => ({ ...fontFace, source: replace(fontFace.source) }))
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
async function inspectFontDirectory(directory, issues) {
|
|
438
|
+
const entries = (await readdir(directory.realPath, { withFileTypes: true })).sort(
|
|
439
|
+
(left, right) => compareCodeUnits(left.name, right.name)
|
|
440
|
+
);
|
|
441
|
+
const fontEntries = entries.filter(
|
|
442
|
+
(entry) => supportedExtensions.has(extname(entry.name).toLowerCase())
|
|
443
|
+
);
|
|
444
|
+
if (fontEntries.length > maximumFontFilesPerDirectory) {
|
|
445
|
+
issues.push({
|
|
446
|
+
message: `At most ${maximumFontFilesPerDirectory} font files are allowed in one directory`,
|
|
447
|
+
path: directory.configPath
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const licenseEntry = entries.find((entry) => entry.name === "LICENSE.txt");
|
|
451
|
+
let license;
|
|
452
|
+
if (fontEntries.length > 0) {
|
|
453
|
+
if (!licenseEntry?.isFile()) {
|
|
454
|
+
issues.push({
|
|
455
|
+
message: "Every directory containing fonts must include the regular file LICENSE.txt",
|
|
456
|
+
path: `${directory.configPath}.LICENSE.txt`
|
|
457
|
+
});
|
|
458
|
+
} else {
|
|
459
|
+
license = await readContainedFile(directory, licenseEntry.name, maximumLicenseBytes, issues);
|
|
460
|
+
if (license && !isValidLicense(license)) {
|
|
461
|
+
issues.push({
|
|
462
|
+
message: "LICENSE.txt must be non-empty UTF-8 text without NUL bytes",
|
|
463
|
+
path: `${directory.configPath}.LICENSE.txt`
|
|
464
|
+
});
|
|
465
|
+
license = void 0;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const faces = [];
|
|
470
|
+
const namesInDirectory = /* @__PURE__ */ new Set();
|
|
471
|
+
for (const entry of fontEntries.slice(0, maximumFontFilesPerDirectory)) {
|
|
472
|
+
const path = `${directory.configPath}.${entry.name}`;
|
|
473
|
+
const extension = extname(entry.name);
|
|
474
|
+
if (!supportedExtensions.has(extension)) {
|
|
475
|
+
issues.push({ message: "Font file extensions must be lowercase", path });
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!entry.isFile()) {
|
|
479
|
+
issues.push({ message: "Font inputs must be regular files", path });
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const source = await readContainedFile(directory, entry.name, maximumFontFileBytes, issues);
|
|
483
|
+
if (!source || !license) continue;
|
|
484
|
+
try {
|
|
485
|
+
assertFontSignature(source, extension);
|
|
486
|
+
const metadata = readFontMetadata(source);
|
|
487
|
+
if (namesInDirectory.has(metadata.canonicalName)) {
|
|
488
|
+
issues.push({
|
|
489
|
+
message: `Directory declares canonical face "${metadata.canonicalName}" more than once`,
|
|
490
|
+
path
|
|
491
|
+
});
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
namesInDirectory.add(metadata.canonicalName);
|
|
495
|
+
faces.push({
|
|
496
|
+
...metadata,
|
|
497
|
+
directory,
|
|
498
|
+
extension,
|
|
499
|
+
fileName: entry.name,
|
|
500
|
+
license,
|
|
501
|
+
source
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
issues.push({
|
|
505
|
+
message: error instanceof Error ? error.message : "Font metadata could not be read",
|
|
506
|
+
path
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return { faces };
|
|
511
|
+
}
|
|
512
|
+
async function readContainedFile(directory, fileName, maximumBytes, issues) {
|
|
513
|
+
const configuredPath = join(directory.realPath, fileName);
|
|
514
|
+
const issuePath = `${directory.configPath}.${fileName}`;
|
|
515
|
+
try {
|
|
516
|
+
if (!(await lstat(configuredPath)).isFile()) throw new Error("Asset must be a regular file");
|
|
517
|
+
const realPath = await realpath(configuredPath);
|
|
518
|
+
if (!isPathWithin(directory.realPath, realPath)) {
|
|
519
|
+
throw new Error("Asset file escapes its declared directory");
|
|
520
|
+
}
|
|
521
|
+
const metadata = await stat(realPath);
|
|
522
|
+
if (metadata.size === 0) throw new Error("Asset file may not be empty");
|
|
523
|
+
if (metadata.size > maximumBytes) {
|
|
524
|
+
throw new Error(`Asset file exceeds the ${maximumBytes}-byte limit`);
|
|
525
|
+
}
|
|
526
|
+
return await readFile(realPath);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
issues.push({
|
|
529
|
+
message: error instanceof Error ? error.message : "Asset file could not be read",
|
|
530
|
+
path: issuePath
|
|
531
|
+
});
|
|
532
|
+
return void 0;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function readFontMetadata(source) {
|
|
536
|
+
let font;
|
|
537
|
+
try {
|
|
538
|
+
font = fontkit.create(source);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`Unable to parse OpenType font${error instanceof Error && error.message ? `: ${error.message}` : ""}`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (!font || typeof font !== "object") throw new Error("Expected one OpenType font face");
|
|
545
|
+
const canonicalName = normalizeCanonicalFontName(font.fullName);
|
|
546
|
+
if (!canonicalName) {
|
|
547
|
+
throw new Error("OpenType fullName must be a canonical name of 1 to 100 characters");
|
|
548
|
+
}
|
|
549
|
+
const os2 = font["OS/2"];
|
|
550
|
+
const rawWeight = os2?.usWeightClass;
|
|
551
|
+
if (!Number.isInteger(rawWeight) || rawWeight < 100 || rawWeight > 900 || rawWeight % 100 !== 0) {
|
|
552
|
+
throw new Error("OpenType OS/2.usWeightClass must be one of 100, 200, ..., 900");
|
|
553
|
+
}
|
|
554
|
+
const italicAngle = font.italicAngle;
|
|
555
|
+
if (italicAngle !== void 0 && (typeof italicAngle !== "number" || !Number.isFinite(italicAngle))) {
|
|
556
|
+
throw new Error("OpenType italicAngle is invalid");
|
|
557
|
+
}
|
|
558
|
+
const style = os2?.fsSelection?.oblique ? "oblique" : os2?.fsSelection?.italic || typeof italicAngle === "number" && italicAngle !== 0 ? "italic" : "normal";
|
|
559
|
+
const weight = String(rawWeight);
|
|
560
|
+
return { canonicalName, fontFace: { family: canonicalName, style, weight } };
|
|
561
|
+
}
|
|
562
|
+
function normalizeCanonicalFontName(input) {
|
|
563
|
+
if (typeof input !== "string" || input !== input.trim()) return void 0;
|
|
564
|
+
const value = input.normalize("NFC");
|
|
565
|
+
if (!value || value.length > 100 || /[\p{Cc}\\]/u.test(value)) return void 0;
|
|
566
|
+
return value;
|
|
567
|
+
}
|
|
568
|
+
function collectPrimaryTextFonts(style, mapName, issues) {
|
|
569
|
+
const result = /* @__PURE__ */ new Set();
|
|
570
|
+
for (const [index, layer] of style.layers.entries()) {
|
|
571
|
+
if (!layer || typeof layer !== "object" || Array.isArray(layer)) continue;
|
|
572
|
+
const layout = layer.layout;
|
|
573
|
+
if (!layout || typeof layout !== "object" || Array.isArray(layout)) continue;
|
|
574
|
+
const textFont = layout["text-font"];
|
|
575
|
+
if (textFont === void 0) continue;
|
|
576
|
+
const layerId = typeof layer.id === "string" ? layer.id : String(index);
|
|
577
|
+
const primary = readStaticPrimaryFont(textFont);
|
|
578
|
+
if (primary === void 0) {
|
|
579
|
+
issues.push({
|
|
580
|
+
message: "Local font providers require a static non-empty text-font stack",
|
|
581
|
+
path: `styles.${mapName}.layers.${layerId}.layout.text-font`
|
|
582
|
+
});
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const canonical = normalizeCanonicalFontName(primary);
|
|
586
|
+
if (!canonical) {
|
|
587
|
+
issues.push({
|
|
588
|
+
message: "Primary text-font must be a canonical name of 1 to 100 characters",
|
|
589
|
+
path: `styles.${mapName}.layers.${layerId}.layout.text-font.0`
|
|
590
|
+
});
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
result.add(canonical);
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
function readStaticPrimaryFont(value) {
|
|
598
|
+
if (typeof value === "string") return value;
|
|
599
|
+
if (!Array.isArray(value) || value.length === 0) return void 0;
|
|
600
|
+
if (value.length === 2 && value[0] === "literal") return readStaticPrimaryFont(value[1]);
|
|
601
|
+
if (value.every((entry) => typeof entry === "string") && !expressionOperators.has(value[0])) {
|
|
602
|
+
return value[0];
|
|
603
|
+
}
|
|
604
|
+
return void 0;
|
|
605
|
+
}
|
|
606
|
+
function withFontFaces(style, fontFaces) {
|
|
607
|
+
const metadata = { ...style.metadata };
|
|
608
|
+
if (fontFaces.length === 0) delete metadata[tileflowStyleFontFacesMetadataKey];
|
|
609
|
+
else metadata[tileflowStyleFontFacesMetadataKey] = fontFaces;
|
|
610
|
+
return { ...style, metadata };
|
|
611
|
+
}
|
|
612
|
+
function addAsset(assets, asset) {
|
|
613
|
+
const previous = assets.get(asset.fileName);
|
|
614
|
+
if (!previous) {
|
|
615
|
+
assets.set(asset.fileName, asset);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (previous.contentType !== asset.contentType || !equalBytes(toBytes(previous.source), toBytes(asset.source))) {
|
|
619
|
+
throw new Error(`Conflicting generated font asset: ${asset.fileName}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function assertFontSignature(source, extension) {
|
|
623
|
+
if (source.byteLength < 4) throw new Error("Font file is too small");
|
|
624
|
+
const signature = String.fromCharCode(source[0], source[1], source[2], source[3]);
|
|
625
|
+
const trueType = source[0] === 0 && source[1] === 1 && source[2] === 0 && source[3] === 0;
|
|
626
|
+
const supported = extension === ".woff2" ? signature === "wOF2" : extension === ".otf" ? signature === "OTTO" : trueType || signature === "true";
|
|
627
|
+
if (!supported) throw new Error(`File bytes do not match the ${extension} format`);
|
|
628
|
+
}
|
|
629
|
+
function isValidLicense(source) {
|
|
630
|
+
try {
|
|
631
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(source);
|
|
632
|
+
return text.trim().length > 0 && !text.includes("\0");
|
|
633
|
+
} catch {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function contentTypeForExtension(extension) {
|
|
638
|
+
if (extension === ".otf") return "font/otf";
|
|
639
|
+
if (extension === ".ttf") return "font/ttf";
|
|
640
|
+
return "font/woff2";
|
|
641
|
+
}
|
|
642
|
+
function slugifyFontName(name) {
|
|
643
|
+
const slug = name.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "");
|
|
644
|
+
return slug || "font";
|
|
645
|
+
}
|
|
646
|
+
function sha256(source) {
|
|
647
|
+
return createHash("sha256").update(source).digest("hex");
|
|
648
|
+
}
|
|
649
|
+
function toBytes(source) {
|
|
650
|
+
return typeof source === "string" ? new TextEncoder().encode(source) : source;
|
|
651
|
+
}
|
|
652
|
+
function equalBytes(left, right) {
|
|
653
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
654
|
+
return left.every((value, index) => value === right[index]);
|
|
655
|
+
}
|
|
656
|
+
function fontFaceIdentity(face) {
|
|
657
|
+
return `${face.family}\0${face.style ?? "normal"}\0${face.weight ?? "400"}`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export {
|
|
661
|
+
normalizeTileflowBasePath,
|
|
662
|
+
joinTileflowPublicUrl,
|
|
663
|
+
resolveTileflowArtifactPublicUrls,
|
|
664
|
+
getTileflowAssetBasePath,
|
|
665
|
+
getTileflowAssetFileName,
|
|
666
|
+
TileflowFontCompilationError,
|
|
667
|
+
getTileflowFontWatchPaths,
|
|
668
|
+
prepareTileflowStyleFonts,
|
|
669
|
+
bindTileflowStyleFontBundle,
|
|
670
|
+
replaceTileflowStyleFontSources
|
|
671
|
+
};
|