feat: allow self-hosted fonts

This commit is contained in:
saberzero1 2026-06-10 23:00:41 +02:00
parent 71e090afd7
commit fb6ece0478
No known key found for this signature in database
15 changed files with 467 additions and 13 deletions

6
dist/emitter.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
import { QuartzEmitterPlugin } from '@quartz-community/types';
import { QuartzFontsOptions } from './types.js';
declare const QuartzFontsEmitter: QuartzEmitterPlugin<Partial<QuartzFontsOptions>>;
export { QuartzFontsEmitter };

147
dist/emitter.js vendored Normal file
View file

@ -0,0 +1,147 @@
import { createRequire } from 'module';
import fs from 'fs';
import path from 'path';
createRequire(import.meta.url);
// src/defaults.ts
var DEFAULT_WEIGHTS = {
title: [400, 700],
header: [400, 700],
body: [400, 600],
code: [400, 600]
};
var DEFAULT_ITALIC = {
title: false,
header: false,
body: true,
code: false
};
// src/util/google-fonts.ts
function normalizeFontSpec(spec) {
return typeof spec === "string" ? { name: spec } : spec;
}
function mergeInto(map, role, spec) {
const { name, weights, includeItalic } = normalizeFontSpec(spec);
const resolvedWeights = weights ?? DEFAULT_WEIGHTS[role];
const italic = includeItalic ?? DEFAULT_ITALIC[role];
const existing = map.get(name);
if (existing) {
for (const w of resolvedWeights) existing.weights.add(w);
if (italic) existing.italic = true;
} else {
map.set(name, { name, weights: new Set(resolvedWeights), italic });
}
}
function formatMergedEntry(entry) {
const sortedWeights = [...entry.weights].sort((a, b) => a - b);
const features = [];
if (entry.italic) {
features.push("ital");
}
if (sortedWeights.length > 1) {
const weightSpec = entry.italic ? sortedWeights.flatMap((w) => [`0,${w}`, `1,${w}`]).sort().join(";") : sortedWeights.join(";");
features.push(`wght@${weightSpec}`);
}
if (features.length > 0) {
return `${entry.name}:${features.join(",")}`;
}
return entry.name;
}
function googleFontHref(fonts) {
const merged = /* @__PURE__ */ new Map();
mergeInto(merged, "header", fonts.header);
mergeInto(merged, "body", fonts.body);
mergeInto(merged, "code", fonts.code);
if (fonts.title) {
mergeInto(merged, "title", fonts.title);
}
const families = [...merged.values()].map(formatMergedEntry);
const params = families.map((f) => `family=${encodeURIComponent(f)}`).join("&");
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
}
// src/util/process-fonts.ts
var fontMimeMap = {
truetype: "ttf",
woff: "woff",
woff2: "woff2",
opentype: "otf"
};
var fontUrlPattern = /url\((https:\/\/fonts.gstatic.com\/.+(?:\/|(?:kit=))(.+?)[.&].+?)\)\sformat\('(\w+?)'\);/g;
function processGoogleFonts(stylesheet, baseUrl) {
const fontFiles = [];
const processedStylesheet = stylesheet.replace(
fontUrlPattern,
(match, url, filename, format) => {
const extension = fontMimeMap[format] ?? format;
const rewrittenUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`;
fontFiles.push({ url, filename, extension });
return match.replace(url, rewrittenUrl);
}
);
return { processedStylesheet, fontFiles };
}
// src/emitter.ts
var QUARTZ_DEFAULT_HEADER = "Schibsted Grotesk";
var QUARTZ_DEFAULT_BODY = "Source Sans Pro";
var QUARTZ_DEFAULT_CODE = "IBM Plex Mono";
var defaultOptions = {
useThemeFonts: true,
fontOrigin: "googleFonts"
};
var QuartzFontsEmitter = (userOptions) => {
const options = { ...defaultOptions, ...userOptions };
return {
name: "QuartzFontsEmitter",
async *emit(ctx, _content, _resources) {
if (options.fontOrigin !== "selfHosted") {
return;
}
const baseUrl = ctx.cfg.configuration.baseUrl;
if (!baseUrl) {
throw new Error("[QuartzFontsEmitter] baseUrl is required for selfHosted fonts.");
}
const headerSpec = options.header ?? QUARTZ_DEFAULT_HEADER;
const bodySpec = options.body ?? QUARTZ_DEFAULT_BODY;
const codeSpec = options.code ?? QUARTZ_DEFAULT_CODE;
const href = googleFontHref({
title: options.title,
header: headerSpec,
body: bodySpec,
code: codeSpec
});
const cssResponse = await fetch(href);
if (!cssResponse.ok) {
throw new Error(
`[QuartzFontsEmitter] Failed to fetch Google Fonts CSS: ${cssResponse.status} ${cssResponse.statusText}`
);
}
const cssText = await cssResponse.text();
const { processedStylesheet, fontFiles } = processGoogleFonts(cssText, baseUrl);
const fontsDir = path.join(ctx.argv.output, "static", "fonts");
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const fontFile of fontFiles) {
const fontResponse = await fetch(fontFile.url);
if (!fontResponse.ok) {
throw new Error(
`[QuartzFontsEmitter] Failed to fetch font file: ${fontFile.url} (${fontResponse.status} ${fontResponse.statusText})`
);
}
const buf = await fontResponse.arrayBuffer();
const filePath = path.join(fontsDir, `${fontFile.filename}.${fontFile.extension}`);
await fs.promises.writeFile(filePath, Buffer.from(buf));
yield filePath;
}
const cssPath = path.join(fontsDir, "quartz-fonts.css");
await fs.promises.writeFile(cssPath, processedStylesheet);
yield cssPath;
}
};
};
export { QuartzFontsEmitter };
//# sourceMappingURL=emitter.js.map
//# sourceMappingURL=emitter.js.map

1
dist/emitter.js.map vendored Normal file

File diff suppressed because one or more lines are too long

5
dist/index.d.ts vendored
View file

@ -1,7 +1,8 @@
import { QuartzTransformerPlugin } from '@quartz-community/types';
export { QuartzTransformerPlugin } from '@quartz-community/types';
export { QuartzEmitterPlugin, QuartzTransformerPlugin } from '@quartz-community/types';
import { QuartzFontsOptions } from './types.js';
export { FontFileEntry, FontSpecification, QuartzFontRegistry } from './types.js';
export { FontFileEntry, FontSpecification, GoogleFontFile, ProcessedFontResult, QuartzFontRegistry } from './types.js';
export { QuartzFontsEmitter } from './emitter.js';
declare const QuartzFonts: QuartzTransformerPlugin<Partial<QuartzFontsOptions>>;

91
dist/index.js vendored
View file

@ -1,4 +1,6 @@
import { createRequire } from 'module';
import fs from 'fs';
import path from 'path';
const require$1 = createRequire(import.meta.url);
var __require = /* @__PURE__ */ ((x2) => typeof require$1 !== "undefined" ? require$1 : typeof Proxy !== "undefined" ? new Proxy(x2, {
@ -316,12 +318,97 @@ var QuartzFonts = (userOptions) => {
{ content: buildLayeredCSS(fonts), inline: true },
{ content: buildUnlayeredCSS(fonts), inline: true }
];
const additionalHead = options.fontOrigin === "googleFonts" ? buildGoogleFontsHead(options) : [];
let additionalHead = [];
if (options.fontOrigin === "googleFonts") {
additionalHead = buildGoogleFontsHead(options);
} else if (options.fontOrigin === "selfHosted") {
additionalHead = [_("link", { rel: "stylesheet", href: "/static/fonts/quartz-fonts.css" })];
}
return { css, js: [], additionalHead };
}
};
};
export { QuartzFonts };
// src/util/process-fonts.ts
var fontMimeMap = {
truetype: "ttf",
woff: "woff",
woff2: "woff2",
opentype: "otf"
};
var fontUrlPattern = /url\((https:\/\/fonts.gstatic.com\/.+(?:\/|(?:kit=))(.+?)[.&].+?)\)\sformat\('(\w+?)'\);/g;
function processGoogleFonts(stylesheet, baseUrl) {
const fontFiles = [];
const processedStylesheet = stylesheet.replace(
fontUrlPattern,
(match, url, filename, format) => {
const extension = fontMimeMap[format] ?? format;
const rewrittenUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`;
fontFiles.push({ url, filename, extension });
return match.replace(url, rewrittenUrl);
}
);
return { processedStylesheet, fontFiles };
}
// src/emitter.ts
var QUARTZ_DEFAULT_HEADER2 = "Schibsted Grotesk";
var QUARTZ_DEFAULT_BODY2 = "Source Sans Pro";
var QUARTZ_DEFAULT_CODE2 = "IBM Plex Mono";
var defaultOptions2 = {
useThemeFonts: true,
fontOrigin: "googleFonts"
};
var QuartzFontsEmitter = (userOptions) => {
const options = { ...defaultOptions2, ...userOptions };
return {
name: "QuartzFontsEmitter",
async *emit(ctx, _content, _resources) {
if (options.fontOrigin !== "selfHosted") {
return;
}
const baseUrl = ctx.cfg.configuration.baseUrl;
if (!baseUrl) {
throw new Error("[QuartzFontsEmitter] baseUrl is required for selfHosted fonts.");
}
const headerSpec = options.header ?? QUARTZ_DEFAULT_HEADER2;
const bodySpec = options.body ?? QUARTZ_DEFAULT_BODY2;
const codeSpec = options.code ?? QUARTZ_DEFAULT_CODE2;
const href = googleFontHref({
title: options.title,
header: headerSpec,
body: bodySpec,
code: codeSpec
});
const cssResponse = await fetch(href);
if (!cssResponse.ok) {
throw new Error(
`[QuartzFontsEmitter] Failed to fetch Google Fonts CSS: ${cssResponse.status} ${cssResponse.statusText}`
);
}
const cssText = await cssResponse.text();
const { processedStylesheet, fontFiles } = processGoogleFonts(cssText, baseUrl);
const fontsDir = path.join(ctx.argv.output, "static", "fonts");
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const fontFile of fontFiles) {
const fontResponse = await fetch(fontFile.url);
if (!fontResponse.ok) {
throw new Error(
`[QuartzFontsEmitter] Failed to fetch font file: ${fontFile.url} (${fontResponse.status} ${fontResponse.statusText})`
);
}
const buf = await fontResponse.arrayBuffer();
const filePath = path.join(fontsDir, `${fontFile.filename}.${fontFile.extension}`);
await fs.promises.writeFile(filePath, Buffer.from(buf));
yield filePath;
}
const cssPath = path.join(fontsDir, "quartz-fonts.css");
await fs.promises.writeFile(cssPath, processedStylesheet);
yield cssPath;
}
};
};
export { QuartzFonts, QuartzFontsEmitter };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

