Compare commits
8 Commits
2b7f21711b
...
cf8d3dffb9
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8d3dffb9 | |||
|
|
1e2daa410c | ||
|
|
adf6dc93ea | ||
|
|
596a023d24 | ||
|
|
8195e9baa8 | ||
|
|
0554fcada7 | ||
|
|
9a794b626b | ||
|
|
40346aa9aa |
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import { getFontUrl } from './getFontUrl';
|
||||
|
||||
/**
|
||||
* Helper function to create a minimal UnifiedFont mock for testing
|
||||
*/
|
||||
function createMockFont(
|
||||
overrides: Partial<UnifiedFont> = {},
|
||||
): UnifiedFont {
|
||||
const baseFont: UnifiedFont = {
|
||||
id: 'test-font',
|
||||
name: 'Test Font',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: [],
|
||||
styles: {},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
|
||||
return { ...baseFont, ...overrides };
|
||||
}
|
||||
|
||||
describe('getFontUrl', () => {
|
||||
describe('basic logic', () => {
|
||||
it('returns URL for exact weight match in variants', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBe('https://example.com/font-400.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for weight 700', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-700.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for weight 100 (lightest)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'100': 'https://example.com/font-100.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 100);
|
||||
|
||||
expect(result).toBe('https://example.com/font-100.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for weight 900 (boldest)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'900': 'https://example.com/font-900.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 900);
|
||||
|
||||
expect(result).toBe('https://example.com/font-900.woff2');
|
||||
});
|
||||
|
||||
it('returns URL for variable font (backend maps weight to VF URL)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-variable.woff2',
|
||||
'700': 'https://example.com/font-variable.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
expect(result400).toBe('https://example.com/font-variable.woff2');
|
||||
expect(result700).toBe('https://example.com/font-variable.woff2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback logic', () => {
|
||||
it('falls back to regular when exact weight not found', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://example.com/font-regular.woff2',
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('falls back to variant 400 when exact weight and regular not found', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-400.woff2');
|
||||
});
|
||||
|
||||
it('falls back to variant regular when exact weight, regular, and 400 not found', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
'regular': 'https://example.com/font-regular.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('prefers regular over variants.400 for fallback', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://example.com/font-regular.woff2',
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('returns undefined when no fallback options available', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for font with empty styles', () => {
|
||||
const font = createMockFont({
|
||||
styles: {},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error for font with undefined styles (invalid font data)', () => {
|
||||
const font = createMockFont({
|
||||
styles: undefined as any,
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 400)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles font with only regular URL (legacy format)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://example.com/font-regular.woff2',
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('handles font with only variants object', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
'700': 'https://example.com/font-700.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
expect(result400).toBe('https://example.com/font-400.woff2');
|
||||
expect(result700).toBe('https://example.com/font-700.woff2');
|
||||
});
|
||||
|
||||
it('handles font with variants but no requested weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://example.com/font-400.woff2');
|
||||
});
|
||||
|
||||
it('handles Google Fonts style with legacy URLs', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 700);
|
||||
|
||||
expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2');
|
||||
});
|
||||
|
||||
it('handles Fontshare fonts with multiple weights', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'100': 'https://cdn.fontshare.com/wf/font-100.woff2',
|
||||
'200': 'https://cdn.fontshare.com/wf/font-200.woff2',
|
||||
'300': 'https://cdn.fontshare.com/wf/font-300.woff2',
|
||||
'400': 'https://cdn.fontshare.com/wf/font-400.woff2',
|
||||
'500': 'https://cdn.fontshare.com/wf/font-500.woff2',
|
||||
'600': 'https://cdn.fontshare.com/wf/font-600.woff2',
|
||||
'700': 'https://cdn.fontshare.com/wf/font-700.woff2',
|
||||
'800': 'https://cdn.fontshare.com/wf/font-800.woff2',
|
||||
'900': 'https://cdn.fontshare.com/wf/font-900.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test all valid weights
|
||||
for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) {
|
||||
const result = getFontUrl(font, weight);
|
||||
expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles font with partial weight coverage', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-regular.woff2',
|
||||
'700': 'https://example.com/font-bold.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
const result500 = getFontUrl(font, 500);
|
||||
|
||||
expect(result400).toBe('https://example.com/font-regular.woff2');
|
||||
expect(result700).toBe('https://example.com/font-bold.woff2');
|
||||
expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback
|
||||
});
|
||||
|
||||
it('handles font with variants.regular as fallback', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'700': 'https://example.com/font-bold.woff2',
|
||||
'regular': 'https://example.com/font-regular.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||
});
|
||||
|
||||
it('handles empty variants object', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when variant URL is null and no fallback available', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': null as any,
|
||||
'700': 'https://example.com/font-bold.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 400);
|
||||
|
||||
// null is falsy, so it falls back to regular, 400, and then regular variant
|
||||
// All are undefined, so returns undefined
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundary tests', () => {
|
||||
it('handles lowest valid weight (100)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'100': 'https://example.com/font-100.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 100);
|
||||
|
||||
expect(result).toBe('https://example.com/font-100.woff2');
|
||||
});
|
||||
|
||||
it('handles highest valid weight (900)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'900': 'https://example.com/font-900.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 900);
|
||||
|
||||
expect(result).toBe('https://example.com/font-900.woff2');
|
||||
});
|
||||
|
||||
it('handles middle weight (500)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'500': 'https://example.com/font-500.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, 500);
|
||||
|
||||
expect(result).toBe('https://example.com/font-500.woff2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid weights', () => {
|
||||
it('throws error for weight below 100', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99');
|
||||
});
|
||||
|
||||
it('throws error for weight above 900', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901');
|
||||
});
|
||||
|
||||
it('throws error for weight 0', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0');
|
||||
});
|
||||
|
||||
it('throws error for negative weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100');
|
||||
});
|
||||
|
||||
it('throws error for non-numeric weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore - Testing invalid input type
|
||||
expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400');
|
||||
});
|
||||
|
||||
it('throws error for decimal weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5');
|
||||
});
|
||||
|
||||
it('throws error for weight with step of 50 (not supported)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450');
|
||||
});
|
||||
|
||||
it('throws error for weight with step of 10 (not supported)', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410');
|
||||
});
|
||||
|
||||
it('throws error for NaN weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN');
|
||||
});
|
||||
|
||||
it('throws error for Infinity weight', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity');
|
||||
});
|
||||
|
||||
it('throws descriptive error message', () => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://example.com/font-400.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
getFontUrl(font, 999);
|
||||
expect.fail('Expected function to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('Invalid weight: 999');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider-specific tests', () => {
|
||||
it('handles Google Fonts with variable fonts', () => {
|
||||
const font = createMockFont({
|
||||
provider: 'google',
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
// Variable fonts return the same URL for all weights
|
||||
expect(result400).toBe(result700);
|
||||
});
|
||||
|
||||
it('handles Fontshare fonts with static weights', () => {
|
||||
const font = createMockFont({
|
||||
provider: 'fontshare',
|
||||
styles: {
|
||||
variants: {
|
||||
'400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2',
|
||||
'700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result400 = getFontUrl(font, 400);
|
||||
const result700 = getFontUrl(font, 700);
|
||||
|
||||
expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2');
|
||||
expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||
expect(result400).not.toBe(result700);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all valid weights test', () => {
|
||||
it('handles all valid weight values', () => {
|
||||
const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
validWeights.forEach(weight => {
|
||||
const font = createMockFont({
|
||||
styles: {
|
||||
variants: {
|
||||
[weight.toString()]: `https://example.com/font-${weight}.woff2`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = getFontUrl(font, weight);
|
||||
expect(result).toBe(`https://example.com/font-${weight}.woff2`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
FontWeight,
|
||||
UnifiedFont,
|
||||
} from '../../model';
|
||||
|
||||
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
/**
|
||||
* Constructs a URL for a font based on the provided font and weight.
|
||||
* @param font - The font object.
|
||||
* @param weight - The weight of the font.
|
||||
* @returns The URL for the font.
|
||||
*/
|
||||
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
|
||||
if (!SIZES.includes(weight)) {
|
||||
throw new Error(`Invalid weight: ${weight}`);
|
||||
}
|
||||
|
||||
const weightKey = weight.toString() as FontWeight;
|
||||
|
||||
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
|
||||
if (font.styles.variants?.[weightKey]) {
|
||||
return font.styles.variants[weightKey];
|
||||
}
|
||||
|
||||
// 2. Fallbacks for Static Fonts (if exact weight missing)
|
||||
// Try 'regular' or '400' as safe defaults
|
||||
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export {
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize/normalize';
|
||||
|
||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
@@ -186,7 +187,7 @@ export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
||||
const variants = apiFont.styles.map(style => {
|
||||
const weightLabel = style.weight.label;
|
||||
const isItalic = style.is_italic;
|
||||
return isItalic ? `${weightLabel}italic` : weightLabel;
|
||||
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
||||
});
|
||||
|
||||
// Map styles to URLs
|
||||
|
||||
@@ -37,6 +37,7 @@ export type {
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createUnifiedFontStore,
|
||||
type FontConfigRequest,
|
||||
selectedFontsStore,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
|
||||
@@ -52,16 +52,27 @@ class AppliedFontsManager {
|
||||
}
|
||||
}
|
||||
|
||||
#getFontKey(id: string, weight: number): string {
|
||||
return `${id.toLowerCase()}@${weight}`;
|
||||
#getFontKey(config: FontConfigRequest): string {
|
||||
if (config.isVariable) {
|
||||
// For variable fonts, the ID is unique enough.
|
||||
// Loading "Roboto" once covers "Roboto 400" and "Roboto 700"
|
||||
return `${config.id.toLowerCase()}@vf`;
|
||||
}
|
||||
// For static fonts, we still need weight separation
|
||||
return `${config.id.toLowerCase()}@${config.weight}`;
|
||||
}
|
||||
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
const now = Date.now();
|
||||
configs.forEach(config => {
|
||||
const key = this.#getFontKey(config.id, config.weight);
|
||||
// Pass the whole config to get key
|
||||
const key = this.#getFontKey(config);
|
||||
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
// If it's already loaded, we don't need to do anything
|
||||
if (this.statuses.get(key) === 'loaded') return;
|
||||
|
||||
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
|
||||
this.#queue.set(key, config);
|
||||
|
||||
@@ -71,8 +82,10 @@ class AppliedFontsManager {
|
||||
});
|
||||
}
|
||||
|
||||
getFontStatus(id: string, weight: number) {
|
||||
return this.statuses.get(this.#getFontKey(id, weight));
|
||||
getFontStatus(id: string, weight: number, isVariable: boolean = false) {
|
||||
// Construct a temp config to generate key
|
||||
const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable });
|
||||
return this.statuses.get(key);
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
@@ -97,27 +110,31 @@ class AppliedFontsManager {
|
||||
this.statuses.set(key, 'loading');
|
||||
this.#idToBatch.set(key, batchId);
|
||||
|
||||
// Construct the @font-face rule
|
||||
// Using format('truetype') for .ttf
|
||||
// If variable, allow the full weight range.
|
||||
// If static, lock it to the specific weight.
|
||||
const weightRule = config.isVariable
|
||||
? '100 900' // Variable range (standard coverage)
|
||||
: config.weight;
|
||||
const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype';
|
||||
|
||||
cssRules += `
|
||||
@font-face {
|
||||
font-family: '${config.name}';
|
||||
src: url('${config.url}') format('truetype');
|
||||
font-weight: ${config.weight};
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
`;
|
||||
@font-face {
|
||||
font-family: '${config.name}';
|
||||
src: url('${config.url}') format('${fontFormat}');
|
||||
font-weight: ${weightRule};
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
`;
|
||||
});
|
||||
|
||||
// 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
|
||||
// Use the requested weight for verification, even if the rule covers a range
|
||||
batchEntries.forEach(([key, config]) => {
|
||||
document.fonts.load(`${config.weight} 1em "${config.name}"`)
|
||||
.then(loaded => {
|
||||
@@ -126,7 +143,6 @@ class AppliedFontsManager {
|
||||
.catch(() => this.statuses.set(key, 'error'));
|
||||
});
|
||||
}
|
||||
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
const batchesToRemove = new Set<string>();
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { UnifiedFont } from '../types';
|
||||
|
||||
/** */
|
||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||
// params = $state<TParams>({} as TParams);
|
||||
cleanup: () => void;
|
||||
|
||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
||||
@@ -18,9 +17,11 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||
params = $derived.by(() => {
|
||||
let merged = { ...this.#internalParams };
|
||||
|
||||
// Loop through every "Cable" plugged into the store
|
||||
// Loop through every "Cable" plugged into the store
|
||||
for (const getter of this.#bindings) {
|
||||
merged = { ...merged, ...getter() };
|
||||
const bindingResult = getter();
|
||||
merged = { ...merged, ...bindingResult };
|
||||
}
|
||||
|
||||
return merged as TParams;
|
||||
@@ -54,7 +55,7 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
||||
|
||||
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
return {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
|
||||
@@ -14,7 +14,10 @@ export {
|
||||
} from './unifiedFontStore.svelte';
|
||||
|
||||
// Applied fonts manager (CSS loading - unchanged)
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
export {
|
||||
appliedFontsManager,
|
||||
type FontConfigRequest,
|
||||
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
// Selected fonts store (user selection - unchanged)
|
||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* - Provider-specific shortcuts for common operations
|
||||
*/
|
||||
|
||||
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||
import type { ProxyFontsParams } from '../../api';
|
||||
import { fetchProxyFonts } from '../../api';
|
||||
import type { UnifiedFont } from '../types';
|
||||
@@ -121,6 +122,19 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
this.#previousFilterParams = filterParams;
|
||||
}
|
||||
});
|
||||
|
||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||
$effect(() => {
|
||||
const data = this.result.data;
|
||||
const offset = this.params.offset || 0;
|
||||
|
||||
// When we have data and we are at the start (offset 0),
|
||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
||||
if (offset === 0 && data && data.length > 0) {
|
||||
this.#accumulatedFonts = data;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,15 +159,26 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
protected getQueryKey(params: ProxyFontsParams) {
|
||||
// Normalize params to treat empty arrays/strings as undefined
|
||||
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||
if (value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
||||
return acc;
|
||||
}
|
||||
return { ...acc, [key]: value };
|
||||
}, {});
|
||||
|
||||
// Return a consistent key
|
||||
return ['unifiedFonts', normalized] as const;
|
||||
}
|
||||
|
||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
|
||||
return {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch function that calls the proxy API
|
||||
* Returns the full response including pagination metadata
|
||||
@@ -187,11 +212,10 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
};
|
||||
|
||||
// Accumulate fonts for infinite scroll
|
||||
if (params.offset === 0) {
|
||||
// Reset when starting from beginning (new search/filter)
|
||||
this.#accumulatedFonts = response.fonts;
|
||||
} else {
|
||||
// Append new fonts to existing ones
|
||||
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||
// This prevents race conditions and double-setting.
|
||||
if (params.offset !== 0) {
|
||||
// Append new fonts to existing ones only for pagination
|
||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||
}
|
||||
|
||||
|
||||
@@ -32,3 +32,27 @@ export interface FontFilters {
|
||||
|
||||
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
||||
export type FilterType = CheckboxFilter | 'searchQuery';
|
||||
|
||||
/**
|
||||
* Standard font weights
|
||||
*/
|
||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
|
||||
/**
|
||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||
*/
|
||||
export type FontWeightItalic = `${FontWeight}italic`;
|
||||
|
||||
/**
|
||||
* All possible font variants
|
||||
* - Numeric weights: "400", "700", etc.
|
||||
* - Italic variants: "400italic", "700italic", etc.
|
||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||
*/
|
||||
export type FontVariant =
|
||||
| FontWeight
|
||||
| FontWeightItalic
|
||||
| 'regular'
|
||||
| 'italic'
|
||||
| 'bold'
|
||||
| 'bolditalic';
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type { FontVariant } from './common';
|
||||
|
||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
|
||||
/**
|
||||
@@ -86,30 +88,6 @@ export interface FontItem {
|
||||
*/
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Standard font weights that can appear in Google Fonts API
|
||||
*/
|
||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
|
||||
/**
|
||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||
*/
|
||||
export type FontWeightItalic = `${FontWeight}italic`;
|
||||
|
||||
/**
|
||||
* All possible font variants in Google Fonts API
|
||||
* - Numeric weights: "400", "700", etc.
|
||||
* - Italic variants: "400italic", "700italic", etc.
|
||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||
*/
|
||||
export type FontVariant =
|
||||
| FontWeight
|
||||
| FontWeightItalic
|
||||
| 'regular'
|
||||
| 'italic'
|
||||
| 'bold'
|
||||
| 'bolditalic';
|
||||
|
||||
/**
|
||||
* Google Fonts API file mapping
|
||||
* Dynamic keys that match the variants array
|
||||
|
||||
@@ -12,15 +12,15 @@ export type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
} from './common';
|
||||
|
||||
// Google Fonts API types
|
||||
export type {
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
GoogleFontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from './google';
|
||||
|
||||
@@ -8,17 +8,18 @@ import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
} from './common';
|
||||
|
||||
/**
|
||||
* Font variant types (standardized)
|
||||
*/
|
||||
export type UnifiedFontVariant = string;
|
||||
export type UnifiedFontVariant = FontVariant;
|
||||
|
||||
/**
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface FontStyleUrls {
|
||||
export interface LegacyFontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
@@ -29,6 +30,10 @@ export interface FontStyleUrls {
|
||||
boldItalic?: string;
|
||||
}
|
||||
|
||||
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font metadata
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,8 @@ interface Props {
|
||||
* Font weight
|
||||
*/
|
||||
weight?: number;
|
||||
|
||||
isVariable?: boolean;
|
||||
/**
|
||||
* Additional classes
|
||||
*/
|
||||
@@ -36,27 +38,42 @@ interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { name, id, url, weight = 400, className, children }: Props = $props();
|
||||
let { name, id, url, weight = 400, isVariable = false, className, children }: Props = $props();
|
||||
let element: Element;
|
||||
|
||||
// Track if the user has actually scrolled this into view
|
||||
let hasEnteredViewport = $state(false);
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable));
|
||||
|
||||
$effect(() => {
|
||||
if (status === 'loaded' || status === 'error') {
|
||||
hasEnteredViewport = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
hasEnteredViewport = true;
|
||||
appliedFontsManager.touch([{ id, weight, name, url }]);
|
||||
|
||||
// Once it has entered, we can stop observing to save CPU
|
||||
// Touch ensures it's in the queue.
|
||||
// It's safe to call this even if VirtualList called it
|
||||
// (Manager dedupes based on key)
|
||||
appliedFontsManager.touch([{
|
||||
id,
|
||||
weight,
|
||||
name,
|
||||
url,
|
||||
isVariable,
|
||||
}]);
|
||||
|
||||
observer.unobserve(element);
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
if (element) observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight));
|
||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||
|
||||
@@ -69,7 +86,7 @@ const transitionClasses = $derived(
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
style:font-family={name}
|
||||
style:font-family={shouldReveal ? `'${name}'` : 'sans-serif'}
|
||||
class={cn(
|
||||
transitionClasses,
|
||||
// If reduced motion is on, we skip the transform/blur entirely
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
- Handles font registration with the manager
|
||||
-->
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
import type { FontConfigRequest } from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import type { FontConfigRequest } from '../../model';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
@@ -21,16 +22,25 @@ interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleIt
|
||||
let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props();
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
const configs: FontConfigRequest[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
|
||||
if (url) {
|
||||
configs.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url,
|
||||
isVariable: item.features?.isVariable,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Auto-register fonts with the manager
|
||||
const configs = visibleItems.map<FontConfigRequest>(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url: item.styles.regular!,
|
||||
}));
|
||||
appliedFontsManager.touch(configs);
|
||||
|
||||
// // Forward the call to any external listener
|
||||
// Forward the call to any external listener
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user