@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.
- 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.ts +25 -0
- package/src/ssr-fonts.test.ts +303 -0
- package/src/ssr-fonts.ts +633 -0
- package/src/ssr-runtime.ts +34 -2
|
@@ -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
|
+
});
|