fontfetch 0.6.0

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/cli.js ADDED
@@ -0,0 +1,927 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/pull.ts
4
+ import fs from "fs/promises";
5
+ import path2 from "path";
6
+
7
+ // src/utils.ts
8
+ import path from "path";
9
+ var FONT_EXT_RE = /\.(woff2|woff|ttf|otf|eot)(\?[^"')\s]*)?$/i;
10
+ var UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
11
+ async function fetchText(url, headers = {}) {
12
+ const res = await fetch(url, { headers: { "User-Agent": UA, ...headers } });
13
+ if (!res.ok) throw new Error(`GET ${url} \u2192 ${res.status}`);
14
+ return await res.text();
15
+ }
16
+ async function fetchBuffer(url, headers = {}) {
17
+ const res = await fetch(url, { headers: { "User-Agent": UA, ...headers } });
18
+ if (!res.ok) throw new Error(`GET ${url} \u2192 ${res.status}`);
19
+ return Buffer.from(await res.arrayBuffer());
20
+ }
21
+ function abs(u, base) {
22
+ try {
23
+ return new URL(u, base).toString();
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+ function siteSlug(url) {
29
+ return new URL(url).hostname.replace(/^www\./, "").replace(/[^a-zA-Z0-9.-]/g, "_");
30
+ }
31
+ function safeFilename(url) {
32
+ const u = new URL(url);
33
+ const base = path.basename(u.pathname) || "font";
34
+ return base.replace(/[^a-zA-Z0-9._-]/g, "_");
35
+ }
36
+ var log = {
37
+ info: (msg) => console.log(msg),
38
+ warn: (msg) => console.warn(msg),
39
+ err: (msg) => console.error(msg)
40
+ };
41
+
42
+ // src/parse.ts
43
+ function extractStylesheetLinks(html, baseUrl) {
44
+ const out = [];
45
+ const linkRe = /<link\b[^>]*rel=["']?stylesheet["']?[^>]*>/gi;
46
+ for (const m of html.matchAll(linkRe)) {
47
+ const href = /href=["']([^"']+)["']/i.exec(m[0])?.[1];
48
+ if (href) {
49
+ const u = abs(href, baseUrl);
50
+ if (u) out.push(u);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function extractInlineStyles(html) {
56
+ const out = [];
57
+ const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
58
+ for (const m of html.matchAll(styleRe)) out.push(m[1]);
59
+ return out;
60
+ }
61
+ function parseFontFace(body, baseUrl) {
62
+ const getProp = (prop) => {
63
+ const m = new RegExp(`${prop}\\s*:\\s*([^;]+)`, "i").exec(body);
64
+ return m ? m[1].trim() : null;
65
+ };
66
+ const family = (getProp("font-family") || "").replace(/^['"]|['"]$/g, "");
67
+ const weight = getProp("font-weight") || "400";
68
+ const style = getProp("font-style") || "normal";
69
+ const display = getProp("font-display");
70
+ const unicodeRange = getProp("unicode-range");
71
+ const srcRaw = getProp("src") || "";
72
+ const sources = [];
73
+ const srcRe = /url\(\s*['"]?([^'")]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")]+)['"]?\s*\))?/gi;
74
+ for (const m of srcRaw.matchAll(srcRe)) {
75
+ const raw = m[1];
76
+ if (raw.startsWith("data:")) continue;
77
+ if (!FONT_EXT_RE.test(raw)) continue;
78
+ const absUrl = abs(raw, baseUrl);
79
+ if (!absUrl) continue;
80
+ sources.push({ url: absUrl, format: m[2] || null });
81
+ }
82
+ if (!family || sources.length === 0) return null;
83
+ return { family, weight, style, display, unicodeRange, sources };
84
+ }
85
+ function extractFontFaces(css, baseUrl) {
86
+ const out = [];
87
+ const faceRe = /@font-face\s*\{([^}]*)\}/gi;
88
+ for (const m of css.matchAll(faceRe)) {
89
+ const parsed = parseFontFace(m[1], baseUrl);
90
+ if (parsed) out.push(parsed);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ // src/emit.ts
96
+ function buildFontsCss(faces) {
97
+ const lines = [
98
+ "/* Auto-generated by fontfetch */",
99
+ "/* Drop this folder into your project and link this CSS file. */",
100
+ ""
101
+ ];
102
+ for (const f of faces) {
103
+ lines.push("@font-face {");
104
+ lines.push(` font-family: '${f.family}';`);
105
+ lines.push(` font-style: ${f.style};`);
106
+ lines.push(` font-weight: ${f.weight};`);
107
+ if (f.display) lines.push(` font-display: ${f.display};`);
108
+ const srcParts = f.sources.map((s) => {
109
+ const local = `url('./files/${s.localFile}')`;
110
+ return s.format ? `${local} format('${s.format}')` : local;
111
+ });
112
+ lines.push(` src: ${srcParts.join(", ")};`);
113
+ if (f.unicodeRange) lines.push(` unicode-range: ${f.unicodeRange};`);
114
+ lines.push("}");
115
+ lines.push("");
116
+ }
117
+ return lines.join("\n");
118
+ }
119
+ function buildFontsJson(faces, orphans = []) {
120
+ return JSON.stringify(
121
+ {
122
+ faces: faces.map((f) => ({
123
+ family: f.family,
124
+ weight: f.weight,
125
+ style: f.style,
126
+ display: f.display,
127
+ unicodeRange: f.unicodeRange,
128
+ files: f.sources.map((s) => ({ file: `files/${s.localFile}`, format: s.format }))
129
+ })),
130
+ orphan_files: orphans.map((o) => ({ file: `files/${o.file}`, url: o.url }))
131
+ },
132
+ null,
133
+ 2
134
+ );
135
+ }
136
+ function buildReadme(host, faces, totalFiles, orphans = []) {
137
+ const byFamily = /* @__PURE__ */ new Map();
138
+ for (const f of faces) {
139
+ if (!byFamily.has(f.family)) byFamily.set(f.family, []);
140
+ byFamily.get(f.family).push(f);
141
+ }
142
+ const lines = [
143
+ `# Fonts from ${host}`,
144
+ "",
145
+ `Downloaded ${totalFiles} font file(s) across ${faces.length} @font-face declaration(s).`,
146
+ "",
147
+ "## Usage",
148
+ "",
149
+ "1. Copy this whole folder into your project (e.g. `public/fonts/` or `src/assets/fonts/`).",
150
+ '2. Include `fonts.css` in your app (import it, or `<link rel="stylesheet" href="...">`).',
151
+ "3. Reference the families below by `font-family` in your CSS.",
152
+ "",
153
+ "## Families",
154
+ ""
155
+ ];
156
+ for (const [family, list] of byFamily) {
157
+ lines.push(`### ${family}`);
158
+ lines.push("");
159
+ const variants = /* @__PURE__ */ new Map();
160
+ for (const f of list) {
161
+ const key = `${f.weight} / ${f.style}`;
162
+ variants.set(key, (variants.get(key) || 0) + 1);
163
+ }
164
+ for (const [key, n] of variants) {
165
+ lines.push(`- ${key}${n > 1 ? ` _(${n} subset files)_` : ""}`);
166
+ }
167
+ lines.push("");
168
+ }
169
+ if (orphans.length > 0) {
170
+ lines.push("## Orphan files");
171
+ lines.push("");
172
+ lines.push(`These ${orphans.length} font file(s) were observed loading in the browser but came from a cross-origin stylesheet whose @font-face rules couldn't be read directly (common for Adobe Typekit and similar services). The files are downloaded into \`files/\` but **not referenced in \`fonts.css\`** \u2014 there's no family/weight/style metadata to construct a rule from.`);
173
+ lines.push("");
174
+ lines.push("To use them, inspect the live site's DevTools to find the matching @font-face declarations, then add them to your CSS pointing at the local files.");
175
+ lines.push("");
176
+ for (const o of orphans) {
177
+ lines.push(`- \`files/${o.file}\` \u2190 ${o.url}`);
178
+ }
179
+ lines.push("");
180
+ }
181
+ lines.push("## Notes");
182
+ lines.push("");
183
+ lines.push("- Multiple files per weight/style usually means the source split the font by `unicode-range` (Latin, Latin-Ext, Cyrillic, etc.). The browser only loads the subsets it needs \u2014 keep them all.");
184
+ lines.push("- For local design exploration. Verify licensing before shipping to production.");
185
+ lines.push("");
186
+ return lines.join("\n");
187
+ }
188
+ function buildLicenseReview(host, classified, summary) {
189
+ const lines = [
190
+ `# License review for ${host}`,
191
+ "",
192
+ "> Heuristic-only. Not legal advice. Verify before shipping.",
193
+ "",
194
+ "## Summary",
195
+ "",
196
+ `- \u2705 **${summary.open} open** \u2014 safe to self-host`,
197
+ `- \u26A0\uFE0F **${summary.commercial} commercial** \u2014 do not ship without a license`,
198
+ `- \u2753 **${summary.unknown} unknown** \u2014 manual review needed`,
199
+ ""
200
+ ];
201
+ const sections = [
202
+ { status: "open", title: "## \u2705 Open / self-hostable" },
203
+ {
204
+ status: "commercial",
205
+ title: "## \u26A0\uFE0F Commercial \u2014 do not ship without a license",
206
+ note: "These came from a commercial foundry CDN. Bundling them into a production app without a paid license violates the foundry EULA."
207
+ },
208
+ {
209
+ status: "unknown",
210
+ title: "## \u2753 Unknown \u2014 manual review",
211
+ note: "Couldn't match against known CDN signatures or the known-open family list. Check the foundry, search Google Fonts for a free alternative, or inspect the site's CSS to confirm."
212
+ }
213
+ ];
214
+ for (const section of sections) {
215
+ const items = classified.filter((c) => c.classification.status === section.status);
216
+ if (items.length === 0) continue;
217
+ lines.push(section.title);
218
+ lines.push("");
219
+ if (section.note) {
220
+ lines.push(`_${section.note}_`);
221
+ lines.push("");
222
+ }
223
+ const byFamily = /* @__PURE__ */ new Map();
224
+ for (const it of items) {
225
+ const list = byFamily.get(it.face.family) ?? [];
226
+ list.push(it);
227
+ byFamily.set(it.face.family, list);
228
+ }
229
+ for (const [family, list] of byFamily) {
230
+ lines.push(`### ${family}`);
231
+ lines.push("");
232
+ lines.push(`- ${list[0].classification.reason}`);
233
+ const fileList = list.flatMap(
234
+ (it) => it.face.sources.map((s) => s.localFile).filter((f) => Boolean(f)).map((f) => `files/${f} (${it.face.weight}/${it.face.style})`)
235
+ );
236
+ const unique = [...new Set(fileList)];
237
+ if (unique.length > 0) {
238
+ lines.push("- Files:");
239
+ for (const f of unique) lines.push(` - \`${f}\``);
240
+ }
241
+ lines.push("");
242
+ }
243
+ }
244
+ return lines.join("\n");
245
+ }
246
+
247
+ // src/license-data.ts
248
+ var OPEN_HOSTS = [
249
+ { host: "fonts.gstatic.com", label: "Google Fonts CDN" },
250
+ { host: "fonts.googleapis.com", label: "Google Fonts API" },
251
+ { host: "cdn.jsdelivr.net/npm/@fontsource/", label: "Fontsource (mostly OFL)" },
252
+ { host: "rsms.me/inter", label: "Inter \u2014 OFL (by Rasmus Andersson)" },
253
+ { host: "cdn.jsdelivr.net/gh/google/fonts", label: "Google Fonts GitHub mirror" }
254
+ ];
255
+ var COMMERCIAL_HOSTS = [
256
+ { host: "use.typekit.net", label: "Adobe Fonts (Typekit)" },
257
+ { host: "fonts.adobe.com", label: "Adobe Fonts" },
258
+ { host: "p.typekit.net", label: "Adobe Fonts (Typekit)" },
259
+ { host: "fast.fonts.net", label: "Monotype (fonts.com)" },
260
+ { host: "cloud.typenetwork.com", label: "Type Network" },
261
+ { host: "cloud.typography.com", label: "Hoefler & Co (Cloud.typography)" },
262
+ { host: "use.fontawesome.com", label: "Font Awesome (commercial tiers)" },
263
+ { host: "fontstand.com", label: "Fontstand" }
264
+ ];
265
+ var KNOWN_OPEN_FAMILIES = [
266
+ // Top sans
267
+ "Inter",
268
+ "Inter Display",
269
+ "Inter Tight",
270
+ "Roboto",
271
+ "Roboto Condensed",
272
+ "Roboto Flex",
273
+ "Open Sans",
274
+ "Lato",
275
+ "Montserrat",
276
+ "Poppins",
277
+ "Oswald",
278
+ "Raleway",
279
+ "Nunito",
280
+ "Nunito Sans",
281
+ "Ubuntu",
282
+ "PT Sans",
283
+ "Source Sans Pro",
284
+ "Source Sans 3",
285
+ "Mulish",
286
+ "Karla",
287
+ "Work Sans",
288
+ "Quicksand",
289
+ "DM Sans",
290
+ "Manrope",
291
+ "Outfit",
292
+ "Space Grotesk",
293
+ "Plus Jakarta Sans",
294
+ "Public Sans",
295
+ "Hind",
296
+ "Cabin",
297
+ "Atkinson Hyperlegible",
298
+ // Geist (Vercel + Basement Studio, OFL)
299
+ "Geist",
300
+ "Geist Mono",
301
+ "Geist Sans",
302
+ // Mono
303
+ "JetBrains Mono",
304
+ "Fira Code",
305
+ "Fira Mono",
306
+ "Source Code Pro",
307
+ "IBM Plex Mono",
308
+ "Space Mono",
309
+ "DM Mono",
310
+ "Roboto Mono",
311
+ // Serif
312
+ "Playfair Display",
313
+ "Merriweather",
314
+ "Noto Serif",
315
+ "Noto Sans",
316
+ "EB Garamond",
317
+ "Lora",
318
+ "Bitter",
319
+ "Cardo",
320
+ "Cormorant",
321
+ "Crimson Text",
322
+ "PT Serif",
323
+ "DM Serif Display",
324
+ "DM Serif Text",
325
+ // IBM Plex (Apache 2.0)
326
+ "IBM Plex Sans",
327
+ "IBM Plex Serif",
328
+ // Fira (OFL)
329
+ "Fira Sans",
330
+ // Display / handwritten
331
+ "Lobster",
332
+ "Pacifico",
333
+ "Sacramento",
334
+ "Dancing Script",
335
+ "Caveat",
336
+ "Indie Flower",
337
+ "Patrick Hand",
338
+ "Comic Neue",
339
+ "Architects Daughter"
340
+ ];
341
+
342
+ // src/license.ts
343
+ var NORMALIZED_OPEN_FAMILIES = new Set(KNOWN_OPEN_FAMILIES.map((f) => f.trim().toLowerCase()));
344
+ function classifyFace(face) {
345
+ for (const src of face.sources) {
346
+ for (const sig of COMMERCIAL_HOSTS) {
347
+ if (src.url.includes(sig.host)) {
348
+ return { status: "commercial", reason: `Served from ${sig.label}` };
349
+ }
350
+ }
351
+ }
352
+ for (const src of face.sources) {
353
+ for (const sig of OPEN_HOSTS) {
354
+ if (src.url.includes(sig.host)) {
355
+ return { status: "open", reason: `Served from ${sig.label}` };
356
+ }
357
+ }
358
+ }
359
+ const fam = (face.family || "").trim().toLowerCase();
360
+ if (NORMALIZED_OPEN_FAMILIES.has(fam)) {
361
+ return { status: "open", reason: `'${face.family}' is on the SIL OFL / Google Fonts catalog` };
362
+ }
363
+ return { status: "unknown", reason: "No matching CDN or known-family signature" };
364
+ }
365
+ function classifyFaces(faces) {
366
+ return faces.map((face) => ({ face, classification: classifyFace(face) }));
367
+ }
368
+ function summarize(classified) {
369
+ let open = 0;
370
+ let commercial = 0;
371
+ let unknown = 0;
372
+ for (const c of classified) {
373
+ if (c.classification.status === "open") open++;
374
+ else if (c.classification.status === "commercial") commercial++;
375
+ else unknown++;
376
+ }
377
+ const total = classified.length;
378
+ return {
379
+ open,
380
+ commercial,
381
+ unknown,
382
+ total,
383
+ allCommercial: total > 0 && commercial === total
384
+ };
385
+ }
386
+
387
+ // src/headless.ts
388
+ var INSTALL_HINT = `
389
+ Playwright is required for --headless mode. To install:
390
+
391
+ npm install playwright
392
+ npx playwright install chromium
393
+
394
+ Or skip --headless to use the static parser.
395
+ `.trim();
396
+ async function fetchHeadless(url, timeoutMs = 3e4) {
397
+ let chromium;
398
+ try {
399
+ ({ chromium } = await import("playwright"));
400
+ } catch {
401
+ throw new Error(INSTALL_HINT);
402
+ }
403
+ let browser;
404
+ try {
405
+ browser = await chromium.launch({ headless: true });
406
+ } catch (e) {
407
+ const msg = e.message;
408
+ if (/Executable doesn't exist|browserType\.launch/i.test(msg)) {
409
+ throw new Error(
410
+ `Playwright is installed but Chromium isn't. Run:
411
+ npx playwright install chromium
412
+
413
+ (${msg})`
414
+ );
415
+ }
416
+ throw e;
417
+ }
418
+ try {
419
+ const context = await browser.newContext({ userAgent: UA });
420
+ const page = await context.newPage();
421
+ const networkFontUrls = /* @__PURE__ */ new Set();
422
+ page.on("response", (response) => {
423
+ const u = response.url();
424
+ const ct = (response.headers()["content-type"] || "").toLowerCase();
425
+ const isFontByExt = /\.(woff2|woff|ttf|otf|eot)(\?|$)/i.test(u);
426
+ const isFontByType = ct.startsWith("font/") || ct === "application/font-woff" || ct === "application/font-woff2";
427
+ if ((isFontByExt || isFontByType) && response.status() < 400) {
428
+ networkFontUrls.add(u);
429
+ }
430
+ });
431
+ await page.goto(url, { waitUntil: "networkidle", timeout: timeoutMs });
432
+ await page.evaluate(() => document.fonts.ready);
433
+ const cssText = await page.evaluate(() => {
434
+ const out = [];
435
+ for (const sheet of Array.from(document.styleSheets)) {
436
+ try {
437
+ const rules = sheet.cssRules;
438
+ if (!rules) continue;
439
+ for (const rule of Array.from(rules)) {
440
+ if (rule.type === CSSRule.FONT_FACE_RULE) {
441
+ out.push(rule.cssText);
442
+ }
443
+ }
444
+ } catch {
445
+ }
446
+ }
447
+ return out.join("\n");
448
+ });
449
+ return {
450
+ cssSources: cssText ? [{ text: cssText, base: url }] : [],
451
+ networkFontUrls: [...networkFontUrls]
452
+ };
453
+ } finally {
454
+ await browser.close();
455
+ }
456
+ }
457
+
458
+ // src/emitters/util.ts
459
+ function familyToIdent(family) {
460
+ const cleaned = family.replace(/[^A-Za-z0-9 ]+/g, " ").trim();
461
+ if (!cleaned) return "font";
462
+ const parts = cleaned.split(/\s+/);
463
+ const first = parts[0][0].toLowerCase() + parts[0].slice(1);
464
+ const rest = parts.slice(1).map((p) => p[0].toUpperCase() + p.slice(1));
465
+ return [first, ...rest].join("");
466
+ }
467
+ function familyToKebab(family) {
468
+ return family.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
469
+ }
470
+ function tailwindBucket(family) {
471
+ const f = family.toLowerCase();
472
+ if (/(mono|code|console|courier|consolas)/.test(f)) return "mono";
473
+ if (/sans/.test(f)) return "sans";
474
+ if (/(serif|garamond|caslon|baskerville|times|georgia|plantijn|tiempos)/.test(f)) return "serif";
475
+ return "sans";
476
+ }
477
+ function groupByFamily(faces) {
478
+ const out = /* @__PURE__ */ new Map();
479
+ for (const f of faces) {
480
+ const existing = out.get(f.family);
481
+ if (existing) existing.push(f);
482
+ else out.set(f.family, [f]);
483
+ }
484
+ return out;
485
+ }
486
+ function pickPrimaryFile(face) {
487
+ if (face.sources.length === 0) return null;
488
+ const woff2 = face.sources.find((s) => s.format === "woff2");
489
+ const chosen = woff2 ?? face.sources[0];
490
+ if (!chosen.localFile) return null;
491
+ return { file: chosen.localFile, format: chosen.format };
492
+ }
493
+
494
+ // src/emitters/next.ts
495
+ var nextEmitter = (faces, ctx) => {
496
+ const byFamily = groupByFamily(faces);
497
+ if (byFamily.size === 0) return null;
498
+ const lines = [
499
+ "/* Auto-generated by fontfetch (`--emit next`). */",
500
+ "/* Copy this folder into your Next.js project and import these exports. */",
501
+ "/* Example: `<html className={`${interFont.variable} ${geistMono.variable}`}>` */",
502
+ "",
503
+ "import localFont from 'next/font/local';",
504
+ ""
505
+ ];
506
+ for (const [family, list] of byFamily) {
507
+ const ident = familyToIdent(family);
508
+ const cssVar = `--font-${familyToKebab(family)}`;
509
+ const sources = list.map((f) => pickPrimaryFile(f)).filter((s) => s !== null);
510
+ if (sources.length === 0) continue;
511
+ lines.push(`export const ${ident} = localFont({`);
512
+ lines.push(" src: [");
513
+ list.forEach((f, i) => {
514
+ const src = pickPrimaryFile(f);
515
+ if (!src) return;
516
+ const weight = f.weight || "400";
517
+ const style = f.style || "normal";
518
+ lines.push(
519
+ ` { path: './${ctx.filesDir}/${src.file}', weight: '${weight}', style: '${style}' },` + (i === list.length - 1 ? "" : "")
520
+ );
521
+ });
522
+ lines.push(" ],");
523
+ lines.push(` variable: '${cssVar}',`);
524
+ lines.push(" display: 'swap',");
525
+ lines.push("});");
526
+ lines.push("");
527
+ }
528
+ return { filename: "next.fonts.ts", content: lines.join("\n") };
529
+ };
530
+
531
+ // src/emitters/tailwind.ts
532
+ var tailwindEmitter = (faces, ctx) => {
533
+ void ctx;
534
+ const byFamily = groupByFamily(faces);
535
+ if (byFamily.size === 0) return null;
536
+ const buckets = { sans: [], serif: [], mono: [] };
537
+ const aliases = [];
538
+ for (const family of byFamily.keys()) {
539
+ const cssVar = `--font-${familyToKebab(family)}`;
540
+ const bucket = tailwindBucket(family);
541
+ buckets[bucket].push(`var(${cssVar})`);
542
+ aliases.push({ alias: familyToIdent(family), family, cssVar });
543
+ }
544
+ const fallback = {
545
+ sans: "'system-ui', 'sans-serif'",
546
+ serif: "'Georgia', 'serif'",
547
+ mono: "'ui-monospace', 'monospace'"
548
+ };
549
+ const lines = [
550
+ "/* Auto-generated by fontfetch (`--emit tailwind`). */",
551
+ "/* Merge `fonts` into your tailwind.config.ts -> theme.extend.fontFamily. */",
552
+ "/* Pair with the Next.js setup from `--emit next` for CSS variables. */",
553
+ "",
554
+ "import type { Config } from 'tailwindcss';",
555
+ "",
556
+ "type FontFamilyConfig = NonNullable<NonNullable<Config['theme']>['extend']>['fontFamily'];",
557
+ "",
558
+ "export const fonts: FontFamilyConfig = {"
559
+ ];
560
+ for (const bucket of ["sans", "serif", "mono"]) {
561
+ const vars = buckets[bucket];
562
+ if (vars.length === 0) continue;
563
+ const stack = [...vars.map((v) => `'${v}'`), fallback[bucket]].join(", ");
564
+ lines.push(` ${bucket}: [${stack}],`);
565
+ }
566
+ for (const { alias, family, cssVar } of aliases) {
567
+ lines.push(` '${alias}': ['var(${cssVar})', /* ${family} */],`);
568
+ }
569
+ lines.push("};");
570
+ lines.push("");
571
+ lines.push("export default fonts;");
572
+ return { filename: "tailwind.fonts.ts", content: lines.join("\n") };
573
+ };
574
+
575
+ // src/emitters/vite.ts
576
+ var viteEmitter = (faces, ctx) => {
577
+ const byFamily = groupByFamily(faces);
578
+ if (byFamily.size === 0) return null;
579
+ const lines = [
580
+ "# Vite integration",
581
+ "",
582
+ "The generated `fonts.css` is already a drop-in `@font-face` stylesheet \u2014 no Vite-specific plugin needed.",
583
+ "",
584
+ "## Steps",
585
+ "",
586
+ "1. Copy this folder into your project, e.g. `src/assets/fonts/<site>/`",
587
+ "2. Import the stylesheet from your entry file:",
588
+ "",
589
+ " ```ts",
590
+ " // src/main.ts (or src/main.tsx, etc.)",
591
+ ` import './assets/fonts/<site>/fonts.css';`,
592
+ " ```",
593
+ "",
594
+ "3. Reference the families in your CSS or Tailwind config:",
595
+ ""
596
+ ];
597
+ for (const family of byFamily.keys()) {
598
+ lines.push(` - \`font-family: '${family}';\``);
599
+ }
600
+ lines.push("");
601
+ lines.push("## Notes");
602
+ lines.push("");
603
+ lines.push("- Vite emits the font files as-is during build; they go through `vite-plugin-static-copy` or your asset pipeline automatically when imported via `url()` in the CSS.");
604
+ lines.push(`- File paths in \`fonts.css\` are relative (\`./${ctx.filesDir}/...\`), so the import works from anywhere you place this folder.`);
605
+ lines.push("");
606
+ return { filename: "vite.fonts.md", content: lines.join("\n") };
607
+ };
608
+
609
+ // src/emitters/types.ts
610
+ var EMIT_TARGETS = ["css", "next", "tailwind", "vite"];
611
+ function isEmitTarget(s) {
612
+ return EMIT_TARGETS.includes(s);
613
+ }
614
+
615
+ // src/emitters/index.ts
616
+ var EMITTERS = {
617
+ next: nextEmitter,
618
+ tailwind: tailwindEmitter,
619
+ vite: viteEmitter
620
+ };
621
+
622
+ // src/provenance.ts
623
+ var RULES = [
624
+ {
625
+ bucket: "google",
626
+ patterns: ["fonts.gstatic.com", "fonts.googleapis.com", "cdn.jsdelivr.net/gh/google/fonts"]
627
+ },
628
+ {
629
+ bucket: "adobe-typekit",
630
+ patterns: ["use.typekit.net", "p.typekit.net", "fonts.adobe.com"]
631
+ },
632
+ {
633
+ bucket: "commercial",
634
+ patterns: [
635
+ "fast.fonts.net",
636
+ "cloud.typography.com",
637
+ "cloud.typenetwork.com",
638
+ "use.fontawesome.com",
639
+ "fontstand.com"
640
+ ]
641
+ },
642
+ {
643
+ bucket: "open-cdn",
644
+ patterns: ["cdn.jsdelivr.net/npm/@fontsource/", "rsms.me"]
645
+ }
646
+ ];
647
+ function strip(host) {
648
+ return host.replace(/^www\./i, "").toLowerCase();
649
+ }
650
+ function sameOrigin(urlHost, pageHost) {
651
+ const a = strip(urlHost);
652
+ const b = strip(pageHost);
653
+ if (a === b) return true;
654
+ return a.endsWith("." + b) || b.endsWith("." + a);
655
+ }
656
+ function bucketForUrl(url, pageHost) {
657
+ for (const rule of RULES) {
658
+ for (const pattern of rule.patterns) {
659
+ if (url.includes(pattern)) return rule.bucket;
660
+ }
661
+ }
662
+ try {
663
+ const u = new URL(url);
664
+ if (sameOrigin(u.hostname, pageHost)) return "self-hosted";
665
+ } catch {
666
+ }
667
+ return "self-hosted";
668
+ }
669
+
670
+ // src/pull.ts
671
+ async function pull({
672
+ url,
673
+ baseDir,
674
+ headless = false,
675
+ emit = [],
676
+ force = false
677
+ }) {
678
+ const host = siteSlug(url);
679
+ const outDir = path2.join(path2.resolve(baseDir), host);
680
+ const filesDir = path2.join(outDir, "files");
681
+ await fs.mkdir(filesDir, { recursive: true });
682
+ log.info(`\u2192 Fetching page: ${url}`);
683
+ const html = await fetchText(url);
684
+ const cssLinks = extractStylesheetLinks(html, url);
685
+ const inlineCss = extractInlineStyles(html);
686
+ log.info(` ${cssLinks.length} external stylesheet(s), ${inlineCss.length} inline <style> block(s)`);
687
+ const cssSources = inlineCss.map((t) => ({ text: t, base: url }));
688
+ for (const link of cssLinks) {
689
+ try {
690
+ log.info(`\u2192 Fetching CSS: ${link}`);
691
+ cssSources.push({ text: await fetchText(link, { Referer: url }), base: link });
692
+ } catch (e) {
693
+ log.warn(` ! Failed: ${e.message}`);
694
+ }
695
+ }
696
+ let networkFontUrls = [];
697
+ if (headless) {
698
+ log.info("\u2192 Running headless mode (Playwright)...");
699
+ try {
700
+ const result = await fetchHeadless(url);
701
+ cssSources.push(...result.cssSources);
702
+ networkFontUrls = result.networkFontUrls;
703
+ log.info(` + ${result.cssSources.length} stylesheet block(s) from headless`);
704
+ if (networkFontUrls.length > 0) {
705
+ log.info(` + ${networkFontUrls.length} font URL(s) observed in network`);
706
+ }
707
+ } catch (e) {
708
+ log.warn(` ! Headless mode failed: ${e.message}`);
709
+ log.warn(" Continuing with static results.");
710
+ }
711
+ }
712
+ const allFaces = cssSources.flatMap(({ text, base }) => extractFontFaces(text, base));
713
+ const seen = /* @__PURE__ */ new Set();
714
+ const faces = allFaces.filter((f) => {
715
+ const sig = `${f.family}|${f.weight}|${f.style}|${f.sources.map((s) => s.url).sort().join(",")}`;
716
+ if (seen.has(sig)) return false;
717
+ seen.add(sig);
718
+ return true;
719
+ });
720
+ const preloadRe = /<link\b[^>]*rel=["']?preload["']?[^>]*as=["']?font["']?[^>]*>/gi;
721
+ const extraUrls = [];
722
+ for (const m of html.matchAll(preloadRe)) {
723
+ const href = /href=["']([^"']+)["']/i.exec(m[0])?.[1];
724
+ if (href) {
725
+ const u = abs(href, url);
726
+ if (u && FONT_EXT_RE.test(u)) extraUrls.push(u);
727
+ }
728
+ }
729
+ const pageHost = new URL(url).hostname;
730
+ const urlToLocal = /* @__PURE__ */ new Map();
731
+ const usedNames = /* @__PURE__ */ new Set();
732
+ const claim = (u) => {
733
+ if (urlToLocal.has(u)) return urlToLocal.get(u);
734
+ const bucket = bucketForUrl(u, pageHost);
735
+ let name = safeFilename(u);
736
+ const candidate = `${bucket}/${name}`;
737
+ if (usedNames.has(candidate)) {
738
+ const h = new URL(u).hostname.replace(/[^a-z0-9]/gi, "_");
739
+ name = `${h}__${name}`;
740
+ }
741
+ const final = `${bucket}/${name}`;
742
+ usedNames.add(final);
743
+ urlToLocal.set(u, final);
744
+ return final;
745
+ };
746
+ for (const f of faces) {
747
+ for (const s of f.sources) s.localFile = claim(s.url);
748
+ }
749
+ for (const u of extraUrls) claim(u);
750
+ const faceUrls = /* @__PURE__ */ new Set();
751
+ for (const f of faces) for (const s of f.sources) faceUrls.add(s.url);
752
+ const orphans = [];
753
+ for (const u of networkFontUrls) {
754
+ if (faceUrls.has(u)) continue;
755
+ if (urlToLocal.has(u)) continue;
756
+ const file = claim(u);
757
+ orphans.push({ url: u, file });
758
+ }
759
+ if (orphans.length > 0) {
760
+ log.info(`\u2192 ${orphans.length} orphan file(s) (cross-origin, no @font-face metadata available)`);
761
+ }
762
+ log.info(`\u2192 Found ${faces.length} @font-face declaration(s), ${urlToLocal.size} unique file(s)`);
763
+ if (urlToLocal.size === 0) {
764
+ log.info(" (Nothing to download. Site may load fonts via JS, or block automated requests. Try --headless.)");
765
+ return { outDir, faces, orphans: [], downloaded: 0, total: 0 };
766
+ }
767
+ const classified = classifyFaces(faces);
768
+ const licenseSummary = summarize(classified);
769
+ log.info(
770
+ `\u2192 License review: ${licenseSummary.open} open / ${licenseSummary.commercial} commercial / ${licenseSummary.unknown} unknown`
771
+ );
772
+ if (licenseSummary.allCommercial && !force) {
773
+ await fs.writeFile(
774
+ path2.join(outDir, "LICENSE_REVIEW.md"),
775
+ buildLicenseReview(host, classified, licenseSummary)
776
+ );
777
+ log.warn("");
778
+ log.warn(`\u2717 All ${licenseSummary.commercial} detected font(s) are served from known commercial CDNs.`);
779
+ log.warn(" Downloading and shipping these without a license violates foundry EULAs.");
780
+ log.warn(` Wrote ${path2.join(outDir, "LICENSE_REVIEW.md")} with the breakdown.`);
781
+ log.warn(" To download anyway (e.g. for a local mockup you have rights to), re-run with --force.");
782
+ log.warn("");
783
+ return { outDir, faces, orphans: [], downloaded: 0, total: urlToLocal.size };
784
+ }
785
+ let downloaded = 0;
786
+ const createdBuckets = /* @__PURE__ */ new Set();
787
+ for (const [fontUrl, name] of urlToLocal) {
788
+ const dest = path2.join(filesDir, name);
789
+ const bucketDir = path2.dirname(dest);
790
+ if (!createdBuckets.has(bucketDir)) {
791
+ await fs.mkdir(bucketDir, { recursive: true });
792
+ createdBuckets.add(bucketDir);
793
+ }
794
+ try {
795
+ const buf = await fetchBuffer(fontUrl, { Referer: url });
796
+ await fs.writeFile(dest, buf);
797
+ log.info(` \u2713 ${name} (${buf.length.toLocaleString()} bytes)`);
798
+ downloaded++;
799
+ } catch (e) {
800
+ log.warn(` \u2717 ${name} \u2014 ${e.message}`);
801
+ }
802
+ }
803
+ await fs.writeFile(path2.join(outDir, "fonts.css"), buildFontsCss(faces));
804
+ await fs.writeFile(path2.join(outDir, "fonts.json"), buildFontsJson(faces, orphans));
805
+ await fs.writeFile(path2.join(outDir, "README.md"), buildReadme(host, faces, downloaded, orphans));
806
+ await fs.writeFile(
807
+ path2.join(outDir, "LICENSE_REVIEW.md"),
808
+ buildLicenseReview(host, classified, licenseSummary)
809
+ );
810
+ for (const target of emit) {
811
+ const emitter = EMITTERS[target];
812
+ if (!emitter) continue;
813
+ const output = emitter(faces, { siteSlug: host, filesDir: "files" });
814
+ if (!output) continue;
815
+ await fs.writeFile(path2.join(outDir, output.filename), output.content);
816
+ log.info(` + emitted ${output.filename} (--emit ${target})`);
817
+ }
818
+ return { outDir, faces, orphans, downloaded, total: urlToLocal.size };
819
+ }
820
+
821
+ // src/cli.ts
822
+ var VERSION = "0.6.0";
823
+ function printHelp() {
824
+ log.info(`fontfetch ${VERSION}
825
+
826
+ Usage:
827
+ fontfetch <url> [outDir] [flags]
828
+
829
+ Arguments:
830
+ <url> Page to download fonts from (https://example.com)
831
+ [outDir] Directory to write output into (default: ./downloaded-fonts)
832
+
833
+ Flags:
834
+ --headless Use Playwright to also capture JS-loaded fonts (SPAs,
835
+ late-injected @font-face rules). Requires:
836
+ npm install playwright
837
+ npx playwright install chromium
838
+ --emit <targets> Comma-separated framework targets to emit alongside the
839
+ default fonts.css. One or more of: ${EMIT_TARGETS.join(", ")}
840
+ Examples:
841
+ --emit next Next.js next/font/local file
842
+ --emit tailwind Tailwind fontFamily snippet
843
+ --emit next,tailwind Both (pair for CSS variables)
844
+ --emit vite Vite integration guide
845
+ --force Download even if every detected font is served from a
846
+ known commercial-foundry CDN. Default behaviour is to
847
+ abort early and emit only LICENSE_REVIEW.md.
848
+ -h, --help Show this help
849
+ -v, --version Print version
850
+
851
+ Examples:
852
+ fontfetch https://stripe.com
853
+ fontfetch https://stripe.com ./fonts
854
+ fontfetch https://linear.app --headless
855
+ fontfetch https://vercel.com --emit next,tailwind
856
+ npx fontfetch https://stripe.com
857
+
858
+ Output (per site):
859
+ <outDir>/<hostname>/
860
+ files/ Raw font files (woff2/woff/ttf/otf/eot)
861
+ fonts.css Ready-to-use @font-face block with local URLs
862
+ fonts.json Manifest grouped by family/weight/style
863
+ README.md Human-readable summary
864
+
865
+ For local design exploration. You're responsible for licensing the fonts you use.
866
+ `);
867
+ }
868
+ async function main() {
869
+ const args = process.argv.slice(2);
870
+ if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
871
+ printHelp();
872
+ process.exit(args.length === 0 ? 1 : 0);
873
+ }
874
+ if (args.includes("-v") || args.includes("--version")) {
875
+ log.info(VERSION);
876
+ process.exit(0);
877
+ }
878
+ const headless = args.includes("--headless");
879
+ const force = args.includes("--force");
880
+ let emit = [];
881
+ const emitIdx = args.findIndex((a) => a === "--emit" || a.startsWith("--emit="));
882
+ if (emitIdx !== -1) {
883
+ const raw = args[emitIdx] === "--emit" ? args[emitIdx + 1] : args[emitIdx].slice("--emit=".length);
884
+ if (!raw) {
885
+ log.err(`--emit requires a value. One or more of: ${EMIT_TARGETS.join(", ")}`);
886
+ process.exit(1);
887
+ }
888
+ const requested = raw.split(",").map((s) => s.trim()).filter(Boolean);
889
+ for (const r of requested) {
890
+ if (!isEmitTarget(r)) {
891
+ log.err(`Unknown --emit target: '${r}'. Valid: ${EMIT_TARGETS.join(", ")}`);
892
+ process.exit(1);
893
+ }
894
+ if (r !== "css") emit.push(r);
895
+ }
896
+ }
897
+ const reservedFlags = /* @__PURE__ */ new Set(["--headless", "--emit", "--force"]);
898
+ const positional = args.filter((a, i) => {
899
+ if (a.startsWith("--")) return false;
900
+ if (i > 0 && args[i - 1] === "--emit") return false;
901
+ if (reservedFlags.has(a)) return false;
902
+ return true;
903
+ });
904
+ const [url, outDir = "./downloaded-fonts"] = positional;
905
+ if (!url) {
906
+ log.err("Missing <url> argument. Run with --help for usage.");
907
+ process.exit(1);
908
+ }
909
+ try {
910
+ new URL(url);
911
+ } catch {
912
+ log.err(`Invalid URL: ${url}`);
913
+ process.exit(1);
914
+ }
915
+ const result = await pull({ url, baseDir: outDir, headless, emit, force });
916
+ log.info("");
917
+ if (result.total === 0) {
918
+ log.info("No fonts found.");
919
+ process.exit(0);
920
+ }
921
+ log.info(`Done. ${result.downloaded}/${result.total} files saved to ${result.outDir}`);
922
+ }
923
+ main().catch((e) => {
924
+ log.err(String(e));
925
+ process.exit(1);
926
+ });
927
+ //# sourceMappingURL=cli.js.map