@pylonsync/functions 0.3.292 → 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.
@@ -33,6 +33,8 @@
33
33
  // prefetch is a follow-up — splitting is the precondition.
34
34
  // - CSS chunking. No CSS support in SSR yet.
35
35
 
36
+ import { buildFonts, readManifestFonts, type ManifestFonts } from "./ssr-fonts";
37
+
36
38
  type Send = (msg: Record<string, unknown>) => void;
37
39
 
38
40
  interface BundleClientMessage {
@@ -724,6 +726,11 @@ export interface PylonBundleManifest {
724
726
  css: string[];
725
727
  }
726
728
  >;
729
+ /** Self-hosted fonts (next/font parity): structured `@font-face`s + the
730
+ * `:root` CSS variables + the woff2 files to preload. Global (route-
731
+ * independent); rendered into every SSR `<head>` against `public_prefix`.
732
+ * Absent when the app declares no `font({...})`. */
733
+ fonts?: ManifestFonts;
727
734
  }
728
735
 
729
736
  /** Result of an in-process build — same shape the protocol returns. */
@@ -1169,6 +1176,24 @@ async function _doBuildInner(
1169
1176
  console.warn(`[pylon ssr] tailwind compile failed: ${twErr?.message ?? twErr}`);
1170
1177
  }
1171
1178
 
1179
+ // Self-hosted fonts (next/font parity). Reads `fonts` from the app's
1180
+ // pylon.manifest.json, fetches + self-hosts each woff2 into outdir (served
1181
+ // under /_pylon/build/), and bakes the structured faces + size-adjusted
1182
+ // fallback metrics into the bundle manifest for SSR head injection. On
1183
+ // Pylon Cloud the builder runs this same path, so the woff2 + faces ship in
1184
+ // the prebuilt artifact. A fetch/parse failure degrades to a variable-only
1185
+ // entry — it never kills the build.
1186
+ try {
1187
+ const declaredFonts = readManifestFonts(fs, path, cwd);
1188
+ if (declaredFonts.length > 0) {
1189
+ const builtFonts = await buildFonts(fs, path, cwd, outdir, declaredFonts);
1190
+ if (builtFonts) manifest.fonts = builtFonts;
1191
+ }
1192
+ } catch (fErr: any) {
1193
+ // eslint-disable-next-line no-console
1194
+ console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
1195
+ }
1196
+
1172
1197
  const manifestPath = path.join(outdir, "manifest.json");
1173
1198
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
1174
1199
 
