refactor(appliedFontsStore): migrate from direct <link> with css towards font-face approach

This commit is contained in:
Ilia Mashkov
2026-02-02 12:10:12 +03:00
parent 5496fd2680
commit 961475dea0

View File

@@ -3,33 +3,47 @@ import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error'; export type FontStatus = 'loading' | 'loaded' | 'error';
export interface FontConfigRequest { export interface FontConfigRequest {
slug: string; /**
* Font id
*/
id: string;
/**
* Real font name (e.g. "Lato")
*/
name: string;
/**
* The .ttf URL
*/
url: string;
/**
* Font weight
*/
weight: number; weight: number;
/**
* Flag of the variable weight
*/
isVariable?: boolean; isVariable?: boolean;
} }
/** /**
* Manager that handles loading of fonts from Fontshare. * Manager that handles loading of fonts.
* Logic: * Logic:
* - Variable fonts: Loaded once per slug (covers all weights). * - Variable fonts: Loaded once per id (covers all weights).
* - Static fonts: Loaded per slug + weight combination. * - Static fonts: Loaded per id + weight combination.
*/ */
class AppliedFontsManager { class AppliedFontsManager {
// Tracking usage: Map<key, timestamp> where key is "slug" or "slug@weight"
#usageTracker = new Map<string, number>(); #usageTracker = new Map<string, number>();
// Map: key -> batchId #idToBatch = new Map<string, string>();
#slugToBatch = new Map<string, string>(); // Changed to HTMLStyleElement
// Map: batchId -> HTMLLinkElement #batchElements = new Map<string, HTMLStyleElement>();
#batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>(); #queue = new Map<string, FontConfigRequest>(); // Track config in queue
#timeoutId: ReturnType<typeof setTimeout> | null = null; #timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; #PURGE_INTERVAL = 60000;
#TTL = 5 * 60 * 1000; #TTL = 5 * 60 * 1000;
#CHUNK_SIZE = 3; #CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
// Reactive status map for UI feedback
statuses = new SvelteMap<string, FontStatus>(); statuses = new SvelteMap<string, FontStatus>();
constructor() { constructor() {
@@ -38,139 +52,119 @@ class AppliedFontsManager {
} }
} }
/** #getFontKey(id: string, weight: number): string {
* Resolves a unique key for the font asset. return `${id.toLowerCase()}@${weight}`;
*/
#getFontKey(slug: string, weight: number, isVariable: boolean): string {
const s = slug.toLowerCase();
// Variable fonts only need one entry regardless of weight
return isVariable ? s : `${s}@${weight}`;
} }
/**
* Call this when a font is rendered on screen.
*/
touch(configs: FontConfigRequest[]) { touch(configs: FontConfigRequest[]) {
const now = Date.now(); const now = Date.now();
const toRegister: string[] = []; configs.forEach(config => {
const key = this.#getFontKey(config.id, config.weight);
configs.forEach(({ slug, weight, isVariable = false }) => {
const key = this.#getFontKey(slug, weight, isVariable);
this.#usageTracker.set(key, now); this.#usageTracker.set(key, now);
if (!this.#slugToBatch.has(key)) { if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
toRegister.push(key); this.#queue.set(key, config);
}
});
if (toRegister.length > 0) this.registerFonts(toRegister);
}
registerFonts(keys: string[]) {
const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k));
if (newKeys.length === 0) return;
newKeys.forEach(k => this.#queue.add(k));
if (this.#timeoutId) clearTimeout(this.#timeoutId); if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50); this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
} }
});
}
getFontStatus(slug: string, weight: number, isVariable: boolean) { getFontStatus(id: string, weight: number) {
return this.statuses.get(this.#getFontKey(slug, weight, isVariable)); return this.statuses.get(this.#getFontKey(id, weight));
} }
#processQueue() { #processQueue() {
const fullQueue = Array.from(this.#queue); const entries = Array.from(this.#queue.entries());
if (fullQueue.length === 0) return; if (entries.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) { for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE)); this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
} }
this.#queue.clear(); this.#queue.clear();
this.#timeoutId = null; this.#timeoutId = null;
} }
#createBatch(keys: string[]) { #createBatch(batchEntries: [string, FontConfigRequest][]) {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID(); const batchId = crypto.randomUUID();
let cssRules = '';
/** batchEntries.forEach(([key, config]) => {
* Fontshare API Logic: this.statuses.set(key, 'loading');
* - If key contains '@', it's static (e.g., satoshi@700) this.#idToBatch.set(key, batchId);
* - If it's a plain slug, it's variable. We append '@1,2' for variable assets.
*/
const query = keys.map(k => {
return k.includes('@') ? `f[]=${k}` : `f[]=${k}@1,2`;
}).join('&');
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; // Construct the @font-face rule
// Using format('truetype') for .ttf
keys.forEach(key => this.statuses.set(key, 'loading')); cssRules += `
@font-face {
const link = document.createElement('link'); font-family: '${config.name}';
link.rel = 'stylesheet'; src: url('${config.url}') format('truetype');
link.href = url; font-weight: ${config.weight};
link.dataset.batchId = batchId; font-style: normal;
document.head.appendChild(link); font-display: swap;
}
this.#batchElements.set(batchId, link); `;
keys.forEach(key => {
this.#slugToBatch.set(key, batchId);
// Determine what to check in the Font Loading API
const isVariable = !key.includes('@');
const [family, staticWeight] = key.split('@');
// For variable fonts, we check a standard weight;
// for static, we check the specific numeric weight requested.
const weightToCheck = isVariable ? '400' : staticWeight;
document.fonts.load(`${weightToCheck} 1em "${family}"`)
.then(loadedFonts => {
this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error');
})
.catch(() => {
this.statuses.set(key, 'error');
}); });
// Create and inject the style tag
const style = document.createElement('style');
style.dataset.batchId = batchId;
style.innerHTML = cssRules;
document.head.appendChild(style);
this.#batchElements.set(batchId, style);
// Verify loading via Font Loading API
batchEntries.forEach(([key, config]) => {
document.fonts.load(`${config.weight} 1em "${config.name}"`)
.then(loaded => {
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
})
.catch(() => this.statuses.set(key, 'error'));
}); });
} }
#purgeUnused() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
const batchesToPotentialDelete = new Set<string>(); const batchesToRemove = new Set<string>();
const keysToDelete: string[] = []; const keysToRemove: string[] = [];
for (const [key, lastUsed] of this.#usageTracker.entries()) { for (const [key, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) { if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(key); const batchId = this.#idToBatch.get(key);
if (batchId) batchesToPotentialDelete.add(batchId); if (batchId) {
keysToDelete.push(key); // Check if EVERY font in this batch is expired
} const batchKeys = Array.from(this.#idToBatch.entries())
}
batchesToPotentialDelete.forEach(batchId => {
const batchKeys = Array.from(this.#slugToBatch.entries())
.filter(([_, bId]) => bId === batchId) .filter(([_, bId]) => bId === batchId)
.map(([key]) => key); .map(([k]) => k);
const allExpired = batchKeys.every(k => keysToDelete.includes(k)); const canDeleteBatch = batchKeys.every(k => {
const lastK = this.#usageTracker.get(k);
return lastK && (now - lastK > this.#TTL);
});
if (allExpired) { if (canDeleteBatch) {
this.#batchElements.get(batchId)?.remove(); batchesToRemove.add(batchId);
this.#batchElements.delete(batchId); keysToRemove.push(...batchKeys);
batchKeys.forEach(k => { }
this.#slugToBatch.delete(k); }
}
}
batchesToRemove.forEach(id => {
this.#batchElements.get(id)?.remove();
this.#batchElements.delete(id);
});
keysToRemove.forEach(k => {
this.#idToBatch.delete(k);
this.#usageTracker.delete(k); this.#usageTracker.delete(k);
this.statuses.delete(k); this.statuses.delete(k);
}); });
} }
});
}
} }
export const appliedFontsManager = new AppliedFontsManager(); export const appliedFontsManager = new AppliedFontsManager();