@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
|
@@ -160,6 +160,38 @@ describe("ssr-client-bundler (Phase 1.5e)", () => {
|
|
|
160
160
|
expect(chunkHasReact).toBe(true);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
test("client runtime wires the nav-fallback guard (uncaught render error → full page load)", async () => {
|
|
164
|
+
// Regression: a page that renders React-19-hoisted <title>/<meta>/<link> in
|
|
165
|
+
// its own tree (use the `metadata` export instead) makes a client-side nav
|
|
166
|
+
// re-render throw in React's commit phase — the URL changes but the page
|
|
167
|
+
// can't swap (white screen). hydrateRoot must carry an onUncaughtError that
|
|
168
|
+
// falls back to a full page load of the in-flight destination so nav
|
|
169
|
+
// degrades gracefully. The distinctive console string is preserved through
|
|
170
|
+
// minification, so we assert the guard actually ships in the bundle.
|
|
171
|
+
tempDir = makeFixture(
|
|
172
|
+
{ "page.tsx": PAGE_BODY("Home") },
|
|
173
|
+
{ "layout.tsx": LAYOUT_BODY },
|
|
174
|
+
);
|
|
175
|
+
originalCwd = process.cwd();
|
|
176
|
+
process.chdir(tempDir);
|
|
177
|
+
|
|
178
|
+
const { outdir } = await buildClientBundle();
|
|
179
|
+
// The runtime (hydrateRoot + guard) lands in the shared chunk with multiple
|
|
180
|
+
// entries, or inlined in the entry with one — read every emitted .js.
|
|
181
|
+
const readJsRecursive = (dir: string): string[] =>
|
|
182
|
+
fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
|
|
183
|
+
const full = path.join(dir, d.name);
|
|
184
|
+
if (d.isDirectory()) return readJsRecursive(full);
|
|
185
|
+
return d.name.endsWith(".js") ? [fs.readFileSync(full, "utf8")] : [];
|
|
186
|
+
});
|
|
187
|
+
const bundled = readJsRecursive(outdir).join("\n");
|
|
188
|
+
|
|
189
|
+
// onUncaughtError is a React hydrateRoot option (property name preserved).
|
|
190
|
+
expect(bundled).toMatch(/onUncaughtError/);
|
|
191
|
+
// The fallback path: a full page load of the pending destination.
|
|
192
|
+
expect(bundled).toMatch(/falling back to a full page load/);
|
|
193
|
+
});
|
|
194
|
+
|
|
163
195
|
test("manifest names every route, each with a non-empty imports list", async () => {
|
|
164
196
|
tempDir = makeFixture(
|
|
165
197
|
{
|
|
@@ -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 {
|
|
@@ -210,6 +212,10 @@ import { createPylonBoundary } from "./client-boundary";
|
|
|
210
212
|
|
|
211
213
|
const routeCache = Object.create(null);
|
|
212
214
|
let activeRoot = null;
|
|
215
|
+
// Destination of an in-flight client navigation. Read by hydrateRoot's
|
|
216
|
+
// onUncaughtError so a re-render that throws mid-nav degrades to a full page
|
|
217
|
+
// load instead of a white screen. Null when no nav is in flight.
|
|
218
|
+
let pendingNav = null;
|
|
213
219
|
let manifestPromise = null;
|
|
214
220
|
const prefetchedChunks = new Set();
|
|
215
221
|
|
|
@@ -464,7 +470,30 @@ export function hydrate(component, Page, Layouts) {
|
|
|
464
470
|
data.component,
|
|
465
471
|
navEpoch,
|
|
466
472
|
);
|
|
467
|
-
activeRoot = hydrateRoot(document, tree
|
|
473
|
+
activeRoot = hydrateRoot(document, tree, {
|
|
474
|
+
// Safety net for client navigation. If a nav re-render throws an uncaught
|
|
475
|
+
// error in React's commit phase, the URL has already changed but the page
|
|
476
|
+
// can't swap — a white/stale screen. The classic trigger is a page that
|
|
477
|
+
// renders hoisted <title>/<meta>/<link> in its own tree (use the
|
|
478
|
+
// \`metadata\` export instead); React 19 owns those head nodes on the client
|
|
479
|
+
// and reconciling them across routes can throw. Rather than strand the
|
|
480
|
+
// user, fall back to a full page load of the pending destination, which
|
|
481
|
+
// re-renders it cleanly from SSR. Non-navigation errors keep React's
|
|
482
|
+
// default reporting.
|
|
483
|
+
onUncaughtError(error) {
|
|
484
|
+
if (pendingNav) {
|
|
485
|
+
const dest = pendingNav;
|
|
486
|
+
pendingNav = null;
|
|
487
|
+
console.error(
|
|
488
|
+
"[pylon ssr] client navigation failed to render; falling back to a full page load:",
|
|
489
|
+
error,
|
|
490
|
+
);
|
|
491
|
+
window.location.href = dest;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
console.error(error);
|
|
495
|
+
},
|
|
496
|
+
});
|
|
468
497
|
installNavHandlers();
|
|
469
498
|
return;
|
|
470
499
|
}
|
|
@@ -542,13 +571,21 @@ async function navigate(href, opts) {
|
|
|
542
571
|
setNavParams(data);
|
|
543
572
|
navEpoch++;
|
|
544
573
|
currentPageProps = withClientProps(data);
|
|
574
|
+
const target = url.pathname + url.search;
|
|
545
575
|
const tree = withBoundary(
|
|
546
576
|
buildTree(route.Page, route.Layouts, currentPageProps),
|
|
547
577
|
data.component,
|
|
548
578
|
navEpoch,
|
|
549
579
|
);
|
|
580
|
+
// Track the in-flight destination so hydrateRoot's onUncaughtError can fall
|
|
581
|
+
// back to a full page load if this re-render throws in React's commit phase
|
|
582
|
+
// (instead of leaving the URL changed but the page unswapped). Cleared on the
|
|
583
|
+
// next macrotask once the commit has settled with no error.
|
|
584
|
+
pendingNav = target;
|
|
550
585
|
activeRoot.render(tree);
|
|
551
|
-
|
|
586
|
+
setTimeout(() => {
|
|
587
|
+
if (pendingNav === target) pendingNav = null;
|
|
588
|
+
}, 0);
|
|
552
589
|
if (opts && opts.replace) {
|
|
553
590
|
history.replaceState({ component: data.component }, "", target);
|
|
554
591
|
} else if (push) {
|
|
@@ -724,6 +761,11 @@ export interface PylonBundleManifest {
|
|
|
724
761
|
css: string[];
|
|
725
762
|
}
|
|
726
763
|
>;
|
|
764
|
+
/** Self-hosted fonts (next/font parity): structured `@font-face`s + the
|
|
765
|
+
* `:root` CSS variables + the woff2 files to preload. Global (route-
|
|
766
|
+
* independent); rendered into every SSR `<head>` against `public_prefix`.
|
|
767
|
+
* Absent when the app declares no `font({...})`. */
|
|
768
|
+
fonts?: ManifestFonts;
|
|
727
769
|
}
|
|
728
770
|
|
|
729
771
|
/** Result of an in-process build — same shape the protocol returns. */
|
|
@@ -1169,6 +1211,24 @@ async function _doBuildInner(
|
|
|
1169
1211
|
console.warn(`[pylon ssr] tailwind compile failed: ${twErr?.message ?? twErr}`);
|
|
1170
1212
|
}
|
|
1171
1213
|
|
|
1214
|
+
// Self-hosted fonts (next/font parity). Reads `fonts` from the app's
|
|
1215
|
+
// pylon.manifest.json, fetches + self-hosts each woff2 into outdir (served
|
|
1216
|
+
// under /_pylon/build/), and bakes the structured faces + size-adjusted
|
|
1217
|
+
// fallback metrics into the bundle manifest for SSR head injection. On
|
|
1218
|
+
// Pylon Cloud the builder runs this same path, so the woff2 + faces ship in
|
|
1219
|
+
// the prebuilt artifact. A fetch/parse failure degrades to a variable-only
|
|
1220
|
+
// entry — it never kills the build.
|
|
1221
|
+
try {
|
|
1222
|
+
const declaredFonts = readManifestFonts(fs, path, cwd);
|
|
1223
|
+
if (declaredFonts.length > 0) {
|
|
1224
|
+
const builtFonts = await buildFonts(fs, path, cwd, outdir, declaredFonts);
|
|
1225
|
+
if (builtFonts) manifest.fonts = builtFonts;
|
|
1226
|
+
}
|
|
1227
|
+
} catch (fErr: any) {
|
|
1228
|
+
// eslint-disable-next-line no-console
|
|
1229
|
+
console.warn(`[pylon ssr] font build failed: ${fErr?.message ?? fErr}`);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1172
1232
|
const manifestPath = path.join(outdir, "manifest.json");
|
|
1173
1233
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
|
|
1174
1234
|
|
|
@@ -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
|
+
});
|