fontaine 0.8.0 → 0.8.2

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/index.mjs CHANGED
@@ -1,283 +1,92 @@
1
- import { generate, parse, walk } from "css-tree";
2
- import { anyOf, char, charIn, createRegExp, exactly, oneOrMore } from "magic-regexp";
1
+ import { a as resolveCategoryFallbacks, c as generateFontFace, d as withoutQueryOrFragment, f as withoutQuotes, i as DEFAULT_CATEGORY_FALLBACKS, l as genericCSSFamilies, n as readMetrics, o as cssWideKeywords, r as readMetricsForSource, s as generateFallbackName, t as getMetricsForFamily, u as parseFontFace } from "./metrics-DgPzfV2U.mjs";
2
+ import { createRequire } from "node:module";
3
+ import { parse, walk } from "css-tree";
4
+ import { anyOf, char, createRegExp, exactly, oneOrMore } from "magic-regexp";
5
+ import { isAbsolute } from "pathe";
3
6
  import { fileURLToPath, pathToFileURL } from "node:url";
4
- import { fromUrl } from "@capsizecss/unpack";
5
- import { fromFile } from "@capsizecss/unpack/fs";
6
- import { parseURL } from "ufo";
7
+ import { readFile } from "node:fs/promises";
7
8
  import MagicString from "magic-string";
8
- import { isAbsolute } from "pathe";
9
9
  import { createUnplugin } from "unplugin";
10
-
11
- //#region src/css.ts
12
- function toPercentage(value, fractionDigits = 4) {
13
- return `${+(value * 100).toFixed(fractionDigits)}%`;
14
- }
15
- function toCSS(properties, indent = 2) {
16
- return Object.entries(properties).map(([key, value]) => `${" ".repeat(indent)}${key}: ${value};`).join("\n");
17
- }
18
- const QUOTES_RE = createRegExp(charIn("\"'").at.lineStart().or(charIn("\"'").at.lineEnd()), ["g"]);
19
- const withoutQuotes = (str) => str.trim().replace(QUOTES_RE, "");
20
- const genericCSSFamilies = new Set([
21
- "serif",
22
- "sans-serif",
23
- "monospace",
24
- "cursive",
25
- "fantasy",
26
- "system-ui",
27
- "ui-serif",
28
- "ui-sans-serif",
29
- "ui-monospace",
30
- "ui-rounded",
31
- "emoji",
32
- "math",
33
- "fangsong"
34
- ]);
35
- const fontProperties = new Set([
36
- "font-weight",
37
- "font-style",
38
- "font-stretch"
39
- ]);
10
+ //#region src/transform.ts
11
+ const supportedExtensions = [
12
+ "woff2",
13
+ "woff",
14
+ "ttf"
15
+ ];
16
+ const CSS_RE = createRegExp(exactly(".").and(anyOf("sass", "css", "scss")).and(exactly("?").and(oneOrMore(char)).optionally()).at.lineEnd());
17
+ const RELATIVE_RE = createRegExp(exactly(".").or("..").and(anyOf("/", "\\")).at.lineStart());
18
+ const SCSS_RE = createRegExp(exactly(".").and(anyOf("sass", "scss")).and(exactly("?").and(oneOrMore(char)).optionally()).at.lineEnd());
40
19
  /**
41
- * Extracts font family and source information from a CSS @font-face rule using css-tree.
20
+ * Replaces `//` line comments with spaces, preserving offsets.
42
21
  *
43
- * @param {string} css - The CSS containing @font-face rules
44
- * @returns Array<{ family?: string, source?: string }> - Array of objects with font family and source information
22
+ * css-tree has no SCSS mode and bails on the first `//` comment, parsing the
23
+ * remainder of the file as a single `Raw` node, which hides every at-rule that
24
+ * follows it.
45
25
  */
