feature/fetch-fonts #14
@@ -61,5 +61,8 @@
|
|||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"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 {
|
export type {
|
||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
} from './model/font';
|
} from './model/types/font';
|
||||||
export type {
|
export type {
|
||||||
FontshareApiModel,
|
FontshareApiModel,
|
||||||
FontshareDesigner,
|
FontshareDesigner,
|
||||||
@@ -13,10 +44,10 @@ export type {
|
|||||||
FontshareStyleProperties,
|
FontshareStyleProperties,
|
||||||
FontshareTag,
|
FontshareTag,
|
||||||
FontshareWeight,
|
FontshareWeight,
|
||||||
} from './model/fontshare_fonts';
|
} from './model/types/fontshare_fonts';
|
||||||
export type {
|
export type {
|
||||||
FontFiles,
|
FontFiles,
|
||||||
FontItem,
|
FontItem,
|
||||||
FontVariant,
|
FontVariant,
|
||||||
GoogleFontsApiModel,
|
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;
|
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
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@testing-library/dom@npm:9.x.x || 10.x.x":
|
||||||
version: 10.4.1
|
version: 10.4.1
|
||||||
resolution: "@testing-library/dom@npm:10.4.1"
|
resolution: "@testing-library/dom@npm:10.4.1"
|
||||||
@@ -2429,6 +2447,7 @@ __metadata:
|
|||||||
"@storybook/svelte-vite": "npm:^10.1.11"
|
"@storybook/svelte-vite": "npm:^10.1.11"
|
||||||
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
|
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
|
||||||
"@tailwindcss/vite": "npm:^4.1.18"
|
"@tailwindcss/vite": "npm:^4.1.18"
|
||||||
|
"@tanstack/svelte-query": "npm:^6.0.14"
|
||||||
"@testing-library/jest-dom": "npm:^6.9.1"
|
"@testing-library/jest-dom": "npm:^6.9.1"
|
||||||
"@testing-library/svelte": "npm:^5.3.1"
|
"@testing-library/svelte": "npm:^5.3.1"
|
||||||
"@tsconfig/svelte": "npm:^5.0.6"
|
"@tsconfig/svelte": "npm:^5.0.6"
|
||||||
|
|||||||
Reference in New Issue
Block a user