@siyahi/tools 0.1.1-next.4 → 0.1.3-next.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @siyahi/tools
2
2
 
3
- Checks for pages built on [`@siyahi/ui`](https://www.npmjs.com/package/@siyahi/ui).
3
+ Checks and build helpers for pages built on [`@siyahi/ui`](https://www.npmjs.com/package/@siyahi/ui).
4
4
  Kept apart from the UI package so installing the UI does not pull in Playwright.
5
5
 
6
6
  ## Lint plugin
@@ -28,6 +28,45 @@ that import `@siyahi/ui`.
28
28
  A Vite+ or ESLint config can spread every rule at once:
29
29
  `import { recommended } from "@siyahi/tools/lint"`.
30
30
 
31
+ ## Font preload
32
+
33
+ A Vite plugin that adds `<link rel="preload" as="font">` tags to the built
34
+ `index.html` for the Latin files of Geist and Geist Mono, and Geist's
35
+ Latin Extended file, which holds ₹. Without them the browser asks for a font
36
+ only once text in it has rendered, so on a slow network the fallback paints
37
+ first and Geist swaps in seconds later.
38
+
39
+ ```ts
40
+ // vite.config.ts
41
+ import { preloadFonts } from "@siyahi/tools/vite";
42
+ export default defineConfig({ plugins: [react(), tailwindcss(), preloadFonts()] });
43
+ ```
44
+
45
+ It runs in `vite build` only. The fonts stay self-hosted: the tags point at
46
+ the files Vite emitted from `@siyahi/ui`'s fontsource imports.
47
+
48
+ ## siyahi-cls
49
+
50
+ ```sh
51
+ npx siyahi-cls http://localhost:4173/ [more URLs] [--out cls] [--font "Geist Variable"] [--wait-for h1] [--network slow-3g|none] [--hold-fonts] [--max-cls 0.01]
52
+ ```
53
+
54
+ Loads each URL in Chromium with a cold cache over Chrome DevTools' Slow 3G
55
+ profile (400 kbit/s, 2 s round trips), 1920 and 390 pixels wide, and reports
56
+ how far the page moves after its first paint: Cumulative Layout Shift, the
57
+ layout shifts behind it, when the first `--font` face loaded, the
58
+ `font-display` in effect, and whether the font was already loaded at the
59
+ first paint. It shoots the page as soon as `--wait-for` is visible and again
60
+ once the font has loaded and the page has settled, writes both PNGs and an
61
+ overlay of the two (ink only the first paint has shows red, ink only the
62
+ second has shows cyan), and lists the text boxes that moved between them.
63
+
64
+ `--hold-fonts` holds every woff2 response until the first shot, so the
65
+ fallback always paints first: the worst case, a first visit on which the font
66
+ arrives after the page. A page fails when its CLS is above `--max-cls` or a
67
+ text box inside the first screen moved; boxes that moved further down are
68
+ counted (`moved=` against `in-view=`) and listed, but do not fail it. Run `npx playwright install chromium` once.
69
+
31
70
  ## siyahi-shots
32
71
 
33
72
  ```sh
package/cls.mjs ADDED
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // Loads pages with a cold cache over a slow network, 1920 and 390 wide, and
4
+ // reports how far the page moves after its first paint: Cumulative Layout
5
+ // Shift, when the web font finished loading, the font-display in effect, and
6
+ // which text boxes moved between the first paint and fonts-ready.
7
+ //
8
+ // siyahi-cls <url>... [--out cls] [--font "Geist Variable"] [--wait-for h1]
9
+ // [--network slow-3g|none] [--hold-fonts] [--max-cls 0.01]
10
+ //
11
+ // The first shot is taken as soon as --wait-for is visible, the second once a
12
+ // --font face has loaded, the fonts are ready and the page has had a second to
13
+ // settle. --hold-fonts holds every woff2 response until the first shot is
14
+ // taken, so the fallback font always paints first: the worst case, a first
15
+ // visit on which the font arrives after the page. Each run writes
16
+ // <name>-<width>-first.png, -ready.png and -overlay.png, where text drawn in
17
+ // the same place in both shots is grey and anything that moved shows as red
18
+ // (first paint) and cyan (fonts ready) fringes.
19
+ //
20
+ // Fails when a page's CLS is above --max-cls or a text box inside the first
21
+ // screen moved. Boxes that moved further down are counted and listed, since
22
+ // they move only for a reader who has already scrolled.
23
+ import { mkdirSync, writeFileSync } from "node:fs";
24
+ import path from "node:path";
25
+ import process from "node:process";
26
+ import { parseArgs } from "node:util";
27
+ import { chromium } from "playwright";
28
+
29
+ /** @typedef {{ x: number, y: number, width: number, height: number }} Rect */
30
+ /** @typedef {{ value: number, startTime: number, sources: { node: string, from: Rect, to: Rect }[] }} Shift */
31
+ /** @typedef {Record<string, Rect>} Boxes */
32
+ /** @typedef {{ shifts: Shift[], fontAt: number | null, display: string[], fcp: number | null }} Timeline */
33
+
34
+ const WIDTHS = [
35
+ { width: 1920, height: 1080 },
36
+ { width: 390, height: 844 },
37
+ ];
38
+ // Chrome DevTools' "Slow 3G" preset: 400 kbit/s each way, 2 s round trips.
39
+ const NETWORKS = {
40
+ "slow-3g": { offline: false, latency: 2000, downloadThroughput: 50_000, uploadThroughput: 50_000 },
41
+ none: null,
42
+ };
43
+ // A throttled 1.5 MB bundle takes half a minute; the font another few seconds.
44
+ const TIMEOUT = 180_000;
45
+ // Shifts after the fonts are ready, such as a late data load, still count.
46
+ const SETTLE_MS = 1000;
47
+ // A box that moved less than this is antialiasing, not a shift.
48
+ const MOVE_PX = 0.5;
49
+
50
+ const { values, positionals } = parseArgs({
51
+ allowPositionals: true,
52
+ options: {
53
+ out: { type: "string", default: "cls" },
54
+ font: { type: "string", default: "Geist Variable" },
55
+ "wait-for": { type: "string", default: "h1" },
56
+ network: { type: "string", default: "slow-3g" },
57
+ "hold-fonts": { type: "boolean", default: false },
58
+ "max-cls": { type: "string", default: "0.01" },
59
+ },
60
+ });
61
+ if (positionals.length === 0 || !(values.network in NETWORKS)) {
62
+ console.error(
63
+ 'usage: siyahi-cls <url>... [--out cls] [--font "Geist Variable"] [--wait-for h1] [--network slow-3g|none] [--hold-fonts] [--max-cls 0.01]',
64
+ );
65
+ process.exit(2);
66
+ }
67
+ const network = NETWORKS[/** @type {keyof typeof NETWORKS} */ (values.network)];
68
+ const maxCls = Number(values["max-cls"]);
69
+ const outDir = path.resolve(values.out);
70
+ mkdirSync(outDir, { recursive: true });
71
+
72
+ /** @param {string} url */
73
+ function slugOf(url) {
74
+ const u = new URL(url);
75
+ const slug = `${u.host}${u.pathname}${u.search}${u.hash}`.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "");
76
+ return slug || "index";
77
+ }
78
+
79
+ // Runs in the page before any of its scripts, so no shift or font load is missed.
80
+ const OBSERVE = `(() => {
81
+ const describe = (node) => {
82
+ if (!(node instanceof Element)) return node ? node.nodeName : "?";
83
+ const slot = node.getAttribute("data-slot");
84
+ const text = (node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
85
+ return node.localName + (slot ? '[data-slot="' + slot + '"]' : "") + (text ? ' "' + text + '"' : "");
86
+ };
87
+ const rect = (r) => ({ x: r.x, y: r.y, width: r.width, height: r.height });
88
+ window.__cls = { shifts: [], fonts: [] };
89
+ new PerformanceObserver((list) => {
90
+ for (const e of list.getEntries()) {
91
+ if (e.hadRecentInput) continue;
92
+ window.__cls.shifts.push({
93
+ value: e.value,
94
+ startTime: e.startTime,
95
+ sources: e.sources.map((s) => ({ node: describe(s.node), from: rect(s.previousRect), to: rect(s.currentRect) })),
96
+ });
97
+ }
98
+ }).observe({ type: "layout-shift", buffered: true });
99
+ // Polled each frame: a face loaded from a preload never fires a loading
100
+ // event, and loadingdone waits for every face in its batch.
101
+ const seen = new Set();
102
+ const poll = () => {
103
+ for (const f of document.fonts) {
104
+ if (f.status !== "loaded" || seen.has(f)) continue;
105
+ seen.add(f);
106
+ window.__cls.fonts.push({ family: f.family.replace(/["']/g, ""), at: performance.now() });
107
+ }
108
+ requestAnimationFrame(poll);
109
+ };
110
+ requestAnimationFrame(poll);
111
+ })()`;
112
+
113
+ /** @param {string} family */
114
+ const fontLoaded = (family) =>
115
+ `[...document.fonts].some((f) => f.family.replace(/["']/g, "") === ${JSON.stringify(family)} && f.status === "loaded")`;
116
+
117
+ // Every element that holds text of its own, keyed by tag, text and how many
118
+ // times that pair came before, so the two shots' boxes can be paired.
119
+ const BOXES = `(() => {
120
+ const boxes = {};
121
+ const seen = {};
122
+ for (const el of document.body.querySelectorAll("*")) {
123
+ const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
124
+ if (!own) continue;
125
+ const r = el.getBoundingClientRect();
126
+ if (r.width === 0 || r.height === 0) continue;
127
+ const base = el.localName + " " + el.textContent.trim().replace(/\\s+/g, " ").slice(0, 40);
128
+ seen[base] = (seen[base] || 0) + 1;
129
+ boxes[base + " #" + seen[base]] = { x: r.x, y: r.y, width: r.width, height: r.height };
130
+ }
131
+ return boxes;
132
+ })()`;
133
+
134
+ /** @param {string} family */
135
+ const timeline = (family) => `(() => {
136
+ const faces = window.__cls.fonts.filter((f) => f.family === ${JSON.stringify(family)});
137
+ const fcp = performance.getEntriesByName("first-contentful-paint")[0];
138
+ return {
139
+ shifts: window.__cls.shifts,
140
+ fontAt: faces.length ? Math.min(...faces.map((f) => f.at)) : null,
141
+ display: [...new Set([...document.fonts].filter((f) => f.family.replace(/["']/g, "") === ${JSON.stringify(family)} && f.status === "loaded").map((f) => f.display))],
142
+ fcp: fcp ? fcp.startTime : null,
143
+ };
144
+ })()`;
145
+
146
+ const SETTLE = `(async () => {
147
+ await document.fonts.ready;
148
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
149
+ })()`;
150
+
151
+ /**
152
+ * CLS as web-vitals defines it: the largest sum of shifts in a session window,
153
+ * a run of shifts less than 1 s apart and at most 5 s long.
154
+ * @param {Shift[]} shifts
155
+ */
156
+ function clsOf(shifts) {
157
+ let best = 0;
158
+ let sum = 0;
159
+ let start = -Infinity;
160
+ let last = -Infinity;
161
+ for (const { value, startTime } of shifts.toSorted((a, b) => a.startTime - b.startTime)) {
162
+ if (startTime - last > 1000 || startTime - start > 5000) {
163
+ sum = 0;
164
+ start = startTime;
165
+ }
166
+ sum += value;
167
+ last = startTime;
168
+ best = Math.max(best, sum);
169
+ }
170
+ return best;
171
+ }
172
+
173
+ /**
174
+ * Pairs the boxes of the two shots and returns the ones that moved or resized.
175
+ * @param {Boxes} first
176
+ * @param {Boxes} ready
177
+ */
178
+ function movedBoxes(first, ready) {
179
+ const moved = [];
180
+ let paired = 0;
181
+ for (const [key, a] of Object.entries(first)) {
182
+ const b = ready[key];
183
+ if (!b) continue;
184
+ paired++;
185
+ const by = Math.max(
186
+ Math.abs(b.x - a.x),
187
+ Math.abs(b.y - a.y),
188
+ Math.abs(b.width - a.width),
189
+ Math.abs(b.height - a.height),
190
+ );
191
+ if (by > MOVE_PX) moved.push({ key, by, from: a, to: b });
192
+ }
193
+ return { paired, moved: moved.toSorted((a, b) => b.by - a.by) };
194
+ }
195
+
196
+ /**
197
+ * Draws the two shots into one image, fonts-ready in the red channel and the
198
+ * first paint in green and blue, so ink only the first paint has shows red,
199
+ * ink only fonts-ready has shows cyan; and counts the pixels that differ.
200
+ * @param {import("playwright").Page} page an unthrottled page to draw in
201
+ * @param {Buffer} first
202
+ * @param {Buffer} ready
203
+ * @returns {Promise<{ overlay: Buffer, changed: number }>}
204
+ */
205
+ async function overlayOf(page, first, ready) {
206
+ const { png, changed } = await page.evaluate(
207
+ async ([a, b]) => {
208
+ const [one, two] = /** @type {[ImageBitmap, ImageBitmap]} */ (
209
+ await Promise.all(
210
+ [a, b].map(async (base64) =>
211
+ createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob()),
212
+ ),
213
+ )
214
+ );
215
+ const canvas = new OffscreenCanvas(one.width, one.height);
216
+ const context = /** @type {OffscreenCanvasRenderingContext2D} */ (canvas.getContext("2d"));
217
+ context.drawImage(one, 0, 0);
218
+ const x = context.getImageData(0, 0, one.width, one.height);
219
+ context.drawImage(two, 0, 0);
220
+ const y = context.getImageData(0, 0, one.width, one.height);
221
+ let differ = 0;
222
+ for (let i = 0; i < x.data.length; i += 4) {
223
+ const lx = x.data.subarray(i, i + 3).reduce((sum, v) => sum + v, 0) / 3;
224
+ const ly = y.data.subarray(i, i + 3).reduce((sum, v) => sum + v, 0) / 3;
225
+ if (Math.abs(lx - ly) > 16) differ++;
226
+ y.data[i] = ly;
227
+ y.data[i + 1] = lx;
228
+ y.data[i + 2] = lx;
229
+ }
230
+ context.putImageData(y, 0, 0);
231
+ const blob = await canvas.convertToBlob({ type: "image/png" });
232
+ const bytes = new Uint8Array(await blob.arrayBuffer());
233
+ let binary = "";
234
+ for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
235
+ return { png: btoa(binary), changed: differ / (x.data.length / 4) };
236
+ },
237
+ [first.toString("base64"), ready.toString("base64")],
238
+ );
239
+ return { overlay: Buffer.from(png, "base64"), changed };
240
+ }
241
+
242
+ /**
243
+ * @param {import("playwright").Browser} browser
244
+ * @param {string} url
245
+ * @param {{ width: number, height: number }} viewport
246
+ */
247
+ async function measure(browser, url, viewport) {
248
+ const context = await browser.newContext({ viewport });
249
+ const page = await context.newPage();
250
+ page.setDefaultTimeout(TIMEOUT);
251
+ const cdp = await context.newCDPSession(page);
252
+ if (network) {
253
+ await cdp.send("Network.enable");
254
+ await cdp.send("Network.emulateNetworkConditions", network);
255
+ }
256
+ // Playwright's own screenshot waits for web fonts, which would hide the
257
+ // very paint this script is after; the DevTools capture takes the frame as is.
258
+ const shot = async () => Buffer.from((await cdp.send("Page.captureScreenshot", { format: "png" })).data, "base64");
259
+ const held = Promise.withResolvers();
260
+ if (values["hold-fonts"])
261
+ await page.route(/\.woff2(\?|$)/, async (route) => {
262
+ await held.promise;
263
+ await route.continue();
264
+ });
265
+ await page.addInitScript(OBSERVE);
266
+ await page.goto(url, { waitUntil: "commit", timeout: TIMEOUT });
267
+ await page.waitForSelector(values["wait-for"]);
268
+ /** @type {Boxes} */
269
+ const firstBoxes = await page.evaluate(BOXES);
270
+ const fontAtFirstShot = await page.evaluate(fontLoaded(values.font));
271
+ const firstShot = await shot();
272
+ held.resolve(undefined);
273
+ await page.waitForFunction(fontLoaded(values.font));
274
+ await page.evaluate(SETTLE);
275
+ await page.waitForTimeout(SETTLE_MS);
276
+ /** @type {Boxes} */
277
+ const readyBoxes = await page.evaluate(BOXES);
278
+ const readyShot = await shot();
279
+ /** @type {Timeline} */
280
+ const times = await page.evaluate(timeline(values.font));
281
+ await context.close();
282
+ return { firstShot, readyShot, fontAtFirstShot, firstBoxes, readyBoxes, times };
283
+ }
284
+
285
+ /** @param {number | null} ms */
286
+ const seconds = (ms) => (ms === null ? "n/a" : `${(ms / 1000).toFixed(2)}s`);
287
+ /** @param {Rect} r */
288
+ const box = (r) => `${r.x.toFixed(1)},${r.y.toFixed(1)} ${r.width.toFixed(1)}x${r.height.toFixed(1)}`;
289
+
290
+ const browser = await chromium.launch();
291
+ const drawing = await browser.newPage();
292
+ let failed = false;
293
+ for (const url of positionals) {
294
+ for (const viewport of WIDTHS) {
295
+ const run = await measure(browser, url, viewport);
296
+ const cls = clsOf(run.times.shifts);
297
+ const { paired, moved } = movedBoxes(run.firstBoxes, run.readyBoxes);
298
+ const inView = moved.filter((m) => m.from.y < viewport.height && m.from.y + m.from.height > 0);
299
+ const { overlay, changed } = await overlayOf(drawing, run.firstShot, run.readyShot);
300
+ const base = path.join(outDir, `${slugOf(url)}-${viewport.width}`);
301
+ writeFileSync(`${base}-first.png`, run.firstShot);
302
+ writeFileSync(`${base}-ready.png`, run.readyShot);
303
+ writeFileSync(`${base}-overlay.png`, overlay);
304
+ const bad = cls > maxCls || inView.length > 0;
305
+ failed ||= bad;
306
+ console.log(
307
+ `${bad ? "FAIL" : "ok "} ${url} ${viewport.width}: cls=${cls.toFixed(4)} shifts=${run.times.shifts.length} ` +
308
+ `fcp=${seconds(run.times.fcp)} font=${seconds(run.times.fontAt)} display=${run.times.display.join("/") || "n/a"} ` +
309
+ `font-at-first-paint=${run.fontAtFirstShot ? "loaded" : "not yet"} moved=${moved.length}/${paired} in-view=${inView.length} ` +
310
+ `pixels-changed=${(changed * 100).toFixed(2)}%`,
311
+ );
312
+ console.log(` ${base}-{first,ready,overlay}.png`);
313
+ for (const shift of run.times.shifts.filter((s) => s.value >= 0.001))
314
+ for (const source of shift.sources)
315
+ console.log(
316
+ ` shift ${shift.value.toFixed(4)} at ${seconds(shift.startTime)}: ${source.node} ${box(source.from)} -> ${box(source.to)}`,
317
+ );
318
+ for (const m of [...inView, ...moved.filter((entry) => !inView.includes(entry))].slice(0, 5))
319
+ console.log(` moved ${m.by.toFixed(1)}px: ${m.key} ${box(m.from)} -> ${box(m.to)}`);
320
+ }
321
+ }
322
+ await browser.close();
323
+ process.exit(failed ? 1 : 0);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@siyahi/tools",
3
- "version": "0.1.1-next.4",
4
- "description": "Checks for Siyahi pages: a lint plugin, a theme contrast check and a screenshot script.",
3
+ "version": "0.1.3-next.1",
4
+ "description": "Checks and build helpers for Siyahi pages: a lint plugin, a font preload plugin, a theme contrast check, and screenshot and layout shift scripts.",
5
5
  "homepage": "https://siyahi.jatinbansal.com",
6
6
  "license": "MIT",
7
7
  "author": "Jatin Bansal",
@@ -11,6 +11,7 @@
11
11
  "directory": "packages/tools"
12
12
  },
13
13
  "bin": {
14
+ "siyahi-cls": "./cls.mjs",
14
15
  "siyahi-contrast": "./contrast.mjs",
15
16
  "siyahi-shots": "./shots.mjs"
16
17
  },
@@ -35,6 +36,10 @@
35
36
  "./theme": {
36
37
  "types": "./types/theme.d.mts",
37
38
  "default": "./theme.mjs"
39
+ },
40
+ "./vite": {
41
+ "types": "./types/vite.d.mts",
42
+ "default": "./vite.mjs"
38
43
  }
39
44
  },
40
45
  "dependencies": {
@@ -46,10 +51,17 @@
46
51
  "@types/node": "24.19.2",
47
52
  "oxlint": "^1.88.0",
48
53
  "typescript": "7.0.2",
54
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
49
55
  "vite-plus": "1.3.0"
50
56
  },
51
57
  "peerDependencies": {
52
- "oxlint": ">=1.80.0"
58
+ "oxlint": ">=1.80.0",
59
+ "vite": ">=7.0.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "vite": {
63
+ "optional": true
64
+ }
53
65
  },
54
66
  "scripts": {
55
67
  "build": "tsc -p tsconfig.build.json",
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ export type Rect = {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ };
8
+ export type Shift = {
9
+ value: number;
10
+ startTime: number;
11
+ sources: {
12
+ node: string;
13
+ from: Rect;
14
+ to: Rect;
15
+ }[];
16
+ };
17
+ export type Boxes = Record<string, Rect>;
18
+ export type Timeline = {
19
+ shifts: Shift[];
20
+ fontAt: number | null;
21
+ display: string[];
22
+ fcp: number | null;
23
+ };
@@ -0,0 +1,2 @@
1
+ /** @returns {import("vite").Plugin} */
2
+ export declare function preloadFonts(): import("vite").Plugin;
package/vite.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ // A Vite plugin that preloads the Latin files of Geist and Geist Mono, the
3
+ // fonts nearly every Siyahi page draws first. Without it the browser asks for
4
+ // a font only once the page has rendered text in it, so on a slow network the
5
+ // fallback paints and Geist swaps in seconds later; with it the font
6
+ // downloads alongside the scripts and is usually there before the first paint.
7
+
8
+ // fontsource's file names, kept by Vite in each emitted asset's names. Geist's
9
+ // latin-ext file holds ₹, which the default locale prints on every amount.
10
+ const PRELOADED = new Set([
11
+ "geist-latin-wght-normal.woff2",
12
+ "geist-latin-ext-wght-normal.woff2",
13
+ "geist-mono-latin-wght-normal.woff2",
14
+ ]);
15
+
16
+ /** @returns {import("vite").Plugin} */
17
+ export function preloadFonts() {
18
+ let base = "/";
19
+ return {
20
+ name: "siyahi:preload-fonts",
21
+ // The dev server serves fonts from node_modules, where a preload has nothing to win.
22
+ apply: "build",
23
+ configResolved(config) {
24
+ base = config.base;
25
+ },
26
+ transformIndexHtml: {
27
+ order: "post",
28
+ handler(_html, { bundle }) {
29
+ // Two copies of fontsource in one bundle emit two files per face; one preload each is enough.
30
+ const files = new Map();
31
+ for (const file of Object.values(bundle ?? {})) {
32
+ if (file.type !== "asset") continue;
33
+ const face = file.names.find((name) => PRELOADED.has(name));
34
+ if (face && !files.has(face)) files.set(face, file.fileName);
35
+ }
36
+ return [...files.values()].map((fileName) => ({
37
+ tag: "link",
38
+ attrs: { rel: "preload", href: base + fileName, as: "font", type: "font/woff2", crossorigin: true },
39
+ injectTo: "head",
40
+ }));
41
+ },
42
+ },
43
+ };
44
+ }