14
dist/types.d.ts vendored
View file

@ -26,9 +26,19 @@ interface QuartzFontsOptions {
* Where to load fonts from.
* - `"googleFonts"`: generate Google Fonts `<link>` tags.
* - `"local"`: assume fonts are available locally (no loading).
* - `"selfHosted"`: download Google Fonts and serve from the build output.
* Defaults to `"local"` (no automatic font loading).
*/
fontOrigin?: "googleFonts" | "local";
fontOrigin?: "googleFonts" | "local" | "selfHosted";
}
interface ProcessedFontResult {
processedStylesheet: string;
fontFiles: GoogleFontFile[];
}
interface GoogleFontFile {
url: string;
filename: string;
extension: string;
}
interface QuartzFontRegistry {
themeName: string;
@ -45,4 +55,4 @@ interface FontFileEntry {
unicodeRange?: string | null;
}
export type { FontFileEntry, FontSpecification, QuartzFontRegistry, QuartzFontsOptions };
export type { FontFileEntry, FontSpecification, GoogleFontFile, ProcessedFontResult, QuartzFontRegistry, QuartzFontsOptions };

View file

@ -88,7 +88,10 @@
"quartz": {
"name": "quartz-fonts",
"displayName": "Quartz Fonts",
"category": "transformer",
"category": [
"transformer",
"emitter"
],
"version": "1.0.0",
"quartzVersion": ">=5.0.0",
"dependencies": [],
@ -403,7 +406,8 @@
"type": "string",
"enum": [
"googleFonts",
"local"
"local",
"selfHosted"
]
}
}

