@pylonsync/functions 0.3.292 → 0.3.294
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/define.d.ts +195 -0
- package/dist/index.d.ts +25 -0
- package/dist/member.d.ts +8 -0
- package/dist/runtime.d.ts +19 -0
- package/dist/slugify.d.ts +49 -0
- package/dist/ssr-client-boundary.d.ts +136 -0
- package/dist/ssr-client-bundler.d.ts +79 -0
- package/dist/ssr-fonts.d.ts +94 -0
- package/dist/ssr-form-runtime.d.ts +33 -0
- package/dist/ssr-runtime.d.ts +419 -0
- package/dist/testing.d.ts +31 -0
- package/dist/types.d.ts +561 -0
- package/dist/validators.d.ts +74 -0
- package/package.json +15 -7
- package/src/ssr-client-bundler.test.ts +32 -0
- package/src/ssr-client-bundler.ts +62 -2
- package/src/ssr-fonts.test.ts +303 -0
- package/src/ssr-fonts.ts +633 -0
- package/src/ssr-runtime.ts +34 -2
package/src/ssr-fonts.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -1066,7 +1066,9 @@ async function collectBoundaryHeadBlob(): Promise<string> {
|
|
|
1066
1066
|
const manifest = await getManifest();
|
|
1067
1067
|
const prefix = manifest.public_prefix || "/_pylon/build/";
|
|
1068
1068
|
const seen = new Set<string>();
|
|
1069
|
-
|
|
1069
|
+
// Fonts first: the @font-face faces + preloads must be discoverable before
|
|
1070
|
+
// the app stylesheet that references the font family.
|
|
1071
|
+
let blob = await buildFontHeadBlob();
|
|
1070
1072
|
for (const route of Object.values(manifest.routes || {}) as any[]) {
|
|
1071
1073
|
for (const css of (route.css || []) as string[]) {
|
|
1072
1074
|
if (seen.has(css)) continue;
|
|
@@ -1080,6 +1082,34 @@ async function collectBoundaryHeadBlob(): Promise<string> {
|
|
|
1080
1082
|
}
|
|
1081
1083
|
}
|
|
1082
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* Build the self-hosted-font `<head>` blob: the inlined `@font-face` + `:root`
|
|
1087
|
+
* variable `<style>` (so the size-adjusted fallback is defined before first
|
|
1088
|
+
* paint — that's what makes the swap zero-CLS) plus a `<link rel=preload>` for
|
|
1089
|
+
* each woff2. Global / route-independent. Returns "" when the app declares no
|
|
1090
|
+
* fonts. URLs resolve against the live `public_prefix` (same-origin or CDN).
|
|
1091
|
+
*/
|
|
1092
|
+
async function buildFontHeadBlob(): Promise<string> {
|
|
1093
|
+
try {
|
|
1094
|
+
const { getManifest } = await import("./ssr-client-bundler");
|
|
1095
|
+
const manifest: any = await getManifest();
|
|
1096
|
+
const fonts = manifest?.fonts;
|
|
1097
|
+
if (!fonts) return "";
|
|
1098
|
+
const { renderFontFaceCss } = await import("./ssr-fonts");
|
|
1099
|
+
const prefix = manifest.public_prefix || "/_pylon/build/";
|
|
1100
|
+
const css = renderFontFaceCss(fonts, prefix);
|
|
1101
|
+
let blob = css ? `<style data-pylon-fonts>${css}</style>` : "";
|
|
1102
|
+
for (const href of (fonts.preload || []) as string[]) {
|
|
1103
|
+
// `crossorigin` is REQUIRED on font preloads even same-origin — fonts
|
|
1104
|
+
// fetch in CORS mode, so without it the browser double-fetches.
|
|
1105
|
+
blob += `<link rel="preload" as="font" type="font/woff2" href="${prefix}${href}" crossorigin>`;
|
|
1106
|
+
}
|
|
1107
|
+
return blob;
|
|
1108
|
+
} catch {
|
|
1109
|
+
return "";
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1083
1113
|
/**
|
|
1084
1114
|
* Build the hydration tail appended after React's stream EOFs: the
|
|
1085
1115
|
* `__PYLON_DATA__` JSON blob (props + ssrData) + the per-route entry
|
|
@@ -1238,6 +1268,7 @@ async function renderBoundaryToClient(
|
|
|
1238
1268
|
}
|
|
1239
1269
|
}
|
|
1240
1270
|
if (manifestRoute) {
|
|
1271
|
+
headBlob += await buildFontHeadBlob();
|
|
1241
1272
|
const co = /^https?:\/\//i.test(publicPrefix) ? " crossorigin" : "";
|
|
1242
1273
|
for (const css of manifestRoute.css) {
|
|
1243
1274
|
headBlob += `<link rel="stylesheet" href="${publicPrefix}${css}">`;
|
|
@@ -1247,7 +1278,7 @@ async function renderBoundaryToClient(
|
|
|
1247
1278
|
}
|
|
1248
1279
|
} else {
|
|
1249
1280
|
// No per-boundary entry → fall back to the global stylesheet union so the
|
|
1250
|
-
// page is at least styled (static).
|
|
1281
|
+
// page is at least styled (static). (collectBoundaryHeadBlob emits fonts.)
|
|
1251
1282
|
headBlob = await collectBoundaryHeadBlob();
|
|
1252
1283
|
}
|
|
1253
1284
|
// renderToReadableStream resolved without throwing → safe to commit the
|
|
@@ -2006,6 +2037,7 @@ export async function handleRenderRoute(
|
|
|
2006
2037
|
// (it needs the inline __PYLON_DATA__ to have been parsed first).
|
|
2007
2038
|
let headBlob = "";
|
|
2008
2039
|
if (preloadManifestRoute) {
|
|
2040
|
+
headBlob += await buildFontHeadBlob();
|
|
2009
2041
|
const co = /^https?:\/\//i.test(preloadPublicPrefix) ? " crossorigin" : "";
|
|
2010
2042
|
for (const css of preloadManifestRoute.css) {
|
|
2011
2043
|
headBlob += `<link rel="stylesheet" href="${preloadPublicPrefix}${css}">`;
|