feat(appliedFontsStore): create Applied Fonts Manager to manage fonts download
This commit is contained in:
@@ -24,6 +24,8 @@ let { children } = $props();
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
|
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="app-root">
|
<div id="app-root">
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
class AppliedFontsManager {
|
||||||
|
// Stores: slug -> timestamp of last visibility
|
||||||
|
#usageTracker = new Map<string, number>();
|
||||||
|
// Stores: slug -> batchId
|
||||||
|
#slugToBatch = new Map<string, string>();
|
||||||
|
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
||||||
|
#batchElements = new Map<string, HTMLLinkElement>();
|
||||||
|
|
||||||
|
#queue = new Set<string>();
|
||||||
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
#PURGE_INTERVAL = 60000; // Check every minute
|
||||||
|
#TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
#CHUNK_SIZE = 3;
|
||||||
|
|
||||||
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Start the "Janitor" loop
|
||||||
|
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the 'last seen' timestamp for fonts.
|
||||||
|
* Prevents them from being purged while they are on screen.
|
||||||
|
*/
|
||||||
|
touch(slugs: string[]) {
|
||||||
|
const now = Date.now();
|
||||||
|
const toRegister: string[] = [];
|
||||||
|
|
||||||
|
slugs.forEach(slug => {
|
||||||
|
this.#usageTracker.set(slug, now);
|
||||||
|
if (!this.#slugToBatch.has(slug)) {
|
||||||
|
toRegister.push(slug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toRegister.length > 0) this.registerFonts(toRegister);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerFonts(slugs: string[]) {
|
||||||
|
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
||||||
|
if (newSlugs.length === 0) return;
|
||||||
|
|
||||||
|
newSlugs.forEach(s => this.#queue.add(s));
|
||||||
|
|
||||||
|
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||||
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFontStatus(slug: string) {
|
||||||
|
return this.statuses.get(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
#processQueue() {
|
||||||
|
const fullQueue = Array.from(this.#queue);
|
||||||
|
if (fullQueue.length === 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
||||||
|
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#queue.clear();
|
||||||
|
this.#timeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#createBatch(slugs: string[]) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
const batchId = crypto.randomUUID();
|
||||||
|
// font-display=swap included for better UX
|
||||||
|
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
|
||||||
|
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
||||||
|
|
||||||
|
// Mark all as loading immediately
|
||||||
|
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
||||||
|
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = url;
|
||||||
|
link.dataset.batchId = batchId;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
this.#batchElements.set(batchId, link);
|
||||||
|
slugs.forEach(slug => {
|
||||||
|
this.#slugToBatch.set(slug, batchId);
|
||||||
|
|
||||||
|
// Use the Native Font Loading API
|
||||||
|
// format: "font-size font-family"
|
||||||
|
document.fonts.load(`1em "${slug}"`)
|
||||||
|
.then(loadedFonts => {
|
||||||
|
if (loadedFonts.length > 0) {
|
||||||
|
this.statuses.set(slug, 'loaded');
|
||||||
|
} else {
|
||||||
|
this.statuses.set(slug, 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.statuses.set(slug, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#purgeUnused() {
|
||||||
|
const now = Date.now();
|
||||||
|
const batchesToPotentialDelete = new Set<string>();
|
||||||
|
const slugsToDelete: string[] = [];
|
||||||
|
|
||||||
|
// Identify expired slugs
|
||||||
|
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
||||||
|
if (now - lastUsed > this.#TTL) {
|
||||||
|
const batchId = this.#slugToBatch.get(slug);
|
||||||
|
if (batchId) batchesToPotentialDelete.add(batchId);
|
||||||
|
slugsToDelete.push(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only remove a batch if ALL fonts in that batch are expired
|
||||||
|
batchesToPotentialDelete.forEach(batchId => {
|
||||||
|
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
||||||
|
.filter(([_, bId]) => bId === batchId)
|
||||||
|
.map(([slug]) => slug);
|
||||||
|
|
||||||
|
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
|
||||||
|
|
||||||
|
if (allExpired) {
|
||||||
|
this.#batchElements.get(batchId)?.remove();
|
||||||
|
this.#batchElements.delete(batchId);
|
||||||
|
batchSlugs.forEach(s => {
|
||||||
|
this.#slugToBatch.delete(s);
|
||||||
|
this.#usageTracker.delete(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appliedFontsManager = new AppliedFontsManager();
|
||||||
Reference in New Issue
Block a user