diff --git a/package.json b/package.json index 3710949..ad26f87 100644 --- a/package.json +++ b/package.json @@ -61,5 +61,8 @@ "vite": "^7.2.6", "vitest": "^4.0.16", "vitest-browser-svelte": "^2.0.1" + }, + "dependencies": { + "@tanstack/svelte-query": "^6.0.14" } } diff --git a/src/entities/Font/api/fontshare.ts b/src/entities/Font/api/fontshare.ts new file mode 100644 index 0000000..6e6df87 --- /dev/null +++ b/src/entities/Font/api/fontshare.ts @@ -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 { + const queryString = buildQueryString(params); + const url = `https://api.fontshare.com/v2${queryString}`; + + try { + const response = await api.get(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 { + 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 { + 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, + }; +} diff --git a/src/entities/Font/api/googleFonts.ts b/src/entities/Font/api/googleFonts.ts new file mode 100644 index 0000000..289a2a7 --- /dev/null +++ b/src/entities/Font/api/googleFonts.ts @@ -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; + 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 { + const queryString = buildQueryString(params); + const url = `${GOOGLE_FONTS_API_URL}${queryString}`; + + try { + const response = await api.get(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 { + const response = await fetchGoogleFonts({ family }); + return response.items.find(item => item.family === family); +} diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts new file mode 100644 index 0000000..25f7752 --- /dev/null +++ b/src/entities/Font/api/index.ts @@ -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'; diff --git a/src/entities/Font/api/normalize.test.ts b/src/entities/Font/api/normalize.test.ts new file mode 100644 index 0000000..60eb1b1 --- /dev/null +++ b/src/entities/Font/api/normalize.test.ts @@ -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: '

Font story

', + 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 = { + 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, + }, + }, + ], + }; +} diff --git a/src/entities/Font/api/normalize.ts b/src/entities/Font/api/normalize.ts new file mode 100644 index 0000000..f337043 --- /dev/null +++ b/src/entities/Font/api/normalize.ts @@ -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 = { + 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); +} diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index 539f081..eba2be9 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -1,8 +1,39 @@ +export { + fetchAllFontshareFonts, + fetchFontshareFontBySlug, + fetchFontshareFonts, +} from './api/fontshare'; +export type { + FontshareParams, + FontshareResponse, +} from './api/fontshare'; +export { + fetchGoogleFontFamily, + fetchGoogleFonts, +} from './api/googleFonts'; +export type { + GoogleFontItem, + GoogleFontsParams, + GoogleFontsResponse, +} from './api/googleFonts'; +export { + normalizeFontshareFont, + normalizeFontshareFonts, + normalizeGoogleFont, + normalizeGoogleFonts, +} from './api/normalize'; +export type { + FontFeatures, + FontMetadata, + FontStyleUrls, + UnifiedFont, + UnifiedFontVariant, +} from './api/normalize'; export type { FontCategory, FontProvider, FontSubset, -} from './model/font'; +} from './model/types/font'; export type { FontshareApiModel, FontshareDesigner, @@ -13,10 +44,10 @@ export type { FontshareStyleProperties, FontshareTag, FontshareWeight, -} from './model/fontshare_fonts'; +} from './model/types/fontshare_fonts'; export type { FontFiles, FontItem, FontVariant, GoogleFontsApiModel, -} from './model/google_fonts'; +} from './model/types/google_fonts'; diff --git a/src/entities/Font/model/stores/fontCollectionStore.ts b/src/entities/Font/model/stores/fontCollectionStore.ts new file mode 100644 index 0000000..7edc0be --- /dev/null +++ b/src/entities/Font/model/stores/fontCollectionStore.ts @@ -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; + /** 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; + /** All fonts as array */ + fonts: Readable; + /** Filtered fonts as array */ + filteredFonts: Readable; + /** Number of fonts in collection */ + count: Readable; + /** Loading state */ + isLoading: Readable; + /** Error state */ + error: Readable; + /** 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) => 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, +): FontCollectionStore { + const cache = createCollectionCache({ + defaultTTL: 5 * 60 * 1000, // 5 minutes + maxSize: 1000, + }); + + const defaultState: FontCollectionState = { + fonts: {}, + filters: {}, + sort: { field: 'name', direction: 'asc' }, + }; + + const state: Writable = writable({ + ...defaultState, + ...initialState, + }); + + const isLoading = writable(false); + const error = writable(); + + // 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) => { + 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, + ); + }, + }; +} diff --git a/src/entities/Font/model/stores/index.ts b/src/entities/Font/model/stores/index.ts new file mode 100644 index 0000000..eba84c8 --- /dev/null +++ b/src/entities/Font/model/stores/index.ts @@ -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'; diff --git a/src/entities/Font/model/font.ts b/src/entities/Font/model/types/font.ts similarity index 100% rename from src/entities/Font/model/font.ts rename to src/entities/Font/model/types/font.ts diff --git a/src/entities/Font/model/fontshare_fonts.ts b/src/entities/Font/model/types/fontshare_fonts.ts similarity index 99% rename from src/entities/Font/model/fontshare_fonts.ts rename to src/entities/Font/model/types/fontshare_fonts.ts index fddce6d..da7da3e 100644 --- a/src/entities/Font/model/fontshare_fonts.ts +++ b/src/entities/Font/model/types/fontshare_fonts.ts @@ -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; diff --git a/src/entities/Font/model/google_fonts.ts b/src/entities/Font/model/types/google_fonts.ts similarity index 100% rename from src/entities/Font/model/google_fonts.ts rename to src/entities/Font/model/types/google_fonts.ts diff --git a/src/features/FetchFonts/index.ts b/src/features/FetchFonts/index.ts new file mode 100644 index 0000000..4ebc6a9 --- /dev/null +++ b/src/features/FetchFonts/index.ts @@ -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'; diff --git a/src/features/FetchFonts/model/services/fetchFontshareFonts.ts b/src/features/FetchFonts/model/services/fetchFontshareFonts.ts new file mode 100644 index 0000000..000946e --- /dev/null +++ b/src/features/FetchFonts/model/services/fetchFontshareFonts.ts @@ -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 + * + * + * {#each fonts as 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 { + 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'], + }); + } +} diff --git a/src/features/FetchFonts/model/services/fetchGoogleFonts.ts b/src/features/FetchFonts/model/services/fetchGoogleFonts.ts new file mode 100644 index 0000000..43c4c3b --- /dev/null +++ b/src/features/FetchFonts/model/services/fetchGoogleFonts.ts @@ -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 + * + * + * {#each fonts as 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 { + 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'], + }); + } +} diff --git a/src/features/FetchFonts/model/types.ts b/src/features/FetchFonts/model/types.ts new file mode 100644 index 0000000..c21eb17 --- /dev/null +++ b/src/features/FetchFonts/model/types.ts @@ -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; +} diff --git a/src/shared/fetch/collectionCache.test.ts b/src/shared/fetch/collectionCache.test.ts new file mode 100644 index 0000000..ee45846 --- /dev/null +++ b/src/shared/fetch/collectionCache.test.ts @@ -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>; + + beforeEach(() => { + cache = createCollectionCache(); + }); + + 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(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(); + 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(); + }); + }); +}); diff --git a/src/shared/fetch/collectionCache.ts b/src/shared/fetch/collectionCache.ts new file mode 100644 index 0000000..80b5fe9 --- /dev/null +++ b/src/shared/fetch/collectionCache.ts @@ -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 { + /** 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>; + /** Store containing internal state (fetching, ready, error) */ + internal: Writable>; + /** Derived store containing cache statistics */ + stats: Readable; +} + +/** + * 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({ + * 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(options: CacheOptions = {}): CollectionCacheManager { + const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options; + + // Stores for reactive data + const data: Writable> = writable({}); + const internal: Writable> = writable({}); + + // Cache statistics store + const statsState = writable({ + 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, + }; +} diff --git a/src/shared/fetch/index.ts b/src/shared/fetch/index.ts new file mode 100644 index 0000000..b123ac0 --- /dev/null +++ b/src/shared/fetch/index.ts @@ -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'; diff --git a/src/shared/fetch/reactiveQueryArgs.ts b/src/shared/fetch/reactiveQueryArgs.ts new file mode 100644 index 0000000..4095ead --- /dev/null +++ b/src/shared/fetch/reactiveQueryArgs.ts @@ -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 = (cb: () => T): Readable => { + const store = writable(); + + // Use $effect.pre() to run before DOM updates + // This ensures stable references while staying reactive + $effect.pre(() => { + store.set(cb()); + }); + + return store; +}; diff --git a/yarn.lock b/yarn.lock index 21b3f3f..e1f0e72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1292,6 +1292,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.90.16": + version: 5.90.16 + resolution: "@tanstack/query-core@npm:5.90.16" + checksum: 10c0/f6a4827feeed2b4118323056bbda8d5099823202d1f29b538204ae2591be4e80f2946f3311eed30fefe866643f431c04b560457f03415d40caf2f353ba1efac0 + languageName: node + linkType: hard + +"@tanstack/svelte-query@npm:^6.0.14": + version: 6.0.14 + resolution: "@tanstack/svelte-query@npm:6.0.14" + dependencies: + "@tanstack/query-core": "npm:5.90.16" + peerDependencies: + svelte: ^5.25.0 + checksum: 10c0/5f7218596e3a2cbe5b877afb2cea678539e38ea9400f000361f859922189273b07e94e42ac8154245f5138fa509e5a24c01b6f7ae5e655acb61daaaef9da80c3 + languageName: node + linkType: hard + "@testing-library/dom@npm:9.x.x || 10.x.x": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -2429,6 +2447,7 @@ __metadata: "@storybook/svelte-vite": "npm:^10.1.11" "@sveltejs/vite-plugin-svelte": "npm:^6.2.1" "@tailwindcss/vite": "npm:^4.1.18" + "@tanstack/svelte-query": "npm:^6.0.14" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/svelte": "npm:^5.3.1" "@tsconfig/svelte": "npm:^5.0.6"