76
src/emitter.ts Normal file
View file

@ -0,0 +1,76 @@
import fs from "fs";
import path from "path";
import type { QuartzEmitterPlugin, BuildCtx, FilePath } from "@quartz-community/types";
import type { QuartzFontsOptions } from "./types";
import { googleFontHref } from "./util/google-fonts";
import { processGoogleFonts } from "./util/process-fonts";
const QUARTZ_DEFAULT_HEADER = "Schibsted Grotesk";
const QUARTZ_DEFAULT_BODY = "Source Sans Pro";
const QUARTZ_DEFAULT_CODE = "IBM Plex Mono";
const defaultOptions: QuartzFontsOptions = {
useThemeFonts: true,
fontOrigin: "googleFonts",
};
export const QuartzFontsEmitter: QuartzEmitterPlugin<Partial<QuartzFontsOptions>> = (
userOptions?: Partial<QuartzFontsOptions>,
) => {
const options: QuartzFontsOptions = { ...defaultOptions, ...userOptions };
return {
name: "QuartzFontsEmitter",
async *emit(ctx: BuildCtx, _content: unknown[], _resources: unknown): AsyncGenerator<FilePath> {
if (options.fontOrigin !== "selfHosted") {
return;
}
const baseUrl = ctx.cfg.configuration.baseUrl;
if (!baseUrl) {
throw new Error("[QuartzFontsEmitter] baseUrl is required for selfHosted fonts.");
}
const headerSpec = options.header ?? QUARTZ_DEFAULT_HEADER;
const bodySpec = options.body ?? QUARTZ_DEFAULT_BODY;
const codeSpec = options.code ?? QUARTZ_DEFAULT_CODE;
const href = googleFontHref({
title: options.title,
header: headerSpec,
body: bodySpec,
code: codeSpec,
});
const cssResponse = await fetch(href);
if (!cssResponse.ok) {
throw new Error(
`[QuartzFontsEmitter] Failed to fetch Google Fonts CSS: ${cssResponse.status} ${cssResponse.statusText}`,
);
}
const cssText = await cssResponse.text();
const { processedStylesheet, fontFiles } = processGoogleFonts(cssText, baseUrl);
const fontsDir = path.join(ctx.argv.output, "static", "fonts");
await fs.promises.mkdir(fontsDir, { recursive: true });
for (const fontFile of fontFiles) {
const fontResponse = await fetch(fontFile.url);
if (!fontResponse.ok) {
throw new Error(
`[QuartzFontsEmitter] Failed to fetch font file: ${fontFile.url} (${fontResponse.status} ${fontResponse.statusText})`,
);
}
const buf = await fontResponse.arrayBuffer();
const filePath = path.join(fontsDir, `${fontFile.filename}.${fontFile.extension}`);
await fs.promises.writeFile(filePath, Buffer.from(buf));
yield filePath as FilePath;
}
const cssPath = path.join(fontsDir, "quartz-fonts.css");
await fs.promises.writeFile(cssPath, processedStylesheet);
yield cssPath as FilePath;
},
};
};

