unplugin-fonts
Version:
Universal Webfont loader
169 lines (168 loc) • 5.9 kB
JavaScript
import { resolveFontFiles, resolveUserOption } from "./custom.mjs";
import MagicString from "magic-string";
import { pathToFileURL } from "node:url";
import { parse, walk } from "css-tree";
import { generateFallbackName, generateFontFace, getMetricsForFamily, readMetrics, resolveCategoryFallbacks } from "fontaine";
//#region src/loaders/fallback.ts
const VARIABLE_SUFFIX_RE = /(?: Variable|-variable)$/;
const CSS_RE = /\.(?:css|scss|sass|less|styl|stylus|pcss|postcss)(?:\?.*)?$/;
const GENERIC_FAMILIES = new Set([
"serif",
"sans-serif",
"monospace",
"cursive",
"fantasy",
"system-ui",
"ui-serif",
"ui-sans-serif",
"ui-monospace",
"ui-rounded",
"emoji",
"math",
"fangsong",
"inherit",
"initial",
"unset",
"revert"
]);
/**
* Yields all font families across loaders that have fallback configured.
* Single iteration point — hasFallbacks, collectFallbackNames, and
* generateAllFallbacks all consume this.
*/
function* iterateFallbackFamilies(options, resolvedCustom, root) {
if (options.custom && resolvedCustom) for (const family of resolvedCustom.families) {
if (!family.fallback) continue;
yield {
familyName: family.name,
config: family.fallback,
fontFilePath: root ? findRepresentativeFontFile(family, resolvedCustom, root) : void 0
};
}
if (options.google) for (const family of options.google.families) {
if (typeof family === "string" || !family.fallback) continue;
yield {
familyName: family.name,
config: family.fallback
};
}
if (options.fontsource) for (const family of options.fontsource.families) {
if (!family || typeof family === "string" || !family.fallback) continue;
yield {
familyName: family.name.replace(VARIABLE_SUFFIX_RE, ""),
config: family.fallback
};
}
if (options.typekit?.families) for (const family of options.typekit.families) {
if (typeof family === "string" || !family.fallback) continue;
yield {
familyName: family.name,
config: family.fallback
};
}
}
/**
* Find a representative font file to read metrics from.
* Prefers regular (400) weight, then .ttf/.woff2 formats.
*/
function findRepresentativeFontFile(family, resolvedOptions, root) {
const faces = resolveFontFiles(family, resolvedOptions, root);
if (faces.length === 0) return void 0;
const regularFace = faces.find((f) => f.weight === 400) ?? faces[0];
for (const ext of [
".ttf",
".woff2",
".woff",
".otf"
]) {
const file = regularFace.files.find((f) => f.ext === ext);
if (file) return file.src;
}
return regularFace.files[0]?.src;
}
async function generateFallbackForFamily(params) {
const { familyName, config, fontFilePath } = params;
let metrics = await getMetricsForFamily(familyName);
if (!metrics && fontFilePath) metrics = await readMetrics(pathToFileURL(fontFilePath)).catch(() => null);
if (!metrics) {
console.warn(`unplugin-fonts: Could not resolve metrics for "${familyName}", skipping fallback generation`);
return "";
}
const systemFonts = config.fallbacks ?? resolveCategoryFallbacks({
fontFamily: familyName,
fallbacks: {},
metrics: config.category ? {
...metrics,
category: config.category
} : metrics
});
const fallbackName = config.name ?? generateFallbackName(familyName);
const systemMetricsList = await Promise.all(systemFonts.map((font) => getMetricsForFamily(font)));
const css = [];
for (let i = 0; i < systemFonts.length; i++) {
const systemMetrics = systemMetricsList[i];
if (!systemMetrics) continue;
css.push(generateFontFace(metrics, {
name: fallbackName,
font: systemFonts[i],
metrics: systemMetrics
}));
}
return css.join("");
}
function hasFallbacks(options) {
return !iterateFallbackFamilies(options, options.custom ? resolveUserOption(options.custom) : void 0).next().done;
}
/**
* Generate fallback CSS for all font families that have opted in.
*/
async function generateAllFallbacks(options, root) {
const entries = [...iterateFallbackFamilies(options, options.custom ? resolveUserOption(options.custom) : void 0, root)];
return (await Promise.all(entries.map((entry) => generateFallbackForFamily(entry)))).filter(Boolean).join("");
}
/**
* Build a map of font family name → fallback name for all fonts
* that have fallback configured. Used by the transform hook.
*/
function collectFallbackNames(options) {
const resolvedCustom = options.custom ? resolveUserOption(options.custom) : void 0;
const map = /* @__PURE__ */ new Map();
for (const { familyName, config } of iterateFallbackFamilies(options, resolvedCustom)) map.set(familyName, config.name ?? generateFallbackName(familyName));
return map;
}
/**
* Transform CSS to append fallback font names to font-family declarations.
* Only modifies declarations that reference a font with fallback configured.
*/
function transformFontFamilyDeclarations(code, id, fallbackNames, sourcemap) {
if (!CSS_RE.test(id)) return void 0;
if (fallbackNames.size === 0) return void 0;
const s = new MagicString(code);
walk(parse(code, { positions: true }), {
visit: "Declaration",
enter(node) {
if (node.property !== "font-family" && node.property !== "font") return;
if (this.atrule && this.atrule.name === "font-face") return;
if (node.value.type !== "Value") return;
for (const child of node.value.children) {
let family;
if (child.type === "String") family = child.value;
else if (child.type === "Identifier" && !GENERIC_FAMILIES.has(child.name)) family = child.name;
if (!family) continue;
const fallbackName = fallbackNames.get(family);
if (!fallbackName) continue;
s.appendRight(child.loc.end.offset, `, "${fallbackName}"`);
}
}
});
if (!s.hasChanged()) return void 0;
return {
code: s.toString(),
map: sourcemap ? s.generateMap({
source: id,
includeContent: true
}) : void 0
};
}
//#endregion
export { collectFallbackNames, generateAllFallbacks, hasFallbacks, transformFontFamilyDeclarations };