import { withHttps, withQuery, resolveURL } from 'ufo'; import { existsSync, readFileSync, copyFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, dirname, extname, posix } from 'node:path'; import { ofetch } from 'ofetch'; import { Hookable } from 'hookable'; import deepmerge from 'deepmerge'; const GOOGLE_FONTS_DOMAIN = "fonts.googleapis.com"; function isValidDisplay(display) { return ["auto", "block", "swap", "fallback", "optional"].includes(display); } function parseStyle(style) { if (["wght", "normal", "regular"].includes(style.toLowerCase())) { return "wght"; } if (["ital", "italic", "i"].includes(style.toLowerCase())) { return "ital"; } return style; } function cartesianProduct(...a) { return a.length < 2 ? a : a.reduce((a2, b) => a2.flatMap((d) => b.map((e) => [d, e].flat()))); } function parseFamilyName(name) { return decodeURIComponent(name).replace(/\+/g, " "); } function constructURL({ families, display, subsets, text } = {}) { const _subsets = (Array.isArray(subsets) ? subsets : [subsets]).filter(Boolean); const family = convertFamiliesToArray(families ?? {}); if (family.length < 1) { return false; } const query = { family }; if (display && isValidDisplay(display)) { query.display = display; } if (_subsets.length > 0) { query.subset = _subsets.join(","); } if (text) { query.text = text; } return withHttps(withQuery(resolveURL(GOOGLE_FONTS_DOMAIN, "css2"), query)); } function convertFamiliesToArray(families) { const result = []; Object.entries(families).forEach(([name, values]) => { if (!name) { return; } name = parseFamilyName(name); if (typeof values === "string" && String(values).includes("..")) { result.push(`${name}:wght@${values}`); return; } if (Array.isArray(values) && values.length > 0) { result.push(`${name}:wght@${values.join(";")}`); return; } if (Object.keys(values).length > 0) { const axes = {}; let italicWeights = []; Object.entries(values).sort(([styleA], [styleB]) => styleA.localeCompare(styleB)).forEach(([style, weight]) => { const parsedStyle = parseStyle(style); if (parsedStyle === "ital") { axes[parsedStyle] = ["0", "1"]; if (weight === true || weight === 400 || weight === 1) { italicWeights = ["*"]; } else { italicWeights = Array.isArray(weight) ? weight.map((w) => String(w)) : [weight]; } } else { axes[parseStyle(style)] = Array.isArray(weight) ? weight.map((w) => String(w)) : [weight]; } }); const strictlyItalic = []; if (Object.keys(axes).length === 1 && Object.hasOwn(axes, "ital")) { if (!(italicWeights.includes("*") || italicWeights.length === 1 && italicWeights.includes("400"))) { axes.wght = italicWeights; strictlyItalic.push(...italicWeights); } } else if (Object.hasOwn(axes, "wght") && !italicWeights.includes("*")) { strictlyItalic.push(...italicWeights.filter((w) => !axes.wght.includes(w))); axes.wght = [.../* @__PURE__ */ new Set([...axes.wght, ...italicWeights])]; } const axisTagList = Object.keys(axes).sort((axisA, axisB) => { const isLowerA = axisA[0] === axisA[0].toLowerCase(); const isLowerB = axisB[0] === axisB[0].toLowerCase(); if (isLowerA && !isLowerB) { return -1; } if (!isLowerA && isLowerB) { return 1; } return axisA.localeCompare(axisB); }); if (axisTagList.length === 1 && axisTagList.includes("ital")) { result.push(`${name}:ital@1`); return; } let axisTupleArrays = cartesianProduct(...axisTagList.map((tag) => axes[tag]), [[]]); const italicIndex = axisTagList.findIndex((i) => i === "ital"); if (italicIndex !== -1) { const weightIndex = axisTagList.findIndex((i) => i === "wght"); if (weightIndex !== -1) { axisTupleArrays = axisTupleArrays.filter((axisTuple) => axisTuple[italicIndex] === "0" && !strictlyItalic.includes(axisTuple[weightIndex]) || axisTuple[italicIndex] === "1" && italicWeights.includes(axisTuple[weightIndex])); } } const axisTupleList = axisTupleArrays.sort((axisTupleA, axisTupleB) => { for (let i = 0; i < axisTupleA.length; i++) { const compareResult = parseInt(axisTupleA[i]) - parseInt(axisTupleB[i]); if (compareResult !== 0) { return compareResult; } } return 0; }).map((axisTuple) => axisTuple.join(",")).join(";"); result.push(`${name}:${axisTagList.join(",")}@${axisTupleList}`); return; } if (values) { result.push(name); } }); return result; } function isValidURL(url) { return RegExp(GOOGLE_FONTS_DOMAIN).test(url); } var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; class Downloader extends Hookable { constructor(url, options) { super(); this.url = url; __publicField(this, "config"); this.config = { base64: false, overwriting: false, outputDir: "./", stylePath: "fonts.css", fontsDir: "fonts", fontsPath: "./fonts", headers: [["user-agent", [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/98.0.4758.102 Safari/537.36" ].join(" ")]], ...options }; } async execute() { if (!isValidURL(this.url)) { throw new Error("Invalid Google Fonts URL"); } const { outputDir, stylePath, headers, fontsPath } = this.config; const cssPath = resolve(outputDir, stylePath); let overwriting = this.config.overwriting; if (!overwriting && existsSync(cssPath)) { const currentCssContent = readFileSync(cssPath, "utf-8"); const currentUrl = (currentCssContent.split(/\r?\n/, 1).shift() || "").replace("/*", "").replace("*/", "").trim(); if (currentUrl === this.url) { return false; } overwriting = true; } await this.callHook("download:start"); const { searchParams } = new URL(this.url); const subsets = searchParams.get("subset") ? searchParams.get("subset")?.split(",") : void 0; await this.callHook("download-css:before", this.url); const _css = await ofetch(this.url, { headers }); const { fonts: fontsFromCss, css: cssContent } = parseFontsFromCss(_css, fontsPath, subsets); await this.callHook("download-css:done", this.url, cssContent, fontsFromCss); const fonts = await this.downloadFonts(fontsFromCss); await this.callHook("write-css:before", cssPath, cssContent, fonts); const newContent = this.writeCss(cssPath, `/* ${this.url} */ ${cssContent}`, fonts); await this.callHook("write-css:done", cssPath, newContent, cssContent); await this.callHook("download:complete"); return true; } async downloadFonts(fonts) { const { headers, base64, outputDir, fontsDir } = this.config; const downloadedFonts = []; const _fonts = []; for (const font of fonts) { const downloadedFont = downloadedFonts.find((f) => f.inputFont === font.inputFont); if (downloadedFont) { if (base64) { font.outputText = downloadedFont.outputText; } else { copyFileSync( resolve(outputDir, fontsDir, downloadedFont.outputFont), resolve(outputDir, fontsDir, font.outputFont) ); } _fonts.push(font); continue; } await this.callHook("download-font:before", font); const response = await ofetch.raw(font.inputFont, { headers, responseType: "arrayBuffer" }); if (!response?._data) { _fonts.push(font); continue; } const buffer = Buffer.from(response?._data); if (base64) { const mime = response.headers.get("content-type") ?? "font/woff2"; font.outputText = `url('data:${mime};base64,${buffer.toString("base64")}')`; } else { const fontPath = resolve(outputDir, fontsDir, font.outputFont); mkdirSync(dirname(fontPath), { recursive: true }); writeFileSync(fontPath, buffer); } _fonts.push(font); await this.callHook("download-font:done", font); downloadedFonts.push(font); } return _fonts; } writeCss(path, content, fonts) { for (const font of fonts) { content = content.replace(font.inputText, font.outputText); } mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, "utf-8"); return content; } } function parseFontsFromCss(content, fontsPath, subsets) { const css = []; const fonts = []; const re = { face: /\s*(?:\/\*\s*(.*?)\s*\*\/)?[^@]*?@font-face\s*{(?:[^}]*?)}\s*/gi, family: /font-family\s*:\s*(?:'|")?([^;]*?)(?:'|")?\s*;/i, style: /font-style\s*:\s*([^;]*?)\s*;/i, weight: /font-weight\s*:\s*([^;]*?)\s*;/i, url: /url\s*\(\s*(?:'|")?\s*([^]*?)\s*(?:'|")?\s*\)\s*?/gi }; let match1; while ((match1 = re.face.exec(content)) !== null) { const [fontface, subset] = match1; const familyRegExpArray = re.family.exec(fontface); const family = familyRegExpArray ? familyRegExpArray[1] : ""; const styleRegExpArray = re.style.exec(fontface); const style = styleRegExpArray ? styleRegExpArray[1] : ""; const weightRegExpArray = re.weight.exec(fontface); const weight = weightRegExpArray ? weightRegExpArray[1] : ""; if (subsets && subsets.length && !subsets.includes(subset)) { continue; } css.push(fontface); let match2; while ((match2 = re.url.exec(fontface)) !== null) { const [forReplace, url] = match2; const ext = extname(url).replace(/^\./, "") || "woff2"; const newFilename = formatFontFileName("{family}-{style}-{weight}-{subset}.{ext}", { family: family.replace(/\s+/g, "_"), style: style.replace(/\s+/g, "_") || "normal", weight: weight.replace(/\s+/g, "_") || "", subset: subset || "text", ext }).replace(/\.$/, ""); fonts.push({ inputFont: url, outputFont: newFilename, inputText: forReplace, outputText: `url('${posix.join(fontsPath, newFilename)}')` }); } } return { css: css.join("\n"), fonts }; } function formatFontFileName(template, values) { return Object.entries(values).filter(([key]) => /^[a-z0-9_-]+$/gi.test(key)).map(([key, value]) => [new RegExp(`([^{]|^){${key}}([^}]|$)`, "g"), `$1${value}$2`]).reduce((str, [regexp, replacement]) => str.replace(regexp, String(replacement)), template).replace(/({|}){2}/g, "$1"); } function download(url, options) { return new Downloader(url, options); } function merge(...fonts) { return deepmerge.all(fonts); } function parse(url) { const result = {}; if (!isValidURL(url)) { return result; } const { searchParams, pathname } = new URL(url); if (!searchParams.has("family")) { return result; } const families = convertFamiliesObject(searchParams.getAll("family"), pathname.endsWith("2")); if (Object.keys(families).length < 1) { return result; } result.families = families; const display = searchParams.get("display"); if (display && isValidDisplay(display)) { result.display = display; } const subsets = searchParams.get("subset"); if (subsets) { result.subsets = subsets.split(","); } const text = searchParams.get("text"); if (text) { result.text = text; } return result; } function convertFamiliesObject(families, v2 = true) { const result = {}; families.flatMap((family) => family.split("|")).forEach((family) => { if (!family) { return; } if (!family.includes(":")) { result[family] = true; return; } const parts = family.split(":"); if (!parts[1]) { return; } const values = {}; if (!v2) { parts[1].split(",").forEach((style) => { const styleParsed = parseStyle(style); if (styleParsed === "wght") { values.wght = true; } if (styleParsed === "ital") { values.ital = true; } if (styleParsed === "bold" || styleParsed === "b") { values.wght = 700; } if (styleParsed === "bolditalic" || styleParsed === "bi") { values.ital = 700; } }); } if (v2) { let [styles, weights] = parts[1].split("@"); if (!weights) { weights = String(styles).replace(",", ";"); styles = "wght"; } styles.split(",").forEach((style) => { const styleParsed = parseStyle(style); values[styleParsed] = weights.split(";").map((weight) => { if (/^\+?\d+$/.test(weight)) { return parseInt(weight); } const [pos, w] = weight.split(","); const index = styleParsed === "wght" ? 0 : 1; if (!w) { return weight; } if (parseInt(pos) !== index) { return 0; } if (/^\+?\d+$/.test(w)) { return parseInt(w); } return w; }).filter((v) => parseInt(v.toString()) > 0 || v.toString().includes("..")); if (!values[styleParsed].length) { values[styleParsed] = true; return; } if (values[styleParsed].length > 1) { return; } const first = values[styleParsed][0]; if (String(first).includes("..")) { values[styleParsed] = first; } if (first === 1 || first === true) { values[styleParsed] = true; } }); } result[parseFamilyName(parts[0])] = values; }); return result; } export { Downloader, constructURL, download, isValidURL, merge, parse };