View file

@ -1,10 +1,13 @@
export { QuartzFonts } from "./transformer";
export { QuartzFontsEmitter } from "./emitter";
export type {
FontSpecification,
QuartzFontsOptions,
QuartzFontRegistry,
FontFileEntry,
ProcessedFontResult,
GoogleFontFile,
} from "./types";
export type { QuartzTransformerPlugin } from "@quartz-community/types";
export type { QuartzTransformerPlugin, QuartzEmitterPlugin } from "@quartz-community/types";

View file

@ -212,8 +212,12 @@ export const QuartzFonts: QuartzTransformerPlugin<Partial<QuartzFontsOptions>> =
{ content: buildUnlayeredCSS(fonts), inline: true },
];
const additionalHead: unknown[] =
options.fontOrigin === "googleFonts" ? buildGoogleFontsHead(options) : [];
let additionalHead: unknown[] = [];
if (options.fontOrigin === "googleFonts") {
additionalHead = buildGoogleFontsHead(options);
} else if (options.fontOrigin === "selfHosted") {
additionalHead = [h("link", { rel: "stylesheet", href: "/static/fonts/quartz-fonts.css" })];
}
return { css, js: [], additionalHead };
},

View file

@ -36,9 +36,21 @@ export interface QuartzFontsOptions {
* Where to load fonts from.
* - `"googleFonts"`: generate Google Fonts `<link>` tags.
* - `"local"`: assume fonts are available locally (no loading).
* - `"selfHosted"`: download Google Fonts and serve from the build output.
* Defaults to `"local"` (no automatic font loading).
*/
fontOrigin?: "googleFonts" | "local";
fontOrigin?: "googleFonts" | "local" | "selfHosted";
}
export interface ProcessedFontResult {
processedStylesheet: string;
fontFiles: GoogleFontFile[];
}
export interface GoogleFontFile {
url: string;
filename: string;
extension: string;
}
export interface QuartzFontRegistry {

27
src/util/process-fonts.ts Normal file
View file

@ -0,0 +1,27 @@
import type { ProcessedFontResult, GoogleFontFile } from "../types";
const fontMimeMap: Record<string, string> = {
truetype: "ttf",
woff: "woff",
woff2: "woff2",
opentype: "otf",
};
const fontUrlPattern =
/url\((https:\/\/fonts.gstatic.com\/.+(?:\/|(?:kit=))(.+?)[.&].+?)\)\sformat\('(\w+?)'\);/g;
export function processGoogleFonts(stylesheet: string, baseUrl: string): ProcessedFontResult {
const fontFiles: GoogleFontFile[] = [];
const processedStylesheet = stylesheet.replace(
fontUrlPattern,
(match, url: string, filename: string, format: string) => {
const extension = fontMimeMap[format] ?? format;
const rewrittenUrl = `https://${baseUrl}/static/fonts/${filename}.${extension}`;
fontFiles.push({ url, filename, extension });
return match.replace(url, rewrittenUrl);
},
);
return { processedStylesheet, fontFiles };
}

View file

@ -3,6 +3,7 @@ import type { BuildCtx } from "@quartz-community/types";
import { QuartzFonts } from "../src/transformer";
import { formatFontSpecification, googleFontHref, getFontName } from "../src/util/google-fonts";
import { processGoogleFonts } from "../src/util/process-fonts";
import { validateFontSpec } from "../src/util/validate";
const REGISTRY_KEY = "__quartzFonts";
@ -200,6 +201,39 @@ describe("QuartzFonts", () => {
});
});
describe("selfHosted fontOrigin", () => {
it("injects link to self-hosted CSS file instead of Google Fonts", () => {
const head = getAdditionalHead(
QuartzFonts({
fontOrigin: "selfHosted",
body: "Inter",
header: "Playfair Display",
code: "JetBrains Mono",
}),
);
expect(head).toHaveLength(1);
const link = head[0] as { props: Record<string, string> };
expect(link.props.rel).toBe("stylesheet");
expect(link.props.href).toBe("/static/fonts/quartz-fonts.css");
});
it("still emits layered CSS with font variables", () => {
const css = getCSSContent(
QuartzFonts({
fontOrigin: "selfHosted",
body: "Inter",
header: "Playfair Display",
code: "JetBrains Mono",
}),
);
expect(css).toContain("--bodyFont: Inter");
expect(css).toContain("--headerFont: Playfair Display");
expect(css).toContain("--codeFont: JetBrains Mono");
});
});
describe("with QuartzTheme registry", () => {
beforeEach(() => {
setRegistry({
@ -380,3 +414,44 @@ describe("validateFontSpec", () => {
expect(Array.isArray(warnings)).toBe(true);
});
});
describe("processGoogleFonts", () => {
it("rewrites font URLs to self-hosted paths", () => {
const css = `
@font-face {
font-family: 'Inter';
src: url(https://fonts.gstatic.com/s/inter/v18/abc123def456.woff2) format('woff2');
}`;
const { processedStylesheet, fontFiles } = processGoogleFonts(css, "example.com");
expect(processedStylesheet).toContain("https://example.com/static/fonts/abc123def456.woff2");
expect(processedStylesheet).not.toContain("fonts.gstatic.com");
expect(fontFiles).toHaveLength(1);
const [first] = fontFiles;
expect(first).toBeDefined();
if (!first) {
throw new Error("Expected at least one font file");
}
expect(first.filename).toBe("abc123def456");
expect(first.extension).toBe("woff2");
});
it("handles multiple font faces", () => {
const css = `
@font-face {
font-family: 'Inter';
src: url(https://fonts.gstatic.com/s/inter/v18/abc123.woff2) format('woff2');
}
@font-face {
font-family: 'Inter';
src: url(https://fonts.gstatic.com/s/inter/v18/def456.woff) format('woff');
}`;
const { fontFiles } = processGoogleFonts(css, "example.com");
expect(fontFiles).toHaveLength(2);
});
it("returns empty fontFiles for CSS without font URLs", () => {
const { fontFiles } = processGoogleFonts("body { color: red; }", "example.com");
expect(fontFiles).toHaveLength(0);
});
});

View file

@ -91,6 +91,7 @@ export default defineConfig({
entry: {
index: "src/index.ts",
types: "src/types.ts",
emitter: "src/emitter.ts",
},
format: ["esm"],
dts: true,