diff --git a/.eleventy.js b/.eleventy.js index 75514ed..30e81a7 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -2,6 +2,7 @@ import md from "markdown-it"; import mdFN from "markdown-it-footnote"; import * as sass from "sass"; import path from "node:path"; +import { promises as fs } from "node:fs"; import { eleventyImageTransformPlugin } from "@11ty/eleventy-img"; import { InputPathToUrlTransformPlugin } from "@11ty/eleventy"; @@ -13,6 +14,7 @@ import purgeCssPlugin from "eleventy-plugin-purgecss"; import helpers from "./src/_data/helpers.js"; import siteConfig from "./src/_data/site.js"; +import fonts from "./src/_data/fonts.js"; export default async function (eleventyConfig) { /* 11ty Plugins */ @@ -80,8 +82,6 @@ export default async function (eleventyConfig) { /**********************/ eleventyConfig.addPassthroughCopy({ "src/assets/css": "assets/css", - "src/assets/files": "assets/files", - "src/assets/fonts": "assets/fonts", "src/404.html": "404.html", }); @@ -202,6 +202,23 @@ export default async function (eleventyConfig) { // Where type is one of: words, sentences, paragraphs eleventyConfig.addFilter("loremIpsum", helpers.loremIpsum); + /* Build event handlers */ + /************************/ + + eleventyConfig.on("eleventy.after", + async () => { + const fontBuffers = await fonts.files(); + + for (const { fontBuffer, fileName } of fontBuffers) { + const outputPath = path.join('_site', fonts.buildFontPath, fileName); + const outputDir = path.dirname(outputPath); + + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(outputPath, fontBuffer); + } + } + ); + return { // Set directories to watch dir: { diff --git a/.gitignore b/.gitignore index 4a130ad..f9ea219 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ _site/ .cache +.DS_Store \ No newline at end of file diff --git a/google-fonts.mjs b/google-fonts.mjs new file mode 100644 index 0000000..4d06ac9 --- /dev/null +++ b/google-fonts.mjs @@ -0,0 +1,505 @@ +import { withHttps, withQuery, resolveURL } from "ufo"; +import { existsSync, readFileSync, 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]) => + 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 extractFontInfo() { + if (!isValidURL(this.url)) { + throw new Error("Invalid Google Fonts URL"); + } + const { headers, fontsPath } = this.config; + const _css = await ofetch(this.url, { headers }); + const { fonts, css } = parseFontsFromCss(_css, fontsPath); + return { fonts, css }; + } + 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) { + font.outputText = downloadedFont.outputText; + _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, "utf-8"); + } + _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, + weight: /font-weight\s*:\s*([^;]*?)\s*;/i, + url: /url\s*\(\s*(?:'|")?\s*([^]*?)\s*(?:'|")?\s*\)\s*?/gi, + }; + let i = 1; + 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 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}-{weight}-{i}.{ext}", { + family: family.replace(/\s+/g, "_"), + weight: weight.replace(/\s+/g, "_") || "", + i: String(i++), + 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); +} +async function getFontInfo(url, options) { + const info = new Downloader(url, options); + const { fonts, css } = await info.extractFontInfo(); + let localCSS = css; + const fontMaps = /* @__PURE__ */ new Map(); + for (const font of fonts) { + localCSS = localCSS.replace(font.inputText, font.outputText); + fontMaps.set(font.inputFont, font.outputFont); + } + return { + localCSS, + fontMaps, + }; +} + +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, + getFontInfo, + isValidURL, + merge, + parse, +}; diff --git a/package-lock.json b/package-lock.json index 28d6033..e10e1ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,18 +10,23 @@ "license": "MIT", "dependencies": { "@11ty/eleventy": "^3.0.0-alpha.17", + "@11ty/eleventy-fetch": "^4.0.1", "@11ty/eleventy-img": "^4.0.2", "@11ty/eleventy-plugin-directory-output": "^1.0.1", "@11ty/eleventy-plugin-rss": "^2.0.2", "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", + "deepmerge": "^4.3.1", "eleventy-plugin-purgecss": "^0.5.0", "gorko": "^0.9.1", + "hookable": "^5.5.3", "lorem-ipsum": "^2.0.8", "luxon": "^3.4.4", "markdown-it": "^14.1.0", "markdown-it-footnote": "^4.0.0", + "ofetch": "^1.3.4", "sass": "^1.77.8", "slugify": "^1.6.6", + "ufo": "^1.5.4", "utopia-core-scss": "^1.1.0" }, "devDependencies": { @@ -1219,6 +1224,14 @@ } } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1267,6 +1280,11 @@ "node": ">=4" } }, + "node_modules/destr": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz", + "integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -1780,6 +1798,11 @@ "node": ">= 0.4" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + }, "node_modules/htmlparser2": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", @@ -2273,6 +2296,11 @@ } } }, + "node_modules/node-fetch-native": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==" + }, "node_modules/node-retrieve-globals": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/node-retrieve-globals/-/node-retrieve-globals-6.0.0.tgz", @@ -2344,6 +2372,16 @@ "node": ">= 0.4" } }, + "node_modules/ofetch": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.3.4.tgz", + "integrity": "sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==", + "dependencies": { + "destr": "^2.0.3", + "node-fetch-native": "^1.6.3", + "ufo": "^1.5.3" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3142,6 +3180,11 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" }, + "node_modules/ufo": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", + "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 4142f3a..cf5a5d2 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "homepage": "https://github.com/alexmensch/11ty-cubetopia#readme", "dependencies": { "@11ty/eleventy": "^3.0.0-alpha.17", + "@11ty/eleventy-fetch": "^4.0.1", "@11ty/eleventy-img": "^4.0.2", "@11ty/eleventy-plugin-directory-output": "^1.0.1", "@11ty/eleventy-plugin-rss": "^2.0.2", diff --git a/src/_data/fonts.js b/src/_data/fonts.js new file mode 100644 index 0000000..81a5394 --- /dev/null +++ b/src/_data/fonts.js @@ -0,0 +1,37 @@ +import EleventyFetch from "@11ty/eleventy-fetch"; +import { getFontInfo } from "../../google-fonts.mjs"; + +const buildFontPath = "/assets/fonts"; +const buildCSS = "/assets/css/fonts.css"; + +/* outputDir: local base directory + stylePath: name of CSS file in ${outputDir} + fontsDir: local directory for fonts: ${outputDir}/${fontsDir} + fontsPath: used to populate url() in CSS file + */ +const { localCSS, fontMaps } = await getFontInfo( + "https://fonts.googleapis.com/css2?family=Roboto", + { + base64: false, + fontsPath: buildFontPath, + }); + +export default { + css: async function () { + return localCSS; + }, + files: async function () { + const fonts = []; + + for (const [url, fileName] of fontMaps) { + const fontBuffer = await EleventyFetch(url, { + duration: "1w", + type: "buffer", + }); + fonts.push({fontBuffer, fileName}); + } + return fonts; + }, + buildFontPath: buildFontPath, + buildCSS: buildCSS, +}; diff --git a/src/_data/site.js b/src/_data/site.js index 9bd5f97..f11e298 100644 --- a/src/_data/site.js +++ b/src/_data/site.js @@ -1,3 +1,5 @@ +import fonts from "./fonts.js"; + export default { domain: "your-domain.com", authorName: "Alex Marshall", @@ -6,7 +8,7 @@ export default { includes: [ { rel: "stylesheet", - href: "/assets/css/fonts.css", + href: fonts.buildCSS, }, { rel: "stylesheet", diff --git a/src/assets/fonts.liquid b/src/assets/fonts.liquid new file mode 100644 index 0000000..b617941 --- /dev/null +++ b/src/assets/fonts.liquid @@ -0,0 +1,4 @@ +--- +permalink: "{{ fonts.buildCSS }}" +--- +{{ fonts.css }}