@@ -0,0 +1,303 @@
1
+ // Tests for the self-hosted font engine: sfnt metrics parsing, size-adjust
2
+ // math, CSS rendering, and the Google Fonts CSS2 request/parse helpers.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import {
6
+ buildGoogleFontsUrl,
7
+ computeFallbackFace,
8
+ decodeFontMetrics,
9
+ parseGoogleFontsCss,
10
+ renderFontFaceCss,
11
+ type ManifestFonts,
12
+ } from "./ssr-fonts";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Synthesize a minimal valid raw sfnt (ttf) with head/hhea/OS-2 tables so we
16
+ // can assert the byte-offset metrics parser against known values.
17
+ // ---------------------------------------------------------------------------
18
+ function makeSfnt(opts: {
19
+ unitsPerEm: number;
20
+ hheaAscent: number;
21
+ hheaDescent: number;
22
+ hheaLineGap: number;
23
+ xAvgCharWidth: number;
24
+ useTypoMetrics: boolean;
25
+ sTypoAscender: number;
26
+ sTypoDescender: number;
27
+ sTypoLineGap: number;
28
+ }): Uint8Array {
29
+ const HEAD_LEN = 54;
30
+ const HHEA_LEN = 36;
31
+ const OS2_LEN = 96;
32
+ const dirStart = 12;
33
+ const numTables = 3;
34
+ const dataStart = dirStart + numTables * 16; // 60
35
+ const headOff = dataStart;
36
+ const hheaOff = headOff + HEAD_LEN;
37
+ const os2Off = hheaOff + HHEA_LEN;
38
+ const total = os2Off + OS2_LEN;
39
+
40
+ const buf = new Uint8Array(total);
41
+ const dv = new DataView(buf.buffer);
42
+
43
+ // sfnt offset table
44
+ dv.setUint32(0, 0x00010000); // version 1.0 (truetype)
45
+ dv.setUint16(4, numTables);
46
+
47
+ // table directory (tag, checksum, offset, length). Tags sorted alphabetically
48
+ // isn't required by our parser, which indexes by tag.
49
+ function writeRecord(i: number, tag: string, off: number, len: number) {
50
+ const p = dirStart + i * 16;
51
+ buf[p] = tag.charCodeAt(0);
52
+ buf[p + 1] = tag.charCodeAt(1);
53
+ buf[p + 2] = tag.charCodeAt(2);
54
+ buf[p + 3] = tag.charCodeAt(3);
55
+ dv.setUint32(p + 4, 0); // checksum (unused by parser)
56
+ dv.setUint32(p + 8, off);
57
+ dv.setUint32(p + 12, len);
58
+ }
59
+ writeRecord(0, "head", headOff, HEAD_LEN);
60
+ writeRecord(1, "hhea", hheaOff, HHEA_LEN);
61
+ writeRecord(2, "OS/2", os2Off, OS2_LEN);
62
+
63
+ // head: unitsPerEm @ +18
64
+ dv.setUint16(headOff + 18, opts.unitsPerEm);
65
+
66
+ // hhea: ascender @ +4, descender @ +6, lineGap @ +8
67
+ dv.setInt16(hheaOff + 4, opts.hheaAscent);
68
+ dv.setInt16(hheaOff + 6, opts.hheaDescent);
69
+ dv.setInt16(hheaOff + 8, opts.hheaLineGap);
70
+
71
+ // OS/2: xAvgCharWidth @ +2, fsSelection @ +62, sTypo* @ +68/+70/+72
72
+ dv.setInt16(os2Off + 2, opts.xAvgCharWidth);
73
+ dv.setUint16(os2Off + 62, opts.useTypoMetrics ? 0x80 : 0x00);
74
+ dv.setInt16(os2Off + 68, opts.sTypoAscender);
75
+ dv.setInt16(os2Off + 70, opts.sTypoDescender);
76
+ dv.setInt16(os2Off + 72, opts.sTypoLineGap);
77
+
78
+ return buf;
79
+ }
80
+
81
+ describe("decodeFontMetrics — raw sfnt", () => {
82
+ test("reads unitsPerEm + hhea metrics + xAvgCharWidth", () => {
83
+ const buf = makeSfnt({
84
+ unitsPerEm: 1000,
85
+ hheaAscent: 950,
86
+ hheaDescent: -250,
87
+ hheaLineGap: 0,
88
+ xAvgCharWidth: 500,
89
+ useTypoMetrics: false,
90
+ sTypoAscender: 800,
91
+ sTypoDescender: -200,
92
+ sTypoLineGap: 10,
93
+ });
94
+ const m = decodeFontMetrics(buf);
95
+ expect(m).not.toBeNull();
96
+ expect(m!.unitsPerEm).toBe(1000);
97
+ // USE_TYPO_METRICS off → hhea ascender/descender/lineGap.
98
+ expect(m!.ascent).toBe(950);
99
+ expect(m!.descent).toBe(-250);
100
+ expect(m!.lineGap).toBe(0);
101
+ expect(m!.xAvgCharWidth).toBe(500);
102
+ });
103
+
104
+ test("prefers OS/2 sTypo* when USE_TYPO_METRICS is set", () => {
105
+ const buf = makeSfnt({
106
+ unitsPerEm: 2048,
107
+ hheaAscent: 1900,
108
+ hheaDescent: -500,
109
+ hheaLineGap: 0,
110
+ xAvgCharWidth: 1000,
111
+ useTypoMetrics: true,
112
+ sTypoAscender: 1600,
113
+ sTypoDescender: -400,
114
+ sTypoLineGap: 90,
115
+ });
116
+ const m = decodeFontMetrics(buf);
117
+ expect(m!.ascent).toBe(1600);
118
+ expect(m!.descent).toBe(-400);
119
+ expect(m!.lineGap).toBe(90);
120
+ });
121
+
122
+ test("returns null for a too-short / unknown buffer", () => {
123
+ expect(decodeFontMetrics(new Uint8Array(4))).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("computeFallbackFace — size-adjust math", () => {
128
+ test("computes size-adjust + overrides against Arial", () => {
129
+ // unitsPerEm 1000, xAvgCharWidth 500 → webAvg 0.5.
130
+ // Arial xWidthAvg/em = 904/2048 = 0.4414062500.
131
+ // sizeAdjust = 0.5 / 0.44140625 = 1.13274336... → "113.27%"
132
+ const face = computeFallbackFace(
133
+ {
134
+ unitsPerEm: 1000,
135
+ ascent: 800,
136
+ descent: -200,
137
+ lineGap: 0,
138
+ xAvgCharWidth: 500,
139
+ },
140
+ "Geist",
141
+ "sans-serif",
142
+ );
143
+ expect(face.family).toBe("Geist Fallback");
144
+ expect(face.local).toBe("Arial");
145
+ expect(face.src).toEqual([]);
146
+ expect(face.sizeAdjust).toBe("113.27%");
147
+ // em = 1000 * 1.13274336 = 1132.7436; ascent 800/em ≈ 0.70625 → ~70.62%
148
+ expect(parseFloat(face.ascentOverride!)).toBeCloseTo(70.62, 1);
149
+ // descent is negative; override is the positive magnitude. 200/em ≈ 17.66%
150
+ expect(parseFloat(face.descentOverride!)).toBeCloseTo(17.66, 1);
151
+ expect(face.lineGapOverride).toBe("0.00%");
152
+ });
153
+
154
+ test("falls back to size-adjust 100% when avg widths are missing", () => {
155
+ const face = computeFallbackFace(
156
+ { unitsPerEm: 1000, ascent: 800, descent: -200, lineGap: 0, xAvgCharWidth: 0 },
157
+ "Mono",
158
+ "sans-serif",
159
+ );
160
+ expect(face.sizeAdjust).toBe("100.00%");
161
+ });
162
+
163
+ test("serif category uses Times New Roman as the local fallback", () => {
164
+ const face = computeFallbackFace(
165
+ { unitsPerEm: 2048, ascent: 1500, descent: -400, lineGap: 0, xAvgCharWidth: 900 },
166
+ "Lora",
167
+ "serif",
168
+ );
169
+ expect(face.local).toBe("Times New Roman");
170
+ });
171
+ });
172
+
173
+ describe("renderFontFaceCss", () => {
174
+ const fonts: ManifestFonts = {
175
+ faces: [
176
+ {
177
+ family: "Geist",
178
+ src: ["font-abc123.woff2"],
179
+ weight: "400",
180
+ style: "normal",
181
+ display: "swap",
182
+ unicodeRange: "U+0000-00FF",
183
+ },
184
+ {
185
+ family: "Geist Fallback",
186
+ src: [],
187
+ local: "Arial",
188
+ sizeAdjust: "113.27%",
189
+ ascentOverride: "70.62%",
190
+ descentOverride: "17.66%",
191
+ lineGapOverride: "0.00%",
192
+ },
193
+ ],
194
+ variables: { "--font-sans": '"Geist", "Geist Fallback", sans-serif' },
195
+ preload: ["font-abc123.woff2"],
196
+ };
197
+
198
+ test("resolves woff2 URLs against the given prefix", () => {
199
+ const css = renderFontFaceCss(fonts, "/_pylon/build/");
200
+ expect(css).toContain(
201
+ 'src:url(/_pylon/build/font-abc123.woff2) format("woff2")',
202
+ );
203
+ });
204
+
205
+ test("uses an absolute CDN prefix verbatim", () => {
206
+ const css = renderFontFaceCss(fonts, "https://cdn.example.com/b/");
207
+ expect(css).toContain("url(https://cdn.example.com/b/font-abc123.woff2)");
208
+ });
209
+
210
+ test("emits the size-adjusted fallback face with local() + overrides", () => {
211
+ const css = renderFontFaceCss(fonts, "/_pylon/build/");
212
+ expect(css).toContain('font-family:"Geist Fallback"');
213
+ expect(css).toContain('src:local("Arial")');
214
+ expect(css).toContain("size-adjust:113.27%");
215
+ expect(css).toContain("ascent-override:70.62%");
216
+ expect(css).toContain("descent-override:17.66%");
217
+ expect(css).toContain("line-gap-override:0.00%");
218
+ });
219
+
220
+ test("emits the :root variable", () => {
221
+ const css = renderFontFaceCss(fonts, "/_pylon/build/");
222
+ expect(css).toContain(
223
+ ':root{--font-sans:"Geist", "Geist Fallback", sans-serif;}',
224
+ );
225
+ });
226
+ });
227
+
228
+ describe("buildGoogleFontsUrl", () => {
229
+ test("single axis, weights sorted ascending", () => {
230
+ const url = buildGoogleFontsUrl({
231
+ family: "Geist",
232
+ weights: ["700", "400"],
233
+ styles: ["normal"],
234
+ subsets: ["latin"],
235
+ display: "swap",
236
+ });
237
+ expect(url).toBe(
238
+ "https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap",
239
+ );
240
+ });
241
+
242
+ test("encodes spaces in the family name", () => {
243
+ const url = buildGoogleFontsUrl({
244
+ family: "Open Sans",
245
+ weights: ["400"],
246
+ styles: ["normal"],
247
+ subsets: ["latin"],
248
+ display: "swap",
249
+ });
250
+ expect(url).toContain("family=Open+Sans:wght@400");
251
+ });
252
+
253
+ test("italic uses the ital,wght axis with sorted tuples", () => {
254
+ const url = buildGoogleFontsUrl({
255
+ family: "Inter",
256
+ weights: ["400", "700"],
257
+ styles: ["normal", "italic"],
258
+ subsets: ["latin"],
259
+ display: "swap",
260
+ });
261
+ expect(url).toContain("Inter:ital,wght@0,400;0,700;1,400;1,700");
262
+ });
263
+ });
264
+
265
+ describe("parseGoogleFontsCss", () => {
266
+ const css = `
267
+ /* cyrillic */
268
+ @font-face {
269
+ font-family: 'Geist';
270
+ font-style: normal;
271
+ font-weight: 400;
272
+ font-display: swap;
273
+ src: url(https://fonts.gstatic.com/s/geist/v1/cyr.woff2) format('woff2');
274
+ unicode-range: U+0301, U+0400-045F;
275
+ }
276
+ /* latin */
277
+ @font-face {
278
+ font-family: 'Geist';
279
+ font-style: normal;
280
+ font-weight: 400;
281
+ font-display: swap;
282
+ src: url(https://fonts.gstatic.com/s/geist/v1/latin.woff2) format('woff2');
283
+ unicode-range: U+0000-00FF;
284
+ }
285
+ `;
286
+
287
+ test("keeps only the requested subsets", () => {
288
+ const blocks = parseGoogleFontsCss(css, ["latin"]);
289
+ expect(blocks.length).toBe(1);
290
+ expect(blocks[0].subset).toBe("latin");
291
+ expect(blocks[0].url).toBe(
292
+ "https://fonts.gstatic.com/s/geist/v1/latin.woff2",
293
+ );
294
+ expect(blocks[0].weight).toBe("400");
295
+ expect(blocks[0].style).toBe("normal");
296
+ expect(blocks[0].unicodeRange).toBe("U+0000-00FF");
297
+ });
298
+
299
+ test("returns every block when no subset filter is given", () => {
300
+ const blocks = parseGoogleFontsCss(css, []);
301
+ expect(blocks.length).toBe(2);
302
+ });
303
+ });