vite-font-extractor-plugin
Version:
Vite plugin for extracting glyphes by ligatures from font and creating new minimized fonts with them
681 lines (671 loc) • 23.1 kB
JavaScript
// src/extractor.ts
import { isCSSRequest, send } from "vite";
import { basename as basename2, isAbsolute } from "node:path";
import { extract } from "fontext";
// src/cache.ts
import { mkdirSync, existsSync, writeFileSync, readFileSync, rmSync } from "node:fs";
// src/utils.ts
import { normalizePath } from "vite";
import { extname, join } from "node:path";
import { createHash } from "node:crypto";
// src/constants.ts
var PLUGIN_NAME = "vite-font-extractor-plugin";
var GOOGLE_FONT_URL_RE = /["'](.+fonts.googleapis.com.+)["']/g;
var FONT_URL_REGEX = /url\(['"]?(.*?)['"]?\)/g;
var FONT_FAMILY_RE = /font-family:\s*(.*?);/;
var SUPPORT_START_FONT_REGEX = /otf|ttf|woff|woff2|ttc|dfont/;
var FONT_FACE_BLOCK_REGEX = /@font-face\s*{([\s\S]*?)}/g;
var SUPPORTED_RESULTS_FORMATS = ["woff2", "woff", "svg", "eot", "ttf"];
var PROCESS_EXTENSION = ".fef";
var GLYPH_REGEX = /content\s*:[^};]*?('|")(.*?)\s*('|"|;)/g;
var UNICODE_REGEX = /\\(\w{4})/;
var SYMBOL_REGEX = /"(.)"/;
// src/utils.ts
var mergePath = (...paths) => normalizePath(join(...paths.filter(Boolean)));
var getHash = (text, length = 8) => createHash("sha256").update(text).digest("hex").substring(0, length);
var getExtension = (filename) => extname(filename).slice(1);
var getFontExtension = (fontFileName) => getExtension(fontFileName);
function exists(value) {
return value !== null && value !== void 0;
}
function intersection(array1, array2) {
return array1.filter((item) => array2.includes(item));
}
function hasDifferent(array1, array2) {
if (array1.length !== array2.length) {
return true;
}
const [biggest, lowest] = array1.length > array2.length ? [array1, array2] : [array2, array1];
return biggest.some((item) => !lowest.includes(item));
}
function createResolvers(config) {
let fontResolve;
return {
get common() {
return fontResolve ?? (fontResolve = config.createResolver({
extensions: [],
tryIndex: false,
preferRelative: false
}));
},
get font() {
return fontResolve ?? (fontResolve = config.createResolver({
extensions: SUPPORTED_RESULTS_FORMATS,
tryIndex: false,
preferRelative: false
}));
}
};
}
var extractFontFaces = (code) => {
const faces = [];
let match = null;
FONT_FACE_BLOCK_REGEX.lastIndex = 0;
while (match = FONT_FACE_BLOCK_REGEX.exec(code)) {
const face = match[0];
if (face) {
faces.push(face);
}
}
return faces;
};
var extractFonts = (fontFaceString) => {
const fonts = [];
let match = null;
FONT_URL_REGEX.lastIndex = 0;
while (match = FONT_URL_REGEX.exec(fontFaceString)) {
const url = match[1];
if (url) {
fonts.push(url);
}
}
return fonts;
};
var extractGoogleFontsUrls = (code) => {
const urls = [];
let match = null;
GOOGLE_FONT_URL_RE.lastIndex = 0;
while (match = GOOGLE_FONT_URL_RE.exec(code)) {
const url = match[1];
if (url) {
urls.push(url);
}
}
return urls;
};
var extractFontName = (fontFaceString) => {
const fontName = FONT_FAMILY_RE.exec(fontFaceString)?.[1];
return fontName?.replace(/["']/g, "") ?? "";
};
var findUnicodeGlyphs = (code) => {
const matches = code.match(GLYPH_REGEX) || [];
return matches.map((match) => {
const [, unicodeMatch] = match.match(UNICODE_REGEX) || [];
if (unicodeMatch) {
return String.fromCharCode(parseInt(unicodeMatch, 16));
}
const [, symbolMatch] = match.match(SYMBOL_REGEX) || [];
if (symbolMatch) {
return symbolMatch;
}
return "";
}).filter(Boolean);
};
// src/cache.ts
import glob from "fast-glob";
var Cache = class {
path;
constructor(to) {
this.path = mergePath(to, ".font-extractor-cache");
this.createDir();
}
get exist() {
return existsSync(this.path);
}
check(key) {
return existsSync(this.getPathTo(key));
}
get(key) {
return readFileSync(this.getPathTo(key));
}
set(key, data) {
this.createDir();
writeFileSync(this.getPathTo(key), data);
}
createDir() {
if (this.exist) {
return;
}
mkdirSync(this.path, { recursive: true });
}
clearCache(pattern) {
const remove = (target, recursive = false) => {
rmSync(target, { recursive });
this.createDir();
};
if (pattern) {
glob.sync(pattern + "/**", {
absolute: true,
onlyFiles: true,
cwd: this.path
}).forEach((target) => {
remove(target);
});
} else {
remove(this.path, true);
}
}
getPathTo(...to) {
return mergePath(this.path, ...to);
}
};
// src/extractor.ts
import { readFileSync as readFileSync2 } from "node:fs";
// src/styler.ts
import color from "picocolors";
import { basename, dirname, sep } from "node:path";
var DEFAULT = (message) => message;
var aliases = {
warn: color.yellow,
tag: color.cyan,
error: color.red,
path: (message) => [color.dim(dirname(message) + sep), color.green(basename(message))].join("")
};
var styler_default = new Proxy({}, {
get(_, key) {
if (key in aliases) {
return aliases[key];
}
if (key in color) {
return (message) => color[key](message);
}
return DEFAULT;
}
});
// src/internal-loger.ts
import { createLogger } from "vite";
var createInternalLogger = (logLevel, customLogger) => {
const prefix = `[${PLUGIN_NAME}]`;
const logger = createLogger(logLevel, {
prefix,
customLogger,
allowClearScreen: true
});
let needFix = false;
const log = (level, message, options) => {
if (needFix) {
logger.info("");
needFix = false;
}
const tag = options?.timestamp ? "" : styler_default.tag(prefix) + " ";
logger[level](`${tag}${styler_default[level](message)}`, options);
};
const error = (message, options) => {
log("error", message, options);
};
const warn = (message, options) => {
log("warn", message, options);
};
const info = (message, options) => {
log("info", message, options);
};
return {
error,
warn,
info,
fix: () => {
needFix = true;
}
};
};
// src/extractor.ts
import groupBy from "lodash.groupby";
import camelcase from "lodash.camelcase";
function FontExtractor(pluginOption = { type: "auto" }) {
const mode = pluginOption.type ?? "manual";
let cache;
let importResolvers;
let logger;
let isServe = false;
const fontServeProxy = /* @__PURE__ */ new Map();
const glyphsFindMap = /* @__PURE__ */ new Map();
const autoTarget = new Proxy(
{
fontName: "ERROR: Illegal access. Font name must be provided from another place instead it",
raws: [],
withWhitespace: true,
ligatures: []
},
{
get(target, key) {
if (key === "fontName") {
throw Error(target[key]);
}
if (key === "raws") {
return Array.from(glyphsFindMap.values()).flat();
}
return target[key];
}
}
);
const autoProxyOption = new Proxy({
sid: "[calculating...]",
target: autoTarget,
auto: true
}, {
get(target, key) {
if (key === "sid") {
return JSON.stringify(autoTarget.raws);
}
return target[key];
}
});
const targets = pluginOption.targets ? Array.isArray(pluginOption.targets) ? pluginOption.targets : [pluginOption.targets] : [];
const casualOptionsMap = new Map(
targets.map((target) => [target.fontName, { sid: JSON.stringify(target), target, auto: false }])
);
const optionsMap = {
get: (key) => {
const option = casualOptionsMap.get(key);
return mode === "auto" ? option ?? autoProxyOption : option;
},
has: (key) => mode === "auto" || casualOptionsMap.has(key)
};
const progress = /* @__PURE__ */ new Map();
const transformMap = /* @__PURE__ */ new Map();
const changeResource = function(code, transform) {
const sid = getHash(transform.sid);
const assetUrlRE = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g;
const oldReferenceId = assetUrlRE.exec(transform.alias)[1];
const referenceId = this.emitFile({
type: "asset",
name: transform.name + PROCESS_EXTENSION,
source: Buffer.from(sid + oldReferenceId)
});
transformMap.set(oldReferenceId, referenceId);
return code.replace(transform.alias, transform.alias.replace(oldReferenceId, referenceId));
};
const getSourceByUrl = async (url, importer) => {
const entrypointFilePath = await importResolvers.font(url, importer);
if (!entrypointFilePath) {
logger.warn(`Can not resolve entrypoint font by url: ${styler_default.path(url)}`);
return null;
}
return readFileSync2(entrypointFilePath);
};
const processMinify = async (fontName, fonts, options) => {
const unsupportedFont = fonts.find((font) => !SUPPORTED_RESULTS_FORMATS.includes(font.extension));
if (unsupportedFont) {
logger.error(`Font face has unsupported extension - ${unsupportedFont.extension ?? "undefined"}`);
return null;
}
const entryPoint = fonts.find((font) => SUPPORT_START_FONT_REGEX.test(font.extension));
if (!entryPoint) {
logger.error("No find supported fonts file extensions for extracting process");
return null;
}
const sid = options.sid;
const cacheKey = camelcase(fontName) + "-" + getHash(sid + entryPoint.url);
const needExtracting = fonts.some((font) => !cache?.check(cacheKey + `.${font.extension}`));
const minifiedBuffers = { meta: [] };
if (needExtracting) {
if (cache) {
logger.info(`Clear cache for ${fontName} because some files have a different content`);
cache.clearCache(fontName);
}
const source = entryPoint.source ?? await getSourceByUrl(entryPoint.url, entryPoint.importer);
if (!source) {
logger.error(`No found source for ${fontName}:${styler_default.path(entryPoint.url)}`);
return null;
}
const minifyResult = await extract(
Buffer.from(source),
{
fontName,
formats: fonts.map((font) => font.extension),
raws: options.target.raws,
ligatures: options.target.ligatures,
withWhitespace: options.target.withWhitespace
}
);
Object.assign(minifiedBuffers, minifyResult);
if (cache) {
fonts.forEach((font) => {
const minifiedBuffer = minifyResult[font.extension];
if (minifiedBuffer) {
logger.info(`Save a minified buffer for ${fontName} to cache`);
cache.set(cacheKey + `.${font.extension}`, minifiedBuffer);
}
});
}
} else {
logger.info(`Get minified fonts from cache for ${fontName}`);
const cacheResult = Object.fromEntries(
fonts.map((font) => [font.extension, cache.get(cacheKey + `.${font.extension}`)])
);
Object.assign(minifiedBuffers, cacheResult);
}
return minifiedBuffers;
};
const checkFontProcessing = (name, id) => {
const duplicateId = progress.get(name);
if (duplicateId && !isServe) {
const placeInfo = `Font placed in "${styler_default.path(id)}" and "${styler_default.path(duplicateId)}"`;
const errorMessage = `Plugin not support a multiply files with same font name [${name}]. ${placeInfo}`;
logger.error(errorMessage);
throw new Error(errorMessage);
} else {
progress.set(name, id);
}
};
const processServeFontMinify = (id, url, fontName) => {
let result = null;
let prevOptions = optionsMap.get(fontName);
return async () => {
const currentOptions = optionsMap.get(fontName);
if (currentOptions && (!result || currentOptions.sid !== prevOptions?.sid)) {
prevOptions = currentOptions;
const extension = getFontExtension(url);
const minifiedBuffers = await processMinify(
fontName,
[{
url,
importer: id,
extension
}],
currentOptions
);
const content = minifiedBuffers?.[extension];
if (content) {
result = {
content,
extension,
id
};
} else {
result = null;
}
}
return result;
};
};
const processServeAutoFontMinify = (id, url, fontName) => {
let previousRaws = autoProxyOption.target.raws ?? [];
let result;
return async () => {
const currentRaws = autoProxyOption.target.raws ?? [];
if (!result || hasDifferent(previousRaws, currentRaws)) {
previousRaws = currentRaws;
const extension = getFontExtension(url);
const minifiedBuffers = await processMinify(
fontName,
[{
url,
importer: id,
extension
}],
autoProxyOption
);
const content = minifiedBuffers?.[extension];
if (content) {
result = {
content,
extension,
id
};
} else {
result = null;
}
}
return result;
};
};
const loadedAutoFontMap = /* @__PURE__ */ new Map();
const processFont = async function(code, id, font) {
checkFontProcessing(font.name, id);
if (isServe) {
font.aliases.forEach((url) => {
if (fontServeProxy.has(url)) {
return;
}
const process = font.options.auto ? processServeAutoFontMinify(id, url, font.name) : processServeFontMinify(id, url, font.name);
fontServeProxy.set(
url,
process
);
if (font.options.auto) {
loadedAutoFontMap.set(url, false);
}
});
} else {
if (mode === "auto") {
const message = `"auto" mod detected. "${font.name}" font is stubbed and result file hash will be recalculated randomly that may potential problem with external cache systems. If this font is not target please add it to ignore`;
logger.warn(message);
}
font.aliases.forEach((alias) => {
code = changeResource.call(
this,
code,
{
alias,
name: font.name,
// TODO: must be reworked
sid: mode === "auto" ? Math.random().toString() : font.options.sid
}
);
});
}
return code;
};
const processGoogleFontUrl = function(code, id, font) {
checkFontProcessing(font.name, id);
const oldText = font.url.searchParams.get("text");
if (oldText) {
logger.warn(`Font [${font.name}] in ${id} has duplicated logic for minification`);
}
const text = [oldText, ...font.options.target.ligatures ?? []].filter(exists).join(" ");
const originalUrl = font.url.toString();
const fixedUrl = new URL(originalUrl);
fixedUrl.searchParams.set("text", text);
return code.replace(originalUrl, fixedUrl.toString());
};
return {
name: PLUGIN_NAME,
configResolved(config) {
logger = createInternalLogger(pluginOption.logLevel ?? config.logLevel, config.customLogger);
logger.fix();
logger.info(`Plugin starts in "${mode}" mode`);
const intersectionIgnoreWithTargets = intersection(pluginOption.ignore ?? [], targets.map((target) => target.fontName));
if (intersectionIgnoreWithTargets.length) {
logger.warn(`Ignore option has intersection with targets: ${intersectionIgnoreWithTargets.toString()}`);
}
importResolvers = createResolvers(config);
if (pluginOption.cache) {
const cachePath = typeof pluginOption.cache === "string" && pluginOption.cache || "node_modules";
const resolvedPath = isAbsolute(cachePath) ? cachePath : mergePath(config.root, cachePath);
cache = new Cache(resolvedPath);
}
},
configureServer(server) {
isServe = true;
server.middlewares.use((req, res, next) => {
const url = req.url;
const process = fontServeProxy.get(url);
if (!process) {
next();
} else {
void (async () => {
const stub = await process();
if (!stub) {
next();
return;
}
logger.fix();
logger.info(`Stub server response for: ${styler_default.path(url)}`);
send(req, res, stub.content, `font/${stub.extension}`, {
cacheControl: "no-cache",
headers: server.config.server.headers,
// Disable cache for font request
etag: ""
});
loadedAutoFontMap.set(url, true);
})();
}
});
},
async transform(code, id) {
logger.fix();
const isCssFile = isCSSRequest(id);
const isAutoType = mode === "auto";
const isCssFileWithFontFaces = isCssFile && code.includes("@font-face");
if (isAutoType && isCssFile) {
const glyphs = findUnicodeGlyphs(code);
glyphsFindMap.set(id, glyphs);
}
if ((id.endsWith(".html") || isCssFile && code.includes("@import")) && code.includes("fonts.googleapis.com")) {
const googleFonts = extractGoogleFontsUrls(code).map((raw) => {
const url = new URL(raw);
const name = url.searchParams.get("family");
if (pluginOption.ignore?.includes(name)) {
return null;
}
if (!name) {
logger.warn(`No specified google font name in ${styler_default.path(id)}`);
return null;
}
if (name.includes("|")) {
logger.warn("Google font url includes multiple families. Not supported");
return null;
}
const options = optionsMap.get(name);
if (!options) {
logger.warn(`Font "${name}" has no minify options`);
return null;
}
return {
name,
options,
url
};
}).filter(exists);
for (const font of googleFonts) {
try {
code = processGoogleFontUrl.call(this, code, id, font);
} catch (e) {
logger.error(`Process ${font.name} Google font is failed`, { error: e });
}
}
}
if (isCssFileWithFontFaces) {
const fonts = extractFontFaces(code).map((face) => {
const name = extractFontName(face);
if (pluginOption.ignore?.includes(name)) {
return null;
}
const options = optionsMap.get(name);
if (!options) {
logger.warn(`Font "${name}" has no minify options`);
return null;
}
const aliases2 = extractFonts(face);
const urlSources = aliases2.filter((alias) => alias.startsWith("http"));
if (urlSources.length) {
logger.warn(`Font "${name}" has external url sources: ${urlSources.toString()}`);
return null;
}
return {
name,
face,
aliases: aliases2,
options
};
}).filter(exists);
for (const font of fonts) {
try {
code = await processFont.call(this, code, id, font);
} catch (e) {
logger.error(`Process ${font.name} local font is failed`, { error: e });
}
}
}
return code;
},
async generateBundle(_, bundle) {
if (!transformMap.size) {
return;
}
logger.fix();
try {
const findAssetByReferenceId = (referenceId) => Object.values(bundle).find(
(asset) => asset.fileName.includes(this.getFileName(referenceId))
);
const resources = Array.from(transformMap.entries()).map(([oldReferenceId, newReferenceId]) => {
return [
findAssetByReferenceId(oldReferenceId),
findAssetByReferenceId(newReferenceId)
];
});
const unminifiedFonts = groupBy(
resources.filter(([_2, newFont]) => newFont.fileName.endsWith(PROCESS_EXTENSION)),
([_2, newFont]) => newFont.name.replace(PROCESS_EXTENSION, "")
);
const stringAssets = Object.values(bundle).filter((asset) => asset.type === "asset" && typeof asset.source === "string");
await Promise.all(Object.entries(unminifiedFonts).map(async ([fontName, transforms]) => {
const minifiedBuffer = await processMinify(
fontName,
transforms.map(([originalFont, newFont]) => ({
extension: getFontExtension(originalFont.fileName),
source: Buffer.from(originalFont.source),
url: ""
})),
optionsMap.get(fontName)
);
transforms.forEach(([originalFont, newFont]) => {
const extension = getFontExtension(originalFont.fileName);
const fixedName = originalFont.name ? basename2(originalFont.name, `.${extension}`) : camelcase(fontName);
const temporalNewFontFilename = newFont.fileName;
const fixedBasename = (basename2(newFont.fileName, PROCESS_EXTENSION) + `.${extension}`).replace(fontName, fixedName);
const correctName = fixedName + `.${extension}`;
Object.defineProperty(newFont, "name", {
get() {
return correctName;
}
});
newFont.fileName = newFont.fileName.replace(basename2(temporalNewFontFilename), fixedBasename);
const potentialTemporalNewFontBaseNames = [
temporalNewFontFilename.replace(" ", "\\ "),
temporalNewFontFilename.replace(" ", "%20")
];
stringAssets.forEach((asset) => {
const temporalNewFontBasename = potentialTemporalNewFontBaseNames.find((candidate) => asset.source.includes(candidate));
if (temporalNewFontBasename) {
logger.info(`Change name from "${styler_default.green(temporalNewFontBasename)}" to "${styler_default.green(newFont.fileName)}" in ${styler_default.path(asset.fileName)}`);
asset.source = asset.source.replace(temporalNewFontBasename, newFont.fileName);
}
});
newFont.source = minifiedBuffer?.[extension] ?? Buffer.alloc(0);
});
}));
resources.forEach(([originalFont, newFont]) => {
const originalBuffer = Buffer.from(originalFont.source);
const newLength = newFont.source.length;
const originalLength = originalBuffer.length;
const resultLessThanOriginal = newLength > 0 && newLength < originalLength;
if (!resultLessThanOriginal) {
const comparePreview = styler_default.red(`[${newLength} < ${originalLength}]`);
logger.warn(`New font no less than original ${comparePreview}. Revert content to original font`);
newFont.source = originalBuffer;
}
logger.info(`Delete old asset from: ${styler_default.path(originalFont.fileName)}`);
delete bundle[originalFont.fileName];
});
} catch (error) {
logger.error("Clean up generated bundle is filed", { error });
}
}
};
}
export {
FontExtractor,
FontExtractor as default
};