@pylonsync/functions 0.3.291 → 0.3.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,633 @@
1
+ // Self-hosted web fonts — Pylon's next/font-parity primitive.
2
+ //
3
+ // At build time, for each `font({...})` declared in app.ts the bundler calls
4
+ // `buildFonts`, which:
5
+ // 1. fetches the family's CSS from the Google Fonts CSS2 API (modern UA → woff2),
6
+ // 2. downloads each woff2 face and self-hosts it under the client-build outdir
7
+ // (served same-origin at `/_pylon/build/<file>.woff2`, immutable-cached),
8
+ // 3. parses the real sfnt metrics out of the woff2 (no third-party dep),
9
+ // 4. computes a size-adjusted fallback `@font-face` (size-adjust +
10
+ // ascent/descent/line-gap overrides) against the local system font so the
11
+ // web font swaps in with ZERO layout shift,
12
+ // 5. emits structured faces + the CSS variable into the bundle manifest.
13
+ //
14
+ // `renderFontFaceCss` (used by the SSR head injector) turns the structured faces
15
+ // back into a `<style>` blob at request time, resolving the woff2 URLs against
16
+ // the LIVE `public_prefix` (same-origin in dev/self-host, the CDN on cloud) so
17
+ // the URLs always match wherever the assets are actually served.
18
+ //
19
+ // The metrics parser reads ONLY the head/hhea/OS-2 tables (the line-box +
20
+ // average-width fields). It supports raw sfnt (ttf/otf) and woff2 (brotli-
21
+ // decompressed); glyf/loca transforms are accounted for when computing table
22
+ // offsets but never decoded.
23
+
24
+ import { brotliDecompressSync } from "node:zlib";
25
+
26
+ /** One `@font-face` rule. A real face carries `src` (woff2 files); a generated
27
+ * fallback face carries `local` + the size-adjust/override metrics instead. */
28
+ export interface FontFace {
29
+ /** `font-family` — the real family, or `"<Family> Fallback"` for the
30
+ * size-adjusted fallback face. */
31
+ family: string;
32
+ /** outdir-relative woff2 filenames (real face). Empty for a fallback face. */
33
+ src: string[];
34
+ /** `local("...")` source for a size-adjusted fallback face, e.g. `"Arial"`. */
35
+ local?: string;
36
+ weight?: string;
37
+ style?: string;
38
+ display?: string;
39
+ unicodeRange?: string;
40
+ /** `size-adjust` percentage (fallback face only), e.g. `"107.40%"`. */
41
+ sizeAdjust?: string;
42
+ /** `ascent-override` percentage (fallback face only). */
43
+ ascentOverride?: string;
44
+ /** `descent-override` percentage (fallback face only). */
45
+ descentOverride?: string;
46
+ /** `line-gap-override` percentage (fallback face only). */
47
+ lineGapOverride?: string;
48
+ }
49
+
50
+ /** The bundle manifest's `fonts` block: structured faces + CSS variables +
51
+ * the woff2 files to `<link rel=preload>`. Rendered into every SSR `<head>`. */
52
+ export interface ManifestFonts {
53
+ faces: FontFace[];
54
+ /** CSS custom property → font-family stack, e.g.
55
+ * `{"--font-sans": '"Geist", "Geist Fallback", sans-serif'}`. */
56
+ variables: Record<string, string>;
57
+ /** outdir-relative woff2 filenames to preload. */
58
+ preload: string[];
59
+ }
60
+
61
+ interface SfntMetrics {
62
+ unitsPerEm: number;
63
+ ascent: number;
64
+ descent: number;
65
+ lineGap: number;
66
+ xAvgCharWidth: number;
67
+ }
68
+
69
+ /** A `ManifestFont` as it appears in `pylon.manifest.json` (snake_case wire
70
+ * shape — see the SDK `font()` helper). */
71
+ interface ManifestFontInput {
72
+ family: string;
73
+ variable: string;
74
+ weights?: string[];
75
+ styles?: string[];
76
+ subsets?: string[];
77
+ display?: string;
78
+ preload?: boolean;
79
+ fallback?: string[];
80
+ adjust_font_fallback?: boolean;
81
+ }
82
+
83
+ // A modern desktop Safari UA makes the Google Fonts CSS2 API return woff2 src
84
+ // URLs (the smallest, universally-supported modern format). We parse metrics
85
+ // straight out of the woff2 — no second request, no UA guessing for a ttf.
86
+ const MODERN_UA =
87
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 " +
88
+ "(KHTML, like Gecko) Version/16.0 Safari/605.1.15";
89
+
90
+ // Average-character-width + em of the two CSS generic fallback faces, used to
91
+ // compute `size-adjust`. Values are the fonts' own OS/2 xAvgCharWidth /
92
+ // unitsPerEm (the same measure we read off the web font), so the ratio is
93
+ // meaningful. Sourced from the Arial / Times New Roman metric tables that
94
+ // @capsizecss/metrics publishes (the set next/font uses).
95
+ const FALLBACK_FONTS: Record<
96
+ "sans-serif" | "serif",
97
+ { name: string; xWidthAvg: number; unitsPerEm: number }
98
+ > = {
99
+ "sans-serif": { name: "Arial", xWidthAvg: 904, unitsPerEm: 2048 },
100
+ serif: { name: "Times New Roman", xWidthAvg: 821, unitsPerEm: 2048 },
101
+ };
102
+
103
+ // The 63 known table tags woff2's compact table directory indexes by ordinal
104
+ // (W3C WOFF2 spec §5.2). Flag index 63 means an explicit 4-byte tag follows.
105
+ const WOFF2_KNOWN_TAGS = [
106
+ "cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post",
107
+ "cvt ", "fpgm", "glyf", "loca", "prep", "CFF ", "VORG", "EBDT",
108
+ "EBLC", "gasp", "hdmx", "kern", "LTSH", "PCLT", "VDMX", "vhea",
109
+ "vmtx", "BASE", "GDEF", "GPOS", "GSUB", "EBSC", "JSTF", "MATH",
110
+ "CBDT", "CBLC", "COLR", "CPAL", "SVG ", "sbix", "acnt", "avar",
111
+ "bdat", "bloc", "bsln", "cvar", "fdsc", "feat", "fmtx", "fvar",
112
+ "gvar", "hsty", "just", "lcar", "mort", "morx", "opbd", "prop",
113
+ "trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill",
114
+ ];
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Metrics parsing
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Decode the line-box metrics from a font buffer (woff2 or raw sfnt). Returns
121
+ * null when the format/tables can't be read — callers degrade gracefully. */
122
+ export function decodeFontMetrics(buf: Uint8Array): SfntMetrics | null {
123
+ if (buf.length < 12) return null;
124
+ const sig = String.fromCharCode(buf[0], buf[1], buf[2], buf[3]);
125
+ if (sig === "wOF2") return decodeWoff2Metrics(buf);
126
+ // Raw sfnt: 0x00010000 (truetype), "true"/"typ1", or "OTTO" (CFF).
127
+ return decodeSfntMetrics(buf);
128
+ }
129
+
130
+ /** UIntBase128: big-endian, 7 bits/byte, high bit = continuation, ≤5 bytes. */
131
+ function readBase128(buf: Uint8Array, p: number): [number, number] {
132
+ let result = 0;
133
+ for (let i = 0; i < 5; i++) {
134
+ const b = buf[p++];
135
+ result = result * 128 + (b & 0x7f);
136
+ if ((b & 0x80) === 0) return [result, p];
137
+ }
138
+ return [result, p];
139
+ }
140
+
141
+ function decodeWoff2Metrics(buf: Uint8Array): SfntMetrics | null {
142
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
143
+ const numTables = dv.getUint16(12);
144
+ const totalCompressedSize = dv.getUint32(20);
145
+ let p = 48; // fixed woff2 header length
146
+
147
+ const dir: { tag: string; streamLen: number }[] = [];
148
+ for (let i = 0; i < numTables; i++) {
149
+ const flags = buf[p++];
150
+ const idx = flags & 0x3f;
151
+ let tag: string;
152
+ if (idx === 0x3f) {
153
+ tag = String.fromCharCode(buf[p], buf[p + 1], buf[p + 2], buf[p + 3]);
154
+ p += 4;
155
+ } else {
156
+ tag = WOFF2_KNOWN_TAGS[idx];
157
+ }
158
+ const transformVersion = (flags >> 6) & 0x3;
159
+ const [origLength, afterOrig] = readBase128(buf, p);
160
+ p = afterOrig;
161
+ // glyf/loca are transformed at version 0 (null transform = version 3); every
162
+ // other table is transformed only at a non-zero version. A transformed table
163
+ // carries a transformLength and occupies THAT many bytes in the stream.
164
+ const transformed =
165
+ tag === "glyf" || tag === "loca"
166
+ ? transformVersion === 0
167
+ : transformVersion !== 0;
168
+ let streamLen = origLength;
169
+ if (transformed) {
170
+ const [tlen, afterTransform] = readBase128(buf, p);
171
+ p = afterTransform;
172
+ streamLen = tlen;
173
+ }
174
+ dir.push({ tag, streamLen });
175
+ }
176
+
177
+ const compressed = buf.subarray(p, p + totalCompressedSize);
178
+ let stream: Uint8Array;
179
+ try {
180
+ stream = brotliDecompressSync(compressed);
181
+ } catch {
182
+ return null;
183
+ }
184
+
185
+ // Tables are concatenated in directory order with no padding in the stream.
186
+ const tables: Record<string, { offset: number; length: number }> = {};
187
+ let off = 0;
188
+ for (const d of dir) {
189
+ tables[d.tag] = { offset: off, length: d.streamLen };
190
+ off += d.streamLen;
191
+ }
192
+ return readMetricsFromTables(stream, tables);
193
+ }
194
+
195
+ function decodeSfntMetrics(buf: Uint8Array): SfntMetrics | null {
196
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
197
+ const numTables = dv.getUint16(4);
198
+ const tables: Record<string, { offset: number; length: number }> = {};
199
+ let p = 12; // after the sfnt offset table
200
+ for (let i = 0; i < numTables; i++) {
201
+ if (p + 16 > buf.length) break;
202
+ const tag = String.fromCharCode(buf[p], buf[p + 1], buf[p + 2], buf[p + 3]);
203
+ const offset = dv.getUint32(p + 8);
204
+ const length = dv.getUint32(p + 12);
205
+ tables[tag] = { offset, length };
206
+ p += 16;
207
+ }
208
+ return readMetricsFromTables(buf, tables);
209
+ }
210
+
211
+ function readMetricsFromTables(
212
+ data: Uint8Array,
213
+ tables: Record<string, { offset: number; length: number }>,
214
+ ): SfntMetrics | null {
215
+ const head = tables["head"];
216
+ const hhea = tables["hhea"];
217
+ if (!head || !hhea) return null;
218
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
219
+
220
+ const unitsPerEm = dv.getUint16(head.offset + 18);
221
+ if (!unitsPerEm) return null;
222
+
223
+ // hhea ascender/descender/lineGap are the default line-box metrics.
224
+ let ascent = dv.getInt16(hhea.offset + 4);
225
+ let descent = dv.getInt16(hhea.offset + 6);
226
+ let lineGap = dv.getInt16(hhea.offset + 8);
227
+ let xAvgCharWidth = 0;
228
+
229
+ const os2 = tables["OS/2"];
230
+ if (os2 && os2.length >= 74) {
231
+ xAvgCharWidth = dv.getInt16(os2.offset + 2);
232
+ const fsSelection = dv.getUint16(os2.offset + 62);
233
+ // USE_TYPO_METRICS (bit 7): prefer the typographic ascender/descender/
234
+ // lineGap — what a conformant browser uses for line layout when set.
235
+ if (fsSelection & 0x80) {
236
+ ascent = dv.getInt16(os2.offset + 68);
237
+ descent = dv.getInt16(os2.offset + 70);
238
+ lineGap = dv.getInt16(os2.offset + 72);
239
+ }
240
+ }
241
+ return { unitsPerEm, ascent, descent, lineGap, xAvgCharWidth };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Size-adjusted fallback computation
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function fmtPct(v: number): string {
249
+ return `${(Math.abs(v) * 100).toFixed(2)}%`;
250
+ }
251
+
252
+ /** Compute the size-adjusted fallback `@font-face` for a family. The web font's
253
+ * metrics, divided by the size-adjust factor, become the overrides — so the
254
+ * local fallback occupies the same line box + average advance as the web font
255
+ * will, eliminating the swap-in shift. */
256
+ export function computeFallbackFace(
257
+ metrics: SfntMetrics,
258
+ family: string,
259
+ category: "sans-serif" | "serif",
260
+ ): FontFace {
261
+ const fb = FALLBACK_FONTS[category];
262
+ const webAvg =
263
+ metrics.xAvgCharWidth && metrics.unitsPerEm
264
+ ? metrics.xAvgCharWidth / metrics.unitsPerEm
265
+ : 0;
266
+ const fbAvg = fb.xWidthAvg / fb.unitsPerEm;
267
+ const sizeAdjust = webAvg && fbAvg ? webAvg / fbAvg : 1;
268
+ const em = metrics.unitsPerEm * sizeAdjust;
269
+ return {
270
+ family: `${family} Fallback`,
271
+ local: fb.name,
272
+ src: [],
273
+ sizeAdjust: `${(sizeAdjust * 100).toFixed(2)}%`,
274
+ ascentOverride: fmtPct(metrics.ascent / em),
275
+ descentOverride: fmtPct(metrics.descent / em),
276
+ lineGapOverride: fmtPct(metrics.lineGap / em),
277
+ };
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // CSS rendering (request-time; resolves URLs against the live prefix)
282
+ // ---------------------------------------------------------------------------
283
+
284
+ /** Render the structured faces + `:root` variables into a CSS string. woff2 URLs
285
+ * are resolved against `prefix` (the live `public_prefix`) so they match
286
+ * wherever the assets are served (same-origin or CDN). */
287
+ export function renderFontFaceCss(
288
+ fonts: ManifestFonts,
289
+ prefix: string,
290
+ ): string {
291
+ let css = "";
292
+ for (const f of fonts.faces || []) {
293
+ css += "@font-face{";
294
+ css += `font-family:"${f.family}";`;
295
+ if (f.style) css += `font-style:${f.style};`;
296
+ if (f.weight) css += `font-weight:${f.weight};`;
297
+ if (f.display) css += `font-display:${f.display};`;
298
+ if (f.src && f.src.length) {
299
+ css += `src:${f.src
300
+ .map((s) => `url(${prefix}${s}) format("woff2")`)
301
+ .join(",")};`;
302
+ } else if (f.local) {
303
+ css += `src:local("${f.local}");`;
304
+ }
305
+ if (f.sizeAdjust) css += `size-adjust:${f.sizeAdjust};`;
306
+ if (f.ascentOverride) css += `ascent-override:${f.ascentOverride};`;
307
+ if (f.descentOverride) css += `descent-override:${f.descentOverride};`;
308
+ if (f.lineGapOverride) css += `line-gap-override:${f.lineGapOverride};`;
309
+ if (f.unicodeRange) css += `unicode-range:${f.unicodeRange};`;
310
+ css += "}";
311
+ }
312
+ const vars = Object.entries(fonts.variables || {});
313
+ if (vars.length) {
314
+ css += ":root{";
315
+ for (const [k, v] of vars) css += `${k}:${v};`;
316
+ css += "}";
317
+ }
318
+ return css;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Google Fonts CSS2 request + parse
323
+ // ---------------------------------------------------------------------------
324
+
325
+ interface FontSpec {
326
+ family: string;
327
+ weights: string[];
328
+ styles: string[];
329
+ subsets: string[];
330
+ display: string;
331
+ }
332
+
333
+ interface FaceBlock {
334
+ subset: string;
335
+ style: string;
336
+ weight: string;
337
+ unicodeRange: string;
338
+ url: string;
339
+ }
340
+
341
+ /** Compare two CSS2 weight tokens ("400", "300..700") ascending by their
342
+ * leading numeric value (CSS2 requires the axis list sorted ascending). */
343
+ function cmpWeight(a: string, b: string): number {
344
+ return parseInt(a, 10) - parseInt(b, 10);
345
+ }
346
+
347
+ /** Build the Google Fonts CSS2 API URL for a spec. */
348
+ export function buildGoogleFontsUrl(spec: FontSpec): string {
349
+ const fam = spec.family.trim().replace(/\s+/g, "+");
350
+ const hasItalic = spec.styles.includes("italic");
351
+ let axis: string;
352
+ if (hasItalic) {
353
+ const tuples: string[] = [];
354
+ for (const st of ["normal", "italic"] as const) {
355
+ if (!spec.styles.includes(st)) continue;
356
+ const ital = st === "italic" ? 1 : 0;
357
+ for (const w of spec.weights) tuples.push(`${ital},${w}`);
358
+ }
359
+ tuples.sort((a, b) => {
360
+ const [ai, aw] = a.split(",");
361
+ const [bi, bw] = b.split(",");
362
+ return ai === bi ? cmpWeight(aw, bw) : Number(ai) - Number(bi);
363
+ });
364
+ axis = `:ital,wght@${tuples.join(";")}`;
365
+ } else {
366
+ const sorted = [...spec.weights].sort(cmpWeight);
367
+ axis = `:wght@${sorted.join(";")}`;
368
+ }
369
+ return `https://fonts.googleapis.com/css2?family=${fam}${axis}&display=${spec.display}`;
370
+ }
371
+
372
+ /** Parse a Google Fonts CSS2 response into per-subset face blocks, keeping only
373
+ * the requested subsets. Each `@font-face` is preceded by a subset comment in
374
+ * the API output. */
375
+ export function parseGoogleFontsCss(
376
+ css: string,
377
+ subsets: string[],
378
+ ): FaceBlock[] {
379
+ const blocks: FaceBlock[] = [];
380
+ const re = /\/\*\s*([^*]+?)\s*\*\/\s*@font-face\s*\{([^}]*)\}/g;
381
+ for (const m of css.matchAll(re)) {
382
+ const subset = m[1].trim();
383
+ if (subsets.length && !subsets.includes(subset)) continue;
384
+ const body = m[2];
385
+ const style = (body.match(/font-style:\s*([^;]+);/)?.[1] || "normal").trim();
386
+ const weight = (body.match(/font-weight:\s*([^;]+);/)?.[1] || "400").trim();
387
+ const unicodeRange = (
388
+ body.match(/unicode-range:\s*([^;]+);/)?.[1] || ""
389
+ ).trim();
390
+ const urlMatch = body.match(/url\(([^)]+)\)\s*format\(['"]?woff2['"]?\)/);
391
+ if (!urlMatch) continue;
392
+ const url = urlMatch[1].replace(/['"]/g, "");
393
+ blocks.push({ subset, style, weight, unicodeRange, url });
394
+ }
395
+ return blocks;
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Build engine
400
+ // ---------------------------------------------------------------------------
401
+
402
+ interface FontCacheEntry {
403
+ faces: FontFace[];
404
+ metrics: SfntMetrics | null;
405
+ }
406
+
407
+ function fontCategory(font: ManifestFontInput): "sans-serif" | "serif" {
408
+ const fb = font.fallback;
409
+ if (fb && fb.includes("serif") && !fb.includes("sans-serif")) return "serif";
410
+ return "sans-serif";
411
+ }
412
+
413
+ function readFontCache(
414
+ fs: any,
415
+ path: any,
416
+ cacheDir: string,
417
+ ): FontCacheEntry | null {
418
+ const metaPath = path.join(cacheDir, "meta.json");
419
+ if (!fs.existsSync(metaPath)) return null;
420
+ try {
421
+ const entry = JSON.parse(
422
+ fs.readFileSync(metaPath, "utf8"),
423
+ ) as FontCacheEntry;
424
+ // Verify every referenced woff2 is still present.
425
+ for (const face of entry.faces) {
426
+ for (const fname of face.src) {
427
+ if (!fs.existsSync(path.join(cacheDir, fname))) return null;
428
+ }
429
+ }
430
+ return entry;
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ async function fetchAndCacheFont(
437
+ fs: any,
438
+ path: any,
439
+ crypto: any,
440
+ cacheDir: string,
441
+ spec: FontSpec,
442
+ ): Promise<FontCacheEntry> {
443
+ const url = buildGoogleFontsUrl(spec);
444
+ const res = await fetch(url, { headers: { "User-Agent": MODERN_UA } });
445
+ if (!res.ok) {
446
+ throw new Error(`Google Fonts CSS ${res.status} for ${url}`);
447
+ }
448
+ const css = await res.text();
449
+ const blocks = parseGoogleFontsCss(css, spec.subsets);
450
+ if (blocks.length === 0) {
451
+ throw new Error(
452
+ `no woff2 @font-face for "${spec.family}" (subsets: ${spec.subsets.join(", ")})`,
453
+ );
454
+ }
455
+
456
+ fs.mkdirSync(cacheDir, { recursive: true });
457
+ const faces: FontFace[] = [];
458
+ let primaryBuf: Uint8Array | null = null;
459
+ let primaryScore = -Infinity;
460
+
461
+ for (const b of blocks) {
462
+ const fontRes = await fetch(b.url);
463
+ if (!fontRes.ok) {
464
+ throw new Error(`woff2 fetch ${fontRes.status} for ${b.url}`);
465
+ }
466
+ const buf = new Uint8Array(await fontRes.arrayBuffer());
467
+ const hash = crypto
468
+ .createHash("sha256")
469
+ .update(buf)
470
+ .digest("hex")
471
+ .slice(0, 12);
472
+ const fname = `font-${hash}.woff2`;
473
+ fs.writeFileSync(path.join(cacheDir, fname), buf);
474
+ faces.push({
475
+ family: spec.family,
476
+ src: [fname],
477
+ weight: b.weight,
478
+ style: b.style,
479
+ unicodeRange: b.unicodeRange,
480
+ });
481
+ // Prefer a normal-style, latin, weight-near-400 face for metrics.
482
+ const score =
483
+ (b.style === "normal" ? 100 : 0) +
484
+ (b.subset === "latin" ? 50 : 0) -
485
+ Math.abs(parseInt(b.weight, 10) - 400) / 100;
486
+ if (score > primaryScore) {
487
+ primaryScore = score;
488
+ primaryBuf = buf;
489
+ }
490
+ }
491
+
492
+ const metrics = primaryBuf ? decodeFontMetrics(primaryBuf) : null;
493
+ const entry: FontCacheEntry = { faces, metrics };
494
+ fs.writeFileSync(
495
+ path.join(cacheDir, "meta.json"),
496
+ JSON.stringify(entry),
497
+ "utf8",
498
+ );
499
+ return entry;
500
+ }
501
+
502
+ async function buildOneFont(
503
+ fs: any,
504
+ path: any,
505
+ crypto: any,
506
+ cacheRoot: string,
507
+ outdir: string,
508
+ font: ManifestFontInput,
509
+ ): Promise<ManifestFonts> {
510
+ const spec: FontSpec = {
511
+ family: font.family,
512
+ weights: font.weights && font.weights.length ? font.weights : ["400"],
513
+ styles: font.styles && font.styles.length ? font.styles : ["normal"],
514
+ subsets: font.subsets && font.subsets.length ? font.subsets : ["latin"],
515
+ display: font.display || "swap",
516
+ };
517
+ const key = crypto
518
+ .createHash("sha256")
519
+ .update(JSON.stringify(spec))
520
+ .digest("hex")
521
+ .slice(0, 16);
522
+ const cacheDir = path.join(cacheRoot, key);
523
+
524
+ let cached = readFontCache(fs, path, cacheDir);
525
+ if (!cached) {
526
+ cached = await fetchAndCacheFont(fs, path, crypto, cacheDir, spec);
527
+ }
528
+
529
+ const category = fontCategory(font);
530
+ const doPreload = font.preload !== false;
531
+ const faces: FontFace[] = [];
532
+ const preloadFiles: string[] = [];
533
+
534
+ // outdir is wiped each build, so re-emit the cached woff2 into it.
535
+ for (const face of cached.faces) {
536
+ const outSrc: string[] = [];
537
+ for (const fname of face.src) {
538
+ fs.copyFileSync(path.join(cacheDir, fname), path.join(outdir, fname));
539
+ outSrc.push(fname);
540
+ if (doPreload) preloadFiles.push(fname);
541
+ }
542
+ faces.push({
543
+ family: font.family,
544
+ src: outSrc,
545
+ weight: face.weight,
546
+ style: face.style,
547
+ unicodeRange: face.unicodeRange,
548
+ display: spec.display,
549
+ });
550
+ }
551
+
552
+ const variables: Record<string, string> = {};
553
+ const userFallback =
554
+ font.fallback && font.fallback.length ? font.fallback : [category];
555
+ if ((font.adjust_font_fallback ?? true) && cached.metrics) {
556
+ faces.push(computeFallbackFace(cached.metrics, font.family, category));
557
+ variables[font.variable] = [
558
+ `"${font.family}"`,
559
+ `"${font.family} Fallback"`,
560
+ ...userFallback,
561
+ ].join(", ");
562
+ } else {
563
+ variables[font.variable] = [`"${font.family}"`, ...userFallback].join(", ");
564
+ }
565
+
566
+ return { faces, variables, preload: Array.from(new Set(preloadFiles)) };
567
+ }
568
+
569
+ /** Build all declared fonts: fetch + self-host + metrics + structured faces.
570
+ * Network/parse failures for a single family degrade to a variable-only entry
571
+ * (so `var(--font-x)` still resolves to the fallback stack) rather than killing
572
+ * the build. Returns null when nothing was produced. */
573
+ export async function buildFonts(
574
+ fs: any,
575
+ path: any,
576
+ cwd: string,
577
+ outdir: string,
578
+ fonts: ManifestFontInput[],
579
+ ): Promise<ManifestFonts | null> {
580
+ if (!fonts || fonts.length === 0) return null;
581
+ const cryptoMod: any = await import("node:crypto");
582
+ const crypto = cryptoMod.default ?? cryptoMod;
583
+ const cacheRoot = path.join(cwd, ".pylon", "font-cache");
584
+
585
+ const result: ManifestFonts = { faces: [], variables: {}, preload: [] };
586
+ for (const font of fonts) {
587
+ try {
588
+ const built = await buildOneFont(
589
+ fs,
590
+ path,
591
+ crypto,
592
+ cacheRoot,
593
+ outdir,
594
+ font,
595
+ );
596
+ result.faces.push(...built.faces);
597
+ Object.assign(result.variables, built.variables);
598
+ result.preload.push(...built.preload);
599
+ } catch (err: any) {
600
+ // eslint-disable-next-line no-console
601
+ console.warn(
602
+ `[pylon ssr] font "${font.family}" failed: ${err?.message ?? err}`,
603
+ );
604
+ const category = fontCategory(font);
605
+ const stack = (
606
+ font.fallback && font.fallback.length ? font.fallback : [category]
607
+ ).join(", ");
608
+ result.variables[font.variable] = `"${font.family}", ${stack}`;
609
+ }
610
+ }
611
+
612
+ if (result.faces.length === 0 && Object.keys(result.variables).length === 0) {
613
+ return null;
614
+ }
615
+ return result;
616
+ }
617
+
618
+ /** Read the `fonts` array from the app's `pylon.manifest.json` (written next to
619
+ * app.ts). Returns [] when absent/unreadable. */
620
+ export function readManifestFonts(
621
+ fs: any,
622
+ path: any,
623
+ cwd: string,
624
+ ): ManifestFontInput[] {
625
+ const manifestPath = path.join(cwd, "pylon.manifest.json");
626
+ if (!fs.existsSync(manifestPath)) return [];
627
+ try {
628
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
629
+ return Array.isArray(m.fonts) ? m.fonts : [];
630
+ } catch {
631
+ return [];
632
+ }
633
+ }