46
- function parseFontFace(css) {
47
- const families = [];
48
- walk(typeof css === "string" ? parse(css, { positions: true }) : css, {
26
+ function blankLineComments(code) {
27
+ let result = "";
28
+ let index = 0;
29
+ while (index < code.length) {
30
+ const char = code[index];
31
+ if (char === "\"" || char === "'") {
32
+ const end = code.indexOf(char, index + 1);
33
+ const stop = end === -1 ? code.length : end + 1;
34
+ result += code.slice(index, stop);
35
+ index = stop;
36
+ continue;
37
+ }
38
+ if (char === "/" && code[index + 1] === "*") {
39
+ const end = code.indexOf("*/", index + 2);
40
+ const stop = end === -1 ? code.length : end + 2;
41
+ result += code.slice(index, stop);
42
+ index = stop;
43
+ continue;
44
+ }
45
+ if ((char === "u" || char === "U") && /^url\(/i.test(code.slice(index, index + 4))) {
46
+ const end = code.indexOf(")", index);
47
+ const stop = end === -1 ? code.length : end + 1;
48
+ result += code.slice(index, stop);
49
+ index = stop;
50
+ continue;
51
+ }
52
+ if (char === "/" && code[index + 1] === "/") {
53
+ const newline = code.indexOf("\n", index);
54
+ const stop = newline === -1 ? code.length : newline;
55
+ result += " ".repeat(stop - index);
56
+ index = stop;
57
+ continue;
58
+ }
59
+ result += char;
60
+ index++;
61
+ }
62
+ return result;
63
+ }
64
+ function extractCssImports(ast) {
65
+ const imports = [];
66
+ walk(ast, {
49
67
  visit: "Atrule",
50
68
  enter(node) {
51
- if (node.name !== "font-face") return;
52
- let family;
53
- const sources = [];
54
- const properties = {};
55
- if (node.block) walk(node.block, {
56
- visit: "Declaration",
57
- enter(declaration) {
58
- if (declaration.property === "font-family" && declaration.value.type === "Value") for (const child of declaration.value.children) {
59
- if (child.type === "String") {
60
- family = withoutQuotes(child.value);
61
- break;
62
- }
63
- if (child.type === "Identifier" && !genericCSSFamilies.has(child.name)) {
64
- family = child.name;
65
- break;
66
- }
67
- }
68
- if (fontProperties.has(declaration.property)) {
69
- if (declaration.value.type === "Value") for (const child of declaration.value.children) {
70
- const hasValue = !!properties[declaration.property];
71
- properties[declaration.property] ||= "";
72
- properties[declaration.property] += (hasValue ? " " : "") + generate(child);
73
- }
74
- }
75
- if (declaration.property === "src") walk(declaration.value, {
76
- visit: "Url",
77
- enter(urlNode) {
78
- const source = withoutQuotes(urlNode.value);
79
- if (source) sources.push(source);
80
- }
81
- });
82
- }
69
+ if (node.name !== "import" || node.prelude?.type !== "AtrulePrelude") return;
70
+ let specifier;
71
+ let conditional = false;
72
+ for (const child of node.prelude.children) if (child.type === "String" || child.type === "Url") specifier ||= withoutQuotes(child.value);
73
+ else if (child.type === "MediaQueryList" || child.type === "Function" && child.name.toLowerCase() === "supports") conditional = true;
74
+ if (specifier && !conditional && !specifier.startsWith("http:") && !specifier.startsWith("https:") && !specifier.startsWith("data:")) imports.push({
75
+ specifier: specifier.replace(/[?#].*$/, ""),
76
+ end: node.loc.end.offset
83
77
  });
84
- if (family) {
85
- for (const source of sources) families.push({
86
- index: node.loc.start.offset,
87
- family,
88
- source,
89
- properties
90
- });
91
- if (!sources.length) families.push({
92
- index: node.loc.start.offset,
93
- family,
94
- properties
95
- });
96
- }
97
78
  }
98
79
  });
99
- return families;
100
- }
101
- /**
102
- * Generates a fallback name based on the first font family specified in the input string.
103
- * @param {string} name - The full font family string.
104
- * @returns {string} - The fallback font name.
105
- */
106
- function generateFallbackName(name) {
107
- return `${withoutQuotes(name.split(",").shift())} fallback`;
80
+ return imports;
108
81
  }
109
- /**
110
- * Generates a CSS `@font-face' declaration for a font, taking fallback and resizing into account.
111
- * @param {FontFaceMetrics} metrics - The metrics of the preferred font. See {@link FontFaceMetrics}.
112
- * @param {FallbackOptions} fallback - The fallback options, including name, font and optional metrics. See {@link FallbackOptions}.
113
- * @returns {string} - The full `@font-face` CSS declaration.
114
- */
115
- function generateFontFace(metrics, fallback) {
116
- const { name: fallbackName, font: fallbackFontName, metrics: fallbackMetrics, ...properties } = fallback;
117
- const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm;
118
- const fallbackFontXAvgRatio = fallbackMetrics ? fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm : 1;
119
- const sizeAdjust = fallbackMetrics && preferredFontXAvgRatio && fallbackFontXAvgRatio ? preferredFontXAvgRatio / fallbackFontXAvgRatio : 1;
120
- const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust;
121
- const ascentOverride = metrics.ascent / adjustedEmSquare;
122
- const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare;
123
- const lineGapOverride = metrics.lineGap / adjustedEmSquare;
124
- return `@font-face {\n${toCSS({
125
- "font-family": JSON.stringify(fallbackName),
126
- "src": `local(${JSON.stringify(fallbackFontName)})`,
127
- "size-adjust": toPercentage(sizeAdjust),
128
- "ascent-override": toPercentage(ascentOverride),
129
- "descent-override": toPercentage(descentOverride),
130
- "line-gap-override": toPercentage(lineGapOverride),
131
- ...properties
132
- })}\n}\n`;
133
- }
134
-
135
- //#endregion
136
- //#region src/fallbacks.ts
137
- /**
138
- * Default fallback font stacks for each font category.
139
- * These are system fonts that work across different platforms.
140
- */
141
- const DEFAULT_CATEGORY_FALLBACKS = {
142
- "sans-serif": [
143
- "BlinkMacSystemFont",
144
- "Segoe UI",
145
- "Helvetica Neue",
146
- "Arial",
147
- "Noto Sans"
148
- ],
149
- "serif": [
150
- "Times New Roman",
151
- "Georgia",
152
- "Noto Serif"
153
- ],
154
- "monospace": [
155
- "Courier New",
156
- "Roboto Mono",
157
- "Noto Sans Mono"
158
- ],
159
- "display": [
160
- "BlinkMacSystemFont",
161
- "Segoe UI",
162
- "Helvetica Neue",
163
- "Arial",
164
- "Noto Sans"
165
- ],
166
- "handwriting": [
167
- "BlinkMacSystemFont",
168
- "Segoe UI",
169
- "Helvetica Neue",
170
- "Arial",
171
- "Noto Sans"
172
- ]
173
- };
174
- /**
175
- * Resolves the appropriate fallback fonts for a given font family.
176
- *
177
- * Resolution order:
178
- * 1. If fallbacks is an array, use it as a global override
179
- * 2. If fallbacks is an object with the font family key, use that override
180
- * 3. If metrics contain a category, use the category-based fallbacks
181
- * 4. Default to sans-serif category fallbacks
182
- *
183
- * @param options - Configuration for fallback resolution
184
- * @returns Array of fallback font family names
185
- */
186
- function resolveCategoryFallbacks(options) {
187
- const { fontFamily, fallbacks, metrics, categoryFallbacks } = options;
188
- const mergedCategoryFallbacks = { ...DEFAULT_CATEGORY_FALLBACKS };
189
- if (categoryFallbacks) for (const category in categoryFallbacks) {
190
- const categoryKey = category;
191
- const categoryFallbacksList = categoryFallbacks[categoryKey];
192
- if (categoryFallbacksList) mergedCategoryFallbacks[categoryKey] = categoryFallbacksList;
193
- }
194
- if (Array.isArray(fallbacks)) return fallbacks;
195
- const familyFallback = fallbacks[fontFamily];
196
- if (familyFallback) return familyFallback;
197
- if (metrics?.category) {
198
- const categoryFallback = mergedCategoryFallbacks[metrics.category];
199
- if (categoryFallback) return categoryFallback;
200
- }
201
- return mergedCategoryFallbacks["sans-serif"];
202
- }
203
-
204
- //#endregion
205
- //#region src/metrics.ts
206
- const metricCache = {};
207
- function filterRequiredMetrics(font) {
208
- return {
209
- ascent: font.ascent,
210
- descent: font.descent,
211
- lineGap: font.lineGap,
212
- unitsPerEm: font.unitsPerEm,
213
- xWidthAvg: font.xWidthAvg,
214
- category: font.category
215
- };
216
- }
217
- /**
218
- * Retrieves the font metrics for a given font family from the metrics collection. Uses caching to avoid redundant calculations.
219
- * @param {string} family - The name of the font family for which metrics are requested.
220
- * @returns {Promise<FontFaceMetrics | null>} - A promise that resolves with the filtered font metrics or null if not found. See {@link FontFaceMetrics}.
221
- * @async
222
- */
223
- async function getMetricsForFamily(family) {
224
- family = withoutQuotes(family);
225
- if (family in metricCache) return metricCache[family] ?? null;
82
+ function resolveCssImport(specifier, importer) {
226
83
  try {
227
- const name = fontFamilyToCamelCase(family);
228
- const { entireMetricsCollection } = await import("#capsize-font-metrics");
229
- const metrics = entireMetricsCollection[name];
230
- /* v8 ignore next 4 */
231
- if (!("descent" in metrics)) {
232
- metricCache[family] = null;
233
- return null;
234
- }
235
- const filteredMetrics = filterRequiredMetrics(metrics);
236
- metricCache[family] = filteredMetrics;
237
- return filteredMetrics;
84
+ if (RELATIVE_RE.test(specifier) || isAbsolute(specifier)) return fileURLToPath(new URL(specifier, pathToFileURL(importer)));
85
+ return createRequire(importer).resolve(specifier.replace(/^~/, ""));
238
86
  } catch {
239
- metricCache[family] = null;
240
- return null;
87
+ return;
241
88
  }
242
89
  }
243
- const urlRequestCache = /* @__PURE__ */ new Map();
244
- /**
245
- * Reads font metrics from a specified source URL or file path. This function supports both local files and remote URLs.
246
- * It caches the results to optimise subsequent requests for the same source.
247
- * @param {URL | string} _source - The source URL or local file path from which to read the font metrics.
248
- * @returns {Promise<FontFaceMetrics | null>} - A promise that resolves to the filtered font metrics or null if the source cannot be processed.
249
- * @async
250
- */
251
- async function readMetrics(_source) {
252
- const source = typeof _source !== "string" && "href" in _source ? _source.href : _source;
253
- if (source in metricCache) return metricCache[source] ?? null;
254
- const { protocol } = parseURL(source);
255
- if (!protocol) return null;
256
- let metrics;
257
- if (protocol === "file:") metrics = await fromFile(fileURLToPath(source));
258
- else if (urlRequestCache.has(source)) metrics = await urlRequestCache.get(source);
259
- else {
260
- const requestPromise = fromUrl(source);
261
- urlRequestCache.set(source, requestPromise);
262
- metrics = await requestPromise;
263
- }
264
- const filteredMetrics = filterRequiredMetrics(metrics);
265
- metricCache[source] = filteredMetrics;
266
- return filteredMetrics;
267
- }
268
- function fontFamilyToCamelCase(str) {
269
- return str.split(/[\s|-]/).filter(Boolean).map((s, i) => `${s.charAt(0)[i > 0 ? "toUpperCase" : "toLowerCase"]()}${s.slice(1)}`).join("");
270
- }
271
-
272
- //#endregion
273
- //#region src/transform.ts
274
- const supportedExtensions = [
275
- "woff2",
276
- "woff",
277
- "ttf"
278
- ];
279
- const CSS_RE = createRegExp(exactly(".").and(anyOf("sass", "css", "scss")).and(exactly("?").and(oneOrMore(char)).optionally()).at.lineEnd());
280
- const RELATIVE_RE = createRegExp(exactly(".").or("..").and(anyOf("/", "\\")).at.lineStart());
281
90
  /**
282
91
  * Transforms CSS files to include font fallbacks.
283
92
  *
@@ -290,9 +99,6 @@ const FontaineTransform = createUnplugin((options) => {
290
99
  const resolvePath = options.resolvePath || ((id) => id);
291
100
  const fallbackName = options.fallbackName || options.overrideName || generateFallbackName;
292
101
  const skipFontFaceGeneration = options.skipFontFaceGeneration || (() => false);
293
- function readMetricsFromId(path, importer) {
294
- return readMetrics(isAbsolute(importer) && RELATIVE_RE.test(path) ? new URL(path, pathToFileURL(importer)) : resolvePath(path));
295
- }
296
102
  return {
297
103
  name: "fontaine-transform",
298
104
  enforce: "pre",
@@ -300,11 +106,46 @@ const FontaineTransform = createUnplugin((options) => {
300
106
  filter: { id: [CSS_RE] },
301
107
  async handler(code, id) {
302
108
  const s = new MagicString(code);
303
- const ast = parse(code, { positions: true });
304
- for (const { family, source, index, properties } of parseFontFace(ast)) {
109
+ const inserted = /* @__PURE__ */ new Set();
110
+ const ast = parse(SCSS_RE.test(id) ? blankLineComments(code) : code, { positions: true });
111
+ const fontFaces = parseFontFace(ast).map((face) => ({
112
+ ...face,
113
+ importer: id,
114
+ prefix: ""
115
+ }));
116
+ if (isAbsolute(id)) {
117
+ const imports = extractCssImports(ast);
118
+ const insertionIndex = imports.length ? Math.max(...imports.map((i) => i.end)) : 0;
119
+ const seen = /* @__PURE__ */ new Set([id]);
120
+ const queue = imports.map((i) => ({
121
+ specifier: i.specifier,
122
+ importer: id
123
+ }));
124
+ while (queue.length) {
125
+ const { specifier, importer } = queue.shift();
126
+ const resolved = resolveCssImport(specifier, importer);
127
+ if (!resolved || seen.has(resolved) || !CSS_RE.test(resolved)) continue;
128
+ seen.add(resolved);
129
+ const importedCss = await readFile(resolved, "utf-8").catch(() => null);
130
+ if (importedCss === null) continue;
131
+ const importedAst = parse(SCSS_RE.test(resolved) ? blankLineComments(importedCss) : importedCss, { positions: true });
132
+ for (const face of parseFontFace(importedAst)) fontFaces.push({
133
+ ...face,
134
+ index: insertionIndex,
135
+ importer: resolved,
136
+ prefix: "\n"
137
+ });
138
+ for (const nested of extractCssImports(importedAst)) queue.push({
139
+ specifier: nested.specifier,
140
+ importer: resolved
141
+ });
142
+ }
143
+ }
144
+ for (const { family, source: rawSource, index, properties, importer, prefix } of fontFaces) {
145
+ const source = rawSource && withoutQueryOrFragment(rawSource);
305
146
  if (!supportedExtensions.some((e) => source?.endsWith(e))) continue;
306
147
  if (skipFontFaceGeneration(fallbackName(family))) continue;
307
- const metrics = await getMetricsForFamily(family) || source && await readMetricsFromId(source, id).catch(() => null);
148
+ const metrics = await getMetricsForFamily(family) || source && await readMetricsForSource(source, importer, resolvePath).catch(() => null);
308
149
  /* v8 ignore next 2 */
309
150
  if (!metrics) continue;
310
151
  const familyFallbacks = resolveCategoryFallbacks({
@@ -323,8 +164,11 @@ const FontaineTransform = createUnplugin((options) => {
323
164
  metrics: fallbackMetrics,
324
165
  ...properties
325
166
  });
167
+ const key = `${index}:${fontFace}`;
168
+ if (inserted.has(key)) continue;
169
+ inserted.add(key);
326
170
  cssContext.value += fontFace;
327
- s.appendLeft(index, fontFace);
171
+ s.appendLeft(index, prefix + fontFace);
328
172
  }
329
173
  }
330
174
  walk(ast, {
@@ -332,20 +176,20 @@ const FontaineTransform = createUnplugin((options) => {
332
176
  enter(node) {
333
177
  if (node.property !== "font-family") return;
334
178
  if (this.atrule && this.atrule.name === "font-face") return;
335
- if (node.value.type !== "Value")
336
- /* v8 ignore next */ return;
179
+ if (node.value.type !== "Value") return;
337
180
  for (const child of node.value.children) {
338
181
  let family;
339
182
  if (child.type === "String") family = withoutQuotes(child.value);
340
- else if (child.type === "Identifier" && child.name !== "inherit") family = child.name;
183
+ else if (child.type === "Identifier" && !cssWideKeywords.has(child.name.toLowerCase())) family = child.name;
341
184
  if (!family) continue;
342
- s.appendRight(child.loc.end.offset, `, "${fallbackName(family)}"`);
185
+ if (!genericCSSFamilies.has(family.toLowerCase())) s.appendRight(child.loc.end.offset, `, "${fallbackName(family)}"`);
343
186
  return;
344
187
  }
345
188
  }
346
189
  });
347
190
  if (s.hasChanged()) return {
348
191
  code: s.toString(),
192
+ /* v8 ignore next 3 */
349
193
  map: options.sourcemap ? s.generateMap({
350
194
  source: id,
351
195
  includeContent: true
@@ -355,6 +199,5 @@ const FontaineTransform = createUnplugin((options) => {
355
199
  }
356
200
  };
357
201
  });
358
-
359
202
  //#endregion
360
- export { DEFAULT_CATEGORY_FALLBACKS, FontaineTransform, generateFallbackName, generateFontFace, getMetricsForFamily, readMetrics, resolveCategoryFallbacks };
203
+ export { DEFAULT_CATEGORY_FALLBACKS, FontaineTransform, generateFallbackName, generateFontFace, getMetricsForFamily, readMetrics, resolveCategoryFallbacks };