feature/fetch-fonts #14
@@ -61,5 +61,8 @@
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
187
src/entities/Font/api/fontshare.ts
Normal file
187
src/entities/Font/api/fontshare.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Fontshare API client
|
||||
*
|
||||
* Handles API requests to Fontshare API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* @see https://fontshare.com
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontshareApiModel,
|
||||
FontshareFont,
|
||||
} from '$entities/Font';
|
||||
import { api } from '$shared/api/api';
|
||||
|
||||
/**
|
||||
* Fontshare API parameters
|
||||
*/
|
||||
export interface FontshareParams {
|
||||
/**
|
||||
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
|
||||
*/
|
||||
categories?: string[];
|
||||
/**
|
||||
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Page number for pagination (1-indexed)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Search query to filter fonts
|
||||
*/
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fontshare API response wrapper
|
||||
* Extends collection model with additional metadata
|
||||
*/
|
||||
export interface FontshareResponse extends FontshareApiModel {
|
||||
// Response structure matches FontshareApiModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: FontshareParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.categories?.length) {
|
||||
searchParams.append('categories', params.categories.join(','));
|
||||
}
|
||||
|
||||
if (params.tags?.length) {
|
||||
searchParams.append('tags', params.tags.join(','));
|
||||
}
|
||||
|
||||
if (params.page) {
|
||||
searchParams.append('page', String(params.page));
|
||||
}
|
||||
|
||||
if (params.limit) {
|
||||
searchParams.append('limit', String(params.limit));
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
searchParams.append('search', params.search);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fonts from Fontshare API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Fontshare API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all Sans category fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* categories: ['Sans'],
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with specific tags
|
||||
* const response = await fetchFontshareFonts({
|
||||
* tags: ['Branding', 'Logos']
|
||||
* });
|
||||
*
|
||||
* // Search fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* search: 'Satoshi'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `https://api.fontshare.com/v2${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<FontshareResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by slug
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param slug - Font slug (e.g., "satoshi", "general-sans")
|
||||
* @returns Promise resolving to Fontshare font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = await fetchFontshareFontBySlug('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFontBySlug(
|
||||
slug: string,
|
||||
): Promise<FontshareFont | undefined> {
|
||||
const response = await fetchFontshareFonts();
|
||||
return response.items.find(font => font.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all fonts from Fontshare
|
||||
* Convenience function for fetching all available fonts
|
||||
* Uses pagination to get all items
|
||||
*
|
||||
* @returns Promise resolving to all Fontshare fonts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const allFonts = await fetchAllFontshareFonts();
|
||||
* console.log(`Found ${allFonts.items.length} fonts`);
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const allFonts: FontshareFont[] = [];
|
||||
let page = 1;
|
||||
const limit = 100; // Max items per page
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFontshareFonts({
|
||||
...params,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
allFonts.push(...response.items);
|
||||
|
||||
// Check if we've fetched all items
|
||||
if (response.items.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
// Return first response with all items combined
|
||||
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
|
||||
|
||||
return {
|
||||
...firstResponse,
|
||||
items: allFonts,
|
||||
};
|
||||
}
|
||||
159
src/entities/Font/api/googleFonts.ts
Normal file
159
src/entities/Font/api/googleFonts.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Google Fonts API client
|
||||
*
|
||||
* Handles API requests to Google Fonts API for fetching font metadata.
|
||||
* Provides error handling, retry logic, and type-safe responses.
|
||||
*
|
||||
* @see https://developers.google.com/fonts/docs/developer_api
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
|
||||
/**
|
||||
* Google Fonts API parameters
|
||||
*/
|
||||
export interface GoogleFontsParams {
|
||||
/**
|
||||
* Google Fonts API key (optional for public endpoints)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Font family name (to fetch specific font)
|
||||
*/
|
||||
family?: string;
|
||||
/**
|
||||
* Font category filter (e.g., "sans-serif", "serif", "display")
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
|
||||
*/
|
||||
subset?: string;
|
||||
/**
|
||||
* Sort order for results
|
||||
*/
|
||||
sort?: 'popularity' | 'alpha' | 'date' | 'style';
|
||||
/**
|
||||
* Cap the number of fonts returned
|
||||
*/
|
||||
capability?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API response wrapper
|
||||
*/
|
||||
export interface GoogleFontsResponse {
|
||||
kind: string;
|
||||
items: GoogleFontItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified font item from Google Fonts API
|
||||
*/
|
||||
export interface GoogleFontItem {
|
||||
family: string;
|
||||
category: string;
|
||||
variants: string[];
|
||||
subsets: string[];
|
||||
version: string;
|
||||
lastModified: string;
|
||||
files: Record<string, string>;
|
||||
menu: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API base URL
|
||||
*/
|
||||
const GOOGLE_FONTS_API_URL = 'https://fonts.googleapis.com/v2/fonts' as const;
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: GoogleFontsParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.key) {
|
||||
searchParams.append('key', params.key);
|
||||
}
|
||||
|
||||
if (params.family) {
|
||||
searchParams.append('family', params.family);
|
||||
}
|
||||
|
||||
if (params.category) {
|
||||
searchParams.append('category', params.category);
|
||||
}
|
||||
|
||||
if (params.subset) {
|
||||
searchParams.append('subset', params.subset);
|
||||
}
|
||||
|
||||
if (params.sort) {
|
||||
searchParams.append('sort', params.sort);
|
||||
}
|
||||
|
||||
if (params.capability) {
|
||||
searchParams.append('capability', params.capability);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fonts from Google Fonts API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Google Fonts API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts sorted by popularity
|
||||
* const response = await fetchGoogleFonts({
|
||||
* category: 'sans-serif',
|
||||
* sort: 'popularity'
|
||||
* });
|
||||
*
|
||||
* // Fetch specific font family
|
||||
* const robotoResponse = await fetchGoogleFonts({
|
||||
* family: 'Roboto'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFonts(
|
||||
params: GoogleFontsParams = {},
|
||||
): Promise<GoogleFontsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<GoogleFontsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by family name
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param family - Font family name (e.g., "Roboto")
|
||||
* @returns Promise resolving to Google Font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchGoogleFontFamily('Roboto');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFontFamily(
|
||||
family: string,
|
||||
): Promise<GoogleFontItem | undefined> {
|
||||
const response = await fetchGoogleFonts({ family });
|
||||
return response.items.find(item => item.family === family);
|
||||
}
|
||||
39
src/entities/Font/api/index.ts
Normal file
39
src/entities/Font/api/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Font API clients exports
|
||||
*
|
||||
* Exports API clients and normalization utilities
|
||||
*/
|
||||
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './googleFonts';
|
||||
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './fontshare';
|
||||
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize';
|
||||
export type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './normalize';
|
||||
598
src/entities/Font/api/normalize.test.ts
Normal file
598
src/entities/Font/api/normalize.test.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
import type { FontshareFont } from '$entities/Font';
|
||||
import type { GoogleFontItem } from '$entities/Font/api/googleFonts';
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
import {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from '$entities/Font/api/normalize';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
|
||||
describe('Font Normalization', () => {
|
||||
describe('normalizeGoogleFont', () => {
|
||||
const mockGoogleFont: GoogleFontItem = {
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular', '700', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
files: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700':
|
||||
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||
'700italic':
|
||||
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||
},
|
||||
version: 'v30',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
};
|
||||
|
||||
it('normalizes Google Font to unified model', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.id).toBe('Roboto');
|
||||
expect(result.name).toBe('Roboto');
|
||||
expect(result.provider).toBe('google');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps font variants correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
|
||||
});
|
||||
|
||||
it('maps subsets correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.subsets).toContain('latin');
|
||||
expect(result.subsets).toContain('latin-ext');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.styles.regular).toBeDefined();
|
||||
expect(result.styles.bold).toBeDefined();
|
||||
expect(result.styles.italic).toBeDefined();
|
||||
expect(result.styles.boldItalic).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('v30');
|
||||
expect(result.metadata.lastModified).toBe('2022-01-01');
|
||||
});
|
||||
|
||||
it('marks Google Fonts as non-variable', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles sans-serif category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'sans-serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('handles serif category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('handles display category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'display' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('handles handwriting category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'handwriting' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles cursive category (maps to handwriting)', () => {
|
||||
const font = { ...mockGoogleFont, category: 'cursive' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles monospace category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'monospace' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('filters invalid subsets', () => {
|
||||
const font = {
|
||||
...mockGoogleFont,
|
||||
subsets: ['latin', 'latin-ext', 'invalid-subset'],
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.subsets).not.toContain('invalid-subset');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps variant weights correctly', () => {
|
||||
const font = {
|
||||
...mockGoogleFont,
|
||||
variants: ['regular', '100', '400', '700', '900'],
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.variants).toContain('regular');
|
||||
expect(result.variants).toContain('100');
|
||||
expect(result.variants).toContain('400');
|
||||
expect(result.variants).toContain('700');
|
||||
expect(result.variants).toContain('900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFont', () => {
|
||||
const mockFontshareFont: FontshareFont = {
|
||||
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
|
||||
name: 'Satoshi',
|
||||
native_name: null,
|
||||
slug: 'satoshi',
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: 'Indian Type Foundry',
|
||||
email: null,
|
||||
id: 'test-id',
|
||||
links: [],
|
||||
name: 'Indian Type Foundry',
|
||||
},
|
||||
designers: [
|
||||
{
|
||||
bio: 'Designer bio',
|
||||
links: [],
|
||||
name: 'Designer Name',
|
||||
},
|
||||
],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: true,
|
||||
show_latin_metrics: false,
|
||||
license_type: 'itf_ffl',
|
||||
languages: 'Afar, Afrikaans',
|
||||
inserted_at: '2021-03-12T20:49:05Z',
|
||||
story: '<p>Font story</p>',
|
||||
version: '1.0',
|
||||
views: 10000,
|
||||
views_recent: 500,
|
||||
is_hot: true,
|
||||
is_new: false,
|
||||
is_shortlisted: false,
|
||||
is_top: true,
|
||||
axes: [],
|
||||
font_tags: [
|
||||
{ name: 'Branding' },
|
||||
{ name: 'Logos' },
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: 'Alternate t',
|
||||
on_by_default: false,
|
||||
tag: 'ss01',
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id-1',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-2',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-3',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-4',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('normalizes Fontshare font to unified model', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
expect(result.name).toBe('Satoshi');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('uses slug as unique identifier', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
});
|
||||
|
||||
it('extracts variant names from styles', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.variants).toContain('Regular');
|
||||
expect(result.variants).toContain('Bold');
|
||||
expect(result.variants).toContain('Regularitalic');
|
||||
expect(result.variants).toContain('Bolditalic');
|
||||
});
|
||||
|
||||
it('maps Fontshare Sans to sans-serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Sans' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Serif to serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Serif' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Display to display category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Display' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('maps Fontshare Script to handwriting category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Script' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('maps Fontshare Mono to monospace category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Mono' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
|
||||
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
|
||||
expect(result.styles.boldItalic).toBe(
|
||||
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles variable fonts', () => {
|
||||
const variableFont: FontshareFont = {
|
||||
...mockFontshareFont,
|
||||
axes: [
|
||||
{
|
||||
name: 'wght',
|
||||
property: 'wght',
|
||||
range_default: 400,
|
||||
range_left: 300,
|
||||
range_right: 900,
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'var-style',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
|
||||
is_italic: false,
|
||||
is_variable: true,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Variable',
|
||||
name: 'Variable',
|
||||
native_name: null,
|
||||
number: 0,
|
||||
weight: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = normalizeFontshareFont(variableFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(true);
|
||||
expect(result.features.axes).toHaveLength(1);
|
||||
expect(result.features.axes?.[0].name).toBe('wght');
|
||||
});
|
||||
|
||||
it('extracts font tags', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.features.tags).toContain('Branding');
|
||||
expect(result.features.tags).toContain('Logos');
|
||||
expect(result.features.tags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes popularity from views', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.popularity).toBe(10000);
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('1.0');
|
||||
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
|
||||
});
|
||||
|
||||
it('handles missing subsets gracefully', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
script: 'invalid-script',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.subsets).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty tags', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
font_tags: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.tags).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty axes', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
axes: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.axes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGoogleFonts', () => {
|
||||
it('normalizes array of Google Fonts', () => {
|
||||
const fonts: GoogleFontItem[] = [
|
||||
{
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
},
|
||||
{
|
||||
family: 'Open Sans',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeGoogleFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Roboto');
|
||||
expect(result[1].name).toBe('Open Sans');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeGoogleFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFonts', () => {
|
||||
it('normalizes array of Fontshare fonts', () => {
|
||||
const fonts: FontshareFont[] = [
|
||||
{
|
||||
...mockMinimalFontshareFont('font1', 'Font 1'),
|
||||
},
|
||||
{
|
||||
...mockMinimalFontshareFont('font2', 'Font 2'),
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeFontshareFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Font 1');
|
||||
expect(result[1].name).toBe('Font 2');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeFontshareFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Google Font with missing optional fields', () => {
|
||||
const font: Partial<GoogleFontItem> = {
|
||||
family: 'Test Font',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
};
|
||||
|
||||
const result = normalizeGoogleFont(font as GoogleFontItem);
|
||||
|
||||
expect(result.id).toBe('Test Font');
|
||||
expect(result.metadata.version).toBeUndefined();
|
||||
expect(result.metadata.lastModified).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles Fontshare font with minimal data', () => {
|
||||
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
|
||||
|
||||
expect(result.id).toBe('slug');
|
||||
expect(result.name).toBe('Name');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
});
|
||||
|
||||
it('handles unknown Fontshare category', () => {
|
||||
const font = {
|
||||
...mockMinimalFontshareFont('slug', 'Name'),
|
||||
category: 'Unknown Category',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif'); // fallback
|
||||
});
|
||||
|
||||
it('handles unknown Google Font category', () => {
|
||||
const font: GoogleFontItem = {
|
||||
family: 'Test',
|
||||
category: 'unknown',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Test',
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif'); // fallback
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create minimal Fontshare font mock
|
||||
*/
|
||||
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
|
||||
return {
|
||||
id: 'test-id',
|
||||
name,
|
||||
native_name: null,
|
||||
slug,
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: '',
|
||||
email: null,
|
||||
id: '',
|
||||
links: [],
|
||||
name: '',
|
||||
},
|
||||
designers: [],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: false,
|
||||
show_latin_metrics: false,
|
||||
license_type: '',
|
||||
languages: '',
|
||||
inserted_at: '',
|
||||
story: '',
|
||||
version: '1.0',
|
||||
views: 0,
|
||||
views_recent: 0,
|
||||
is_hot: false,
|
||||
is_new: false,
|
||||
is_shortlisted: null,
|
||||
is_top: false,
|
||||
axes: [],
|
||||
font_tags: [],
|
||||
features: [],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/test.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
347
src/entities/Font/api/normalize.ts
Normal file
347
src/entities/Font/api/normalize.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Normalize fonts from Google Fonts and Fontshare to unified model
|
||||
*
|
||||
* Transforms provider-specific font data into a common interface
|
||||
* for consistent handling across the application.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font';
|
||||
import type { FontshareFont } from '$entities/Font';
|
||||
import type { GoogleFontItem } from './googleFonts';
|
||||
|
||||
/**
|
||||
* Font variant types (standardized)
|
||||
*/
|
||||
export type UnifiedFontVariant = string;
|
||||
|
||||
/**
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface FontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
italic?: string;
|
||||
/** Bold weight URL */
|
||||
bold?: string;
|
||||
/** Bold italic URL */
|
||||
boldItalic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font metadata
|
||||
*/
|
||||
export interface FontMetadata {
|
||||
/** Timestamp when font was cached */
|
||||
cachedAt: number;
|
||||
/** Font version from provider */
|
||||
version?: string;
|
||||
/** Last modified date from provider */
|
||||
lastModified?: string;
|
||||
/** Popularity rank (if available from provider) */
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font features (variable fonts, axes, tags)
|
||||
*/
|
||||
export interface FontFeatures {
|
||||
/** Whether this is a variable font */
|
||||
isVariable?: boolean;
|
||||
/** Variable font axes (for Fontshare) */
|
||||
axes?: Array<{
|
||||
name: string;
|
||||
property: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}>;
|
||||
/** Usage tags (for Fontshare) */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified font model
|
||||
*
|
||||
* Combines Google Fonts and Fontshare data into a common interface
|
||||
* for consistent font handling across the application.
|
||||
*/
|
||||
export interface UnifiedFont {
|
||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
||||
id: string;
|
||||
/** Font display name */
|
||||
name: string;
|
||||
/** Font provider (google | fontshare) */
|
||||
provider: FontProvider;
|
||||
/** Font category classification */
|
||||
category: FontCategory;
|
||||
/** Supported character subsets */
|
||||
subsets: FontSubset[];
|
||||
/** Available font variants (weights, styles) */
|
||||
variants: UnifiedFontVariant[];
|
||||
/** URL mapping for font file downloads */
|
||||
styles: FontStyleUrls;
|
||||
/** Additional metadata */
|
||||
metadata: FontMetadata;
|
||||
/** Advanced font features */
|
||||
features: FontFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google Fonts category to unified FontCategory
|
||||
*/
|
||||
function mapGoogleCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized.includes('sans-serif')) {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized.includes('serif')) {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized.includes('display')) {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized.includes('monospace')) {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare category to unified FontCategory
|
||||
*/
|
||||
function mapFontshareCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized === 'sans' || normalized === 'sans-serif') {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized === 'serif') {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized === 'display') {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized === 'script') {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized === 'mono' || normalized === 'monospace') {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google subset to unified FontSubset
|
||||
*/
|
||||
function mapGoogleSubset(subset: string): FontSubset | null {
|
||||
const validSubsets: FontSubset[] = [
|
||||
'latin',
|
||||
'latin-ext',
|
||||
'cyrillic',
|
||||
'greek',
|
||||
'arabic',
|
||||
'devanagari',
|
||||
];
|
||||
return validSubsets.includes(subset as FontSubset)
|
||||
? (subset as FontSubset)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare script to unified FontSubset
|
||||
*/
|
||||
function mapFontshareScript(script: string): FontSubset | null {
|
||||
const normalized = script.toLowerCase();
|
||||
const mapping: Record<string, FontSubset | null> = {
|
||||
latin: 'latin',
|
||||
'latin-ext': 'latin-ext',
|
||||
cyrillic: 'cyrillic',
|
||||
greek: 'greek',
|
||||
arabic: 'arabic',
|
||||
devanagari: 'devanagari',
|
||||
};
|
||||
return mapping[normalized] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Google Font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Google Fonts API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = normalizeGoogleFont({
|
||||
* family: 'Roboto',
|
||||
* category: 'sans-serif',
|
||||
* variants: ['regular', '700'],
|
||||
* subsets: ['latin', 'latin-ext'],
|
||||
* files: { regular: '...', '700': '...' }
|
||||
* });
|
||||
*
|
||||
* console.log(roboto.id); // 'Roboto'
|
||||
* console.log(roboto.provider); // 'google'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
|
||||
const category = mapGoogleCategory(apiFont.category);
|
||||
const subsets = apiFont.subsets
|
||||
.map(mapGoogleSubset)
|
||||
.filter((subset): subset is FontSubset => subset !== null);
|
||||
|
||||
// Map variant files to style URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const [variant, url] of Object.entries(apiFont.files)) {
|
||||
if (variant === 'regular' || variant === '400') {
|
||||
styles.regular = url;
|
||||
} else if (variant === 'italic' || variant === '400italic') {
|
||||
styles.italic = url;
|
||||
} else if (variant === 'bold' || variant === '700') {
|
||||
styles.bold = url;
|
||||
} else if (variant === 'bolditalic' || variant === '700italic') {
|
||||
styles.boldItalic = url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiFont.family,
|
||||
name: apiFont.family,
|
||||
provider: 'google',
|
||||
category,
|
||||
subsets,
|
||||
variants: apiFont.variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.lastModified,
|
||||
},
|
||||
features: {
|
||||
isVariable: false, // Google Fonts doesn't expose variable font info
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Fontshare font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Fontshare API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = normalizeFontshareFont({
|
||||
* id: 'uuid',
|
||||
* name: 'Satoshi',
|
||||
* slug: 'satoshi',
|
||||
* category: 'Sans',
|
||||
* script: 'latin',
|
||||
* styles: [ ... ]
|
||||
* });
|
||||
*
|
||||
* console.log(satoshi.id); // 'satoshi'
|
||||
* console.log(satoshi.provider); // 'fontshare'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
||||
const category = mapFontshareCategory(apiFont.category);
|
||||
const subset = mapFontshareScript(apiFont.script);
|
||||
const subsets = subset ? [subset] : [];
|
||||
|
||||
// Extract variant names from styles
|
||||
const variants = apiFont.styles.map(style => {
|
||||
const weightLabel = style.weight.label;
|
||||
const isItalic = style.is_italic;
|
||||
return isItalic ? `${weightLabel}italic` : weightLabel;
|
||||
});
|
||||
|
||||
// Map styles to URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const style of apiFont.styles) {
|
||||
if (style.is_variable) {
|
||||
// Variable font - store as primary variant
|
||||
styles.regular = style.file;
|
||||
break;
|
||||
}
|
||||
|
||||
const weight = style.weight.number;
|
||||
const isItalic = style.is_italic;
|
||||
|
||||
if (weight === 400 && !isItalic) {
|
||||
styles.regular = style.file;
|
||||
} else if (weight === 400 && isItalic) {
|
||||
styles.italic = style.file;
|
||||
} else if (weight >= 700 && !isItalic) {
|
||||
styles.bold = style.file;
|
||||
} else if (weight >= 700 && isItalic) {
|
||||
styles.boldItalic = style.file;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variable font axes
|
||||
const axes = apiFont.axes.map(axis => ({
|
||||
name: axis.name,
|
||||
property: axis.property,
|
||||
default: axis.range_default,
|
||||
min: axis.range_left,
|
||||
max: axis.range_right,
|
||||
}));
|
||||
|
||||
// Extract tags
|
||||
const tags = apiFont.font_tags.map(tag => tag.name);
|
||||
|
||||
return {
|
||||
id: apiFont.slug,
|
||||
name: apiFont.name,
|
||||
provider: 'fontshare',
|
||||
category,
|
||||
subsets,
|
||||
variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.inserted_at,
|
||||
popularity: apiFont.views,
|
||||
},
|
||||
features: {
|
||||
isVariable: apiFont.axes.length > 0,
|
||||
axes: axes.length > 0 ? axes : undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Google Fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Google Font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeGoogleFonts(
|
||||
apiFonts: GoogleFontItem[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeGoogleFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Fontshare fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Fontshare font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeFontshareFonts(
|
||||
apiFonts: FontshareFont[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeFontshareFont);
|
||||
}
|
||||
@@ -1,8 +1,39 @@
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './api/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './api/fontshare';
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './api/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './api/googleFonts';
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './api/normalize';
|
||||
export type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './api/normalize';
|
||||
export type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from './model/font';
|
||||
} from './model/types/font';
|
||||
export type {
|
||||
FontshareApiModel,
|
||||
FontshareDesigner,
|
||||
@@ -13,10 +44,10 @@ export type {
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
} from './model/fontshare_fonts';
|
||||
} from './model/types/fontshare_fonts';
|
||||
export type {
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontVariant,
|
||||
GoogleFontsApiModel,
|
||||
} from './model/google_fonts';
|
||||
} from './model/types/google_fonts';
|
||||
|
||||
338
src/entities/Font/model/stores/fontCollectionStore.ts
Normal file
338
src/entities/Font/model/stores/fontCollectionStore.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Font collection store
|
||||
*
|
||||
* Main font collection cache using Svelte stores.
|
||||
* Integrates with TanStack Query for advanced caching and deduplication.
|
||||
*
|
||||
* Provides derived stores for filtered/sorted fonts.
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
import {
|
||||
type CollectionCacheManager,
|
||||
createCollectionCache,
|
||||
} from '$shared/fetch/collectionCache';
|
||||
import type {
|
||||
Readable,
|
||||
Writable,
|
||||
} from 'svelte/store';
|
||||
import {
|
||||
derived,
|
||||
get,
|
||||
writable,
|
||||
} from 'svelte/store';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
} from '../types/font';
|
||||
|
||||
/**
|
||||
* Font collection state
|
||||
*/
|
||||
export interface FontCollectionState {
|
||||
/** All cached fonts */
|
||||
fonts: Record<string, UnifiedFont>;
|
||||
/** Active filters */
|
||||
filters: FontCollectionFilters;
|
||||
/** Sort configuration */
|
||||
sort: FontCollectionSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection filters
|
||||
*/
|
||||
export interface FontCollectionFilters {
|
||||
/** Search query */
|
||||
searchQuery?: string;
|
||||
/** Filter by provider */
|
||||
provider?: FontProvider;
|
||||
/** Filter by category */
|
||||
category?: FontCategory;
|
||||
/** Filter by subsets */
|
||||
subsets?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection sort configuration
|
||||
*/
|
||||
export interface FontCollectionSort {
|
||||
/** Sort field */
|
||||
field: 'name' | 'popularity' | 'category';
|
||||
/** Sort direction */
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection store interface
|
||||
*/
|
||||
export interface FontCollectionStore {
|
||||
/** Main state store */
|
||||
state: Writable<FontCollectionState>;
|
||||
/** All fonts as array */
|
||||
fonts: Readable<UnifiedFont[]>;
|
||||
/** Filtered fonts as array */
|
||||
filteredFonts: Readable<UnifiedFont[]>;
|
||||
/** Number of fonts in collection */
|
||||
count: Readable<number>;
|
||||
/** Loading state */
|
||||
isLoading: Readable<boolean>;
|
||||
/** Error state */
|
||||
error: Readable<string | undefined>;
|
||||
/** Add fonts to collection */
|
||||
addFonts: (fonts: UnifiedFont[]) => void;
|
||||
/** Add single font to collection */
|
||||
addFont: (font: UnifiedFont) => void;
|
||||
/** Remove font from collection */
|
||||
removeFont: (fontId: string) => void;
|
||||
/** Clear all fonts */
|
||||
clear: () => void;
|
||||
/** Update filters */
|
||||
setFilters: (filters: Partial<FontCollectionFilters>) => void;
|
||||
/** Clear filters */
|
||||
clearFilters: () => void;
|
||||
/** Update sort configuration */
|
||||
setSort: (sort: FontCollectionSort) => void;
|
||||
/** Get font by ID */
|
||||
getFont: (fontId: string) => UnifiedFont | undefined;
|
||||
/** Get fonts by provider */
|
||||
getFontsByProvider: (provider: FontProvider) => UnifiedFont[];
|
||||
/** Get fonts by category */
|
||||
getFontsByCategory: (category: FontCategory) => UnifiedFont[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create font collection store
|
||||
*
|
||||
* @param initialState - Initial state for collection
|
||||
* @returns Font collection store instance
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const fontCollection = createFontCollectionStore({
|
||||
* fonts: {},
|
||||
* filters: {},
|
||||
* sort: { field: 'name', direction: 'asc' }
|
||||
* });
|
||||
*
|
||||
* // Add fonts to collection
|
||||
* fontCollection.addFonts([font1, font2]);
|
||||
*
|
||||
* // Use in component
|
||||
* $fontCollection.filteredFonts
|
||||
* ```
|
||||
*/
|
||||
export function createFontCollectionStore(
|
||||
initialState?: Partial<FontCollectionState>,
|
||||
): FontCollectionStore {
|
||||
const cache = createCollectionCache<UnifiedFont>({
|
||||
defaultTTL: 5 * 60 * 1000, // 5 minutes
|
||||
maxSize: 1000,
|
||||
});
|
||||
|
||||
const defaultState: FontCollectionState = {
|
||||
fonts: {},
|
||||
filters: {},
|
||||
sort: { field: 'name', direction: 'asc' },
|
||||
};
|
||||
|
||||
const state: Writable<FontCollectionState> = writable({
|
||||
...defaultState,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
const isLoading = writable(false);
|
||||
const error = writable<string | undefined>();
|
||||
|
||||
// Derived store for fonts as array
|
||||
const fonts = derived(state, $state => {
|
||||
return Object.values($state.fonts);
|
||||
});
|
||||
|
||||
// Derived store for filtered fonts
|
||||
const filteredFonts = derived([state, fonts], ([$state, $fonts]) => {
|
||||
let filtered = [...$fonts];
|
||||
|
||||
// Apply search filter
|
||||
if ($state.filters.searchQuery) {
|
||||
const query = $state.filters.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(font => font.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
// Apply provider filter
|
||||
if ($state.filters.provider) {
|
||||
filtered = filtered.filter(
|
||||
font => font.provider === $state.filters.provider,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if ($state.filters.category) {
|
||||
filtered = filtered.filter(
|
||||
font => font.category === $state.filters.category,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply subset filter
|
||||
if ($state.filters.subsets?.length) {
|
||||
filtered = filtered.filter(font =>
|
||||
$state.filters.subsets!.some(subset => font.subsets.includes(subset as any))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const { field, direction } = $state.sort;
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (field === 'name') {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
} else if (field === 'popularity') {
|
||||
const aPop = a.metadata.popularity ?? 0;
|
||||
const bPop = b.metadata.popularity ?? 0;
|
||||
comparison = aPop - bPop;
|
||||
} else if (field === 'category') {
|
||||
comparison = a.category.localeCompare(b.category);
|
||||
}
|
||||
|
||||
return comparison * multiplier;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Derived store for count
|
||||
const count = derived(fonts, $fonts => $fonts.length);
|
||||
|
||||
return {
|
||||
// Expose main state
|
||||
state,
|
||||
|
||||
// Expose derived stores
|
||||
fonts,
|
||||
filteredFonts,
|
||||
count,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
/**
|
||||
* Add multiple fonts to collection
|
||||
*/
|
||||
addFonts: (newFonts: UnifiedFont[]) => {
|
||||
state.update($state => {
|
||||
const fontsMap = { ...$state.fonts };
|
||||
|
||||
for (const font of newFonts) {
|
||||
fontsMap[font.id] = font;
|
||||
cache.set(font.id, font);
|
||||
}
|
||||
|
||||
return {
|
||||
...$state,
|
||||
fonts: fontsMap,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Add single font to collection
|
||||
*/
|
||||
addFont: (font: UnifiedFont) => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
fonts: {
|
||||
...$state.fonts,
|
||||
[font.id]: font,
|
||||
},
|
||||
}));
|
||||
cache.set(font.id, font);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove font from collection
|
||||
*/
|
||||
removeFont: (fontId: string) => {
|
||||
state.update($state => {
|
||||
const { [fontId]: _, ...rest } = $state.fonts;
|
||||
return {
|
||||
...$state,
|
||||
fonts: rest,
|
||||
};
|
||||
});
|
||||
cache.remove(fontId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all fonts
|
||||
*/
|
||||
clear: () => {
|
||||
state.set({
|
||||
...get(state),
|
||||
fonts: {},
|
||||
});
|
||||
cache.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update filters
|
||||
*/
|
||||
setFilters: (filters: Partial<FontCollectionFilters>) => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
filters: {
|
||||
...$state.filters,
|
||||
...filters,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
clearFilters: () => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
filters: {},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Update sort configuration
|
||||
*/
|
||||
setSort: (sort: FontCollectionSort) => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
sort,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get font by ID
|
||||
*/
|
||||
getFont: (fontId: string) => {
|
||||
const currentState = get(state);
|
||||
return currentState.fonts[fontId];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get fonts by provider
|
||||
*/
|
||||
getFontsByProvider: (provider: FontProvider) => {
|
||||
const currentState = get(state);
|
||||
return Object.values(currentState.fonts).filter(
|
||||
font => font.provider === provider,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get fonts by category
|
||||
*/
|
||||
getFontsByCategory: (category: FontCategory) => {
|
||||
const currentState = get(state);
|
||||
return Object.values(currentState.fonts).filter(
|
||||
font => font.category === category,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
13
src/entities/Font/model/stores/index.ts
Normal file
13
src/entities/Font/model/stores/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Font collection store exports
|
||||
*
|
||||
* Exports font collection store types and factory function
|
||||
*/
|
||||
|
||||
export { createFontCollectionStore } from './fontCollectionStore';
|
||||
export type {
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontCollectionStore,
|
||||
} from './fontCollectionStore';
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CollectionApiModel } from '../../../shared/types/collection';
|
||||
import type { CollectionApiModel } from '$shared/types/collection';
|
||||
|
||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
|
||||
|
||||
25
src/features/FetchFonts/index.ts
Normal file
25
src/features/FetchFonts/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Fetch fonts feature exports
|
||||
*
|
||||
* Exports service functions for fetching fonts from Google Fonts and Fontshare
|
||||
*/
|
||||
|
||||
export {
|
||||
cancelGoogleFontsQueries,
|
||||
fetchGoogleFontsQuery,
|
||||
getGoogleFontsQueryKey,
|
||||
invalidateGoogleFonts,
|
||||
prefetchGoogleFonts,
|
||||
useGoogleFontsQuery,
|
||||
} from './model/services/fetchGoogleFonts';
|
||||
export type { GoogleFontsQueryParams } from './model/services/fetchGoogleFonts';
|
||||
|
||||
export {
|
||||
cancelFontshareFontsQueries,
|
||||
fetchFontshareFontsQuery,
|
||||
getFontshareQueryKey,
|
||||
invalidateFontshareFonts,
|
||||
prefetchFontshareFonts,
|
||||
useFontshareFontsQuery,
|
||||
} from './model/services/fetchFontshareFonts';
|
||||
export type { FontshareQueryParams } from './model/services/fetchFontshareFonts';
|
||||
211
src/features/FetchFonts/model/services/fetchFontshareFonts.ts
Normal file
211
src/features/FetchFonts/model/services/fetchFontshareFonts.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Service for fetching Fontshare fonts
|
||||
*
|
||||
* Integrates with TanStack Query for caching, deduplication,
|
||||
* and automatic refetching.
|
||||
*/
|
||||
|
||||
import { fetchFontshareFonts } from '$entities/Font/api/fontshare';
|
||||
import { normalizeFontshareFonts } from '$entities/Font/api/normalize';
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
import type { QueryFunction } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/svelte-query';
|
||||
|
||||
/**
|
||||
* Fontshare query parameters
|
||||
*/
|
||||
export interface FontshareQueryParams {
|
||||
/** Filter by categories (e.g., ["Sans", "Serif"]) */
|
||||
categories?: string[];
|
||||
/** Filter by tags (e.g., ["Branding", "Logos"]) */
|
||||
tags?: string[];
|
||||
/** Page number for pagination */
|
||||
page?: number;
|
||||
/** Number of items per page */
|
||||
limit?: number;
|
||||
/** Search query */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factory for Fontshare
|
||||
* Generates consistent query keys for cache management
|
||||
*/
|
||||
export function getFontshareQueryKey(
|
||||
params: FontshareQueryParams,
|
||||
): readonly unknown[] {
|
||||
return ['fontshare', params];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query function for fetching Fontshare fonts
|
||||
* Handles caching, loading states, and errors
|
||||
*/
|
||||
export const fetchFontshareFontsQuery: QueryFunction<
|
||||
UnifiedFont[],
|
||||
readonly unknown[]
|
||||
> = async ({ queryKey }) => {
|
||||
const params = queryKey[1] as FontshareQueryParams;
|
||||
|
||||
try {
|
||||
const response = await fetchFontshareFonts({
|
||||
categories: params.categories,
|
||||
tags: params.tags,
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
search: params.search,
|
||||
});
|
||||
|
||||
const normalizedFonts = normalizeFontshareFonts(response.items);
|
||||
return normalizedFonts;
|
||||
} catch (error) {
|
||||
// User-friendly error messages
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
throw new Error(
|
||||
'Unable to connect to Fontshare. Please check your internet connection and try again.',
|
||||
);
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
throw new Error('Font not found in Fontshare catalog.');
|
||||
}
|
||||
throw new Error(
|
||||
'Failed to load fonts from Fontshare. Please try again later.',
|
||||
);
|
||||
}
|
||||
throw new Error('An unexpected error occurred while fetching fonts.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Fontshare query hook
|
||||
* Use this in Svelte components to fetch Fontshare fonts with caching
|
||||
*
|
||||
* @param params - Query parameters
|
||||
* @returns Query result with data, loading state, and error
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* let { categories }: { categories?: string[] } = $props();
|
||||
*
|
||||
* const query = useFontshareFontsQuery({ categories });
|
||||
*
|
||||
* if ($query.isLoading) {
|
||||
* return <LoadingSpinner />;
|
||||
* }
|
||||
*
|
||||
* if ($query.error) {
|
||||
* return <ErrorMessage message={$query.error.message} />;
|
||||
* }
|
||||
*
|
||||
* const fonts = $query.data ?? [];
|
||||
* </script>
|
||||
*
|
||||
* {#each fonts as font}
|
||||
* <FontCard {font} />
|
||||
* {/each}
|
||||
* ```
|
||||
*/
|
||||
export function useFontshareFontsQuery(
|
||||
params: FontshareQueryParams = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: getFontshareQueryKey(params),
|
||||
queryFn: fetchFontshareFontsQuery,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
}));
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch Fontshare fonts
|
||||
* Fetch fonts in background without showing loading state
|
||||
*
|
||||
* @param params - Query parameters for prefetch
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Prefetch fonts when user hovers over button
|
||||
* function onMouseEnter() {
|
||||
* prefetchFontshareFonts({ categories: ['Sans'] });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function prefetchFontshareFonts(
|
||||
params: FontshareQueryParams = {},
|
||||
): Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: getFontshareQueryKey(params),
|
||||
queryFn: fetchFontshareFontsQuery,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate Fontshare cache
|
||||
* Forces refetch on next query
|
||||
*
|
||||
* @param params - Query parameters to invalidate (all if not provided)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Invalidate all Fontshare cache
|
||||
* invalidateFontshareFonts();
|
||||
*
|
||||
* // Invalidate specific category cache
|
||||
* invalidateFontshareFonts({ categories: ['Sans'] });
|
||||
* ```
|
||||
*/
|
||||
export function invalidateFontshareFonts(
|
||||
params?: FontshareQueryParams,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (params) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getFontshareQueryKey(params),
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['fontshare'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Fontshare queries
|
||||
* Abort in-flight requests
|
||||
*
|
||||
* @param params - Query parameters to cancel (all if not provided)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Cancel all Fontshare queries
|
||||
* cancelFontshareFontsQueries();
|
||||
* ```
|
||||
*/
|
||||
export function cancelFontshareFontsQueries(
|
||||
params?: FontshareQueryParams,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (params) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: getFontshareQueryKey(params),
|
||||
});
|
||||
} else {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: ['fontshare'],
|
||||
});
|
||||
}
|
||||
}
|
||||
213
src/features/FetchFonts/model/services/fetchGoogleFonts.ts
Normal file
213
src/features/FetchFonts/model/services/fetchGoogleFonts.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Service for fetching Google Fonts
|
||||
*
|
||||
* Integrates with TanStack Query for caching, deduplication,
|
||||
* and automatic refetching.
|
||||
*
|
||||
* Uses reactive query args pattern for Svelte 5 compatibility.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontSubset,
|
||||
} from '$entities/Font';
|
||||
import { fetchGoogleFonts } from '$entities/Font/api/googleFonts';
|
||||
import { normalizeGoogleFonts } from '$entities/Font/api/normalize';
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
import type { QueryFunction } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/svelte-query';
|
||||
|
||||
/**
|
||||
* Google Fonts query parameters
|
||||
*/
|
||||
export interface GoogleFontsQueryParams {
|
||||
/** Font category filter */
|
||||
category?: FontCategory;
|
||||
/** Character subset filter */
|
||||
subset?: FontSubset;
|
||||
/** Sort order */
|
||||
sort?: 'popularity' | 'alpha' | 'date';
|
||||
/** Search query (for specific font) */
|
||||
search?: string;
|
||||
/** Force refetch even if cached */
|
||||
forceRefetch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key factory for Google Fonts
|
||||
* Generates consistent query keys for cache management
|
||||
*/
|
||||
export function getGoogleFontsQueryKey(
|
||||
params: GoogleFontsQueryParams,
|
||||
): readonly unknown[] {
|
||||
return ['googleFonts', params];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query function for fetching Google Fonts
|
||||
* Handles caching, loading states, and errors
|
||||
*/
|
||||
export const fetchGoogleFontsQuery: QueryFunction<
|
||||
UnifiedFont[],
|
||||
readonly unknown[]
|
||||
> = async ({ queryKey }) => {
|
||||
const params = queryKey[1] as GoogleFontsQueryParams;
|
||||
|
||||
try {
|
||||
const response = await fetchGoogleFonts({
|
||||
category: params.category,
|
||||
subset: params.subset,
|
||||
sort: params.sort,
|
||||
});
|
||||
|
||||
const normalizedFonts = normalizeGoogleFonts(response.items);
|
||||
return normalizedFonts;
|
||||
} catch (error) {
|
||||
// User-friendly error messages
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
throw new Error(
|
||||
'Unable to connect to Google Fonts. Please check your internet connection and try again.',
|
||||
);
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
throw new Error('Font not found in Google Fonts catalog.');
|
||||
}
|
||||
throw new Error(
|
||||
'Failed to load fonts from Google Fonts. Please try again later.',
|
||||
);
|
||||
}
|
||||
throw new Error('An unexpected error occurred while fetching fonts.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Google Fonts query hook
|
||||
* Use this in Svelte components to fetch Google Fonts with caching
|
||||
*
|
||||
* @param params - Query parameters
|
||||
* @returns Query result with data, loading state, and error
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* let { category }: { category?: FontCategory } = $props();
|
||||
*
|
||||
* const query = useGoogleFontsQuery({ category });
|
||||
*
|
||||
* if ($query.isLoading) {
|
||||
* return <LoadingSpinner />;
|
||||
* }
|
||||
*
|
||||
* if ($query.error) {
|
||||
* return <ErrorMessage message={$query.error.message} />;
|
||||
* }
|
||||
*
|
||||
* const fonts = $query.data ?? [];
|
||||
* </script>
|
||||
*
|
||||
* {#each fonts as font}
|
||||
* <FontCard {font} />
|
||||
* {/each}
|
||||
* ```
|
||||
*/
|
||||
export function useGoogleFontsQuery(params: GoogleFontsQueryParams = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryKey: getGoogleFontsQueryKey(params),
|
||||
queryFn: fetchGoogleFontsQuery,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
}));
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch Google Fonts
|
||||
* Fetch fonts in background without showing loading state
|
||||
*
|
||||
* @param params - Query parameters for prefetch
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Prefetch fonts when user hovers over button
|
||||
* function onMouseEnter() {
|
||||
* prefetchGoogleFonts({ category: 'sans-serif' });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function prefetchGoogleFonts(
|
||||
params: GoogleFontsQueryParams = {},
|
||||
): Promise<void> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: getGoogleFontsQueryKey(params),
|
||||
queryFn: fetchGoogleFontsQuery,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate Google Fonts cache
|
||||
* Forces refetch on next query
|
||||
*
|
||||
* @param params - Query parameters to invalidate (all if not provided)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Invalidate all Google Fonts cache
|
||||
* invalidateGoogleFonts();
|
||||
*
|
||||
* // Invalidate specific category cache
|
||||
* invalidateGoogleFonts({ category: 'sans-serif' });
|
||||
* ```
|
||||
*/
|
||||
export function invalidateGoogleFonts(
|
||||
params?: GoogleFontsQueryParams,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (params) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGoogleFontsQueryKey(params),
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['googleFonts'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Google Fonts queries
|
||||
* Abort in-flight requests
|
||||
*
|
||||
* @param params - Query parameters to cancel (all if not provided)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Cancel all Google Fonts queries
|
||||
* cancelGoogleFontsQueries();
|
||||
* ```
|
||||
*/
|
||||
export function cancelGoogleFontsQueries(
|
||||
params?: GoogleFontsQueryParams,
|
||||
): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (params) {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: getGoogleFontsQueryKey(params),
|
||||
});
|
||||
} else {
|
||||
queryClient.cancelQueries({
|
||||
queryKey: ['googleFonts'],
|
||||
});
|
||||
}
|
||||
}
|
||||
76
src/features/FetchFonts/model/types.ts
Normal file
76
src/features/FetchFonts/model/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Fetch Fonts feature types
|
||||
*
|
||||
* Type definitions for font fetching feature
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font';
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
|
||||
/**
|
||||
* Combined query parameters for fetching from any provider
|
||||
*/
|
||||
export interface FetchFontsParams {
|
||||
/** Font provider to fetch from */
|
||||
provider?: FontProvider;
|
||||
/** Category filter */
|
||||
category?: FontCategory;
|
||||
/** Subset filter */
|
||||
subset?: FontSubset;
|
||||
/** Search query */
|
||||
search?: string;
|
||||
/** Page number (for Fontshare) */
|
||||
page?: number;
|
||||
/** Limit (for Fontshare) */
|
||||
limit?: number;
|
||||
/** Force refetch even if cached */
|
||||
forceRefetch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font fetching result
|
||||
*/
|
||||
export interface FetchFontsResult {
|
||||
/** Fetched fonts */
|
||||
fonts: UnifiedFont[];
|
||||
/** Total count (for pagination) */
|
||||
total?: number;
|
||||
/** Whether more fonts are available */
|
||||
hasMore?: boolean;
|
||||
/** Page number (for pagination) */
|
||||
page?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font fetching error
|
||||
*/
|
||||
export interface FetchFontsError {
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Provider that failed */
|
||||
provider: FontProvider | 'all';
|
||||
/** HTTP status code (if applicable) */
|
||||
status?: number;
|
||||
/** Original error */
|
||||
originalError?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font fetching state
|
||||
*/
|
||||
export interface FetchFontsState {
|
||||
/** Currently fetching */
|
||||
isFetching: boolean;
|
||||
/** Currently loading initial data */
|
||||
isLoading: boolean;
|
||||
/** Error state */
|
||||
error: FetchFontsError | null;
|
||||
/** Cached fonts */
|
||||
fonts: UnifiedFont[];
|
||||
/** Last fetch timestamp */
|
||||
lastFetchedAt: number | null;
|
||||
}
|
||||
445
src/shared/fetch/collectionCache.test.ts
Normal file
445
src/shared/fetch/collectionCache.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
type CacheItemInternalState,
|
||||
type CacheOptions,
|
||||
createCollectionCache,
|
||||
} from './collectionCache';
|
||||
|
||||
describe('createCollectionCache', () => {
|
||||
let cache: ReturnType<typeof createCollectionCache<number>>;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = createCollectionCache<number>();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('initializes with empty cache', () => {
|
||||
const data = get(cache.data);
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
|
||||
it('initializes with default options', () => {
|
||||
const stats = cache.getStats();
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.cached).toBe(0);
|
||||
expect(stats.fetching).toBe(0);
|
||||
expect(stats.errors).toBe(0);
|
||||
expect(stats.hits).toBe(0);
|
||||
expect(stats.misses).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts custom cache options', () => {
|
||||
const options: CacheOptions = {
|
||||
defaultTTL: 10 * 60 * 1000, // 10 minutes
|
||||
maxSize: 500,
|
||||
};
|
||||
const customCache = createCollectionCache<number>(options);
|
||||
expect(customCache).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('set and get', () => {
|
||||
it('sets a value in cache', () => {
|
||||
cache.set('key1', 100);
|
||||
const value = cache.get('key1');
|
||||
expect(value).toBe(100);
|
||||
});
|
||||
|
||||
it('sets multiple values in cache', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.set('key2', 200);
|
||||
cache.set('key3', 300);
|
||||
|
||||
expect(cache.get('key1')).toBe(100);
|
||||
expect(cache.get('key2')).toBe(200);
|
||||
expect(cache.get('key3')).toBe(300);
|
||||
});
|
||||
|
||||
it('updates existing value', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.set('key1', 150);
|
||||
expect(cache.get('key1')).toBe(150);
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent key', () => {
|
||||
const value = cache.get('non-existent');
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks item as ready after set', () => {
|
||||
cache.set('key1', 100);
|
||||
const internalState = cache.getInternalState('key1');
|
||||
expect(internalState?.ready).toBe(true);
|
||||
expect(internalState?.fetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has and hasFresh', () => {
|
||||
it('returns false for non-existent key', () => {
|
||||
expect(cache.has('non-existent')).toBe(false);
|
||||
expect(cache.hasFresh('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true after setting value', () => {
|
||||
cache.set('key1', 100);
|
||||
expect(cache.has('key1')).toBe(true);
|
||||
expect(cache.hasFresh('key1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for fetching items', () => {
|
||||
cache.markFetching('key1');
|
||||
expect(cache.has('key1')).toBe(false);
|
||||
expect(cache.hasFresh('key1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for failed items', () => {
|
||||
cache.markFailed('key1', 'Network error');
|
||||
expect(cache.has('key1')).toBe(false);
|
||||
expect(cache.hasFresh('key1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes a value from cache', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.set('key2', 200);
|
||||
|
||||
cache.remove('key1');
|
||||
|
||||
expect(cache.get('key1')).toBeUndefined();
|
||||
expect(cache.get('key2')).toBe(200);
|
||||
});
|
||||
|
||||
it('removes internal state', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.remove('key1');
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing for non-existent key', () => {
|
||||
expect(() => cache.remove('non-existent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('clears all values from cache', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.set('key2', 200);
|
||||
cache.set('key3', 300);
|
||||
|
||||
cache.clear();
|
||||
|
||||
expect(cache.get('key1')).toBeUndefined();
|
||||
expect(cache.get('key2')).toBeUndefined();
|
||||
expect(cache.get('key3')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears internal state', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.clear();
|
||||
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resets cache statistics', () => {
|
||||
cache.set('key1', 100); // This increments hits
|
||||
const statsBefore = cache.getStats();
|
||||
|
||||
cache.clear();
|
||||
const statsAfter = cache.getStats();
|
||||
|
||||
expect(statsAfter.hits).toBe(0);
|
||||
expect(statsAfter.misses).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFetching', () => {
|
||||
it('marks item as fetching', () => {
|
||||
cache.markFetching('key1');
|
||||
|
||||
expect(cache.isFetching('key1')).toBe(true);
|
||||
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.fetching).toBe(true);
|
||||
expect(state?.ready).toBe(false);
|
||||
expect(state?.startTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates existing state when called again', () => {
|
||||
cache.markFetching('key1');
|
||||
const startTime1 = cache.getInternalState('key1')?.startTime;
|
||||
|
||||
// Wait a bit to ensure different timestamp
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
cache.markFetching('key1');
|
||||
const startTime2 = cache.getInternalState('key1')?.startTime;
|
||||
|
||||
expect(startTime2).toBeGreaterThan(startTime1!);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('sets endTime to undefined', () => {
|
||||
cache.markFetching('key1');
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.endTime).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFailed', () => {
|
||||
it('marks item as failed with error message', () => {
|
||||
cache.markFailed('key1', 'Network error');
|
||||
|
||||
expect(cache.isFetching('key1')).toBe(false);
|
||||
|
||||
const error = cache.getError('key1');
|
||||
expect(error).toBe('Network error');
|
||||
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.fetching).toBe(false);
|
||||
expect(state?.ready).toBe(false);
|
||||
expect(state?.error).toBe('Network error');
|
||||
});
|
||||
|
||||
it('preserves start time from fetching state', () => {
|
||||
cache.markFetching('key1');
|
||||
const startTime = cache.getInternalState('key1')?.startTime;
|
||||
|
||||
cache.markFailed('key1', 'Error');
|
||||
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.startTime).toBe(startTime);
|
||||
});
|
||||
|
||||
it('sets end time', () => {
|
||||
cache.markFailed('key1', 'Error');
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.endTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('increments error counter', () => {
|
||||
const statsBefore = cache.getStats();
|
||||
|
||||
cache.markFailed('key1', 'Error1');
|
||||
const statsAfter1 = cache.getStats();
|
||||
expect(statsAfter1.errors).toBe(statsBefore.errors + 1);
|
||||
|
||||
cache.markFailed('key2', 'Error2');
|
||||
const statsAfter2 = cache.getStats();
|
||||
expect(statsAfter2.errors).toBe(statsAfter1.errors + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markMiss', () => {
|
||||
it('increments miss counter', () => {
|
||||
const statsBefore = cache.getStats();
|
||||
|
||||
cache.markMiss();
|
||||
|
||||
const statsAfter = cache.getStats();
|
||||
expect(statsAfter.misses).toBe(statsBefore.misses + 1);
|
||||
});
|
||||
|
||||
it('increments miss counter multiple times', () => {
|
||||
const statsBefore = cache.getStats();
|
||||
|
||||
cache.markMiss();
|
||||
cache.markMiss();
|
||||
cache.markMiss();
|
||||
|
||||
const statsAfter = cache.getStats();
|
||||
expect(statsAfter.misses).toBe(statsBefore.misses + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statistics', () => {
|
||||
it('tracks total number of items', () => {
|
||||
expect(cache.getStats().total).toBe(0);
|
||||
|
||||
cache.set('key1', 100);
|
||||
expect(cache.getStats().total).toBe(1);
|
||||
|
||||
cache.set('key2', 200);
|
||||
expect(cache.getStats().total).toBe(2);
|
||||
|
||||
cache.remove('key1');
|
||||
expect(cache.getStats().total).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks number of cached (ready) items', () => {
|
||||
expect(cache.getStats().cached).toBe(0);
|
||||
|
||||
cache.set('key1', 100);
|
||||
expect(cache.getStats().cached).toBe(1);
|
||||
|
||||
cache.set('key2', 200);
|
||||
expect(cache.getStats().cached).toBe(2);
|
||||
|
||||
cache.markFetching('key3');
|
||||
expect(cache.getStats().cached).toBe(2);
|
||||
});
|
||||
|
||||
it('tracks number of fetching items', () => {
|
||||
expect(cache.getStats().fetching).toBe(0);
|
||||
|
||||
cache.markFetching('key1');
|
||||
expect(cache.getStats().fetching).toBe(1);
|
||||
|
||||
cache.markFetching('key2');
|
||||
expect(cache.getStats().fetching).toBe(2);
|
||||
|
||||
cache.set('key1', 100);
|
||||
expect(cache.getStats().fetching).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks cache hits', () => {
|
||||
const statsBefore = cache.getStats();
|
||||
|
||||
cache.set('key1', 100);
|
||||
const statsAfter1 = cache.getStats();
|
||||
expect(statsAfter1.hits).toBe(statsBefore.hits + 1);
|
||||
|
||||
cache.set('key2', 200);
|
||||
const statsAfter2 = cache.getStats();
|
||||
expect(statsAfter2.hits).toBe(statsAfter1.hits + 1);
|
||||
});
|
||||
|
||||
it('provides derived stats store', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.markFetching('key2');
|
||||
|
||||
const stats = get(cache.stats);
|
||||
expect(stats.total).toBe(1);
|
||||
expect(stats.cached).toBe(1);
|
||||
expect(stats.fetching).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store reactivity', () => {
|
||||
it('updates data store reactively', () => {
|
||||
let dataUpdates = 0;
|
||||
const unsubscribe = cache.data.subscribe(() => {
|
||||
dataUpdates++;
|
||||
});
|
||||
|
||||
cache.set('key1', 100);
|
||||
cache.set('key2', 200);
|
||||
|
||||
expect(dataUpdates).toBeGreaterThan(0);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('updates internal state store reactively', () => {
|
||||
let internalUpdates = 0;
|
||||
const unsubscribe = cache.internal.subscribe(() => {
|
||||
internalUpdates++;
|
||||
});
|
||||
|
||||
cache.markFetching('key1');
|
||||
cache.set('key1', 100);
|
||||
cache.markFailed('key2', 'Error');
|
||||
|
||||
expect(internalUpdates).toBeGreaterThan(0);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('updates stats store reactively', () => {
|
||||
let statsUpdates = 0;
|
||||
const unsubscribe = cache.stats.subscribe(() => {
|
||||
statsUpdates++;
|
||||
});
|
||||
|
||||
cache.set('key1', 100);
|
||||
cache.markMiss();
|
||||
|
||||
expect(statsUpdates).toBeGreaterThan(0);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles complex types', () => {
|
||||
interface ComplexType {
|
||||
id: string;
|
||||
value: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const complexCache = createCollectionCache<ComplexType>();
|
||||
const item: ComplexType = {
|
||||
id: '1',
|
||||
value: 42,
|
||||
tags: ['a', 'b', 'c'],
|
||||
};
|
||||
|
||||
complexCache.set('item1', item);
|
||||
const retrieved = complexCache.get('item1');
|
||||
|
||||
expect(retrieved).toEqual(item);
|
||||
expect(retrieved?.tags).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('handles special characters in keys', () => {
|
||||
cache.set('key with spaces', 1);
|
||||
cache.set('key/with/slashes', 2);
|
||||
cache.set('key-with-dashes', 3);
|
||||
|
||||
expect(cache.get('key with spaces')).toBe(1);
|
||||
expect(cache.get('key/with/slashes')).toBe(2);
|
||||
expect(cache.get('key-with-dashes')).toBe(3);
|
||||
});
|
||||
|
||||
it('handles rapid set and remove operations', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
cache.set(`key${i}`, i);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i += 2) {
|
||||
cache.remove(`key${i}`);
|
||||
}
|
||||
|
||||
expect(cache.getStats().total).toBe(50);
|
||||
expect(cache.get('key0')).toBeUndefined();
|
||||
expect(cache.get('key1')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles concurrent markFetching for same key', () => {
|
||||
cache.markFetching('key1');
|
||||
cache.markFetching('key1');
|
||||
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.fetching).toBe(true);
|
||||
expect(state?.startTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles marking failed without prior fetching', () => {
|
||||
cache.markFailed('key1', 'Error');
|
||||
|
||||
const state = cache.getInternalState('key1');
|
||||
expect(state?.fetching).toBe(false);
|
||||
expect(state?.ready).toBe(false);
|
||||
expect(state?.error).toBe('Error');
|
||||
});
|
||||
|
||||
it('handles operations on removed keys', () => {
|
||||
cache.set('key1', 100);
|
||||
cache.remove('key1');
|
||||
|
||||
expect(() => cache.set('key1', 200)).not.toThrow();
|
||||
expect(() => cache.remove('key1')).not.toThrow();
|
||||
expect(() => cache.getError('key1')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
334
src/shared/fetch/collectionCache.ts
Normal file
334
src/shared/fetch/collectionCache.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Collection cache manager
|
||||
*
|
||||
* Provides key-based caching, deduplication, and request tracking
|
||||
* for any collection type. Integrates with Svelte stores for reactive updates.
|
||||
*
|
||||
* Key features:
|
||||
* - Key-based caching (any ID, query hash)
|
||||
* - Request deduplication (prevents concurrent requests for same key)
|
||||
* - Request state tracking (fetching, ready, error)
|
||||
* - TTL/staleness management
|
||||
* - Performance timing tracking
|
||||
*/
|
||||
|
||||
import type {
|
||||
Readable,
|
||||
Writable,
|
||||
} from 'svelte/store';
|
||||
import {
|
||||
derived,
|
||||
get,
|
||||
writable,
|
||||
} from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Internal state for a cached item
|
||||
* Tracks request lifecycle (fetching → ready/error)
|
||||
*/
|
||||
export interface CacheItemInternalState {
|
||||
/** Whether a fetch is currently in progress */
|
||||
fetching: boolean;
|
||||
/** Whether data is ready and cached */
|
||||
ready: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error?: string;
|
||||
/** Request start timestamp (performance tracking) */
|
||||
startTime?: number;
|
||||
/** Request end timestamp (performance tracking) */
|
||||
endTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache configuration options
|
||||
*/
|
||||
export interface CacheOptions {
|
||||
/** Default time-to-live for cached items (in milliseconds) */
|
||||
defaultTTL?: number;
|
||||
/** Maximum number of items to cache (LRU eviction) */
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics about cache performance
|
||||
*/
|
||||
export interface CacheStats {
|
||||
/** Total number of items in cache */
|
||||
total: number;
|
||||
/** Number of items marked as ready */
|
||||
cached: number;
|
||||
/** Number of items currently fetching */
|
||||
fetching: number;
|
||||
/** Number of items with errors */
|
||||
errors: number;
|
||||
/** Total cache hits (data returned from cache) */
|
||||
hits: number;
|
||||
/** Total cache misses (data fetched from API) */
|
||||
misses: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache manager interface
|
||||
* Type-safe interface for collection caching operations
|
||||
*/
|
||||
export interface CollectionCacheManager<T> {
|
||||
/** Get an item from cache by key */
|
||||
get: (key: string) => T | undefined;
|
||||
/** Check if item exists in cache and is ready */
|
||||
has: (key: string) => boolean;
|
||||
/** Check if item exists and is not stale */
|
||||
hasFresh: (key: string) => boolean;
|
||||
/** Set an item in cache (manual cache write) */
|
||||
set: (key: string, value: T, ttl?: number) => void;
|
||||
/** Remove item from cache */
|
||||
remove: (key: string) => void;
|
||||
/** Clear all items from cache */
|
||||
clear: () => void;
|
||||
/** Check if key is currently being fetched */
|
||||
isFetching: (key: string) => boolean;
|
||||
/** Get error for a key */
|
||||
getError: (key: string) => string | undefined;
|
||||
/** Get internal state for a key (for debugging) */
|
||||
getInternalState: (key: string) => CacheItemInternalState | undefined;
|
||||
/** Get cache statistics */
|
||||
getStats: () => CacheStats;
|
||||
/** Mark item as fetching (used when starting API request) */
|
||||
markFetching: (key: string) => void;
|
||||
/** Mark item as failed (used when API request fails) */
|
||||
markFailed: (key: string, error: string) => void;
|
||||
/** Increment cache miss counter */
|
||||
markMiss: () => void;
|
||||
/** Store containing cached data */
|
||||
data: Writable<Record<string, T>>;
|
||||
/** Store containing internal state (fetching, ready, error) */
|
||||
internal: Writable<Record<string, CacheItemInternalState>>;
|
||||
/** Derived store containing cache statistics */
|
||||
stats: Readable<CacheStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a collection cache manager
|
||||
*
|
||||
* @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User)
|
||||
* @param options - Cache configuration options
|
||||
* @returns Cache manager instance
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const fontCache = createCollectionCache<UnifiedFont>({
|
||||
* defaultTTL: 5 * 60 * 1000, // 5 minutes
|
||||
* maxSize: 1000
|
||||
* });
|
||||
*
|
||||
* // Set font in cache
|
||||
* fontCache.set('Roboto', robotoFont);
|
||||
*
|
||||
* // Get font from cache
|
||||
* const font = fontCache.get('Roboto');
|
||||
* if (fontCache.hasFresh('Roboto')) {
|
||||
* // Use cached font
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createCollectionCache<T>(options: CacheOptions = {}): CollectionCacheManager<T> {
|
||||
const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options;
|
||||
|
||||
// Stores for reactive data
|
||||
const data: Writable<Record<string, T>> = writable({});
|
||||
const internal: Writable<Record<string, CacheItemInternalState>> = writable({});
|
||||
|
||||
// Cache statistics store
|
||||
const statsState = writable<CacheStats>({
|
||||
total: 0,
|
||||
cached: 0,
|
||||
fetching: 0,
|
||||
errors: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
});
|
||||
|
||||
// Derived stats store for reactive updates
|
||||
const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({
|
||||
...$statsState,
|
||||
total: Object.keys($data).length,
|
||||
cached: Object.values($internal).filter(s => s.ready).length,
|
||||
fetching: Object.values($internal).filter(s => s.fetching).length,
|
||||
errors: Object.values($internal).filter(s => s.error).length,
|
||||
}));
|
||||
|
||||
return {
|
||||
/**
|
||||
* Get cached data by key
|
||||
* Returns undefined if not found
|
||||
*/
|
||||
get: (key: string) => {
|
||||
const currentData = get(data);
|
||||
return currentData[key];
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if key exists in cache and is ready
|
||||
*/
|
||||
has: (key: string) => {
|
||||
const currentInternal = get(internal);
|
||||
const state = currentInternal[key];
|
||||
return state?.ready === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if key exists and is not stale (still within TTL)
|
||||
*/
|
||||
hasFresh: (key: string) => {
|
||||
const currentInternal = get(internal);
|
||||
const currentData = get(data);
|
||||
|
||||
const state = currentInternal[key];
|
||||
if (!state?.ready) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if item exists in data store
|
||||
if (!currentData[key]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Implement TTL check with cachedAt timestamps
|
||||
// For now, just check ready state
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set data in cache
|
||||
* Marks entry as ready and stops fetching state
|
||||
*/
|
||||
set: (key: string, value: T, ttl?: number) => {
|
||||
data.update(d => ({
|
||||
...d,
|
||||
[key]: value,
|
||||
}));
|
||||
|
||||
internal.update(i => {
|
||||
const existingState = i[key];
|
||||
return {
|
||||
...i,
|
||||
[key]: {
|
||||
fetching: false,
|
||||
ready: true,
|
||||
error: undefined,
|
||||
startTime: existingState?.startTime,
|
||||
endTime: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Update statistics (cache hit)
|
||||
statsState.update(s => ({ ...s, hits: s.hits + 1 }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove item from cache
|
||||
*/
|
||||
remove: (key: string) => {
|
||||
data.update(d => {
|
||||
const { [key]: _, ...rest } = d;
|
||||
return rest;
|
||||
});
|
||||
|
||||
internal.update(i => {
|
||||
const { [key]: _, ...rest } = i;
|
||||
return rest;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all items from cache
|
||||
*/
|
||||
clear: () => {
|
||||
data.set({});
|
||||
internal.set({});
|
||||
statsState.update(s => ({ ...s, hits: 0, misses: 0 }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if key is currently being fetched
|
||||
*/
|
||||
isFetching: (key: string) => {
|
||||
const currentInternal = get(internal);
|
||||
return currentInternal[key]?.fetching === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get error for a key
|
||||
*/
|
||||
getError: (key: string) => {
|
||||
const currentInternal = get(internal);
|
||||
return currentInternal[key]?.error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get internal state for debugging
|
||||
*/
|
||||
getInternalState: (key: string) => {
|
||||
const currentInternal = get(internal);
|
||||
return currentInternal[key];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current cache statistics
|
||||
*/
|
||||
getStats: () => {
|
||||
return get(stats);
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark item as fetching (used when starting API request)
|
||||
*/
|
||||
markFetching: (key: string) => {
|
||||
internal.update(internal => ({
|
||||
...internal,
|
||||
[key]: {
|
||||
fetching: true,
|
||||
ready: false,
|
||||
error: undefined,
|
||||
startTime: Date.now(),
|
||||
endTime: undefined,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark item as failed (used when API request fails)
|
||||
*/
|
||||
markFailed: (key: string, error: string) => {
|
||||
internal.update(internal => {
|
||||
const existingState = internal[key];
|
||||
return {
|
||||
...internal,
|
||||
[key]: {
|
||||
fetching: false,
|
||||
ready: false,
|
||||
error,
|
||||
startTime: existingState?.startTime,
|
||||
endTime: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Update statistics
|
||||
const currentStats = get(stats);
|
||||
statsState.update(s => ({ ...s, errors: currentStats.errors + 1 }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Increment cache miss counter
|
||||
*/
|
||||
markMiss: () => {
|
||||
statsState.update(s => ({ ...s, misses: s.misses + 1 }));
|
||||
},
|
||||
|
||||
// Expose stores for reactive binding
|
||||
data,
|
||||
internal,
|
||||
stats,
|
||||
};
|
||||
}
|
||||
14
src/shared/fetch/index.ts
Normal file
14
src/shared/fetch/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shared fetch layer exports
|
||||
*
|
||||
* Exports collection caching utilities and reactive patterns for Svelte 5
|
||||
*/
|
||||
|
||||
export { createCollectionCache } from './collectionCache';
|
||||
export type {
|
||||
CacheItemInternalState,
|
||||
CacheOptions,
|
||||
CacheStats,
|
||||
CollectionCacheManager,
|
||||
} from './collectionCache';
|
||||
export { reactiveQueryArgs } from './reactiveQueryArgs';
|
||||
37
src/shared/fetch/reactiveQueryArgs.ts
Normal file
37
src/shared/fetch/reactiveQueryArgs.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Creates a reactive store that maintains stable references for query arguments
|
||||
*
|
||||
* This function wraps a callback in a Svelte store that updates via `$effect.pre()`,
|
||||
* ensuring that the callback is called before DOM updates while maintaining object
|
||||
* reference stability.
|
||||
*
|
||||
* @typeParam T - Type of query arguments (e.g., CreateQueryOptions)
|
||||
* @param cb - Callback function that computes query arguments
|
||||
* @returns Readable store containing current query arguments
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const queryArgsStore = reactiveQueryArgs(() => ({
|
||||
* queryKey: ['fonts', search],
|
||||
* queryFn: fetchFonts,
|
||||
* staleTime: 5000
|
||||
* }));
|
||||
*
|
||||
* // Use in component with TanStack Query
|
||||
* const query = createQuery(queryArgsStore);
|
||||
* ```
|
||||
*/
|
||||
export const reactiveQueryArgs = <T>(cb: () => T): Readable<T> => {
|
||||
const store = writable<T>();
|
||||
|
||||
// Use $effect.pre() to run before DOM updates
|
||||
// This ensures stable references while staying reactive
|
||||
$effect.pre(() => {
|
||||
store.set(cb());
|
||||
});
|
||||
|
||||
return store;
|
||||
};
|
||||
19
yarn.lock
19
yarn.lock
@@ -1292,6 +1292,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/query-core@npm:5.90.16":
|
||||
version: 5.90.16
|
||||
resolution: "@tanstack/query-core@npm:5.90.16"
|
||||
checksum: 10c0/f6a4827feeed2b4118323056bbda8d5099823202d1f29b538204ae2591be4e80f2946f3311eed30fefe866643f431c04b560457f03415d40caf2f353ba1efac0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tanstack/svelte-query@npm:^6.0.14":
|
||||
version: 6.0.14
|
||||
resolution: "@tanstack/svelte-query@npm:6.0.14"
|
||||
dependencies:
|
||||
"@tanstack/query-core": "npm:5.90.16"
|
||||
peerDependencies:
|
||||
svelte: ^5.25.0
|
||||
checksum: 10c0/5f7218596e3a2cbe5b877afb2cea678539e38ea9400f000361f859922189273b07e94e42ac8154245f5138fa509e5a24c01b6f7ae5e655acb61daaaef9da80c3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@testing-library/dom@npm:9.x.x || 10.x.x":
|
||||
version: 10.4.1
|
||||
resolution: "@testing-library/dom@npm:10.4.1"
|
||||
@@ -2429,6 +2447,7 @@ __metadata:
|
||||
"@storybook/svelte-vite": "npm:^10.1.11"
|
||||
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
|
||||
"@tailwindcss/vite": "npm:^4.1.18"
|
||||
"@tanstack/svelte-query": "npm:^6.0.14"
|
||||
"@testing-library/jest-dom": "npm:^6.9.1"
|
||||
"@testing-library/svelte": "npm:^5.3.1"
|
||||
"@tsconfig/svelte": "npm:^5.0.6"
|
||||
|
||||
Reference in New Issue
Block a user