fontfetch 1.3.1 → 1.4.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 CHANGED
@@ -126892,7 +126892,7 @@ var require_decompress = __commonJS({
126892
126892
 
126893
126893
  // ../core/src/pipeline/pull.ts
126894
126894
  import fs4 from "fs/promises";
126895
- import path5 from "path";
126895
+ import path6 from "path";
126896
126896
 
126897
126897
  // ../core/src/lib/utils.ts
126898
126898
  import path from "path";
@@ -127103,6 +127103,67 @@ function buildReadme(host, faces, totalFiles, orphans = []) {
127103
127103
  lines.push("");
127104
127104
  return lines.join("\n");
127105
127105
  }
127106
+ function buildProvenanceJson(host, sourceUrl, classified, orphans, fileSizes) {
127107
+ const faces = classified.map((c) => ({
127108
+ family: c.face.family,
127109
+ weight: c.face.weight,
127110
+ style: c.face.style,
127111
+ unicodeRange: c.face.unicodeRange,
127112
+ classification: {
127113
+ status: c.classification.status,
127114
+ reason: c.classification.reason,
127115
+ ...c.classification.hasRFN ? { hasRFN: true } : {}
127116
+ },
127117
+ files: c.face.sources.filter((s) => Boolean(s.localFile)).map((s) => {
127118
+ const filePath = `files/${s.localFile}`;
127119
+ const bucket = s.localFile.includes("/") ? s.localFile.split("/")[0] : "self-hosted";
127120
+ return {
127121
+ file: filePath,
127122
+ bucket,
127123
+ url: s.url,
127124
+ format: s.format,
127125
+ bytes: fileSizes.get(s.localFile) ?? null
127126
+ };
127127
+ })
127128
+ }));
127129
+ const familiesWithRFN = [
127130
+ ...new Set(classified.filter((c) => c.classification.hasRFN).map((c) => c.face.family))
127131
+ ];
127132
+ const byStatus = { open: 0, commercial: 0, unknown: 0 };
127133
+ for (const c of classified) byStatus[c.classification.status]++;
127134
+ const byBucket = {};
127135
+ let totalBytes = 0;
127136
+ let totalFiles = 0;
127137
+ for (const f of faces) {
127138
+ for (const file of f.files) {
127139
+ byBucket[file.bucket] = (byBucket[file.bucket] ?? 0) + 1;
127140
+ totalFiles++;
127141
+ if (file.bytes) totalBytes += file.bytes;
127142
+ }
127143
+ }
127144
+ const report = {
127145
+ schemaVersion: "1.0",
127146
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
127147
+ host,
127148
+ sourceUrl,
127149
+ summary: {
127150
+ families: new Set(classified.map((c) => c.face.family)).size,
127151
+ faces: classified.length,
127152
+ files: totalFiles,
127153
+ bytes: totalBytes,
127154
+ byStatus,
127155
+ byBucket,
127156
+ familiesWithRFN
127157
+ },
127158
+ faces,
127159
+ orphans: orphans.map((o) => ({
127160
+ url: o.url,
127161
+ file: `files/${o.file}`,
127162
+ bytes: fileSizes.get(o.file) ?? null
127163
+ }))
127164
+ };
127165
+ return JSON.stringify(report, null, 2);
127166
+ }
127106
127167
  function buildLicenseReview(host, classified, summary) {
127107
127168
  const lines = [
127108
127169
  `# License review for ${host}`,
@@ -127167,6 +127228,178 @@ function buildLicenseReview(host, classified, summary) {
127167
127228
  return lines.join("\n");
127168
127229
  }
127169
127230
 
127231
+ // ../core/src/license/provenance.ts
127232
+ var RULES = [
127233
+ {
127234
+ bucket: "google",
127235
+ patterns: ["fonts.gstatic.com", "fonts.googleapis.com", "cdn.jsdelivr.net/gh/google/fonts"]
127236
+ },
127237
+ {
127238
+ bucket: "adobe-typekit",
127239
+ patterns: ["use.typekit.net", "p.typekit.net", "fonts.adobe.com"]
127240
+ },
127241
+ {
127242
+ bucket: "commercial",
127243
+ patterns: [
127244
+ "fast.fonts.net",
127245
+ "cloud.typography.com",
127246
+ "cloud.typenetwork.com",
127247
+ "use.fontawesome.com",
127248
+ "fontstand.com"
127249
+ ]
127250
+ },
127251
+ {
127252
+ bucket: "open-cdn",
127253
+ patterns: ["cdn.jsdelivr.net/npm/@fontsource/", "rsms.me"]
127254
+ }
127255
+ ];
127256
+ function strip(host) {
127257
+ return host.replace(/^www\./i, "").toLowerCase();
127258
+ }
127259
+ function sameOrigin(urlHost, pageHost) {
127260
+ const a = strip(urlHost);
127261
+ const b = strip(pageHost);
127262
+ if (a === b) return true;
127263
+ return a.endsWith("." + b) || b.endsWith("." + a);
127264
+ }
127265
+ function bucketForUrl(url, pageHost) {
127266
+ for (const rule of RULES) {
127267
+ for (const pattern of rule.patterns) {
127268
+ if (url.includes(pattern)) return rule.bucket;
127269
+ }
127270
+ }
127271
+ try {
127272
+ const u = new URL(url);
127273
+ if (sameOrigin(u.hostname, pageHost)) return "self-hosted";
127274
+ } catch {
127275
+ }
127276
+ return "self-hosted";
127277
+ }
127278
+
127279
+ // ../core/src/emit/gdpr.ts
127280
+ var REMEDIATION_BY_BUCKET = {
127281
+ google: {
127282
+ severity: "high",
127283
+ hint: "Self-host the Google Fonts CDN binaries. Use fontfetch to extract them, then serve from your own origin. Avoid bunny.net Fonts only if your DPA also covers Bunny."
127284
+ },
127285
+ "adobe-typekit": {
127286
+ severity: "high",
127287
+ hint: "Adobe Fonts requires an active licence. You cannot self-host the binaries under the standard agreement \u2014 you must remove the family OR negotiate a bespoke licence that allows self-hosting."
127288
+ },
127289
+ commercial: {
127290
+ severity: "high",
127291
+ hint: "Commercial foundry CDN. Check the licence for self-host rights, then download the binaries directly from the foundry portal (not via this CDN)."
127292
+ },
127293
+ "open-cdn": {
127294
+ severity: "medium",
127295
+ hint: "Open CDN (Fontsource, rsms.me, jsdelivr). Self-host the binaries OR use Bunny Fonts as a GDPR-compliant proxy. Fontsource also publishes per-family npm packages."
127296
+ },
127297
+ "self-hosted": {
127298
+ severity: "low",
127299
+ hint: "Already on the same origin. No third-party request; nothing to remediate."
127300
+ }
127301
+ };
127302
+ function buildGdprReport(host, sourceUrl, faces) {
127303
+ const findings = [];
127304
+ const seen = /* @__PURE__ */ new Set();
127305
+ const pageHost = (() => {
127306
+ try {
127307
+ return new URL(sourceUrl).hostname;
127308
+ } catch {
127309
+ return host;
127310
+ }
127311
+ })();
127312
+ for (const f of faces) {
127313
+ for (const s of f.sources) {
127314
+ const bucket = bucketForUrl(s.url, pageHost);
127315
+ const key = `${f.family}|${bucket}|${s.url}`;
127316
+ if (seen.has(key)) continue;
127317
+ seen.add(key);
127318
+ const meta = REMEDIATION_BY_BUCKET[bucket];
127319
+ findings.push({
127320
+ family: f.family,
127321
+ bucket,
127322
+ url: s.url,
127323
+ severity: meta.severity,
127324
+ remediation: meta.hint
127325
+ });
127326
+ }
127327
+ }
127328
+ const thirdParty = findings.filter((f) => f.bucket !== "self-hosted").length;
127329
+ const selfHosted = findings.filter((f) => f.bucket === "self-hosted").length;
127330
+ const highSeverity = findings.filter((f) => f.severity === "high").length;
127331
+ return {
127332
+ schemaVersion: "1.0",
127333
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
127334
+ host,
127335
+ sourceUrl,
127336
+ findings,
127337
+ summary: { thirdParty, selfHosted, highSeverity }
127338
+ };
127339
+ }
127340
+ function formatGdprMarkdown(report) {
127341
+ const lines = [
127342
+ `# GDPR review for ${report.host}`,
127343
+ "",
127344
+ "> Heuristic-only. Not legal advice. Built on the post-LG M\xFCnchen I 20 O 1393/21 understanding that cross-origin font loading from a third-party CDN exposes the visitor's IP address to a controller the site operator has no DPA with.",
127345
+ "",
127346
+ "## Summary",
127347
+ "",
127348
+ `- ${report.summary.thirdParty} third-party font request(s)`,
127349
+ `- ${report.summary.selfHosted} self-hosted request(s)`,
127350
+ `- ${report.summary.highSeverity} high-severity finding(s)`,
127351
+ ""
127352
+ ];
127353
+ if (report.summary.thirdParty === 0) {
127354
+ lines.push("\u2713 No third-party font requests detected. Fonts are served from the same origin.");
127355
+ return lines.join("\n");
127356
+ }
127357
+ lines.push("## Findings");
127358
+ lines.push("");
127359
+ const byBucket = /* @__PURE__ */ new Map();
127360
+ for (const f of report.findings) {
127361
+ if (f.bucket === "self-hosted") continue;
127362
+ const list = byBucket.get(f.bucket) ?? [];
127363
+ list.push(f);
127364
+ byBucket.set(f.bucket, list);
127365
+ }
127366
+ const severityIcon = {
127367
+ high: "\u{1F534}",
127368
+ medium: "\u{1F7E1}",
127369
+ low: "\u{1F7E2}"
127370
+ };
127371
+ for (const [bucket, list] of byBucket) {
127372
+ lines.push(`### ${bucketLabel(bucket)}`);
127373
+ lines.push("");
127374
+ const families = /* @__PURE__ */ new Map();
127375
+ for (const f of list) {
127376
+ const arr = families.get(f.family) ?? [];
127377
+ arr.push(f);
127378
+ families.set(f.family, arr);
127379
+ }
127380
+ for (const [family, occurrences] of families) {
127381
+ const sev = occurrences[0].severity;
127382
+ lines.push(`- ${severityIcon[sev]} **${family}** \u2014 ${occurrences[0].remediation}`);
127383
+ }
127384
+ lines.push("");
127385
+ }
127386
+ return lines.join("\n");
127387
+ }
127388
+ function bucketLabel(b) {
127389
+ switch (b) {
127390
+ case "google":
127391
+ return "Google Fonts CDN (high risk \u2014 German court precedent)";
127392
+ case "adobe-typekit":
127393
+ return "Adobe Fonts / Typekit";
127394
+ case "commercial":
127395
+ return "Commercial foundry CDN";
127396
+ case "open-cdn":
127397
+ return "Open-licence CDN";
127398
+ case "self-hosted":
127399
+ return "Self-hosted";
127400
+ }
127401
+ }
127402
+
127170
127403
  // ../core/src/license/license-data.ts
127171
127404
  var OPEN_HOSTS = [
127172
127405
  { host: "fonts.gstatic.com", label: "Google Fonts CDN" },
@@ -137897,16 +138130,16 @@ var $f43aec954cdfdf21$export$2e2bcd8739ae039 = class _$f43aec954cdfdf21$export$2
137897
138130
  * @return {Path}
137898
138131
  */
137899
138132
  mapPoints(fn) {
137900
- let path7 = new _$f43aec954cdfdf21$export$2e2bcd8739ae039();
138133
+ let path8 = new _$f43aec954cdfdf21$export$2e2bcd8739ae039();
137901
138134
  for (let c of this.commands) {
137902
138135
  let args = [];
137903
138136
  for (let i = 0; i < c.args.length; i += 2) {
137904
138137
  let [x, y] = fn(c.args[i], c.args[i + 1]);
137905
138138
  args.push(x, y);
137906
138139
  }
137907
- path7[c.command](...args);
138140
+ path8[c.command](...args);
137908
138141
  }
137909
- return path7;
138142
+ return path8;
137910
138143
  }
137911
138144
  /**
137912
138145
  * Transforms the path by the given matrix.
@@ -138599,7 +138832,7 @@ var $69aac16029968692$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138599
138832
  // Converts contours to a Path object that can be rendered
138600
138833
  _getPath() {
138601
138834
  let contours = this._getContours();
138602
- let path7 = new (0, $f43aec954cdfdf21$export$2e2bcd8739ae039)();
138835
+ let path8 = new (0, $f43aec954cdfdf21$export$2e2bcd8739ae039)();
138603
138836
  for (let i = 0; i < contours.length; i++) {
138604
138837
  let contour = contours[i];
138605
138838
  let firstPt = contour[0];
@@ -138615,26 +138848,26 @@ var $69aac16029968692$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138615
138848
  firstPt = new $69aac16029968692$export$baf26146a414f24a(false, false, (firstPt.x + lastPt.x) / 2, (firstPt.y + lastPt.y) / 2);
138616
138849
  var curvePt = firstPt;
138617
138850
  }
138618
- path7.moveTo(firstPt.x, firstPt.y);
138851
+ path8.moveTo(firstPt.x, firstPt.y);
138619
138852
  for (let j = start; j < contour.length; j++) {
138620
138853
  let pt = contour[j];
138621
138854
  let prevPt = j === 0 ? firstPt : contour[j - 1];
138622
- if (prevPt.onCurve && pt.onCurve) path7.lineTo(pt.x, pt.y);
138855
+ if (prevPt.onCurve && pt.onCurve) path8.lineTo(pt.x, pt.y);
138623
138856
  else if (prevPt.onCurve && !pt.onCurve) var curvePt = pt;
138624
138857
  else if (!prevPt.onCurve && !pt.onCurve) {
138625
138858
  let midX = (prevPt.x + pt.x) / 2;
138626
138859
  let midY = (prevPt.y + pt.y) / 2;
138627
- path7.quadraticCurveTo(prevPt.x, prevPt.y, midX, midY);
138860
+ path8.quadraticCurveTo(prevPt.x, prevPt.y, midX, midY);
138628
138861
  var curvePt = pt;
138629
138862
  } else if (!prevPt.onCurve && pt.onCurve) {
138630
- path7.quadraticCurveTo(curvePt.x, curvePt.y, pt.x, pt.y);
138863
+ path8.quadraticCurveTo(curvePt.x, curvePt.y, pt.x, pt.y);
138631
138864
  var curvePt = null;
138632
138865
  } else throw new Error("Unknown TTF path state");
138633
138866
  }
138634
- if (curvePt) path7.quadraticCurveTo(curvePt.x, curvePt.y, firstPt.x, firstPt.y);
138635
- path7.closePath();
138867
+ if (curvePt) path8.quadraticCurveTo(curvePt.x, curvePt.y, firstPt.x, firstPt.y);
138868
+ path8.closePath();
138636
138869
  }
138637
- return path7;
138870
+ return path8;
138638
138871
  }
138639
138872
  constructor(...args) {
138640
138873
  super(...args);
@@ -138657,7 +138890,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138657
138890
  let str = cff.topDict.CharStrings[this.id];
138658
138891
  let end = str.offset + str.length;
138659
138892
  stream.pos = str.offset;
138660
- let path7 = new (0, $f43aec954cdfdf21$export$2e2bcd8739ae039)();
138893
+ let path8 = new (0, $f43aec954cdfdf21$export$2e2bcd8739ae039)();
138661
138894
  let stack = [];
138662
138895
  let trans = [];
138663
138896
  let width = null;
@@ -138685,8 +138918,8 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138685
138918
  return stack.length = 0;
138686
138919
  }
138687
138920
  function moveTo(x2, y2) {
138688
- if (open) path7.closePath();
138689
- path7.moveTo(x2, y2);
138921
+ if (open) path8.closePath();
138922
+ path8.moveTo(x2, y2);
138690
138923
  open = true;
138691
138924
  }
138692
138925
  let parse = function() {
@@ -138713,7 +138946,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138713
138946
  while (stack.length >= 2) {
138714
138947
  x += stack.shift();
138715
138948
  y += stack.shift();
138716
- path7.lineTo(x, y);
138949
+ path8.lineTo(x, y);
138717
138950
  }
138718
138951
  break;
138719
138952
  case 6:
@@ -138722,7 +138955,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138722
138955
  while (stack.length >= 1) {
138723
138956
  if (phase) x += stack.shift();
138724
138957
  else y += stack.shift();
138725
- path7.lineTo(x, y);
138958
+ path8.lineTo(x, y);
138726
138959
  phase = !phase;
138727
138960
  }
138728
138961
  break;
@@ -138734,7 +138967,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138734
138967
  c2y = c1y + stack.shift();
138735
138968
  x = c2x + stack.shift();
138736
138969
  y = c2y + stack.shift();
138737
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138970
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138738
138971
  }
138739
138972
  break;
138740
138973
  case 10:
@@ -138758,7 +138991,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138758
138991
  if (cff.version >= 2) break;
138759
138992
  if (stack.length > 0) checkWidth();
138760
138993
  if (open) {
138761
- path7.closePath();
138994
+ path8.closePath();
138762
138995
  open = false;
138763
138996
  }
138764
138997
  break;
@@ -138806,17 +139039,17 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138806
139039
  c2y = c1y + stack.shift();
138807
139040
  x = c2x + stack.shift();
138808
139041
  y = c2y + stack.shift();
138809
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
139042
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138810
139043
  }
138811
139044
  x += stack.shift();
138812
139045
  y += stack.shift();
138813
- path7.lineTo(x, y);
139046
+ path8.lineTo(x, y);
138814
139047
  break;
138815
139048
  case 25:
138816
139049
  while (stack.length >= 8) {
138817
139050
  x += stack.shift();
138818
139051
  y += stack.shift();
138819
- path7.lineTo(x, y);
139052
+ path8.lineTo(x, y);
138820
139053
  }
138821
139054
  c1x = x + stack.shift();
138822
139055
  c1y = y + stack.shift();
@@ -138824,7 +139057,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138824
139057
  c2y = c1y + stack.shift();
138825
139058
  x = c2x + stack.shift();
138826
139059
  y = c2y + stack.shift();
138827
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
139060
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138828
139061
  break;
138829
139062
  case 26:
138830
139063
  if (stack.length % 2) x += stack.shift();
@@ -138835,7 +139068,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138835
139068
  c2y = c1y + stack.shift();
138836
139069
  x = c2x;
138837
139070
  y = c2y + stack.shift();
138838
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
139071
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138839
139072
  }
138840
139073
  break;
138841
139074
  case 27:
@@ -138847,7 +139080,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138847
139080
  c2y = c1y + stack.shift();
138848
139081
  x = c2x + stack.shift();
138849
139082
  y = c2y;
138850
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
139083
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138851
139084
  }
138852
139085
  break;
138853
139086
  case 28:
@@ -138886,7 +139119,7 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
138886
139119
  x = c2x + stack.shift();
138887
139120
  y = c2y + (stack.length === 1 ? stack.shift() : 0);
138888
139121
  }
138889
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
139122
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
138890
139123
  phase = !phase;
138891
139124
  }
138892
139125
  break;
@@ -139012,8 +139245,8 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
139012
139245
  c6y = c5y;
139013
139246
  x = c6x;
139014
139247
  y = c6y;
139015
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
139016
- path7.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
139248
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
139249
+ path8.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
139017
139250
  break;
139018
139251
  case 35:
139019
139252
  pts = [];
@@ -139022,8 +139255,8 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
139022
139255
  y += stack.shift();
139023
139256
  pts.push(x, y);
139024
139257
  }
139025
- path7.bezierCurveTo(...pts.slice(0, 6));
139026
- path7.bezierCurveTo(...pts.slice(6));
139258
+ path8.bezierCurveTo(...pts.slice(0, 6));
139259
+ path8.bezierCurveTo(...pts.slice(6));
139027
139260
  stack.shift();
139028
139261
  break;
139029
139262
  case 36:
@@ -139041,8 +139274,8 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
139041
139274
  c6y = c5y;
139042
139275
  x = c6x;
139043
139276
  y = c6y;
139044
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
139045
- path7.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
139277
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
139278
+ path8.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
139046
139279
  break;
139047
139280
  case 37:
139048
139281
  let startx = x;
@@ -139061,8 +139294,8 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
139061
139294
  y += stack.shift();
139062
139295
  }
139063
139296
  pts.push(x, y);
139064
- path7.bezierCurveTo(...pts.slice(0, 6));
139065
- path7.bezierCurveTo(...pts.slice(6));
139297
+ path8.bezierCurveTo(...pts.slice(0, 6));
139298
+ path8.bezierCurveTo(...pts.slice(6));
139066
139299
  break;
139067
139300
  default:
139068
139301
  throw new Error(`Unknown op: 12 ${op}`);
@@ -139082,8 +139315,8 @@ var $62cc5109c6101893$export$2e2bcd8739ae039 = class extends (0, $f92906be28e617
139082
139315
  }
139083
139316
  };
139084
139317
  parse();
139085
- if (open) path7.closePath();
139086
- return path7;
139318
+ if (open) path8.closePath();
139319
+ return path8;
139087
139320
  }
139088
139321
  constructor(...args) {
139089
139322
  super(...args);
@@ -139543,7 +139776,7 @@ var $807e58506be70005$var$Glyf = new Struct({
139543
139776
  yPoints: new ArrayT($807e58506be70005$var$Point, 0)
139544
139777
  });
139545
139778
  var $807e58506be70005$export$2e2bcd8739ae039 = class {
139546
- encodeSimple(path7, instructions = []) {
139779
+ encodeSimple(path8, instructions = []) {
139547
139780
  let endPtsOfContours = [];
139548
139781
  let xPoints = [];
139549
139782
  let yPoints = [];
@@ -139551,14 +139784,14 @@ var $807e58506be70005$export$2e2bcd8739ae039 = class {
139551
139784
  let same = 0;
139552
139785
  let lastX = 0, lastY = 0, lastFlag = 0;
139553
139786
  let pointCount = 0;
139554
- for (let i = 0; i < path7.commands.length; i++) {
139555
- let c = path7.commands[i];
139787
+ for (let i = 0; i < path8.commands.length; i++) {
139788
+ let c = path8.commands[i];
139556
139789
  for (let j = 0; j < c.args.length; j += 2) {
139557
139790
  let x = c.args[j];
139558
139791
  let y = c.args[j + 1];
139559
139792
  let flag = 0;
139560
139793
  if (c.command === "quadraticCurveTo" && j === 2) {
139561
- let next = path7.commands[i + 1];
139794
+ let next = path8.commands[i + 1];
139562
139795
  if (next && next.command === "quadraticCurveTo") {
139563
139796
  let midX = (lastX + next.args[0]) / 2;
139564
139797
  let midY = (lastY + next.args[1]) / 2;
@@ -139585,8 +139818,8 @@ var $807e58506be70005$export$2e2bcd8739ae039 = class {
139585
139818
  }
139586
139819
  if (c.command === "closePath") endPtsOfContours.push(pointCount - 1);
139587
139820
  }
139588
- if (path7.commands.length > 1 && path7.commands[path7.commands.length - 1].command !== "closePath") endPtsOfContours.push(pointCount - 1);
139589
- let bbox = path7.bbox;
139821
+ if (path8.commands.length > 1 && path8.commands[path8.commands.length - 1].command !== "closePath") endPtsOfContours.push(pointCount - 1);
139822
+ let bbox = path8.bbox;
139590
139823
  let glyf = {
139591
139824
  numberOfContours: endPtsOfContours.length,
139592
139825
  xMin: bbox.minX,
@@ -139693,7 +139926,7 @@ var $001d739428a71d5a$export$2e2bcd8739ae039 = class extends (0, $5cc7476da92df3
139693
139926
  for (let gid of this.glyphs) {
139694
139927
  this.charstrings.push(this.cff.getCharString(gid));
139695
139928
  let glyph = this.font.getGlyph(gid);
139696
- let path7 = glyph.path;
139929
+ let path8 = glyph.path;
139697
139930
  for (let subr in glyph._usedGsubrs) gsubrs[subr] = true;
139698
139931
  }
139699
139932
  this.gsubrs = this.subsetSubrs(this.cff.globalSubrIndex, gsubrs);
@@ -139731,7 +139964,7 @@ var $001d739428a71d5a$export$2e2bcd8739ae039 = class extends (0, $5cc7476da92df3
139731
139964
  used_fds[fd] = true;
139732
139965
  topDict.FDSelect.fds.push(fd_select[fd]);
139733
139966
  let glyph = this.font.getGlyph(gid);
139734
- let path7 = glyph.path;
139967
+ let path8 = glyph.path;
139735
139968
  for (let subr in glyph._usedSubrs) used_subrs[fd_select[fd]][subr] = true;
139736
139969
  }
139737
139970
  for (let i = 0; i < topDict.FDArray.length; i++) {
@@ -139748,7 +139981,7 @@ var $001d739428a71d5a$export$2e2bcd8739ae039 = class extends (0, $5cc7476da92df3
139748
139981
  let used_subrs = {};
139749
139982
  for (let gid of this.glyphs) {
139750
139983
  let glyph = this.font.getGlyph(gid);
139751
- let path7 = glyph.path;
139984
+ let path8 = glyph.path;
139752
139985
  for (let subr in glyph._usedSubrs) used_subrs[subr] = true;
139753
139986
  }
139754
139987
  let privateDict = Object.assign({}, this.cff.topDict.Private);
@@ -141165,8 +141398,71 @@ var viteEmitter = (faces, ctx) => {
141165
141398
  return { filename: "vite.fonts.md", content: lines.join("\n") };
141166
141399
  };
141167
141400
 
141401
+ // ../core/src/emit/emitters/tokens.ts
141402
+ var tokensEmitter = (faces) => {
141403
+ const byFamily = groupByFamily(faces);
141404
+ if (byFamily.size === 0) return null;
141405
+ const familyTokens = {};
141406
+ const weightTokens = {};
141407
+ const seenWeights = /* @__PURE__ */ new Set();
141408
+ for (const [family, list] of byFamily) {
141409
+ const key = familyToKebab(family);
141410
+ familyTokens[key] = {
141411
+ $type: "fontFamily",
141412
+ $value: [family, `${family} Fallback`, "system-ui", "sans-serif"]
141413
+ };
141414
+ for (const f of list) {
141415
+ const w = parseInt(f.weight, 10);
141416
+ if (Number.isFinite(w) && !seenWeights.has(w)) {
141417
+ seenWeights.add(w);
141418
+ weightTokens[weightLabel(w)] = { $type: "fontWeight", $value: w };
141419
+ }
141420
+ }
141421
+ }
141422
+ const sizeTokens = {
141423
+ xs: { $type: "dimension", $value: "0.75rem" },
141424
+ sm: { $type: "dimension", $value: "0.875rem" },
141425
+ base: { $type: "dimension", $value: "1rem" },
141426
+ lg: { $type: "dimension", $value: "1.125rem" },
141427
+ xl: { $type: "dimension", $value: "1.25rem" },
141428
+ "2xl": { $type: "dimension", $value: "1.5rem" },
141429
+ "3xl": { $type: "dimension", $value: "1.875rem" },
141430
+ "4xl": { $type: "dimension", $value: "2.25rem" }
141431
+ };
141432
+ const lineHeightTokens = {
141433
+ tight: { $type: "number", $value: 1.2 },
141434
+ snug: { $type: "number", $value: 1.3 },
141435
+ normal: { $type: "number", $value: 1.5 },
141436
+ relaxed: { $type: "number", $value: 1.7 },
141437
+ loose: { $type: "number", $value: 2 }
141438
+ };
141439
+ const out = {
141440
+ $schema: "https://design-tokens.github.io/community-group/format/tokens.schema.json",
141441
+ $description: "Generated by fontfetch \u2014 W3C DTCG-compatible design tokens.",
141442
+ font: {
141443
+ family: familyTokens,
141444
+ weight: weightTokens,
141445
+ size: sizeTokens,
141446
+ lineHeight: lineHeightTokens
141447
+ }
141448
+ };
141449
+ return { filename: "fonts.tokens.json", content: `${JSON.stringify(out, null, 2)}
141450
+ ` };
141451
+ };
141452
+ function weightLabel(weight) {
141453
+ if (weight <= 100) return "thin";
141454
+ if (weight <= 200) return "extralight";
141455
+ if (weight <= 300) return "light";
141456
+ if (weight <= 400) return "regular";
141457
+ if (weight <= 500) return "medium";
141458
+ if (weight <= 600) return "semibold";
141459
+ if (weight <= 700) return "bold";
141460
+ if (weight <= 800) return "extrabold";
141461
+ return "black";
141462
+ }
141463
+
141168
141464
  // ../core/src/emit/emitters/types.ts
141169
- var EMIT_TARGETS = ["css", "next", "tailwind", "vite"];
141465
+ var EMIT_TARGETS = ["css", "next", "tailwind", "vite", "tokens"];
141170
141466
  function isEmitTarget(s) {
141171
141467
  return EMIT_TARGETS.includes(s);
141172
141468
  }
@@ -141175,57 +141471,10 @@ function isEmitTarget(s) {
141175
141471
  var EMITTERS = {
141176
141472
  next: nextEmitter,
141177
141473
  tailwind: tailwindEmitter,
141178
- vite: viteEmitter
141474
+ vite: viteEmitter,
141475
+ tokens: tokensEmitter
141179
141476
  };
141180
141477
 
141181
- // ../core/src/license/provenance.ts
141182
- var RULES = [
141183
- {
141184
- bucket: "google",
141185
- patterns: ["fonts.gstatic.com", "fonts.googleapis.com", "cdn.jsdelivr.net/gh/google/fonts"]
141186
- },
141187
- {
141188
- bucket: "adobe-typekit",
141189
- patterns: ["use.typekit.net", "p.typekit.net", "fonts.adobe.com"]
141190
- },
141191
- {
141192
- bucket: "commercial",
141193
- patterns: [
141194
- "fast.fonts.net",
141195
- "cloud.typography.com",
141196
- "cloud.typenetwork.com",
141197
- "use.fontawesome.com",
141198
- "fontstand.com"
141199
- ]
141200
- },
141201
- {
141202
- bucket: "open-cdn",
141203
- patterns: ["cdn.jsdelivr.net/npm/@fontsource/", "rsms.me"]
141204
- }
141205
- ];
141206
- function strip(host) {
141207
- return host.replace(/^www\./i, "").toLowerCase();
141208
- }
141209
- function sameOrigin(urlHost, pageHost) {
141210
- const a = strip(urlHost);
141211
- const b = strip(pageHost);
141212
- if (a === b) return true;
141213
- return a.endsWith("." + b) || b.endsWith("." + a);
141214
- }
141215
- function bucketForUrl(url, pageHost) {
141216
- for (const rule of RULES) {
141217
- for (const pattern of rule.patterns) {
141218
- if (url.includes(pattern)) return rule.bucket;
141219
- }
141220
- }
141221
- try {
141222
- const u = new URL(url);
141223
- if (sameOrigin(u.hostname, pageHost)) return "self-hosted";
141224
- } catch {
141225
- }
141226
- return "self-hosted";
141227
- }
141228
-
141229
141478
  // ../core/src/inspect/fallback.ts
141230
141479
  import fs3 from "fs/promises";
141231
141480
  import path4 from "path";
@@ -145341,16 +145590,16 @@ var Path = class Path2 {
145341
145590
  * Applies a mapping function to each point in the path.
145342
145591
  */
145343
145592
  mapPoints(fn) {
145344
- const path7 = new Path2();
145593
+ const path8 = new Path2();
145345
145594
  for (const c of this.commands) {
145346
145595
  const args = [];
145347
145596
  for (let i = 0; i < c.args.length; i += 2) {
145348
145597
  const [x, y] = fn(c.args[i], c.args[i + 1]);
145349
145598
  args.push(x, y);
145350
145599
  }
145351
- path7[c.command](...args);
145600
+ path8[c.command](...args);
145352
145601
  }
145353
- return path7;
145602
+ return path8;
145354
145603
  }
145355
145604
  /**
145356
145605
  * Transforms the path by the given matrix.
@@ -145992,7 +146241,7 @@ var TTFGlyph = class extends Glyph {
145992
146241
  }
145993
146242
  _getPath() {
145994
146243
  const contours = this._getContours();
145995
- const path7 = new Path();
146244
+ const path8 = new Path();
145996
146245
  let curvePt;
145997
146246
  for (const contour of contours) {
145998
146247
  let firstPt = contour[0];
@@ -146006,26 +146255,26 @@ var TTFGlyph = class extends Glyph {
146006
146255
  else firstPt = new Point(false, false, (firstPt.x + lastPt.x) / 2, (firstPt.y + lastPt.y) / 2);
146007
146256
  curvePt = firstPt;
146008
146257
  }
146009
- path7.moveTo(firstPt.x, firstPt.y);
146258
+ path8.moveTo(firstPt.x, firstPt.y);
146010
146259
  for (let j = start; j < contour.length; j++) {
146011
146260
  const pt = contour[j];
146012
146261
  const prevPt = j === 0 ? firstPt : contour[j - 1];
146013
- if (prevPt.onCurve && pt.onCurve) path7.lineTo(pt.x, pt.y);
146262
+ if (prevPt.onCurve && pt.onCurve) path8.lineTo(pt.x, pt.y);
146014
146263
  else if (prevPt.onCurve && !pt.onCurve) curvePt = pt;
146015
146264
  else if (!prevPt.onCurve && !pt.onCurve) {
146016
146265
  const midX = (prevPt.x + pt.x) / 2;
146017
146266
  const midY = (prevPt.y + pt.y) / 2;
146018
- path7.quadraticCurveTo(prevPt.x, prevPt.y, midX, midY);
146267
+ path8.quadraticCurveTo(prevPt.x, prevPt.y, midX, midY);
146019
146268
  curvePt = pt;
146020
146269
  } else if (!prevPt.onCurve && pt.onCurve) {
146021
- path7.quadraticCurveTo(curvePt.x, curvePt.y, pt.x, pt.y);
146270
+ path8.quadraticCurveTo(curvePt.x, curvePt.y, pt.x, pt.y);
146022
146271
  curvePt = null;
146023
146272
  } else throw new Error("Unknown TTF path state");
146024
146273
  }
146025
- if (curvePt) path7.quadraticCurveTo(curvePt.x, curvePt.y, firstPt.x, firstPt.y);
146026
- path7.closePath();
146274
+ if (curvePt) path8.quadraticCurveTo(curvePt.x, curvePt.y, firstPt.x, firstPt.y);
146275
+ path8.closePath();
146027
146276
  }
146028
- return path7;
146277
+ return path8;
146029
146278
  }
146030
146279
  };
146031
146280
  var CFFGlyph = class extends Glyph {
@@ -146043,7 +146292,7 @@ var CFFGlyph = class extends Glyph {
146043
146292
  const str = cff.topDict.CharStrings[this.id];
146044
146293
  let end = str.offset + str.length;
146045
146294
  stream.pos = str.offset;
146046
- const path7 = new Path();
146295
+ const path8 = new Path();
146047
146296
  const stack = [];
146048
146297
  const trans = [];
146049
146298
  let width = null;
@@ -146071,8 +146320,8 @@ var CFFGlyph = class extends Glyph {
146071
146320
  return stack.length = 0;
146072
146321
  };
146073
146322
  const moveTo = (x2, y2) => {
146074
- if (open) path7.closePath();
146075
- path7.moveTo(x2, y2);
146323
+ if (open) path8.closePath();
146324
+ path8.moveTo(x2, y2);
146076
146325
  open = true;
146077
146326
  };
146078
146327
  const parse = () => {
@@ -146099,7 +146348,7 @@ var CFFGlyph = class extends Glyph {
146099
146348
  while (stack.length >= 2) {
146100
146349
  x += stack.shift();
146101
146350
  y += stack.shift();
146102
- path7.lineTo(x, y);
146351
+ path8.lineTo(x, y);
146103
146352
  }
146104
146353
  break;
146105
146354
  case 6:
@@ -146108,7 +146357,7 @@ var CFFGlyph = class extends Glyph {
146108
146357
  while (stack.length >= 1) {
146109
146358
  if (phase) x += stack.shift();
146110
146359
  else y += stack.shift();
146111
- path7.lineTo(x, y);
146360
+ path8.lineTo(x, y);
146112
146361
  phase = !phase;
146113
146362
  }
146114
146363
  break;
@@ -146120,7 +146369,7 @@ var CFFGlyph = class extends Glyph {
146120
146369
  c2y = c1y + stack.shift();
146121
146370
  x = c2x + stack.shift();
146122
146371
  y = c2y + stack.shift();
146123
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146372
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146124
146373
  }
146125
146374
  break;
146126
146375
  case 10:
@@ -146144,7 +146393,7 @@ var CFFGlyph = class extends Glyph {
146144
146393
  if (cff.version >= 2) break;
146145
146394
  if (stack.length > 0) checkWidth();
146146
146395
  if (open) {
146147
- path7.closePath();
146396
+ path8.closePath();
146148
146397
  open = false;
146149
146398
  }
146150
146399
  break;
@@ -146192,17 +146441,17 @@ var CFFGlyph = class extends Glyph {
146192
146441
  c2y = c1y + stack.shift();
146193
146442
  x = c2x + stack.shift();
146194
146443
  y = c2y + stack.shift();
146195
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146444
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146196
146445
  }
146197
146446
  x += stack.shift();
146198
146447
  y += stack.shift();
146199
- path7.lineTo(x, y);
146448
+ path8.lineTo(x, y);
146200
146449
  break;
146201
146450
  case 25:
146202
146451
  while (stack.length >= 8) {
146203
146452
  x += stack.shift();
146204
146453
  y += stack.shift();
146205
- path7.lineTo(x, y);
146454
+ path8.lineTo(x, y);
146206
146455
  }
146207
146456
  c1x = x + stack.shift();
146208
146457
  c1y = y + stack.shift();
@@ -146210,7 +146459,7 @@ var CFFGlyph = class extends Glyph {
146210
146459
  c2y = c1y + stack.shift();
146211
146460
  x = c2x + stack.shift();
146212
146461
  y = c2y + stack.shift();
146213
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146462
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146214
146463
  break;
146215
146464
  case 26:
146216
146465
  if (stack.length % 2) x += stack.shift();
@@ -146221,7 +146470,7 @@ var CFFGlyph = class extends Glyph {
146221
146470
  c2y = c1y + stack.shift();
146222
146471
  x = c2x;
146223
146472
  y = c2y + stack.shift();
146224
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146473
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146225
146474
  }
146226
146475
  break;
146227
146476
  case 27:
@@ -146233,7 +146482,7 @@ var CFFGlyph = class extends Glyph {
146233
146482
  c2y = c1y + stack.shift();
146234
146483
  x = c2x + stack.shift();
146235
146484
  y = c2y;
146236
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146485
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146237
146486
  }
146238
146487
  break;
146239
146488
  case 28:
@@ -146272,7 +146521,7 @@ var CFFGlyph = class extends Glyph {
146272
146521
  x = c2x + stack.shift();
146273
146522
  y = c2y + (stack.length === 1 ? stack.shift() : 0);
146274
146523
  }
146275
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146524
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
146276
146525
  phase = !phase;
146277
146526
  }
146278
146527
  break;
@@ -146398,8 +146647,8 @@ var CFFGlyph = class extends Glyph {
146398
146647
  c6y = c5y;
146399
146648
  x = c6x;
146400
146649
  y = c6y;
146401
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
146402
- path7.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
146650
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
146651
+ path8.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
146403
146652
  break;
146404
146653
  case 35:
146405
146654
  pts = [];
@@ -146408,8 +146657,8 @@ var CFFGlyph = class extends Glyph {
146408
146657
  y += stack.shift();
146409
146658
  pts.push(x, y);
146410
146659
  }
146411
- path7.bezierCurveTo(...pts.slice(0, 6));
146412
- path7.bezierCurveTo(...pts.slice(6));
146660
+ path8.bezierCurveTo(...pts.slice(0, 6));
146661
+ path8.bezierCurveTo(...pts.slice(6));
146413
146662
  stack.shift();
146414
146663
  break;
146415
146664
  case 36:
@@ -146427,8 +146676,8 @@ var CFFGlyph = class extends Glyph {
146427
146676
  c6y = c5y;
146428
146677
  x = c6x;
146429
146678
  y = c6y;
146430
- path7.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
146431
- path7.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
146679
+ path8.bezierCurveTo(c1x, c1y, c2x, c2y, c3x, c3y);
146680
+ path8.bezierCurveTo(c4x, c4y, c5x, c5y, c6x, c6y);
146432
146681
  break;
146433
146682
  case 37:
146434
146683
  const startx = x;
@@ -146447,8 +146696,8 @@ var CFFGlyph = class extends Glyph {
146447
146696
  y += stack.shift();
146448
146697
  }
146449
146698
  pts.push(x, y);
146450
- path7.bezierCurveTo(...pts.slice(0, 6));
146451
- path7.bezierCurveTo(...pts.slice(6));
146699
+ path8.bezierCurveTo(...pts.slice(0, 6));
146700
+ path8.bezierCurveTo(...pts.slice(6));
146452
146701
  break;
146453
146702
  default:
146454
146703
  throw new Error(`Unknown op: 12 ${op}`);
@@ -146468,8 +146717,8 @@ var CFFGlyph = class extends Glyph {
146468
146717
  }
146469
146718
  };
146470
146719
  parse();
146471
- if (open) path7.closePath();
146472
- return path7;
146720
+ if (open) path8.closePath();
146721
+ return path8;
146473
146722
  }
146474
146723
  };
146475
146724
  var SBIXImage = new Struct2({
@@ -149582,57 +149831,57 @@ async function computeFallback(filePath, options = {}) {
149582
149831
  };
149583
149832
  }
149584
149833
  function formatFallbackCss(metrics) {
149834
+ const variantTag = metrics.fontWeight || metrics.fontStyle ? ` (${metrics.fontWeight ?? "400"}${metrics.fontStyle && metrics.fontStyle !== "normal" ? " " + metrics.fontStyle : ""})` : "";
149585
149835
  const lines = [
149586
- `/* CLS-killing fallback for ${metrics.familyName} \u2014 generated by fontfetch */`,
149836
+ `/* CLS-killing fallback for ${metrics.familyName}${variantTag} \u2014 generated by fontfetch */`,
149587
149837
  `@font-face {`,
149588
- ` font-family: '${metrics.fallbackFamily}';`,
149838
+ ` font-family: '${metrics.fallbackFamily}';`
149839
+ ];
149840
+ if (metrics.fontWeight) lines.push(` font-weight: ${metrics.fontWeight};`);
149841
+ if (metrics.fontStyle) lines.push(` font-style: ${metrics.fontStyle};`);
149842
+ lines.push(
149589
149843
  ` src: local('${SYSTEM_FALLBACKS[metrics.generic].familyName}');`,
149590
149844
  ` size-adjust: ${metrics.sizeAdjust};`,
149591
149845
  ` ascent-override: ${metrics.ascentOverride};`,
149592
149846
  ` descent-override: ${metrics.descentOverride};`,
149593
149847
  ` line-gap-override: ${metrics.lineGapOverride};`,
149594
149848
  `}`
149595
- ];
149849
+ );
149596
149850
  return lines.join("\n");
149597
149851
  }
149598
- async function buildFallbacksForDir(filesDir) {
149599
- let entries;
149600
- try {
149601
- entries = await collectFontFiles2(filesDir);
149602
- } catch (e) {
149603
- return { css: "", count: 0, errors: [{ file: filesDir, reason: e.message }] };
149604
- }
149605
- const seen = /* @__PURE__ */ new Set();
149852
+ async function buildPerFaceFallbacks(filesDir, faces) {
149606
149853
  const blocks = [];
149607
149854
  const errors = [];
149608
- for (const file of entries) {
149855
+ const seen = /* @__PURE__ */ new Set();
149856
+ const metricsByFile = /* @__PURE__ */ new Map();
149857
+ for (const face of faces) {
149858
+ const primary = face.sources.find((s) => s.localFile);
149859
+ if (!primary?.localFile) continue;
149860
+ const key = `${face.family}|${face.weight}|${face.style}`;
149861
+ if (seen.has(key)) continue;
149862
+ seen.add(key);
149863
+ const abs2 = path4.join(filesDir, primary.localFile);
149609
149864
  try {
149610
- const m = await computeFallback(file);
149611
- if (seen.has(m.familyName)) continue;
149612
- seen.add(m.familyName);
149613
- blocks.push(formatFallbackCss(m));
149865
+ let base = metricsByFile.get(abs2);
149866
+ if (!base) {
149867
+ base = await computeFallback(abs2);
149868
+ metricsByFile.set(abs2, base);
149869
+ }
149870
+ blocks.push(
149871
+ formatFallbackCss({
149872
+ ...base,
149873
+ familyName: face.family,
149874
+ fallbackFamily: `${face.family} Fallback`,
149875
+ fontWeight: face.weight || "400",
149876
+ fontStyle: face.style || "normal"
149877
+ })
149878
+ );
149614
149879
  } catch (e) {
149615
- errors.push({ file, reason: e.message });
149880
+ errors.push({ file: primary.localFile, reason: e.message });
149616
149881
  }
149617
149882
  }
149618
149883
  return { css: blocks.join("\n\n"), count: blocks.length, errors };
149619
149884
  }
149620
- async function collectFontFiles2(root) {
149621
- const out = [];
149622
- async function walk(dir) {
149623
- const items = await fs3.readdir(dir, { withFileTypes: true });
149624
- for (const it of items) {
149625
- const p = path4.join(dir, it.name);
149626
- if (it.isDirectory()) {
149627
- await walk(p);
149628
- } else if (/\.(woff2|woff|ttf|otf)$/i.test(it.name)) {
149629
- out.push(p);
149630
- }
149631
- }
149632
- }
149633
- await walk(root);
149634
- return out;
149635
- }
149636
149885
 
149637
149886
  // ../core/src/parse/crawl.ts
149638
149887
  var CRAWL_PAGE_CAP = 50;
@@ -149679,6 +149928,88 @@ function stripHash(u) {
149679
149928
  return clone.toString();
149680
149929
  }
149681
149930
 
149931
+ // ../core/src/parse/consistency.ts
149932
+ function computeConsistency(perPage) {
149933
+ if (perPage.length === 0) {
149934
+ return { shared: [], perPage: [], divergent: [] };
149935
+ }
149936
+ const sortedPerPage = perPage.map((p) => ({ url: p.url, families: [...p.families].sort() }));
149937
+ const allFamilySets = sortedPerPage.map((p) => new Set(p.families));
149938
+ const shared = [...allFamilySets[0]].filter(
149939
+ (fam) => allFamilySets.every((s) => s.has(fam))
149940
+ );
149941
+ shared.sort();
149942
+ const entry = allFamilySets[0];
149943
+ const divergent = [];
149944
+ for (let i = 1; i < sortedPerPage.length; i++) {
149945
+ const here = allFamilySets[i];
149946
+ const onlyHere = [...here].filter((f) => !entry.has(f)).sort();
149947
+ const missingHere = [...entry].filter((f) => !here.has(f)).sort();
149948
+ if (onlyHere.length > 0 || missingHere.length > 0) {
149949
+ divergent.push({ url: sortedPerPage[i].url, onlyHere, missingHere });
149950
+ }
149951
+ }
149952
+ return { shared, perPage: sortedPerPage, divergent };
149953
+ }
149954
+ function buildPageFaceMap(pageUrls, sourceToPage, facesPerSource) {
149955
+ const pageFamilies = /* @__PURE__ */ new Map();
149956
+ for (const url of pageUrls) pageFamilies.set(url, /* @__PURE__ */ new Set());
149957
+ for (let i = 0; i < facesPerSource.length; i++) {
149958
+ const pageIdx = sourceToPage[i];
149959
+ const pageUrl = pageUrls[pageIdx];
149960
+ if (!pageUrl) continue;
149961
+ const set = pageFamilies.get(pageUrl);
149962
+ for (const f of facesPerSource[i]) set.add(f.family);
149963
+ }
149964
+ return pageUrls.map((url) => ({ url, families: [...pageFamilies.get(url) ?? []] }));
149965
+ }
149966
+ function buildConsistencyReport(report, host) {
149967
+ const lines = [
149968
+ `# Cross-page font consistency for ${host}`,
149969
+ "",
149970
+ `Crawled ${report.perPage.length} page(s). ${report.shared.length} family/families used everywhere; ${report.divergent.length} page(s) diverge from the entry page.`,
149971
+ ""
149972
+ ];
149973
+ lines.push("## Shared across every page");
149974
+ lines.push("");
149975
+ if (report.shared.length === 0) {
149976
+ lines.push("_No families appeared on every page._");
149977
+ } else {
149978
+ for (const fam of report.shared) lines.push(`- ${fam}`);
149979
+ }
149980
+ lines.push("");
149981
+ lines.push("## Per-page families");
149982
+ lines.push("");
149983
+ for (const page of report.perPage) {
149984
+ lines.push(`### ${page.url}`);
149985
+ lines.push("");
149986
+ if (page.families.length === 0) {
149987
+ lines.push("_No @font-face declarations found on this page._");
149988
+ } else {
149989
+ for (const fam of page.families) lines.push(`- ${fam}`);
149990
+ }
149991
+ lines.push("");
149992
+ }
149993
+ if (report.divergent.length > 0) {
149994
+ lines.push("## Divergence from the entry page");
149995
+ lines.push("");
149996
+ lines.push("_The entry page (first URL crawled) is the baseline. Each row below is a page whose typography differs from the entry._");
149997
+ lines.push("");
149998
+ for (const d of report.divergent) {
149999
+ lines.push(`### ${d.url}`);
150000
+ lines.push("");
150001
+ if (d.onlyHere.length > 0) {
150002
+ lines.push(`- **Only here**: ${d.onlyHere.join(", ")}`);
150003
+ }
150004
+ if (d.missingHere.length > 0) {
150005
+ lines.push(`- **Missing here** (but on entry): ${d.missingHere.join(", ")}`);
150006
+ }
150007
+ lines.push("");
150008
+ }
150009
+ }
150010
+ return lines.join("\n");
150011
+ }
150012
+
149682
150013
  // ../core/src/platforms/nextjs.ts
149683
150014
  var NEXT_SUBSET_RE = /^(.+\/_next\/static\/media\/[^/?#]+-s\.)([a-z0-9]+)(\.(?:woff2|woff|ttf|otf))(\?[^#]*)?(#.*)?$/i;
149684
150015
  var SIBLING_LETTERS = "abcdefghijklmnopqrstuvwxyz".split("");
@@ -149716,6 +150047,63 @@ async function probeNextjsSiblings(url, headers = {}) {
149716
150047
  return found.sort();
149717
150048
  }
149718
150049
 
150050
+ // ../core/src/inspect/collapse.ts
150051
+ import path5 from "path";
150052
+ function detectCollapseOpportunities(variableFonts, faces, fileSizes) {
150053
+ const out = [];
150054
+ for (const vf of variableFonts) {
150055
+ const hasWeightAxis = vf.axes.some((a) => a.tag === "wght" && a.min !== a.max);
150056
+ if (!hasWeightAxis) continue;
150057
+ const variableFileName = path5.basename(vf.filePath);
150058
+ const staticFiles = /* @__PURE__ */ new Map();
150059
+ for (const f of faces) {
150060
+ if (f.family !== vf.family) continue;
150061
+ for (const s of f.sources) {
150062
+ if (!s.localFile) continue;
150063
+ const baseName = path5.basename(s.localFile);
150064
+ if (baseName === variableFileName) continue;
150065
+ if (/\.subset\./i.test(baseName)) continue;
150066
+ staticFiles.set(s.localFile, { weight: f.weight, style: f.style });
150067
+ }
150068
+ }
150069
+ if (staticFiles.size < 2) continue;
150070
+ const variableLocalKey = findLocalKeyForFile(faces, variableFileName);
150071
+ const variableBytes = variableLocalKey ? fileSizes.get(variableLocalKey) ?? 0 : 0;
150072
+ const staticEntries = [];
150073
+ let staticBytes = 0;
150074
+ for (const [file, meta] of staticFiles) {
150075
+ const bytes = fileSizes.get(file) ?? 0;
150076
+ staticBytes += bytes;
150077
+ staticEntries.push({ file, bytes, weight: meta.weight, style: meta.style });
150078
+ }
150079
+ const savedBytes = staticBytes - variableBytes;
150080
+ if (savedBytes <= 0) continue;
150081
+ out.push({
150082
+ family: vf.family,
150083
+ variableFile: vf.filePath,
150084
+ variableBytes,
150085
+ staticFiles: staticEntries,
150086
+ staticBytes,
150087
+ savedBytes
150088
+ });
150089
+ }
150090
+ return out;
150091
+ }
150092
+ function findLocalKeyForFile(faces, baseName) {
150093
+ for (const f of faces) {
150094
+ for (const s of f.sources) {
150095
+ if (s.localFile && path5.basename(s.localFile) === baseName) {
150096
+ return s.localFile;
150097
+ }
150098
+ }
150099
+ }
150100
+ return void 0;
150101
+ }
150102
+ function formatCollapseHint(op) {
150103
+ const savedKb = (op.savedBytes / 1024).toFixed(1);
150104
+ return ` \u2139 ${op.family}: drop ${op.staticFiles.length} static weight file(s) \u2014 variable font already covers them. Saves ~${savedKb} KB.`;
150105
+ }
150106
+
149719
150107
  // ../core/src/formats/formats.ts
149720
150108
  var FONT_FORMATS = ["woff2", "woff", "ttf", "otf", "eot"];
149721
150109
  function isFontFormat(s) {
@@ -149764,11 +150152,12 @@ async function pull({
149764
150152
  onProgress,
149765
150153
  fallback = false,
149766
150154
  pages = 1,
149767
- formats: formats2
150155
+ formats: formats2,
150156
+ gdprReport = false
149768
150157
  }) {
149769
150158
  const host = siteSlug(url);
149770
- const outDir = path5.join(path5.resolve(baseDir), host);
149771
- const filesDir = path5.join(outDir, "files");
150159
+ const outDir = path6.join(path6.resolve(baseDir), host);
150160
+ const filesDir = path6.join(outDir, "files");
149772
150161
  await fs4.mkdir(filesDir, { recursive: true });
149773
150162
  const cappedPages = Math.max(1, Math.min(Math.floor(pages), CRAWL_PAGE_CAP));
149774
150163
  onProgress?.({ type: "phase", phase: "fetch_html" });
@@ -149796,6 +150185,7 @@ async function pull({
149796
150185
  }
149797
150186
  onProgress?.({ type: "phase", phase: "parse_css" });
149798
150187
  const cssSources = [];
150188
+ const sourceToPage = [];
149799
150189
  const cssLinkSet = /* @__PURE__ */ new Set();
149800
150190
  const perPage = [];
149801
150191
  let totalInline = 0;
@@ -149812,12 +150202,17 @@ async function pull({
149812
150202
  perPage.push({ page, uniqueLinks, inline });
149813
150203
  }
149814
150204
  log.info(` ${cssLinkSet.size} external stylesheet(s), ${totalInline} inline <style> block(s)`);
149815
- for (const { page, uniqueLinks, inline } of perPage) {
149816
- for (const text of inline) cssSources.push({ text, base: page.url });
150205
+ for (let pageIdx = 0; pageIdx < perPage.length; pageIdx++) {
150206
+ const { page, uniqueLinks, inline } = perPage[pageIdx];
150207
+ for (const text of inline) {
150208
+ cssSources.push({ text, base: page.url });
150209
+ sourceToPage.push(pageIdx);
150210
+ }
149817
150211
  for (const link of uniqueLinks) {
149818
150212
  try {
149819
150213
  log.info(`\u2192 Fetching CSS: ${link}`);
149820
150214
  cssSources.push({ text: await fetchText(link, { Referer: page.url }), base: link });
150215
+ sourceToPage.push(pageIdx);
149821
150216
  onProgress?.({ type: "css_fetched", url: link });
149822
150217
  } catch (e) {
149823
150218
  const reason = e.message;
@@ -149831,7 +150226,10 @@ async function pull({
149831
150226
  log.info("\u2192 Running headless mode (Playwright)...");
149832
150227
  try {
149833
150228
  const result = await fetchHeadless(url);
149834
- cssSources.push(...result.cssSources);
150229
+ for (const src of result.cssSources) {
150230
+ cssSources.push(src);
150231
+ sourceToPage.push(0);
150232
+ }
149835
150233
  networkFontUrls = result.networkFontUrls;
149836
150234
  log.info(` + ${result.cssSources.length} stylesheet block(s) from headless`);
149837
150235
  if (networkFontUrls.length > 0) {
@@ -149842,7 +150240,8 @@ async function pull({
149842
150240
  log.warn(" Continuing with static results.");
149843
150241
  }
149844
150242
  }
149845
- const allFaces = cssSources.flatMap(({ text, base }) => extractFontFaces(text, base));
150243
+ const facesPerSource = cssSources.map(({ text, base }) => extractFontFaces(text, base));
150244
+ const allFaces = facesPerSource.flat();
149846
150245
  const seen = /* @__PURE__ */ new Set();
149847
150246
  const dedupedFaces = allFaces.filter((f) => {
149848
150247
  const sig = `${f.family}|${f.weight}|${f.style}|${f.sources.map((s) => s.url).sort().join(",")}`;
@@ -149974,13 +150373,13 @@ async function pull({
149974
150373
  });
149975
150374
  if (licenseSummary.allCommercial && !force) {
149976
150375
  await fs4.writeFile(
149977
- path5.join(outDir, "LICENSE_REVIEW.md"),
150376
+ path6.join(outDir, "LICENSE_REVIEW.md"),
149978
150377
  buildLicenseReview(host, classified, licenseSummary)
149979
150378
  );
149980
150379
  log.warn("");
149981
150380
  log.warn(`\u2717 All ${licenseSummary.commercial} detected font(s) are served from known commercial CDNs.`);
149982
150381
  log.warn(" Downloading and shipping these without a license violates foundry EULAs.");
149983
- log.warn(` Wrote ${path5.join(outDir, "LICENSE_REVIEW.md")} with the breakdown.`);
150382
+ log.warn(` Wrote ${path6.join(outDir, "LICENSE_REVIEW.md")} with the breakdown.`);
149984
150383
  log.warn(" To download anyway (e.g. for a local mockup you have rights to), re-run with --force.");
149985
150384
  log.warn("");
149986
150385
  onProgress?.({ type: "aborted_all_commercial", count: licenseSummary.commercial });
@@ -150001,10 +150400,11 @@ async function pull({
150001
150400
  let downloaded = 0;
150002
150401
  let index = 0;
150003
150402
  const createdBuckets = /* @__PURE__ */ new Set();
150403
+ const fileSizes = /* @__PURE__ */ new Map();
150004
150404
  for (const [fontUrl, name] of urlToLocal) {
150005
150405
  index++;
150006
- const dest = path5.join(filesDir, name);
150007
- const bucketDir = path5.dirname(dest);
150406
+ const dest = path6.join(filesDir, name);
150407
+ const bucketDir = path6.dirname(dest);
150008
150408
  if (!createdBuckets.has(bucketDir)) {
150009
150409
  await fs4.mkdir(bucketDir, { recursive: true });
150010
150410
  createdBuckets.add(bucketDir);
@@ -150012,6 +150412,7 @@ async function pull({
150012
150412
  try {
150013
150413
  const buf = await fetchBuffer(fontUrl, { Referer: url });
150014
150414
  await fs4.writeFile(dest, buf);
150415
+ fileSizes.set(name, buf.length);
150015
150416
  log.info(` \u2713 ${name} (${buf.length.toLocaleString()} bytes)`);
150016
150417
  const bucket = name.includes("/") ? name.split("/")[0] : "self-hosted";
150017
150418
  onProgress?.({
@@ -150052,35 +150453,82 @@ async function pull({
150052
150453
  }
150053
150454
  onProgress?.({ type: "variable_fonts", fonts: variableFonts });
150054
150455
  }
150456
+ const collapseOpportunities = detectCollapseOpportunities(variableFonts, faces, fileSizes);
150457
+ if (collapseOpportunities.length > 0) {
150458
+ log.info(" \u2139 Variable-font collapse opportunities:");
150459
+ for (const op of collapseOpportunities) log.info(formatCollapseHint(op));
150460
+ }
150055
150461
  let fallbackBlocks = [];
150056
150462
  if (fallback) {
150057
- log.info("\u2192 Computing CLS-killing fallback metrics (capsize)");
150058
- const { css, count, errors } = await buildFallbacksForDir(filesDir);
150463
+ log.info("\u2192 Computing CLS-killing fallback metrics (capsize, per weight)");
150464
+ const { css, count, errors } = await buildPerFaceFallbacks(filesDir, faces);
150059
150465
  if (count > 0) {
150060
150466
  fallbackBlocks = [css];
150061
- log.info(` + ${count} fallback @font-face block(s) generated`);
150467
+ log.info(` + ${count} fallback @font-face block(s) generated (one per weight/style)`);
150062
150468
  }
150063
150469
  for (const err of errors) {
150064
- log.warn(` ! fallback skipped for ${path5.basename(err.file)}: ${err.reason}`);
150470
+ log.warn(` ! fallback skipped for ${path6.basename(err.file)}: ${err.reason}`);
150065
150471
  }
150066
150472
  }
150067
150473
  const preloadHints = buildPreloadHints(faces);
150068
150474
  await fs4.writeFile(
150069
- path5.join(outDir, "fonts.css"),
150475
+ path6.join(outDir, "fonts.css"),
150070
150476
  buildFontsCss(faces, { preloadHints, extraBlocks: fallbackBlocks })
150071
150477
  );
150072
- await fs4.writeFile(path5.join(outDir, "fonts.json"), buildFontsJson(faces, orphans));
150073
- await fs4.writeFile(path5.join(outDir, "README.md"), buildReadme(host, faces, downloaded, orphans));
150478
+ await fs4.writeFile(path6.join(outDir, "fonts.json"), buildFontsJson(faces, orphans));
150479
+ await fs4.writeFile(path6.join(outDir, "README.md"), buildReadme(host, faces, downloaded, orphans));
150074
150480
  await fs4.writeFile(
150075
- path5.join(outDir, "LICENSE_REVIEW.md"),
150481
+ path6.join(outDir, "LICENSE_REVIEW.md"),
150076
150482
  buildLicenseReview(host, refined, refinedSummary)
150077
150483
  );
150484
+ await fs4.writeFile(
150485
+ path6.join(outDir, "provenance.json"),
150486
+ buildProvenanceJson(host, url, refined, orphans, fileSizes)
150487
+ );
150488
+ if (gdprReport) {
150489
+ const report = buildGdprReport(host, url, faces);
150490
+ await fs4.writeFile(path6.join(outDir, "GDPR.md"), formatGdprMarkdown(report));
150491
+ await fs4.writeFile(
150492
+ path6.join(outDir, "gdpr.json"),
150493
+ JSON.stringify(report, null, 2)
150494
+ );
150495
+ if (report.summary.highSeverity > 0) {
150496
+ log.info(
150497
+ `\u2192 GDPR review: ${report.summary.thirdParty} third-party request(s), ${report.summary.highSeverity} high-severity \u2014 see GDPR.md`
150498
+ );
150499
+ } else if (report.summary.thirdParty > 0) {
150500
+ log.info(
150501
+ `\u2192 GDPR review: ${report.summary.thirdParty} third-party request(s) \u2014 see GDPR.md`
150502
+ );
150503
+ } else {
150504
+ log.info("\u2192 GDPR review: no third-party font requests detected");
150505
+ }
150506
+ }
150507
+ let consistencyReport;
150508
+ if (fetchedPages.length > 1) {
150509
+ const pageUrls = fetchedPages.map((p) => p.url);
150510
+ const pageFaceMap = buildPageFaceMap(pageUrls, sourceToPage, facesPerSource);
150511
+ consistencyReport = computeConsistency(pageFaceMap);
150512
+ await fs4.writeFile(
150513
+ path6.join(outDir, "CONSISTENCY.md"),
150514
+ buildConsistencyReport(consistencyReport, host)
150515
+ );
150516
+ if (consistencyReport.divergent.length > 0) {
150517
+ log.info(
150518
+ `\u2192 Cross-page consistency: ${consistencyReport.shared.length} shared / ${consistencyReport.divergent.length} divergent page(s) \u2014 see CONSISTENCY.md`
150519
+ );
150520
+ } else {
150521
+ log.info(
150522
+ `\u2192 Cross-page consistency: ${consistencyReport.shared.length} family/families used on every crawled page`
150523
+ );
150524
+ }
150525
+ }
150078
150526
  for (const target of emit) {
150079
150527
  const emitter = EMITTERS[target];
150080
150528
  if (!emitter) continue;
150081
150529
  const output = emitter(faces, { siteSlug: host, filesDir: "files" });
150082
150530
  if (!output) continue;
150083
- await fs4.writeFile(path5.join(outDir, output.filename), output.content);
150531
+ await fs4.writeFile(path6.join(outDir, output.filename), output.content);
150084
150532
  log.info(` + emitted ${output.filename} (--emit ${target})`);
150085
150533
  }
150086
150534
  onProgress?.({ type: "phase", phase: "done" });
@@ -150093,13 +150541,16 @@ async function pull({
150093
150541
  total: urlToLocal.size,
150094
150542
  variableFonts,
150095
150543
  pagesCrawled: fetchedPages.length,
150096
- discoveredNextjsSiblings
150544
+ discoveredNextjsSiblings,
150545
+ ...consistencyReport ? { consistency: consistencyReport } : {},
150546
+ fileSizes: Object.fromEntries(fileSizes),
150547
+ collapseOpportunities
150097
150548
  };
150098
150549
  }
150099
150550
 
150100
150551
  // ../core/src/pipeline/subset.ts
150101
150552
  import fs5 from "fs/promises";
150102
- import path6 from "path";
150553
+ import path7 from "path";
150103
150554
 
150104
150555
  // ../core/src/formats/codepoints.ts
150105
150556
  var ENTRY_RE = /^U\+([0-9A-Fa-f]{1,6})(?:-([0-9A-Fa-f]{1,6}))?$/;
@@ -150239,7 +150690,7 @@ async function subset(options) {
150239
150690
  if (options.skipPull) {
150240
150691
  const host = new URL(options.url).hostname.replace(/^www\./, "").replace(/[^a-zA-Z0-9.-]/g, "_");
150241
150692
  pullResult = {
150242
- outDir: path6.join(path6.resolve(options.baseDir), host),
150693
+ outDir: path7.join(path7.resolve(options.baseDir), host),
150243
150694
  faces: [],
150244
150695
  orphans: [],
150245
150696
  downloaded: 0,
@@ -150267,8 +150718,8 @@ async function subset(options) {
150267
150718
  if (options.whitelist) extras.push(...options.whitelist);
150268
150719
  const codepoints = uniqueCodepoints(renderedText, extras);
150269
150720
  log.info(` ${codepoints.length.toLocaleString()} unique codepoint(s) observed`);
150270
- const filesDir = path6.join(pullResult.outDir, "files");
150271
- const fontFiles = await collectFontFiles3(filesDir);
150721
+ const filesDir = path7.join(pullResult.outDir, "files");
150722
+ const fontFiles = await collectFontFiles2(filesDir);
150272
150723
  const perFile = [];
150273
150724
  const errors = [];
150274
150725
  let totalOriginal = 0;
@@ -150283,10 +150734,10 @@ async function subset(options) {
150283
150734
  const savedBytes = original.length - subsetBuffer.length;
150284
150735
  const savedPct = original.length > 0 ? savedBytes / original.length * 100 : 0;
150285
150736
  log.info(
150286
- ` \u2713 ${path6.relative(filesDir, outFile)} ${(subsetBuffer.length / 1024).toFixed(1)} KB (was ${(original.length / 1024).toFixed(1)} KB, \u2212${savedPct.toFixed(0)}%)`
150737
+ ` \u2713 ${path7.relative(filesDir, outFile)} ${(subsetBuffer.length / 1024).toFixed(1)} KB (was ${(original.length / 1024).toFixed(1)} KB, \u2212${savedPct.toFixed(0)}%)`
150287
150738
  );
150288
150739
  perFile.push({
150289
- file: path6.relative(filesDir, outFile),
150740
+ file: path7.relative(filesDir, outFile),
150290
150741
  originalBytes: original.length,
150291
150742
  subsetBytes: subsetBuffer.length,
150292
150743
  saved: savedBytes,
@@ -150296,8 +150747,8 @@ async function subset(options) {
150296
150747
  totalSubset += subsetBuffer.length;
150297
150748
  } catch (e) {
150298
150749
  const reason = e.message;
150299
- log.warn(` \u2717 ${path6.relative(filesDir, file)} \u2014 ${reason}`);
150300
- errors.push({ file: path6.relative(filesDir, file), reason });
150750
+ log.warn(` \u2717 ${path7.relative(filesDir, file)} \u2014 ${reason}`);
150751
+ errors.push({ file: path7.relative(filesDir, file), reason });
150301
150752
  }
150302
150753
  }
150303
150754
  const totalSaved = totalOriginal - totalSubset;
@@ -150335,8 +150786,8 @@ async function runSplit(pullResult, options, subsetFont) {
150335
150786
  bucket: b,
150336
150787
  codepoints: expandBucket(b)
150337
150788
  }));
150338
- const filesDir = path6.join(pullResult.outDir, "files");
150339
- const fontFiles = await collectFontFiles3(filesDir);
150789
+ const filesDir = path7.join(pullResult.outDir, "files");
150790
+ const fontFiles = await collectFontFiles2(filesDir);
150340
150791
  const perFile = [];
150341
150792
  const splits = [];
150342
150793
  const errors = [];
@@ -150356,19 +150807,19 @@ async function runSplit(pullResult, options, subsetFont) {
150356
150807
  try {
150357
150808
  original = await fs5.readFile(file);
150358
150809
  } catch (e) {
150359
- errors.push({ file: path6.relative(filesDir, file), reason: e.message });
150810
+ errors.push({ file: path7.relative(filesDir, file), reason: e.message });
150360
150811
  continue;
150361
150812
  }
150362
150813
  let charsetInfo;
150363
150814
  try {
150364
150815
  charsetInfo = readFontCharset(original);
150365
150816
  } catch (e) {
150366
- errors.push({ file: path6.relative(filesDir, file), reason: `fontkit: ${e.message}` });
150817
+ errors.push({ file: path7.relative(filesDir, file), reason: `fontkit: ${e.message}` });
150367
150818
  continue;
150368
150819
  }
150369
- const localRel = path6.relative(filesDir, file);
150820
+ const localRel = path7.relative(filesDir, file);
150370
150821
  const faceMeta = faceByLocal.get(localRel);
150371
- const family = faceMeta?.family ?? charsetInfo.family ?? path6.basename(file, path6.extname(file));
150822
+ const family = faceMeta?.family ?? charsetInfo.family ?? path7.basename(file, path7.extname(file));
150372
150823
  const familyReport = {
150373
150824
  family,
150374
150825
  sourceFile: localRel,
@@ -150390,7 +150841,7 @@ async function runSplit(pullResult, options, subsetFont) {
150390
150841
  `.${bucket.name}.subset.woff2`
150391
150842
  );
150392
150843
  await fs5.writeFile(outFile, subsetBuffer);
150393
- const outRel = path6.relative(filesDir, outFile);
150844
+ const outRel = path7.relative(filesDir, outFile);
150394
150845
  const savedBytes = original.length - subsetBuffer.length;
150395
150846
  const savedPct = original.length > 0 ? savedBytes / original.length * 100 : 0;
150396
150847
  log.info(
@@ -150424,7 +150875,7 @@ async function runSplit(pullResult, options, subsetFont) {
150424
150875
  splits.push(familyReport);
150425
150876
  }
150426
150877
  }
150427
- const splitCssPath = path6.join(pullResult.outDir, "fonts.subset.css");
150878
+ const splitCssPath = path7.join(pullResult.outDir, "fonts.subset.css");
150428
150879
  await fs5.writeFile(splitCssPath, buildSplitCss(splits, expandedBuckets, faceByLocal));
150429
150880
  const totalSaved = totalOriginal - totalSubset;
150430
150881
  const totalSavedPct = totalOriginal > 0 ? totalSaved / totalOriginal * 100 : 0;
@@ -150442,7 +150893,7 @@ async function runSplit(pullResult, options, subsetFont) {
150442
150893
  perFile,
150443
150894
  errors,
150444
150895
  splits,
150445
- splitCss: path6.relative(pullResult.outDir, splitCssPath)
150896
+ splitCss: path7.relative(pullResult.outDir, splitCssPath)
150446
150897
  };
150447
150898
  }
150448
150899
  function buildSplitCss(splits, expandedBuckets, faceByLocal) {
@@ -150477,7 +150928,7 @@ function buildSplitCss(splits, expandedBuckets, faceByLocal) {
150477
150928
  }
150478
150929
  return lines.join("\n");
150479
150930
  }
150480
- async function collectFontFiles3(root) {
150931
+ async function collectFontFiles2(root) {
150481
150932
  const out = [];
150482
150933
  async function walk(dir) {
150483
150934
  let items;
@@ -150487,7 +150938,7 @@ async function collectFontFiles3(root) {
150487
150938
  return;
150488
150939
  }
150489
150940
  for (const it of items) {
150490
- const p = path6.join(dir, it.name);
150941
+ const p = path7.join(dir, it.name);
150491
150942
  if (it.isDirectory()) await walk(p);
150492
150943
  else if (/\.(woff2|woff|ttf|otf)$/i.test(it.name)) out.push(p);
150493
150944
  }
@@ -150511,8 +150962,204 @@ function emptyReport(outDir, url) {
150511
150962
  };
150512
150963
  }
150513
150964
 
150965
+ // ../core/src/pipeline/diff.ts
150966
+ async function diffPulls(urlA, urlB, baseDir, options = {}) {
150967
+ const [resultA, resultB] = await Promise.all([
150968
+ pull({ url: urlA, baseDir, ...options }),
150969
+ pull({ url: urlB, baseDir, ...options })
150970
+ ]);
150971
+ const sideA = summarise(urlA, resultA.faces, resultA.fileSizes);
150972
+ const sideB = summarise(urlB, resultB.faces, resultB.fileSizes);
150973
+ const famSetA = new Set(sideA.families);
150974
+ const famSetB = new Set(sideB.families);
150975
+ const added = [...famSetB].filter((f) => !famSetA.has(f)).sort();
150976
+ const removed = [...famSetA].filter((f) => !famSetB.has(f)).sort();
150977
+ const shared = [...famSetA].filter((f) => famSetB.has(f)).sort();
150978
+ return {
150979
+ schemaVersion: "1.0",
150980
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
150981
+ a: sideA,
150982
+ b: sideB,
150983
+ added,
150984
+ removed,
150985
+ shared,
150986
+ byteDelta: sideB.totalBytes - sideA.totalBytes,
150987
+ commercialDelta: sideB.commercialCount - sideA.commercialCount
150988
+ };
150989
+ }
150990
+ function summarise(url, faces, fileSizes) {
150991
+ const families = [...new Set(faces.map((f) => f.family))].sort();
150992
+ const sizes = fileSizes ?? {};
150993
+ let totalBytes = 0;
150994
+ let fileCount = 0;
150995
+ const seenFiles = /* @__PURE__ */ new Set();
150996
+ for (const f of faces) {
150997
+ for (const s of f.sources) {
150998
+ if (!s.localFile) continue;
150999
+ if (seenFiles.has(s.localFile)) continue;
151000
+ seenFiles.add(s.localFile);
151001
+ fileCount++;
151002
+ totalBytes += sizes[s.localFile] ?? 0;
151003
+ }
151004
+ }
151005
+ const classified = classifyFaces(faces);
151006
+ const commercialCount = classified.filter((c) => c.classification.status === "commercial").length;
151007
+ return { url, families, totalBytes, commercialCount, fileCount };
151008
+ }
151009
+ function formatFontDiff(diff) {
151010
+ const lines = [
151011
+ `# Font diff`,
151012
+ "",
151013
+ `- **A** ${diff.a.url}`,
151014
+ `- **B** ${diff.b.url}`,
151015
+ "",
151016
+ `## Summary`,
151017
+ "",
151018
+ `- Added: ${diff.added.length} family/families`,
151019
+ `- Removed: ${diff.removed.length} family/families`,
151020
+ `- Shared: ${diff.shared.length} family/families`,
151021
+ `- Byte delta: ${formatBytes(diff.byteDelta)}`,
151022
+ `- Commercial delta: ${signed(diff.commercialDelta)} face(s)`,
151023
+ ""
151024
+ ];
151025
+ if (diff.added.length > 0) {
151026
+ lines.push("## Added (only in B)");
151027
+ lines.push("");
151028
+ for (const f of diff.added) lines.push(`- ${f}`);
151029
+ lines.push("");
151030
+ }
151031
+ if (diff.removed.length > 0) {
151032
+ lines.push("## Removed (only in A)");
151033
+ lines.push("");
151034
+ for (const f of diff.removed) lines.push(`- ${f}`);
151035
+ lines.push("");
151036
+ }
151037
+ if (diff.shared.length > 0) {
151038
+ lines.push("## Shared");
151039
+ lines.push("");
151040
+ for (const f of diff.shared) lines.push(`- ${f}`);
151041
+ lines.push("");
151042
+ }
151043
+ return lines.join("\n");
151044
+ }
151045
+ function formatBytes(bytes) {
151046
+ const sign = bytes >= 0 ? "+" : "\u2212";
151047
+ const abs2 = Math.abs(bytes);
151048
+ if (abs2 < 1024) return `${sign}${abs2} B`;
151049
+ if (abs2 < 1024 * 1024) return `${sign}${(abs2 / 1024).toFixed(1)} KB`;
151050
+ return `${sign}${(abs2 / (1024 * 1024)).toFixed(1)} MB`;
151051
+ }
151052
+ function signed(n) {
151053
+ if (n === 0) return "0";
151054
+ return n > 0 ? `+${n}` : `${n}`;
151055
+ }
151056
+
151057
+ // ../core/src/pipeline/audit.ts
151058
+ async function audit(url, baseDir, options = {}) {
151059
+ const result = await pull({ url, baseDir, ...options.pull });
151060
+ const classified = classifyFaces(result.faces);
151061
+ const sizes = result.fileSizes ?? {};
151062
+ const perFamilyBytes = perFamily(result.faces, sizes);
151063
+ const totalBytes = Object.values(perFamilyBytes).reduce((a, b) => a + b, 0);
151064
+ const files = countFiles(result.faces);
151065
+ const byStatus = { open: 0, commercial: 0, unknown: 0 };
151066
+ for (const c of classified) byStatus[c.classification.status]++;
151067
+ const violations = [];
151068
+ if (options.maxKb !== void 0 && totalBytes / 1024 > options.maxKb) {
151069
+ violations.push({
151070
+ type: "budget_exceeded",
151071
+ message: `Total font bundle is ${(totalBytes / 1024).toFixed(1)} KB, exceeds budget of ${options.maxKb} KB`,
151072
+ detail: { actualKb: Math.round(totalBytes / 1024), budgetKb: options.maxKb }
151073
+ });
151074
+ }
151075
+ if (options.perFamilyKb) {
151076
+ for (const [family, budget] of Object.entries(options.perFamilyKb)) {
151077
+ const actual = perFamilyBytes[family] ?? 0;
151078
+ if (actual / 1024 > budget) {
151079
+ violations.push({
151080
+ type: "family_budget_exceeded",
151081
+ message: `Family "${family}" is ${(actual / 1024).toFixed(1)} KB, exceeds budget of ${budget} KB`,
151082
+ detail: { family, actualKb: Math.round(actual / 1024), budgetKb: budget }
151083
+ });
151084
+ }
151085
+ }
151086
+ }
151087
+ if (options.noCommercial && byStatus.commercial > 0) {
151088
+ const offenders = classified.filter((c) => c.classification.status === "commercial").map((c) => c.face.family);
151089
+ violations.push({
151090
+ type: "commercial_present",
151091
+ message: `${byStatus.commercial} commercial face(s) detected: ${[...new Set(offenders)].join(", ")}`,
151092
+ detail: { count: byStatus.commercial, families: [...new Set(offenders)].join(", ") }
151093
+ });
151094
+ }
151095
+ return {
151096
+ schemaVersion: "1.0",
151097
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
151098
+ url,
151099
+ passed: violations.length === 0,
151100
+ violations,
151101
+ summary: {
151102
+ families: new Set(result.faces.map((f) => f.family)).size,
151103
+ faces: result.faces.length,
151104
+ files,
151105
+ totalBytes,
151106
+ byStatus,
151107
+ perFamilyBytes
151108
+ }
151109
+ };
151110
+ }
151111
+ function perFamily(faces, sizes) {
151112
+ const out = {};
151113
+ const seenFiles = /* @__PURE__ */ new Set();
151114
+ for (const f of faces) {
151115
+ let familyBytes = 0;
151116
+ for (const s of f.sources) {
151117
+ if (!s.localFile) continue;
151118
+ if (seenFiles.has(s.localFile)) continue;
151119
+ seenFiles.add(s.localFile);
151120
+ familyBytes += sizes[s.localFile] ?? 0;
151121
+ }
151122
+ out[f.family] = (out[f.family] ?? 0) + familyBytes;
151123
+ }
151124
+ return out;
151125
+ }
151126
+ function countFiles(faces) {
151127
+ const seen = /* @__PURE__ */ new Set();
151128
+ for (const f of faces) {
151129
+ for (const s of f.sources) {
151130
+ if (s.localFile) seen.add(s.localFile);
151131
+ }
151132
+ }
151133
+ return seen.size;
151134
+ }
151135
+ function formatAuditReport(report) {
151136
+ const lines = [];
151137
+ lines.push(`# Audit: ${report.url}`);
151138
+ lines.push("");
151139
+ if (report.passed) {
151140
+ lines.push(`\u2713 Passed. ${report.summary.faces} face(s), ${(report.summary.totalBytes / 1024).toFixed(1)} KB total.`);
151141
+ } else {
151142
+ lines.push(`\u2717 Failed with ${report.violations.length} violation(s).`);
151143
+ lines.push("");
151144
+ for (const v of report.violations) {
151145
+ lines.push(`- **${v.type}**: ${v.message}`);
151146
+ }
151147
+ }
151148
+ lines.push("");
151149
+ lines.push("## Summary");
151150
+ lines.push("");
151151
+ lines.push(`- Families: ${report.summary.families}`);
151152
+ lines.push(`- Faces: ${report.summary.faces}`);
151153
+ lines.push(`- Files: ${report.summary.files}`);
151154
+ lines.push(`- Total bytes: ${(report.summary.totalBytes / 1024).toFixed(1)} KB`);
151155
+ lines.push(
151156
+ `- License: ${report.summary.byStatus.open} open / ${report.summary.byStatus.commercial} commercial / ${report.summary.byStatus.unknown} unknown`
151157
+ );
151158
+ return lines.join("\n");
151159
+ }
151160
+
150514
151161
  // src/cli.ts
150515
- var VERSION = "1.3.0";
151162
+ var VERSION = "1.4.0";
150516
151163
  function printHelp() {
150517
151164
  log.info(`fontfetch ${VERSION}
150518
151165
 
@@ -150520,6 +151167,9 @@ Usage:
150520
151167
  fontfetch <url> [outDir] [flags] Extract every webfont from a URL
150521
151168
  fontfetch inspect <file> Print a terminal report for a font file
150522
151169
  fontfetch subset <url> [outDir] Extract + subset to glyphs actually rendered on the page
151170
+ fontfetch diff <urlA> <urlB> Diff the font set between two URLs (v1.4)
151171
+ fontfetch audit <url> [flags] CI-friendly checks: budget, no-commercial (v1.4, non-zero exit on fail)
151172
+ fontfetch budget <url> --max-kb N Convenience around audit for the bundle-size dimension (v1.4)
150523
151173
 
150524
151174
  Arguments (default command):
150525
151175
  <url> Page to download fonts from (https://example.com)
@@ -150547,6 +151197,11 @@ Flags (default command):
150547
151197
  --emit tailwind Tailwind fontFamily snippet
150548
151198
  --emit next,tailwind Both (pair for CSS variables)
150549
151199
  --emit vite Vite integration guide
151200
+ --emit tokens W3C / Style Dictionary design tokens (v1.4)
151201
+ --gdpr-report Emit GDPR.md + gdpr.json listing every third-party font
151202
+ request with self-host remediation. Post-LG M\xFCnchen I
151203
+ 20 O 1393/21 (2022) German court ruling on Google Fonts
151204
+ CDN. (v1.4)
150550
151205
  --fallback Emit a CLS-killing 'Fallback' @font-face for every family,
150551
151206
  with size-adjust / ascent-override / descent-override /
150552
151207
  line-gap-override matched to a system fallback (Arial /
@@ -150592,6 +151247,19 @@ Examples:
150592
151247
  fontfetch subset https://stripe.com --split-ranges=latin,latin-ext
150593
151248
  npx fontfetch https://shinobidata.com
150594
151249
 
151250
+ Flags (audit / budget subcommand, v1.4):
151251
+ --max-kb <N> Total bundle size budget in KB; exceeds \u2192 exit 1
151252
+ --per-family-kb <list>
151253
+ Per-family budgets: "Inter:30,Geist:40"
151254
+ --no-commercial Exit 1 if any face is classified as commercial
151255
+ --json Emit machine-readable JSON instead of human-readable output
151256
+
151257
+ Examples (v1.4):
151258
+ fontfetch diff https://staging.acme.com https://acme.com
151259
+ fontfetch diff https://staging.acme.com https://acme.com --json
151260
+ fontfetch audit https://acme.com --max-kb 200 --no-commercial
151261
+ fontfetch budget https://acme.com --max-kb 100 --json
151262
+
150595
151263
  Output (per site):
150596
151264
  <outDir>/<hostname>/
150597
151265
  files/ Raw font files (woff2/woff/ttf/otf/eot)
@@ -150600,6 +151268,9 @@ Output (per site):
150600
151268
  fonts.json Manifest grouped by family/weight/style
150601
151269
  README.md Human-readable summary
150602
151270
  LICENSE_REVIEW.md Per-face license verdict (open / commercial / unknown)
151271
+ provenance.json Machine-readable license + provenance report (v1.4)
151272
+ CONSISTENCY.md Cross-page font consistency (when --pages > 1, v1.4)
151273
+ fonts.tokens.json W3C design tokens (when --emit tokens, v1.4)
150603
151274
 
150604
151275
  For local design exploration. You're responsible for licensing the fonts you use.
150605
151276
  `);
@@ -150696,6 +151367,7 @@ async function runPull(args) {
150696
151367
  const headless = args.includes("--headless");
150697
151368
  const force = args.includes("--force");
150698
151369
  const fallback = args.includes("--fallback");
151370
+ const gdprReport = args.includes("--gdpr-report");
150699
151371
  const emit = [];
150700
151372
  const emitIdx = args.findIndex((a) => a === "--emit" || a.startsWith("--emit="));
150701
151373
  if (emitIdx !== -1) {
@@ -150756,7 +151428,8 @@ async function runPull(args) {
150756
151428
  "--force",
150757
151429
  "--fallback",
150758
151430
  "--pages",
150759
- "--formats"
151431
+ "--formats",
151432
+ "--gdpr-report"
150760
151433
  ]);
150761
151434
  const positional = args.filter((a, i) => {
150762
151435
  if (a.startsWith("--")) return false;
@@ -150784,7 +151457,8 @@ async function runPull(args) {
150784
151457
  force,
150785
151458
  fallback,
150786
151459
  pages,
150787
- formats: formats2
151460
+ formats: formats2,
151461
+ gdprReport
150788
151462
  });
150789
151463
  log.info("");
150790
151464
  if (result.total === 0) {
@@ -150793,6 +151467,109 @@ async function runPull(args) {
150793
151467
  }
150794
151468
  log.info(`Done. ${result.downloaded}/${result.total} files saved to ${result.outDir}`);
150795
151469
  }
151470
+ async function runDiff(args) {
151471
+ const wantJson = args.includes("--json");
151472
+ const positional = args.filter((a) => !a.startsWith("--"));
151473
+ const [urlA, urlB, outDir = "./downloaded-fonts"] = positional;
151474
+ if (!urlA || !urlB) {
151475
+ log.err("Usage: fontfetch diff <urlA> <urlB> [outDir] [--json]");
151476
+ process.exit(1);
151477
+ }
151478
+ try {
151479
+ new URL(urlA);
151480
+ new URL(urlB);
151481
+ } catch {
151482
+ log.err(`Invalid URL passed to diff`);
151483
+ process.exit(1);
151484
+ }
151485
+ try {
151486
+ const diff = await diffPulls(urlA, urlB, outDir);
151487
+ if (wantJson) {
151488
+ process.stdout.write(JSON.stringify(diff, null, 2) + "\n");
151489
+ } else {
151490
+ log.info("");
151491
+ log.info(formatFontDiff(diff));
151492
+ }
151493
+ process.exit(0);
151494
+ } catch (e) {
151495
+ log.err(`diff failed: ${e.message}`);
151496
+ process.exit(1);
151497
+ }
151498
+ }
151499
+ async function runAudit(args) {
151500
+ const wantJson = args.includes("--json");
151501
+ const noCommercial = args.includes("--no-commercial");
151502
+ let maxKb;
151503
+ const maxKbIdx = args.findIndex((a) => a === "--max-kb" || a.startsWith("--max-kb="));
151504
+ if (maxKbIdx !== -1) {
151505
+ const raw = args[maxKbIdx] === "--max-kb" ? args[maxKbIdx + 1] : args[maxKbIdx].slice("--max-kb=".length);
151506
+ const n = Number.parseInt(raw, 10);
151507
+ if (!Number.isFinite(n) || n <= 0) {
151508
+ log.err(`--max-kb must be a positive integer, got '${raw}'`);
151509
+ process.exit(1);
151510
+ }
151511
+ maxKb = n;
151512
+ }
151513
+ let perFamilyKb;
151514
+ const pfIdx = args.findIndex(
151515
+ (a) => a === "--per-family-kb" || a.startsWith("--per-family-kb=")
151516
+ );
151517
+ if (pfIdx !== -1) {
151518
+ const raw = args[pfIdx] === "--per-family-kb" ? args[pfIdx + 1] : args[pfIdx].slice("--per-family-kb=".length);
151519
+ perFamilyKb = {};
151520
+ for (const part of raw.split(",")) {
151521
+ const [family, kb] = part.split(":");
151522
+ if (!family || !kb) {
151523
+ log.err(`--per-family-kb expects 'Family:KB' pairs; got '${part}'`);
151524
+ process.exit(1);
151525
+ }
151526
+ const n = Number.parseInt(kb, 10);
151527
+ if (!Number.isFinite(n) || n <= 0) {
151528
+ log.err(`--per-family-kb value must be positive integer; got '${kb}'`);
151529
+ process.exit(1);
151530
+ }
151531
+ perFamilyKb[family.trim()] = n;
151532
+ }
151533
+ }
151534
+ const reserved = /* @__PURE__ */ new Set(["--json", "--no-commercial", "--max-kb", "--per-family-kb"]);
151535
+ const positional = args.filter((a, i) => {
151536
+ if (a.startsWith("--")) return false;
151537
+ if (i > 0 && (args[i - 1] === "--max-kb" || args[i - 1] === "--per-family-kb")) return false;
151538
+ if (reserved.has(a)) return false;
151539
+ return true;
151540
+ });
151541
+ const [url, outDir = "./downloaded-fonts"] = positional;
151542
+ if (!url) {
151543
+ log.err("Usage: fontfetch audit <url> [outDir] [--max-kb N] [--no-commercial] [--per-family-kb F:N,...] [--json]");
151544
+ process.exit(1);
151545
+ }
151546
+ try {
151547
+ new URL(url);
151548
+ } catch {
151549
+ log.err(`Invalid URL: ${url}`);
151550
+ process.exit(1);
151551
+ }
151552
+ try {
151553
+ const report = await audit(url, outDir, { maxKb, perFamilyKb, noCommercial });
151554
+ if (wantJson) {
151555
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
151556
+ } else {
151557
+ log.info("");
151558
+ log.info(formatAuditReport(report));
151559
+ }
151560
+ process.exit(report.passed ? 0 : 1);
151561
+ } catch (e) {
151562
+ log.err(`audit failed: ${e.message}`);
151563
+ process.exit(1);
151564
+ }
151565
+ }
151566
+ async function runBudget(args) {
151567
+ if (!args.some((a) => a === "--max-kb" || a.startsWith("--max-kb="))) {
151568
+ log.err("Usage: fontfetch budget <url> --max-kb <N> [outDir] [--json]");
151569
+ process.exit(1);
151570
+ }
151571
+ await runAudit(args);
151572
+ }
150796
151573
  async function main() {
150797
151574
  const args = process.argv.slice(2);
150798
151575
  if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
@@ -150812,6 +151589,18 @@ async function main() {
150812
151589
  await runSubset(rest);
150813
151590
  return;
150814
151591
  }
151592
+ if (command === "diff") {
151593
+ await runDiff(rest);
151594
+ return;
151595
+ }
151596
+ if (command === "audit") {
151597
+ await runAudit(rest);
151598
+ return;
151599
+ }
151600
+ if (command === "budget") {
151601
+ await runBudget(rest);
151602
+ return;
151603
+ }
150815
151604
  await runPull(args);
150816
151605
  }
150817
151606
  main().catch((e) => {