From 29d1cc0cdca5ae93db670bdf8073e86fb4eaa0f5 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 14:38:55 +0300 Subject: [PATCH 01/76] refactor(shared): rename fontCache to collectionCache - Rename fontCache.ts to collectionCache.ts - Rename FontCacheManager interface to CollectionCacheManager - Make implementation fully generic (already was, just renamed interface) - Update exports in shared/fetch/index.ts - Fix getStats() to return derived store value for accurate statistics - Add comprehensive test coverage for collection cache manager - 41 test cases covering all functionality - Tests for caching, deduplication, state tracking - Tests for statistics, reactivity, and edge cases Closes task-1 of Phase 1 refactoring --- package.json | 3 + src/entities/Font/api/fontshare.ts | 187 ++++++ src/entities/Font/api/googleFonts.ts | 159 +++++ src/entities/Font/api/index.ts | 39 ++ src/entities/Font/api/normalize.test.ts | 598 ++++++++++++++++++ src/entities/Font/api/normalize.ts | 347 ++++++++++ src/entities/Font/index.ts | 37 +- .../Font/model/stores/fontCollectionStore.ts | 338 ++++++++++ src/entities/Font/model/stores/index.ts | 13 + src/entities/Font/model/{ => types}/font.ts | 0 .../Font/model/{ => types}/fontshare_fonts.ts | 2 +- .../Font/model/{ => types}/google_fonts.ts | 0 src/features/FetchFonts/index.ts | 25 + .../model/services/fetchFontshareFonts.ts | 211 ++++++ .../model/services/fetchGoogleFonts.ts | 213 +++++++ src/features/FetchFonts/model/types.ts | 76 +++ src/shared/fetch/collectionCache.test.ts | 445 +++++++++++++ src/shared/fetch/collectionCache.ts | 334 ++++++++++ src/shared/fetch/index.ts | 14 + src/shared/fetch/reactiveQueryArgs.ts | 37 ++ yarn.lock | 19 + 21 files changed, 3093 insertions(+), 4 deletions(-) create mode 100644 src/entities/Font/api/fontshare.ts create mode 100644 src/entities/Font/api/googleFonts.ts create mode 100644 src/entities/Font/api/index.ts create mode 100644 src/entities/Font/api/normalize.test.ts create mode 100644 src/entities/Font/api/normalize.ts create mode 100644 src/entities/Font/model/stores/fontCollectionStore.ts create mode 100644 src/entities/Font/model/stores/index.ts rename src/entities/Font/model/{ => types}/font.ts (100%) rename src/entities/Font/model/{ => types}/fontshare_fonts.ts (99%) rename src/entities/Font/model/{ => types}/google_fonts.ts (100%) create mode 100644 src/features/FetchFonts/index.ts create mode 100644 src/features/FetchFonts/model/services/fetchFontshareFonts.ts create mode 100644 src/features/FetchFonts/model/services/fetchGoogleFonts.ts create mode 100644 src/features/FetchFonts/model/types.ts create mode 100644 src/shared/fetch/collectionCache.test.ts create mode 100644 src/shared/fetch/collectionCache.ts create mode 100644 src/shared/fetch/index.ts create mode 100644 src/shared/fetch/reactiveQueryArgs.ts 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" -- 2.49.1 From 9abec4210c1b7632b6ec026d792b0ee507e19035 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 15:00:31 +0300 Subject: [PATCH 02/76] feat(utils): add generic buildQueryString utility - Add type-safe buildQueryString function to /utils - Support primitives, arrays, and optional values - Proper URL encoding for special characters - Add comprehensive tests (25 test cases, all passing) - Update Google Fonts API client to use shared utility - Update Fontshare API client to use shared utility - Export utility from /utils/index.ts Benefits: - DRY - Single source of truth for query string logic - Type-safe - Proper TypeScript support with QueryParams type - Tested - Comprehensive test coverage - Maintainable - One place to fix bugs --- .../Font/api/{ => fontshare}/fontshare.ts | 34 +-- .../Font/api/{ => google}/googleFonts.ts | 38 +--- src/entities/Font/api/index.ts | 12 +- .../api/{ => normalize}/normalize.test.ts | 16 +- .../Font/api/{ => normalize}/normalize.ts | 0 .../Font/model/stores/fontCollectionStore.ts | 5 +- src/shared/utils/buildQueryString.test.ts | 194 ++++++++++++++++++ src/shared/utils/buildQueryString.ts | 79 +++++++ src/shared/utils/index.ts | 9 + 9 files changed, 303 insertions(+), 84 deletions(-) rename src/entities/Font/api/{ => fontshare}/fontshare.ts (82%) rename src/entities/Font/api/{ => google}/googleFonts.ts (78%) rename src/entities/Font/api/{ => normalize}/normalize.test.ts (99%) rename src/entities/Font/api/{ => normalize}/normalize.ts (100%) create mode 100644 src/shared/utils/buildQueryString.test.ts create mode 100644 src/shared/utils/buildQueryString.ts create mode 100644 src/shared/utils/index.ts diff --git a/src/entities/Font/api/fontshare.ts b/src/entities/Font/api/fontshare/fontshare.ts similarity index 82% rename from src/entities/Font/api/fontshare.ts rename to src/entities/Font/api/fontshare/fontshare.ts index 6e6df87..ae8420a 100644 --- a/src/entities/Font/api/fontshare.ts +++ b/src/entities/Font/api/fontshare/fontshare.ts @@ -12,11 +12,13 @@ import type { FontshareFont, } from '$entities/Font'; import { api } from '$shared/api/api'; +import { buildQueryString } from '$shared/utils'; +import type { QueryParams } from '$shared/utils'; /** * Fontshare API parameters */ -export interface FontshareParams { +export interface FontshareParams extends QueryParams { /** * Filter by categories (e.g., ["Sans", "Serif", "Display"]) */ @@ -47,36 +49,6 @@ 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 * diff --git a/src/entities/Font/api/googleFonts.ts b/src/entities/Font/api/google/googleFonts.ts similarity index 78% rename from src/entities/Font/api/googleFonts.ts rename to src/entities/Font/api/google/googleFonts.ts index 289a2a7..de3295b 100644 --- a/src/entities/Font/api/googleFonts.ts +++ b/src/entities/Font/api/google/googleFonts.ts @@ -8,11 +8,13 @@ */ import { api } from '$shared/api/api'; +import { buildQueryString } from '$shared/utils'; +import type { QueryParams } from '$shared/utils'; /** * Google Fonts API parameters */ -export interface GoogleFontsParams { +export interface GoogleFontsParams extends QueryParams { /** * Google Fonts API key (optional for public endpoints) */ @@ -66,40 +68,6 @@ export interface GoogleFontItem { */ 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 * diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts index 25f7752..6656408 100644 --- a/src/entities/Font/api/index.ts +++ b/src/entities/Font/api/index.ts @@ -7,33 +7,33 @@ export { fetchGoogleFontFamily, fetchGoogleFonts, -} from './googleFonts'; +} from './google/googleFonts'; export type { GoogleFontItem, GoogleFontsParams, GoogleFontsResponse, -} from './googleFonts'; +} from './google/googleFonts'; export { fetchAllFontshareFonts, fetchFontshareFontBySlug, fetchFontshareFonts, -} from './fontshare'; +} from './fontshare/fontshare'; export type { FontshareParams, FontshareResponse, -} from './fontshare'; +} from './fontshare/fontshare'; export { normalizeFontshareFont, normalizeFontshareFonts, normalizeGoogleFont, normalizeGoogleFonts, -} from './normalize'; +} from './normalize/normalize'; export type { FontFeatures, FontMetadata, FontStyleUrls, UnifiedFont, UnifiedFontVariant, -} from './normalize'; +} from './normalize/normalize'; diff --git a/src/entities/Font/api/normalize.test.ts b/src/entities/Font/api/normalize/normalize.test.ts similarity index 99% rename from src/entities/Font/api/normalize.test.ts rename to src/entities/Font/api/normalize/normalize.test.ts index 60eb1b1..f757d06 100644 --- a/src/entities/Font/api/normalize.test.ts +++ b/src/entities/Font/api/normalize/normalize.test.ts @@ -1,17 +1,17 @@ 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'; +import type { GoogleFontItem } from '../google/googleFonts'; +import type { UnifiedFont } from './normalize'; +import { + normalizeFontshareFont, + normalizeFontshareFonts, + normalizeGoogleFont, + normalizeGoogleFonts, +} from './normalize'; describe('Font Normalization', () => { describe('normalizeGoogleFont', () => { diff --git a/src/entities/Font/api/normalize.ts b/src/entities/Font/api/normalize/normalize.ts similarity index 100% rename from src/entities/Font/api/normalize.ts rename to src/entities/Font/api/normalize/normalize.ts diff --git a/src/entities/Font/model/stores/fontCollectionStore.ts b/src/entities/Font/model/stores/fontCollectionStore.ts index 7edc0be..f9e1aa9 100644 --- a/src/entities/Font/model/stores/fontCollectionStore.ts +++ b/src/entities/Font/model/stores/fontCollectionStore.ts @@ -8,10 +8,7 @@ */ import type { UnifiedFont } from '$entities/Font/api/normalize'; -import { - type CollectionCacheManager, - createCollectionCache, -} from '$shared/fetch/collectionCache'; +import { createCollectionCache } from '$shared/fetch/collectionCache'; import type { Readable, Writable, diff --git a/src/shared/utils/buildQueryString.test.ts b/src/shared/utils/buildQueryString.test.ts new file mode 100644 index 0000000..b7e1d7a --- /dev/null +++ b/src/shared/utils/buildQueryString.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for buildQueryString utility + */ + +import { + describe, + expect, + test, +} from 'vitest'; +import { buildQueryString } from './buildQueryString'; + +describe('buildQueryString', () => { + describe('basic parameter building', () => { + test('should build query string with string parameter', () => { + const result = buildQueryString({ category: 'serif' }); + expect(result).toBe('?category=serif'); + }); + + test('should build query string with number parameter', () => { + const result = buildQueryString({ limit: 50 }); + expect(result).toBe('?limit=50'); + }); + + test('should build query string with boolean parameter', () => { + const result = buildQueryString({ active: true }); + expect(result).toBe('?active=true'); + }); + + test('should build query string with multiple parameters', () => { + const result = buildQueryString({ + category: 'serif', + limit: 50, + page: 1, + }); + expect(result).toBe('?category=serif&limit=50&page=1'); + }); + }); + + describe('array handling', () => { + test('should handle array of strings', () => { + const result = buildQueryString({ + subsets: ['latin', 'latin-ext', 'cyrillic'], + }); + expect(result).toBe('?subsets=latin&subsets=latin-ext&subsets=cyrillic'); + }); + + test('should handle array of numbers', () => { + const result = buildQueryString({ ids: [1, 2, 3] }); + expect(result).toBe('?ids=1&ids=2&ids=3'); + }); + + test('should handle mixed arrays and primitives', () => { + const result = buildQueryString({ + category: 'serif', + subsets: ['latin', 'latin-ext'], + limit: 50, + }); + expect(result).toBe('?category=serif&subsets=latin&subsets=latin-ext&limit=50'); + }); + + test('should filter out null/undefined values in arrays', () => { + const result = buildQueryString({ + // @ts-expect-error - Testing runtime behavior with invalid types + ids: [1, null, 3, undefined], + }); + expect(result).toBe('?ids=1&ids=3'); + }); + }); + + describe('optional values', () => { + test('should exclude undefined values', () => { + const result = buildQueryString({ + category: 'serif', + search: undefined, + }); + expect(result).toBe('?category=serif'); + }); + + test('should exclude null values', () => { + const result = buildQueryString({ + category: 'serif', + search: null, + }); + expect(result).toBe('?category=serif'); + }); + + test('should handle all undefined/null values', () => { + const result = buildQueryString({ + category: undefined, + search: null, + }); + expect(result).toBe(''); + }); + }); + + describe('URL encoding', () => { + test('should encode spaces', () => { + const result = buildQueryString({ search: 'hello world' }); + expect(result).toBe('?search=hello+world'); + }); + + test('should encode special characters', () => { + const result = buildQueryString({ query: 'a&b=c+d' }); + expect(result).toBe('?query=a%26b%3Dc%2Bd'); + }); + + test('should encode Unicode characters', () => { + const result = buildQueryString({ text: 'café' }); + expect(result).toBe('?text=caf%C3%A9'); + }); + + test('should encode reserved URL characters', () => { + const result = buildQueryString({ url: 'https://example.com' }); + expect(result).toBe('?url=https%3A%2F%2Fexample.com'); + }); + }); + + describe('edge cases', () => { + test('should return empty string for empty object', () => { + const result = buildQueryString({}); + expect(result).toBe(''); + }); + + test('should return empty string when all values are excluded', () => { + const result = buildQueryString({ + a: undefined, + b: null, + }); + expect(result).toBe(''); + }); + + test('should handle empty arrays', () => { + const result = buildQueryString({ tags: [] }); + expect(result).toBe(''); + }); + + test('should handle zero values', () => { + const result = buildQueryString({ page: 0, count: 0 }); + expect(result).toBe('?page=0&count=0'); + }); + + test('should handle false boolean', () => { + const result = buildQueryString({ active: false }); + expect(result).toBe('?active=false'); + }); + + test('should handle empty string', () => { + const result = buildQueryString({ search: '' }); + expect(result).toBe('?search='); + }); + }); + + describe('parameter order', () => { + test('should maintain parameter order from input object', () => { + const result = buildQueryString({ + a: '1', + b: '2', + c: '3', + }); + expect(result).toBe('?a=1&b=2&c=3'); + }); + }); + + describe('real-world examples', () => { + test('should handle Google Fonts API parameters', () => { + const result = buildQueryString({ + category: 'sans-serif', + sort: 'popularity', + subset: 'latin', + }); + expect(result).toBe('?category=sans-serif&sort=popularity&subset=latin'); + }); + + test('should handle Fontshare API parameters', () => { + const result = buildQueryString({ + categories: ['Sans', 'Serif'], + page: 1, + limit: 50, + search: 'satoshi', + }); + expect(result).toBe('?categories=Sans&categories=Serif&page=1&limit=50&search=satoshi'); + }); + + test('should handle pagination parameters', () => { + const result = buildQueryString({ + page: 2, + per_page: 20, + sort: 'name', + order: 'desc', + }); + expect(result).toBe('?page=2&per_page=20&sort=name&order=desc'); + }); + }); +}); diff --git a/src/shared/utils/buildQueryString.ts b/src/shared/utils/buildQueryString.ts new file mode 100644 index 0000000..fc09249 --- /dev/null +++ b/src/shared/utils/buildQueryString.ts @@ -0,0 +1,79 @@ +/** + * Build query string from URL parameters + * + * Generic, type-safe function to build properly encoded query strings + * from URL parameters. Supports primitives, arrays, and optional values. + * + * @param params - Object containing query parameters + * @returns Encoded query string (empty string if no parameters) + * + * @example + * ```ts + * buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] }) + * // Returns: "category=serif&subsets=latin&subsets=latin-ext" + * + * buildQueryString({ limit: 50, page: 1 }) + * // Returns: "limit=50&page=1" + * + * buildQueryString({}) + * // Returns: "" + * + * buildQueryString({ search: 'hello world', active: true }) + * // Returns: "search=hello%20world&active=true" + * ``` + */ + +/** + * Query parameter value type + * Supports primitives, arrays, and excludes null/undefined + */ +export type QueryParamValue = string | number | boolean | string[] | number[]; + +/** + * Query parameters object + */ +export type QueryParams = Record; + +/** + * Build query string from URL parameters + * + * Handles: + * - Primitive values (string, number, boolean) + * - Arrays (multiple values with same key) + * - Optional values (excludes undefined/null) + * - Proper URL encoding + * + * Edge cases: + * - Empty object → empty string + * - No parameters → empty string + * - Nested objects → flattens to string representation + * - Special characters → proper encoding + * + * @param params - Object containing query parameters + * @returns Encoded query string (with "?" prefix if non-empty) + */ +export function buildQueryString(params: QueryParams): string { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + // Skip undefined/null values + if (value === undefined || value === null) { + continue; + } + + // Handle arrays (multiple values with same key) + if (Array.isArray(value)) { + for (const item of value) { + if (item !== undefined && item !== null) { + searchParams.append(key, String(item)); + } + } + } else { + // Handle primitives + searchParams.append(key, String(value)); + } + } + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +} diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..32e423b --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Shared utility functions + */ + +export { buildQueryString } from './buildQueryString'; +export type { + QueryParams, + QueryParamValue, +} from './buildQueryString'; -- 2.49.1 From 9f8b840e7af1a9c551dd933363c5b06595146e58 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 15:06:38 +0300 Subject: [PATCH 03/76] refactor(font): consolidate all types into single types.ts file - Created unified model/types.ts with all type definitions - Consolidated domain types (FontCategory, FontProvider, FontSubset) - Consolidated Google Fonts API types (FontItem, GoogleFontsApiModel, etc.) - Consolidated Fontshare API types (FontshareFont, FontshareStyle, etc.) - Consolidated normalization types (UnifiedFont, FontStyleUrls, etc.) - Consolidated store types (FontCollectionStore, FontCollectionFilters, etc.) - Removed duplicate type files (font.ts, google_fonts.ts, fontshare_fonts.ts) - Updated all imports to use consolidated types - Updated normalize module to import from /Font - Updated API clients to re-export types for backward compatibility - Updated store to use centralized types - Updated Font index.ts to export all types Benefits: - Centralized type definitions in single location - Cleaner imports (single import from /Font) - Better code organization with clear sections - Follows FSD principles (types in model layer) - No duplicate type definitions --- src/entities/Font/api/fontshare/fontshare.ts | 6 +- src/entities/Font/api/google/googleFonts.ts | 22 +- .../Font/api/normalize/normalize.test.ts | 8 +- src/entities/Font/api/normalize/normalize.ts | 89 +---- src/entities/Font/index.ts | 37 ++- .../Font/model/stores/fontCollectionStore.ts | 91 +----- .../{types/fontshare_fonts.ts => types.ts} | 305 ++++++++++++++++++ src/entities/Font/model/types/font.ts | 14 - src/entities/Font/model/types/google_fonts.ts | 104 ------ 9 files changed, 361 insertions(+), 315 deletions(-) rename src/entities/Font/model/{types/fontshare_fonts.ts => types.ts} (51%) delete mode 100644 src/entities/Font/model/types/font.ts delete mode 100644 src/entities/Font/model/types/google_fonts.ts diff --git a/src/entities/Font/api/fontshare/fontshare.ts b/src/entities/Font/api/fontshare/fontshare.ts index ae8420a..0689d93 100644 --- a/src/entities/Font/api/fontshare/fontshare.ts +++ b/src/entities/Font/api/fontshare/fontshare.ts @@ -43,11 +43,9 @@ export interface FontshareParams extends QueryParams { /** * Fontshare API response wrapper - * Extends collection model with additional metadata + * Re-exported from model/types for backward compatibility */ -export interface FontshareResponse extends FontshareApiModel { - // Response structure matches FontshareApiModel -} +export type FontshareResponse = FontshareApiModel; /** * Fetch fonts from Fontshare API diff --git a/src/entities/Font/api/google/googleFonts.ts b/src/entities/Font/api/google/googleFonts.ts index de3295b..07ba6ad 100644 --- a/src/entities/Font/api/google/googleFonts.ts +++ b/src/entities/Font/api/google/googleFonts.ts @@ -7,6 +7,10 @@ * @see https://developers.google.com/fonts/docs/developer_api */ +import type { + FontItem, + GoogleFontsApiModel, +} from '$entities/Font'; import { api } from '$shared/api/api'; import { buildQueryString } from '$shared/utils'; import type { QueryParams } from '$shared/utils'; @@ -43,25 +47,15 @@ export interface GoogleFontsParams extends QueryParams { /** * Google Fonts API response wrapper + * Re-exported from model/types for backward compatibility */ -export interface GoogleFontsResponse { - kind: string; - items: GoogleFontItem[]; -} +export type GoogleFontsResponse = GoogleFontsApiModel; /** * Simplified font item from Google Fonts API + * Re-exported from model/types for backward compatibility */ -export interface GoogleFontItem { - family: string; - category: string; - variants: string[]; - subsets: string[]; - version: string; - lastModified: string; - files: Record; - menu: string; -} +export type GoogleFontItem = FontItem; /** * Google Fonts API base URL diff --git a/src/entities/Font/api/normalize/normalize.test.ts b/src/entities/Font/api/normalize/normalize.test.ts index f757d06..c1e5dd2 100644 --- a/src/entities/Font/api/normalize/normalize.test.ts +++ b/src/entities/Font/api/normalize/normalize.test.ts @@ -1,11 +1,13 @@ -import type { FontshareFont } from '$entities/Font'; +import type { + FontshareFont, + GoogleFontItem, + UnifiedFont, +} from '$entities/Font'; import { describe, expect, it, } from 'vitest'; -import type { GoogleFontItem } from '../google/googleFonts'; -import type { UnifiedFont } from './normalize'; import { normalizeFontshareFont, normalizeFontshareFonts, diff --git a/src/entities/Font/api/normalize/normalize.ts b/src/entities/Font/api/normalize/normalize.ts index f337043..2dda371 100644 --- a/src/entities/Font/api/normalize/normalize.ts +++ b/src/entities/Font/api/normalize/normalize.ts @@ -7,89 +7,16 @@ import type { FontCategory, + FontFeatures, + FontMetadata, FontProvider, + FontStyleUrls, 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; -} + FontshareFont, + GoogleFontItem, + UnifiedFont, + UnifiedFontVariant, +} from '../../model/types'; /** * Map Google Fonts category to unified FontCategory diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index eba2be9..dfd765f 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -23,31 +23,38 @@ export { normalizeGoogleFonts, } from './api/normalize'; export type { - FontFeatures, - FontMetadata, - FontStyleUrls, - UnifiedFont, - UnifiedFontVariant, -} from './api/normalize'; -export type { + // Domain types FontCategory, + FontCollectionFilters, + FontCollectionSort, + // Store types + FontCollectionState, + FontCollectionStore, + FontFeatures, + FontFiles, + FontItem, + FontMetadata, FontProvider, - FontSubset, -} from './model/types/font'; -export type { + // Fontshare API types FontshareApiModel, + FontshareAxis, FontshareDesigner, FontshareFeature, FontshareFont, + FontshareLink, FontsharePublisher, FontshareStyle, FontshareStyleProperties, FontshareTag, FontshareWeight, -} from './model/types/fontshare_fonts'; -export type { - FontFiles, - FontItem, + FontStyleUrls, + FontSubset, FontVariant, + FontWeight, + FontWeightItalic, + // Google Fonts API types GoogleFontsApiModel, -} from './model/types/google_fonts'; + // Normalization types + UnifiedFont, + UnifiedFontVariant, +} from './model/types'; diff --git a/src/entities/Font/model/stores/fontCollectionStore.ts b/src/entities/Font/model/stores/fontCollectionStore.ts index f9e1aa9..cff581a 100644 --- a/src/entities/Font/model/stores/fontCollectionStore.ts +++ b/src/entities/Font/model/stores/fontCollectionStore.ts @@ -7,7 +7,16 @@ * Provides derived stores for filtered/sorted fonts. */ -import type { UnifiedFont } from '$entities/Font/api/normalize'; +import type { + FontCategory, + FontCollectionFilters, + FontCollectionSort, + FontCollectionState, + FontCollectionStore, + FontProvider, + FontSubset, + UnifiedFont, +} from '$entities/Font'; import { createCollectionCache } from '$shared/fetch/collectionCache'; import type { Readable, @@ -18,84 +27,6 @@ import { 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 @@ -172,7 +103,7 @@ export function createFontCollectionStore( // Apply subset filter if ($state.filters.subsets?.length) { filtered = filtered.filter(font => - $state.filters.subsets!.some(subset => font.subsets.includes(subset as any)) + $state.filters.subsets!.some(subset => font.subsets.includes(subset as FontSubset)) ); } diff --git a/src/entities/Font/model/types/fontshare_fonts.ts b/src/entities/Font/model/types.ts similarity index 51% rename from src/entities/Font/model/types/fontshare_fonts.ts rename to src/entities/Font/model/types.ts index da7da3e..9b8093f 100644 --- a/src/entities/Font/model/types/fontshare_fonts.ts +++ b/src/entities/Font/model/types.ts @@ -1,3 +1,144 @@ +/** + * ============================================================================ + * DOMAIN TYPES + * ============================================================================ + */ + +/** + * Font category + */ +export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; + +/** + * Font provider + */ +export type FontProvider = 'google' | 'fontshare'; + +/** + * Font subset + */ +export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari'; + +/** + * ============================================================================ + * GOOGLE FONTS API TYPES + * ============================================================================ + */ + +/** + * Model of google fonts api response + */ +export interface GoogleFontsApiModel { + /** + * Array of font items returned by the Google Fonts API + * Contains all font families matching the requested query parameters + */ + items: FontItem[]; +} + +/** + * Individual font from Google Fonts API + */ +export interface FontItem { + /** + * Font family name (e.g., "Roboto", "Open Sans", "Lato") + * This is the name used in CSS font-family declarations + */ + family: string; + + /** + * Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace") + * Useful for grouping and filtering fonts by style + */ + category: string; + + /** + * Available font variants for this font family + * Array of strings representing available weights and styles + * Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"] + * The keys in the `files` object correspond to these variant values + */ + variants: FontVariant[]; + + /** + * Supported character subsets for this font + * Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.] + * Determines which character sets are included in the font files + */ + subsets: string[]; + + /** + * Font version identifier + * Format: "v" followed by version number (e.g., "v31", "v20", "v1") + * Used to track font updates and cache busting + */ + version: string; + + /** + * Last modification date of the font + * Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01") + * Indicates when the font was last updated by the font foundry + */ + lastModified: string; + + /** + * Mapping of font variants to their downloadable URLs + * Keys correspond to values in the `variants` array + * Examples: + * - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..." + * - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..." + * - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..." + */ + files: FontFiles; + + /** + * URL to the font menu preview image + * Typically a PNG showing the font family name in the font + * Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2" + */ + menu: string; +} + +/** + * Standard font weights that can appear in Google Fonts API + */ +export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + +/** + * Italic variant format: e.g., "100italic", "400italic", "700italic" + */ +export type FontWeightItalic = `${FontWeight}italic`; + +/** + * All possible font variants in Google Fonts API + * - Numeric weights: "400", "700", etc. + * - Italic variants: "400italic", "700italic", etc. + * - Legacy names: "regular", "italic", "bold", "bolditalic" + */ +export type FontVariant = + | FontWeight + | FontWeightItalic + | 'regular' + | 'italic' + | 'bold' + | 'bolditalic'; + +/** + * Google Fonts API file mapping + * Dynamic keys that match the variants array + * + * Examples: + * - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." } + * - { "400": "...", "400italic": "...", "900": "..." } + */ +export type FontFiles = Partial>; + +/** + * ============================================================================ + * FONTHARE API TYPES + * ============================================================================ + */ + import type { CollectionApiModel } from '$shared/types/collection'; export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const; @@ -439,3 +580,167 @@ export interface FontshareWeight { */ weight: number; } + +/** + * ============================================================================ + * NORMALIZATION TYPES + * ============================================================================ + */ + +/** + * 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; +} + +/** + * ============================================================================ + * STORE TYPES + * ============================================================================ + */ + +/** + * 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: import('svelte/store').Writable; + /** All fonts as array */ + fonts: import('svelte/store').Readable; + /** Filtered fonts as array */ + filteredFonts: import('svelte/store').Readable; + /** Number of fonts in collection */ + count: import('svelte/store').Readable; + /** Loading state */ + isLoading: import('svelte/store').Readable; + /** Error state */ + error: import('svelte/store').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[]; +} diff --git a/src/entities/Font/model/types/font.ts b/src/entities/Font/model/types/font.ts deleted file mode 100644 index 3222835..0000000 --- a/src/entities/Font/model/types/font.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Font category - */ -export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; - -/** - * Font provider - */ -export type FontProvider = 'google' | 'fontshare'; - -/** - * Font subset - */ -export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari'; diff --git a/src/entities/Font/model/types/google_fonts.ts b/src/entities/Font/model/types/google_fonts.ts deleted file mode 100644 index 6dd253c..0000000 --- a/src/entities/Font/model/types/google_fonts.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Model of google fonts api response - */ -export interface GoogleFontsApiModel { - /** - * Array of font items returned by the Google Fonts API - * Contains all font families matching the requested query parameters - */ - items: FontItem[]; -} - -export interface FontItem { - /** - * Font family name (e.g., "Roboto", "Open Sans", "Lato") - * This is the name used in CSS font-family declarations - */ - family: string; - - /** - * Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace") - * Useful for grouping and filtering fonts by style - */ - category: string; - - /** - * Available font variants for this font family - * Array of strings representing available weights and styles - * Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"] - * The keys in the `files` object correspond to these variant values - */ - variants: FontVariant[]; - - /** - * Supported character subsets for this font - * Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.] - * Determines which character sets are included in the font files - */ - subsets: string[]; - - /** - * Font version identifier - * Format: "v" followed by version number (e.g., "v31", "v20", "v1") - * Used to track font updates and cache busting - */ - version: string; - - /** - * Last modification date of the font - * Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01") - * Indicates when the font was last updated by the font foundry - */ - lastModified: string; - - /** - * Mapping of font variants to their downloadable URLs - * Keys correspond to values in the `variants` array - * Examples: - * - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..." - * - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..." - * - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..." - */ - files: FontFiles; - - /** - * URL to the font menu preview image - * Typically a PNG showing the font family name in the font - * Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2" - */ - menu: string; -} - -/** - * Standard font weights that can appear in Google Fonts API - */ -export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; - -/** - * Italic variant format: e.g., "100italic", "400italic", "700italic" - */ -export type FontWeightItalic = `${FontWeight}italic`; - -/** - * All possible font variants in Google Fonts API - * - Numeric weights: "400", "700", etc. - * - Italic variants: "400italic", "700italic", etc. - * - Legacy names: "regular", "italic", "bold", "bolditalic" - */ -export type FontVariant = - | FontWeight - | FontWeightItalic - | 'regular' - | 'italic' - | 'bold' - | 'bolditalic'; - -/** - * Google Fonts API file mapping - * Dynamic keys that match the variants array - * - * Examples: - * - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." } - * - { "400": "...", "400italic": "...", "900": "..." } - */ -export type FontFiles = Partial>; -- 2.49.1 From db814f0b9354ad56395719d5ee2184b7bd56db63 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 15:11:16 +0300 Subject: [PATCH 04/76] fix(types): resolve import path and type issues after consolidation - Added GoogleFontItem type alias for backward compatibility - Updated normalize.ts to properly type Record values - Fixed import paths in Font index.ts (added subdirectory paths) - Removed unused Readable import from store - Removed type exports from normalize and API index files - Updated stores index.ts to import types from parent types.ts - All tests passing (129 tests) All imports now use centralized types from model/types.ts: - API clients re-export types for backward compatibility - Normalize module imports types and exports functions - Store module imports types and exports factory - Main index.ts exports all types from model/types.ts --- src/entities/Font/api/index.ts | 7 -- src/entities/Font/api/normalize/normalize.ts | 10 +-- src/entities/Font/index.ts | 8 +-- .../Font/model/stores/fontCollectionStore.ts | 5 +- src/entities/Font/model/stores/index.ts | 4 +- src/entities/Font/model/types.ts | 72 ++++++++++++++++++- 6 files changed, 82 insertions(+), 24 deletions(-) diff --git a/src/entities/Font/api/index.ts b/src/entities/Font/api/index.ts index 6656408..9e9ed8e 100644 --- a/src/entities/Font/api/index.ts +++ b/src/entities/Font/api/index.ts @@ -30,10 +30,3 @@ export { normalizeGoogleFont, normalizeGoogleFonts, } from './normalize/normalize'; -export type { - FontFeatures, - FontMetadata, - FontStyleUrls, - UnifiedFont, - UnifiedFontVariant, -} from './normalize/normalize'; diff --git a/src/entities/Font/api/normalize/normalize.ts b/src/entities/Font/api/normalize/normalize.ts index 2dda371..116d64d 100644 --- a/src/entities/Font/api/normalize/normalize.ts +++ b/src/entities/Font/api/normalize/normalize.ts @@ -15,7 +15,6 @@ import type { FontshareFont, GoogleFontItem, UnifiedFont, - UnifiedFontVariant, } from '../../model/types'; /** @@ -128,14 +127,15 @@ export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont { // Map variant files to style URLs const styles: FontStyleUrls = {}; for (const [variant, url] of Object.entries(apiFont.files)) { + const urlString = url as string; // Type assertion for Record if (variant === 'regular' || variant === '400') { - styles.regular = url; + styles.regular = urlString; } else if (variant === 'italic' || variant === '400italic') { - styles.italic = url; + styles.italic = urlString; } else if (variant === 'bold' || variant === '700') { - styles.bold = url; + styles.bold = urlString; } else if (variant === 'bolditalic' || variant === '700italic') { - styles.boldItalic = url; + styles.boldItalic = urlString; } } diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index dfd765f..74250a0 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -2,20 +2,20 @@ export { fetchAllFontshareFonts, fetchFontshareFontBySlug, fetchFontshareFonts, -} from './api/fontshare'; +} from './api/fontshare/fontshare'; export type { FontshareParams, FontshareResponse, -} from './api/fontshare'; +} from './api/fontshare/fontshare'; export { fetchGoogleFontFamily, fetchGoogleFonts, -} from './api/googleFonts'; +} from './api/google/googleFonts'; export type { GoogleFontItem, GoogleFontsParams, GoogleFontsResponse, -} from './api/googleFonts'; +} from './api/google/googleFonts'; export { normalizeFontshareFont, normalizeFontshareFonts, diff --git a/src/entities/Font/model/stores/fontCollectionStore.ts b/src/entities/Font/model/stores/fontCollectionStore.ts index cff581a..b4cca62 100644 --- a/src/entities/Font/model/stores/fontCollectionStore.ts +++ b/src/entities/Font/model/stores/fontCollectionStore.ts @@ -18,10 +18,7 @@ import type { UnifiedFont, } from '$entities/Font'; import { createCollectionCache } from '$shared/fetch/collectionCache'; -import type { - Readable, - Writable, -} from 'svelte/store'; +import type { Writable } from 'svelte/store'; import { derived, get, diff --git a/src/entities/Font/model/stores/index.ts b/src/entities/Font/model/stores/index.ts index eba84c8..e260461 100644 --- a/src/entities/Font/model/stores/index.ts +++ b/src/entities/Font/model/stores/index.ts @@ -4,10 +4,10 @@ * Exports font collection store types and factory function */ -export { createFontCollectionStore } from './fontCollectionStore'; export type { FontCollectionFilters, FontCollectionSort, FontCollectionState, FontCollectionStore, -} from './fontCollectionStore'; +} from '../types'; +export { createFontCollectionStore } from './fontCollectionStore'; diff --git a/src/entities/Font/model/types.ts b/src/entities/Font/model/types.ts index 9b8093f..2aa6c21 100644 --- a/src/entities/Font/model/types.ts +++ b/src/entities/Font/model/types.ts @@ -22,8 +22,7 @@ export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' /** * ============================================================================ * GOOGLE FONTS API TYPES - * ============================================================================ - */ + * ============================================================================ */ /** * Model of google fonts api response @@ -99,6 +98,75 @@ export interface FontItem { menu: string; } +/** + * Type alias for backward compatibility + * Google Fonts API font item + */ +export type GoogleFontItem = FontItem; + +/** + * Individual font from Google Fonts API + */ +export interface FontItem { + /** + * Font family name (e.g., "Roboto", "Open Sans", "Lato") + * This is the name used in CSS font-family declarations + */ + family: string; + + /** + * Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace") + * Useful for grouping and filtering fonts by style + */ + category: string; + + /** + * Available font variants for this font family + * Array of strings representing available weights and styles + * Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"] + * The keys in the `files` object correspond to these variant values + */ + variants: FontVariant[]; + + /** + * Supported character subsets for this font + * Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.] + * Determines which character sets are included in the font files + */ + subsets: string[]; + + /** + * Font version identifier + * Format: "v" followed by version number (e.g., "v31", "v20", "v1") + * Used to track font updates and cache busting + */ + version: string; + + /** + * Last modification date of the font + * Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01") + * Indicates when the font was last updated by the font foundry + */ + lastModified: string; + + /** + * Mapping of font variants to their downloadable URLs + * Keys correspond to values in the `variants` array + * Examples: + * - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..." + * - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..." + * - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..." + */ + files: FontFiles; + + /** + * URL to the font menu preview image + * Typically a PNG showing the font family name in the font + * Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2" + */ + menu: string; +} + /** * Standard font weights that can appear in Google Fonts API */ -- 2.49.1 From be14a62e833cb2f1add53d5dab10cbb41e311225 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 15:23:08 +0300 Subject: [PATCH 05/76] refactor(font): split types into separate files for better maintainability --- src/entities/Font/api/fontshare/fontshare.ts | 4 +- src/entities/Font/api/google/googleFonts.ts | 6 +- .../Font/api/normalize/normalize.test.ts | 6 +- src/entities/Font/api/normalize/normalize.ts | 11 +- src/entities/Font/index.ts | 4 +- .../Font/model/stores/fontCollectionStore.ts | 2 +- src/entities/Font/model/types.ts | 814 ------------------ src/entities/Font/model/types/common.ts | 20 + src/entities/Font/model/types/fontshare.ts | 447 ++++++++++ src/entities/Font/model/types/google.ts | 119 +++ src/entities/Font/model/types/index.ts | 59 ++ src/entities/Font/model/types/normalize.ts | 89 ++ src/entities/Font/model/types/store.ts | 86 ++ .../model/services/fetchFontshareFonts.ts | 6 +- .../model/services/fetchGoogleFonts.ts | 8 +- src/features/FetchFonts/model/types.ts | 4 +- 16 files changed, 847 insertions(+), 838 deletions(-) delete mode 100644 src/entities/Font/model/types.ts create mode 100644 src/entities/Font/model/types/common.ts create mode 100644 src/entities/Font/model/types/fontshare.ts create mode 100644 src/entities/Font/model/types/google.ts create mode 100644 src/entities/Font/model/types/index.ts create mode 100644 src/entities/Font/model/types/normalize.ts create mode 100644 src/entities/Font/model/types/store.ts diff --git a/src/entities/Font/api/fontshare/fontshare.ts b/src/entities/Font/api/fontshare/fontshare.ts index 0689d93..3630edb 100644 --- a/src/entities/Font/api/fontshare/fontshare.ts +++ b/src/entities/Font/api/fontshare/fontshare.ts @@ -10,7 +10,7 @@ import type { FontshareApiModel, FontshareFont, -} from '$entities/Font'; +} from '$entities/Font/model/types/fontshare'; import { api } from '$shared/api/api'; import { buildQueryString } from '$shared/utils'; import type { QueryParams } from '$shared/utils'; @@ -43,7 +43,7 @@ export interface FontshareParams extends QueryParams { /** * Fontshare API response wrapper - * Re-exported from model/types for backward compatibility + * Re-exported from model/types/fontshare for backward compatibility */ export type FontshareResponse = FontshareApiModel; diff --git a/src/entities/Font/api/google/googleFonts.ts b/src/entities/Font/api/google/googleFonts.ts index 07ba6ad..12749da 100644 --- a/src/entities/Font/api/google/googleFonts.ts +++ b/src/entities/Font/api/google/googleFonts.ts @@ -10,7 +10,7 @@ import type { FontItem, GoogleFontsApiModel, -} from '$entities/Font'; +} from '$entities/Font/model/types/google'; import { api } from '$shared/api/api'; import { buildQueryString } from '$shared/utils'; import type { QueryParams } from '$shared/utils'; @@ -47,13 +47,13 @@ export interface GoogleFontsParams extends QueryParams { /** * Google Fonts API response wrapper - * Re-exported from model/types for backward compatibility + * Re-exported from model/types/google for backward compatibility */ export type GoogleFontsResponse = GoogleFontsApiModel; /** * Simplified font item from Google Fonts API - * Re-exported from model/types for backward compatibility + * Re-exported from model/types/google for backward compatibility */ export type GoogleFontItem = FontItem; diff --git a/src/entities/Font/api/normalize/normalize.test.ts b/src/entities/Font/api/normalize/normalize.test.ts index c1e5dd2..a11dc6a 100644 --- a/src/entities/Font/api/normalize/normalize.test.ts +++ b/src/entities/Font/api/normalize/normalize.test.ts @@ -2,7 +2,7 @@ import type { FontshareFont, GoogleFontItem, UnifiedFont, -} from '$entities/Font'; +} from '$entities/Font/model/types'; import { describe, expect, @@ -136,9 +136,9 @@ describe('Font Normalization', () => { }); it('maps variant weights correctly', () => { - const font = { + const font: GoogleFontItem = { ...mockGoogleFont, - variants: ['regular', '100', '400', '700', '900'], + variants: ['regular', '100', '400', '700', '900'] as any, }; const result = normalizeGoogleFont(font); diff --git a/src/entities/Font/api/normalize/normalize.ts b/src/entities/Font/api/normalize/normalize.ts index 116d64d..7bc78a1 100644 --- a/src/entities/Font/api/normalize/normalize.ts +++ b/src/entities/Font/api/normalize/normalize.ts @@ -12,10 +12,10 @@ import type { FontProvider, FontStyleUrls, FontSubset, - FontshareFont, - GoogleFontItem, - UnifiedFont, -} from '../../model/types'; +} from '$entities/Font/model/types'; +import type { FontshareFont } from '$entities/Font/model/types/fontshare'; +import type { GoogleFontItem } from '$entities/Font/model/types/google'; +import type { UnifiedFont } from '$entities/Font/model/types/normalize'; /** * Map Google Fonts category to unified FontCategory @@ -272,3 +272,6 @@ export function normalizeFontshareFonts( ): UnifiedFont[] { return apiFonts.map(normalizeFontshareFont); } + +// Re-export UnifiedFont for backward compatibility +export type { UnifiedFont } from '$entities/Font/model/types/normalize'; diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index 74250a0..1a26f54 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -21,7 +21,7 @@ export { normalizeFontshareFonts, normalizeGoogleFont, normalizeGoogleFonts, -} from './api/normalize'; +} from './api/normalize/normalize'; export type { // Domain types FontCategory, @@ -57,4 +57,4 @@ export type { // Normalization types UnifiedFont, UnifiedFontVariant, -} from './model/types'; +} from './model/types/index'; diff --git a/src/entities/Font/model/stores/fontCollectionStore.ts b/src/entities/Font/model/stores/fontCollectionStore.ts index b4cca62..be6cf4d 100644 --- a/src/entities/Font/model/stores/fontCollectionStore.ts +++ b/src/entities/Font/model/stores/fontCollectionStore.ts @@ -16,7 +16,7 @@ import type { FontProvider, FontSubset, UnifiedFont, -} from '$entities/Font'; +} from '$entities/Font/model/types'; import { createCollectionCache } from '$shared/fetch/collectionCache'; import type { Writable } from 'svelte/store'; import { diff --git a/src/entities/Font/model/types.ts b/src/entities/Font/model/types.ts deleted file mode 100644 index 2aa6c21..0000000 --- a/src/entities/Font/model/types.ts +++ /dev/null @@ -1,814 +0,0 @@ -/** - * ============================================================================ - * DOMAIN TYPES - * ============================================================================ - */ - -/** - * Font category - */ -export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; - -/** - * Font provider - */ -export type FontProvider = 'google' | 'fontshare'; - -/** - * Font subset - */ -export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari'; - -/** - * ============================================================================ - * GOOGLE FONTS API TYPES - * ============================================================================ */ - -/** - * Model of google fonts api response - */ -export interface GoogleFontsApiModel { - /** - * Array of font items returned by the Google Fonts API - * Contains all font families matching the requested query parameters - */ - items: FontItem[]; -} - -/** - * Individual font from Google Fonts API - */ -export interface FontItem { - /** - * Font family name (e.g., "Roboto", "Open Sans", "Lato") - * This is the name used in CSS font-family declarations - */ - family: string; - - /** - * Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace") - * Useful for grouping and filtering fonts by style - */ - category: string; - - /** - * Available font variants for this font family - * Array of strings representing available weights and styles - * Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"] - * The keys in the `files` object correspond to these variant values - */ - variants: FontVariant[]; - - /** - * Supported character subsets for this font - * Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.] - * Determines which character sets are included in the font files - */ - subsets: string[]; - - /** - * Font version identifier - * Format: "v" followed by version number (e.g., "v31", "v20", "v1") - * Used to track font updates and cache busting - */ - version: string; - - /** - * Last modification date of the font - * Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01") - * Indicates when the font was last updated by the font foundry - */ - lastModified: string; - - /** - * Mapping of font variants to their downloadable URLs - * Keys correspond to values in the `variants` array - * Examples: - * - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..." - * - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..." - * - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..." - */ - files: FontFiles; - - /** - * URL to the font menu preview image - * Typically a PNG showing the font family name in the font - * Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2" - */ - menu: string; -} - -/** - * Type alias for backward compatibility - * Google Fonts API font item - */ -export type GoogleFontItem = FontItem; - -/** - * Individual font from Google Fonts API - */ -export interface FontItem { - /** - * Font family name (e.g., "Roboto", "Open Sans", "Lato") - * This is the name used in CSS font-family declarations - */ - family: string; - - /** - * Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace") - * Useful for grouping and filtering fonts by style - */ - category: string; - - /** - * Available font variants for this font family - * Array of strings representing available weights and styles - * Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"] - * The keys in the `files` object correspond to these variant values - */ - variants: FontVariant[]; - - /** - * Supported character subsets for this font - * Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.] - * Determines which character sets are included in the font files - */ - subsets: string[]; - - /** - * Font version identifier - * Format: "v" followed by version number (e.g., "v31", "v20", "v1") - * Used to track font updates and cache busting - */ - version: string; - - /** - * Last modification date of the font - * Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01") - * Indicates when the font was last updated by the font foundry - */ - lastModified: string; - - /** - * Mapping of font variants to their downloadable URLs - * Keys correspond to values in the `variants` array - * Examples: - * - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..." - * - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..." - * - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..." - */ - files: FontFiles; - - /** - * URL to the font menu preview image - * Typically a PNG showing the font family name in the font - * Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2" - */ - menu: string; -} - -/** - * Standard font weights that can appear in Google Fonts API - */ -export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; - -/** - * Italic variant format: e.g., "100italic", "400italic", "700italic" - */ -export type FontWeightItalic = `${FontWeight}italic`; - -/** - * All possible font variants in Google Fonts API - * - Numeric weights: "400", "700", etc. - * - Italic variants: "400italic", "700italic", etc. - * - Legacy names: "regular", "italic", "bold", "bolditalic" - */ -export type FontVariant = - | FontWeight - | FontWeightItalic - | 'regular' - | 'italic' - | 'bold' - | 'bolditalic'; - -/** - * Google Fonts API file mapping - * Dynamic keys that match the variants array - * - * Examples: - * - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." } - * - { "400": "...", "400italic": "...", "900": "..." } - */ -export type FontFiles = Partial>; - -/** - * ============================================================================ - * FONTHARE API TYPES - * ============================================================================ - */ - -import type { CollectionApiModel } from '$shared/types/collection'; - -export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const; - -/** - * Model of Fontshare API response - * @see https://fontshare.com - */ -export type FontshareApiModel = CollectionApiModel; - -/** - * Individual font metadata from Fontshare API - */ -export interface FontshareFont { - /** - * Unique identifier for the font - * UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896") - */ - id: string; - - /** - * Display name of the font family - * Examples: "Satoshi", "General Sans", "Clash Display" - */ - name: string; - - /** - * Native/localized name of the font (if available) - * Often null for Latin-script fonts - */ - native_name: string | null; - - /** - * URL-friendly identifier for the font - * Used in URLs: e.g., "satoshi", "general-sans", "clash-display" - */ - slug: string; - - /** - * Font category classification - * Examples: "Sans", "Serif", "Display", "Script" - */ - category: string; - - /** - * Script/writing system supported by the font - * Examples: "latin", "arabic", "devanagari" - */ - script: string; - - /** - * Font publisher/foundry information - */ - publisher: FontsharePublisher; - - /** - * Array of designers who created this font - * Multiple designers may have collaborated on a single font - */ - designers: FontshareDesigner[]; - - /** - * Related font families (if any) - * Often null, as fonts are typically independent - */ - related_families: string | null; - - /** - * Whether to display publisher as the designer instead of individual designers - */ - display_publisher_as_designer: boolean; - - /** - * Whether trial downloads are enabled for this font - */ - trials_enabled: boolean; - - /** - * Whether to show Latin-specific metrics - */ - show_latin_metrics: boolean; - - /** - * Type of license for this font - * Examples: "itf_ffl" (ITF Free Font License) - */ - license_type: string; - - /** - * Comma-separated list of languages supported by this font - * Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..." - */ - languages: string; - - /** - * ISO 8601 timestamp when the font was added to Fontshare - * Format: "2021-03-12T20:49:05Z" - */ - inserted_at: string; - - /** - * HTML-formatted story/description about the font - * Contains marketing text, design philosophy, and usage recommendations - */ - story: string; - - /** - * Version of the font family - * Format: "1.0", "1.2", etc. - */ - version: string; - - /** - * Total number of times this font has been viewed - */ - views: number; - - /** - * Number of views in the recent time period - */ - views_recent: number; - - /** - * Whether this font is marked as "hot"/trending - */ - is_hot: boolean; - - /** - * Whether this font is marked as new - */ - is_new: boolean; - - /** - * Whether this font is in the shortlisted collection - */ - is_shortlisted: boolean | null; - - /** - * Whether this font is marked as top/popular - */ - is_top: boolean; - - /** - * Variable font axes (for variable fonts) - * Empty array [] for static fonts - */ - axes: FontshareAxis[]; - - /** - * Tags/categories for this font - * Examples: ["Magazines", "Branding", "Logos", "Posters"] - */ - font_tags: FontshareTag[]; - - /** - * OpenType features available in this font - */ - features: FontshareFeature[]; - - /** - * Array of available font styles/variants - * Each style represents a different font file (weight, italic, variable) - */ - styles: FontshareStyle[]; -} - -/** - * Publisher/foundry information - */ -export interface FontsharePublisher { - /** - * Description/bio of the publisher - * Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..." - */ - bio: string; - - /** - * Publisher email (if available) - */ - email: string | null; - - /** - * Unique publisher identifier - * UUID format - */ - id: string; - - /** - * Publisher links (social media, website, etc.) - */ - links: FontshareLink[]; - - /** - * Publisher name - * Example: "Indian Type Foundry" - */ - name: string; -} - -/** - * Designer information - */ -export interface FontshareDesigner { - /** - * Designer bio/description - */ - bio: string; - - /** - * Designer links (Twitter, website, etc.) - */ - links: FontshareLink[]; - - /** - * Designer name - */ - name: string; -} - -/** - * Link information - */ -export interface FontshareLink { - /** - * Name of the link platform/site - * Examples: "Twitter", "GitHub", "Website" - */ - name: string; - - /** - * URL of the link (may be null) - */ - url: string | null; -} - -/** - * Font tag/category - */ -export interface FontshareTag { - /** - * Tag name - * Examples: "Magazines", "Branding", "Logos", "Posters" - */ - name: string; -} - -/** - * OpenType feature - */ -export interface FontshareFeature { - /** - * Feature name (descriptive name or null) - * Examples: "Alternate t", "All Alternates", or null - */ - name: string | null; - - /** - * Whether this feature is on by default - */ - on_by_default: boolean; - - /** - * OpenType feature tag (4-character code) - * Examples: "ss01", "frac", "liga", "aalt", "case" - */ - tag: string; -} - -/** - * Variable font axis (for variable fonts) - * Defines the range and properties of a variable font axis (e.g., weight) - */ -export interface FontshareAxis { - /** - * Name of the axis - * Example: "wght" (weight axis) - */ - name: string; - - /** - * CSS property name for the axis - * Example: "wght" - */ - property: string; - - /** - * Default value for the axis - * Example: 420.0, 650.0, 700.0 - */ - range_default: number; - - /** - * Minimum value for the axis - * Example: 300.0, 100.0, 200.0 - */ - range_left: number; - - /** - * Maximum value for the axis - * Example: 900.0, 700.0, 800.0 - */ - range_right: number; -} - -/** - * Individual font style/variant - * Each style represents a single downloadable font file - */ -export interface FontshareStyle { - /** - * Unique identifier for this style - * UUID format - */ - id: string; - - /** - * Whether this is the default style for the font family - * Typically, one style per font is marked as default - */ - default: boolean; - - /** - * CDN URL to the font file - * Protocol-relative URL: "//cdn.fontshare.com/wf/..." - * Note: URL starts with "//" (protocol-relative), may need protocol prepended - */ - file: string; - - /** - * Whether this style is italic - * false for upright, true for italic styles - */ - is_italic: boolean; - - /** - * Whether this is a variable font - * Variable fonts have adjustable axes (weight, slant, etc.) - */ - is_variable: boolean; - - /** - * Typography properties for this style - * Contains measurements like cap height, x-height, ascenders/descenders - * May be empty object {} for some styles - */ - properties: FontshareStyleProperties | Record; - - /** - * Weight information for this style - */ - weight: FontshareWeight; -} - -/** - * Typography/measurement properties for a font style - */ -export interface FontshareStyleProperties { - /** - * Distance from baseline to the top of ascenders - * Example: 1010, 990, 1000 - */ - ascending_leading: number | null; - - /** - * Height of uppercase letters (cap height) - * Example: 710, 680, 750 - */ - cap_height: number | null; - - /** - * Distance from baseline to the bottom of descenders (negative value) - * Example: -203, -186, -220 - */ - descending_leading: number | null; - - /** - * Body height of the font - * Often null in Fontshare data - */ - body_height: number | null; - - /** - * Maximum character width in the font - * Example: 1739, 1739, 1739 - */ - max_char_width: number | null; - - /** - * Height of lowercase x-height - * Example: 480, 494, 523 - */ - x_height: number | null; - - /** - * Maximum Y coordinate (top of ascenders) - * Example: 1010, 990, 1026 - */ - y_max: number | null; - - /** - * Minimum Y coordinate (bottom of descenders) - * Example: -240, -250, -280 - */ - y_min: number | null; -} - -/** - * Weight information for a font style - */ -export interface FontshareWeight { - /** - * Display label for the weight - * Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic" - */ - label: string; - - /** - * Internal name for the weight - * Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic" - */ - name: string; - - /** - * Native/localized name for the weight (if available) - * Often null for Latin-script fonts - */ - native_name: string | null; - - /** - * Numeric weight value - * Examples: 300, 400, 700, 0 (for variable fonts), 1, 2 - * Note: This matches the `weight` property - */ - number: number; - - /** - * Numeric weight value (duplicate of `number`) - * Appears to be redundant with `number` field - */ - weight: number; -} - -/** - * ============================================================================ - * NORMALIZATION TYPES - * ============================================================================ - */ - -/** - * 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; -} - -/** - * ============================================================================ - * STORE TYPES - * ============================================================================ - */ - -/** - * 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: import('svelte/store').Writable; - /** All fonts as array */ - fonts: import('svelte/store').Readable; - /** Filtered fonts as array */ - filteredFonts: import('svelte/store').Readable; - /** Number of fonts in collection */ - count: import('svelte/store').Readable; - /** Loading state */ - isLoading: import('svelte/store').Readable; - /** Error state */ - error: import('svelte/store').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[]; -} diff --git a/src/entities/Font/model/types/common.ts b/src/entities/Font/model/types/common.ts new file mode 100644 index 0000000..de8fea9 --- /dev/null +++ b/src/entities/Font/model/types/common.ts @@ -0,0 +1,20 @@ +/** + * ============================================================================ + * DOMAIN TYPES + * ============================================================================ + */ + +/** + * Font category + */ +export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; + +/** + * Font provider + */ +export type FontProvider = 'google' | 'fontshare'; + +/** + * Font subset + */ +export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari'; diff --git a/src/entities/Font/model/types/fontshare.ts b/src/entities/Font/model/types/fontshare.ts new file mode 100644 index 0000000..49ff8dc --- /dev/null +++ b/src/entities/Font/model/types/fontshare.ts @@ -0,0 +1,447 @@ +/** + * ============================================================================ + * FONTHARE API TYPES + * ============================================================================ + */ + +import type { CollectionApiModel } from '$shared/types/collection'; + +export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const; + +/** + * Model of Fontshare API response + * @see https://fontshare.com + */ +export type FontshareApiModel = CollectionApiModel; + +/** + * Individual font metadata from Fontshare API + */ +export interface FontshareFont { + /** + * Unique identifier for the font + * UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896") + */ + id: string; + + /** + * Display name of the font family + * Examples: "Satoshi", "General Sans", "Clash Display" + */ + name: string; + + /** + * Native/localized name of the font (if available) + * Often null for Latin-script fonts + */ + native_name: string | null; + + /** + * URL-friendly identifier for the font + * Used in URLs: e.g., "satoshi", "general-sans", "clash-display" + */ + slug: string; + + /** + * Font category classification + * Examples: "Sans", "Serif", "Display", "Script" + */ + category: string; + + /** + * Script/writing system supported by the font + * Examples: "latin", "arabic", "devanagari" + */ + script: string; + + /** + * Font publisher/foundry information + */ + publisher: FontsharePublisher; + + /** + * Array of designers who created this font + * Multiple designers may have collaborated on a single font + */ + designers: FontshareDesigner[]; + + /** + * Related font families (if any) + * Often null, as fonts are typically independent + */ + related_families: string | null; + + /** + * Whether to display publisher as the designer instead of individual designers + */ + display_publisher_as_designer: boolean; + + /** + * Whether trial downloads are enabled for this font + */ + trials_enabled: boolean; + + /** + * Whether to show Latin-specific metrics + */ + show_latin_metrics: boolean; + + /** + * Type of license for this font + * Examples: "itf_ffl" (ITF Free Font License) + */ + license_type: string; + + /** + * Comma-separated list of languages supported by this font + * Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..." + */ + languages: string; + + /** + * ISO 8601 timestamp when the font was added to Fontshare + * Format: "2021-03-12T20:49:05Z" + */ + inserted_at: string; + + /** + * HTML-formatted story/description about the font + * Contains marketing text, design philosophy, and usage recommendations + */ + story: string; + + /** + * Version of the font family + * Format: "1.0", "1.2", etc. + */ + version: string; + + /** + * Total number of times this font has been viewed + */ + views: number; + + /** + * Number of views in the recent time period + */ + views_recent: number; + + /** + * Whether this font is marked as "hot"/trending + */ + is_hot: boolean; + + /** + * Whether this font is marked as new + */ + is_new: boolean; + + /** + * Whether this font is in the shortlisted collection + */ + is_shortlisted: boolean | null; + + /** + * Whether this font is marked as top/popular + */ + is_top: boolean; + + /** + * Variable font axes (for variable fonts) + * Empty array [] for static fonts + */ + axes: FontshareAxis[]; + + /** + * Tags/categories for this font + * Examples: ["Magazines", "Branding", "Logos", "Posters"] + */ + font_tags: FontshareTag[]; + + /** + * OpenType features available in this font + */ + features: FontshareFeature[]; + + /** + * Array of available font styles/variants + * Each style represents a different font file (weight, italic, variable) + */ + styles: FontshareStyle[]; +} + +/** + * Publisher/foundry information + */ +export interface FontsharePublisher { + /** + * Description/bio of the publisher + * Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..." + */ + bio: string; + + /** + * Publisher email (if available) + */ + email: string | null; + + /** + * Unique publisher identifier + * UUID format + */ + id: string; + + /** + * Publisher links (social media, website, etc.) + */ + links: FontshareLink[]; + + /** + * Publisher name + * Example: "Indian Type Foundry" + */ + name: string; +} + +/** + * Designer information + */ +export interface FontshareDesigner { + /** + * Designer bio/description + */ + bio: string; + + /** + * Designer links (Twitter, website, etc.) + */ + links: FontshareLink[]; + + /** + * Designer name + */ + name: string; +} + +/** + * Link information + */ +export interface FontshareLink { + /** + * Name of the link platform/site + * Examples: "Twitter", "GitHub", "Website" + */ + name: string; + + /** + * URL of the link (may be null) + */ + url: string | null; +} + +/** + * Font tag/category + */ +export interface FontshareTag { + /** + * Tag name + * Examples: "Magazines", "Branding", "Logos", "Posters" + */ + name: string; +} + +/** + * OpenType feature + */ +export interface FontshareFeature { + /** + * Feature name (descriptive name or null) + * Examples: "Alternate t", "All Alternates", or null + */ + name: string | null; + + /** + * Whether this feature is on by default + */ + on_by_default: boolean; + + /** + * OpenType feature tag (4-character code) + * Examples: "ss01", "frac", "liga", "aalt", "case" + */ + tag: string; +} + +/** + * Variable font axis (for variable fonts) + * Defines the range and properties of a variable font axis (e.g., weight) + */ +export interface FontshareAxis { + /** + * Name of the axis + * Example: "wght" (weight axis) + */ + name: string; + + /** + * CSS property name for the axis + * Example: "wght" + */ + property: string; + + /** + * Default value for the axis + * Example: 420.0, 650.0, 700.0 + */ + range_default: number; + + /** + * Minimum value for the axis + * Example: 300.0, 100.0, 200.0 + */ + range_left: number; + + /** + * Maximum value for the axis + * Example: 900.0, 700.0, 800.0 + */ + range_right: number; +} + +/** + * Individual font style/variant + * Each style represents a single downloadable font file + */ +export interface FontshareStyle { + /** + * Unique identifier for this style + * UUID format + */ + id: string; + + /** + * Whether this is the default style for the font family + * Typically, one style per font is marked as default + */ + default: boolean; + + /** + * CDN URL to the font file + * Protocol-relative URL: "//cdn.fontshare.com/wf/..." + * Note: URL starts with "//" (protocol-relative), may need protocol prepended + */ + file: string; + + /** + * Whether this style is italic + * false for upright, true for italic styles + */ + is_italic: boolean; + + /** + * Whether this is a variable font + * Variable fonts have adjustable axes (weight, slant, etc.) + */ + is_variable: boolean; + + /** + * Typography properties for this style + * Contains measurements like cap height, x-height, ascenders/descenders + * May be empty object {} for some styles + */ + properties: FontshareStyleProperties | Record; + + /** + * Weight information for this style + */ + weight: FontshareWeight; +} + +/** + * Typography/measurement properties for a font style + */ +export interface FontshareStyleProperties { + /** + * Distance from baseline to the top of ascenders + * Example: 1010, 990, 1000 + */ + ascending_leading: number | null; + + /** + * Height of uppercase letters (cap height) + * Example: 710, 680, 750 + */ + cap_height: number | null; + + /** + * Distance from baseline to the bottom of descenders (negative value) + * Example: -203, -186, -220 + */ + descending_leading: number | null; + + /** + * Body height of the font + * Often null in Fontshare data + */ + body_height: number | null; + + /** + * Maximum character width in the font + * Example: 1739, 1739, 1739 + */ + max_char_width: number | null; + + /** + * Height of lowercase x-height + * Example: 480, 494, 523 + */ + x_height: number | null; + + /** + * Maximum Y coordinate (top of ascenders) + * Example: 1010, 990, 1026 + */ + y_max: number | null; + + /** + * Minimum Y coordinate (bottom of descenders) + * Example: -240, -250, -280 + */ + y_min: number | null; +} + +/** + * Weight information for a font style + */ +export interface FontshareWeight { + /** + * Display label for the weight + * Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic" + */ + label: string; + + /** + * Internal name for the weight + * Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic" + */ + name: string; + + /** + * Native/localized name for the weight (if available) + * Often null for Latin-script fonts + */ + native_name: string | null; + + /** + * Numeric weight value + * Examples: 300, 400, 700, 0 (for variable fonts), 1, 2 + * Note: This matches the `weight` property + */ + number: number; + + /** + * Numeric weight value (duplicate of `number`) + * Appears to be redundant with `number` field + */ + weight: number; +} diff --git a/src/entities/Font/model/types/google.ts b/src/entities/Font/model/types/google.ts new file mode 100644 index 0000000..26a1f56 --- /dev/null +++ b/src/entities/Font/model/types/google.ts @@ -0,0 +1,119 @@ +/** + * ============================================================================ + * GOOGLE FONTS API TYPES + * ============================================================================ + */ + +/** + * Model of google fonts api response + */ +export interface GoogleFontsApiModel { + /** + * Array of font items returned by the Google Fonts API + * Contains all font families matching the requested query parameters + */ + items: FontItem[]; +} + +/** + * Individual font from Google Fonts API + */ +export interface FontItem { + /** + * Font family name (e.g., "Roboto", "Open Sans", "Lato") + * This is the name used in CSS font-family declarations + */ + family: string; + + /** + * Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace") + * Useful for grouping and filtering fonts by style + */ + category: string; + + /** + * Available font variants for this font family + * Array of strings representing available weights and styles + * Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"] + * The keys in the `files` object correspond to these variant values + */ + variants: FontVariant[]; + + /** + * Supported character subsets for this font + * Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.] + * Determines which character sets are included in the font files + */ + subsets: string[]; + + /** + * Font version identifier + * Format: "v" followed by version number (e.g., "v31", "v20", "v1") + * Used to track font updates and cache busting + */ + version: string; + + /** + * Last modification date of the font + * Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01") + * Indicates when the font was last updated by the font foundry + */ + lastModified: string; + + /** + * Mapping of font variants to their downloadable URLs + * Keys correspond to values in the `variants` array + * Examples: + * - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..." + * - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..." + * - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..." + */ + files: FontFiles; + + /** + * URL to the font menu preview image + * Typically a PNG showing the font family name in the font + * Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2" + */ + menu: string; +} + +/** + * Type alias for backward compatibility + * Google Fonts API font item + */ +export type GoogleFontItem = FontItem; + +/** + * Standard font weights that can appear in Google Fonts API + */ +export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + +/** + * Italic variant format: e.g., "100italic", "400italic", "700italic" + */ +export type FontWeightItalic = `${FontWeight}italic`; + +/** + * All possible font variants in Google Fonts API + * - Numeric weights: "400", "700", etc. + * - Italic variants: "400italic", "700italic", etc. + * - Legacy names: "regular", "italic", "bold", "bolditalic" + */ +export type FontVariant = + | FontWeight + | FontWeightItalic + | 'regular' + | 'italic' + | 'bold' + | 'bolditalic'; + +/** + * Google Fonts API file mapping + * Dynamic keys that match the variants array + * + * Examples: + * - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." } + * - { "400": "...", "400italic": "...", "900": "..." } + */ +export type FontFiles = Partial>; diff --git a/src/entities/Font/model/types/index.ts b/src/entities/Font/model/types/index.ts new file mode 100644 index 0000000..368f3d7 --- /dev/null +++ b/src/entities/Font/model/types/index.ts @@ -0,0 +1,59 @@ +/** + * ============================================================================ + * SINGLE EXPORT POINT + * ============================================================================ + * + * This is the single export point for all Font types. + * All imports should use: `import { X } from '$entities/Font/model/types'` + */ + +// Domain types +export type { + FontCategory, + FontProvider, + FontSubset, +} from './common'; + +// Google Fonts API types +export type { + FontFiles, + FontItem, + FontVariant, + FontWeight, + FontWeightItalic, + GoogleFontItem, + GoogleFontsApiModel, +} from './google'; + +// Fontshare API types +export type { + FontshareApiModel, + FontshareAxis, + FontshareDesigner, + FontshareFeature, + FontshareFont, + FontshareLink, + FontsharePublisher, + FontshareStyle, + FontshareStyleProperties, + FontshareTag, + FontshareWeight, +} from './fontshare'; +export { FONTSHARE_API_URL } from './fontshare'; + +// Normalization types +export type { + FontFeatures, + FontMetadata, + FontStyleUrls, + UnifiedFont, + UnifiedFontVariant, +} from './normalize'; + +// Store types +export type { + FontCollectionFilters, + FontCollectionSort, + FontCollectionState, + FontCollectionStore, +} from './store'; diff --git a/src/entities/Font/model/types/normalize.ts b/src/entities/Font/model/types/normalize.ts new file mode 100644 index 0000000..91f58eb --- /dev/null +++ b/src/entities/Font/model/types/normalize.ts @@ -0,0 +1,89 @@ +/** + * ============================================================================ + * NORMALIZATION TYPES + * ============================================================================ + */ + +import type { + FontCategory, + FontProvider, + FontSubset, +} from './common'; + +/** + * 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; +} diff --git a/src/entities/Font/model/types/store.ts b/src/entities/Font/model/types/store.ts new file mode 100644 index 0000000..5cc2383 --- /dev/null +++ b/src/entities/Font/model/types/store.ts @@ -0,0 +1,86 @@ +/** + * ============================================================================ + * STORE TYPES + * ============================================================================ + */ + +import type { + FontCategory, + FontProvider, + FontSubset, +} from './common'; +import type { UnifiedFont } from './normalize'; + +/** + * 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: import('svelte/store').Writable; + /** All fonts as array */ + fonts: import('svelte/store').Readable; + /** Filtered fonts as array */ + filteredFonts: import('svelte/store').Readable; + /** Number of fonts in collection */ + count: import('svelte/store').Readable; + /** Loading state */ + isLoading: import('svelte/store').Readable; + /** Error state */ + error: import('svelte/store').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[]; +} diff --git a/src/features/FetchFonts/model/services/fetchFontshareFonts.ts b/src/features/FetchFonts/model/services/fetchFontshareFonts.ts index 000946e..d9310b3 100644 --- a/src/features/FetchFonts/model/services/fetchFontshareFonts.ts +++ b/src/features/FetchFonts/model/services/fetchFontshareFonts.ts @@ -5,9 +5,9 @@ * 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 { fetchFontshareFonts } from '$entities/Font/api/fontshare/fontshare'; +import { normalizeFontshareFonts } from '$entities/Font/api/normalize/normalize'; +import type { UnifiedFont } from '$entities/Font/model/types/normalize'; import type { QueryFunction } from '@tanstack/svelte-query'; import { createQuery, diff --git a/src/features/FetchFonts/model/services/fetchGoogleFonts.ts b/src/features/FetchFonts/model/services/fetchGoogleFonts.ts index 43c4c3b..2259980 100644 --- a/src/features/FetchFonts/model/services/fetchGoogleFonts.ts +++ b/src/features/FetchFonts/model/services/fetchGoogleFonts.ts @@ -7,13 +7,13 @@ * Uses reactive query args pattern for Svelte 5 compatibility. */ +import { fetchGoogleFonts } from '$entities/Font/api/google/googleFonts'; +import { normalizeGoogleFonts } from '$entities/Font/api/normalize/normalize'; 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'; +} from '$entities/Font/model/types'; +import type { UnifiedFont } from '$entities/Font/model/types/normalize'; import type { QueryFunction } from '@tanstack/svelte-query'; import { createQuery, diff --git a/src/features/FetchFonts/model/types.ts b/src/features/FetchFonts/model/types.ts index c21eb17..a4f4ea0 100644 --- a/src/features/FetchFonts/model/types.ts +++ b/src/features/FetchFonts/model/types.ts @@ -8,8 +8,8 @@ import type { FontCategory, FontProvider, FontSubset, -} from '$entities/Font'; -import type { UnifiedFont } from '$entities/Font/api/normalize'; +} from '$entities/Font/model/types'; +import type { UnifiedFont } from '$entities/Font/model/types/normalize'; /** * Combined query parameters for fetching from any provider -- 2.49.1 From 2c666646cbb18bae0a81b612ced058815bdd33ce Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 15:24:34 +0300 Subject: [PATCH 06/76] style(font): fix lint warnings - remove unused imports and variables - Removed unused FontFeatures, FontMetadata, FontProvider from normalize.ts imports - Removed unused UnifiedFont from normalize.test.ts imports - Removed unused FontSubset from store.ts imports - Changed unused queryClient variables to void calls to suppress warnings --- src/entities/Font/api/normalize/normalize.test.ts | 1 - src/entities/Font/api/normalize/normalize.ts | 3 --- src/entities/Font/index.ts | 2 +- src/entities/Font/model/types/store.ts | 1 - src/features/FetchFonts/model/services/fetchFontshareFonts.ts | 2 +- src/features/FetchFonts/model/services/fetchGoogleFonts.ts | 2 +- 6 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/entities/Font/api/normalize/normalize.test.ts b/src/entities/Font/api/normalize/normalize.test.ts index a11dc6a..c6c8482 100644 --- a/src/entities/Font/api/normalize/normalize.test.ts +++ b/src/entities/Font/api/normalize/normalize.test.ts @@ -1,7 +1,6 @@ import type { FontshareFont, GoogleFontItem, - UnifiedFont, } from '$entities/Font/model/types'; import { describe, diff --git a/src/entities/Font/api/normalize/normalize.ts b/src/entities/Font/api/normalize/normalize.ts index 7bc78a1..643bbbf 100644 --- a/src/entities/Font/api/normalize/normalize.ts +++ b/src/entities/Font/api/normalize/normalize.ts @@ -7,9 +7,6 @@ import type { FontCategory, - FontFeatures, - FontMetadata, - FontProvider, FontStyleUrls, FontSubset, } from '$entities/Font/model/types'; diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index 1a26f54..38a04ba 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -57,4 +57,4 @@ export type { // Normalization types UnifiedFont, UnifiedFontVariant, -} from './model/types/index'; +} from './model/types'; diff --git a/src/entities/Font/model/types/store.ts b/src/entities/Font/model/types/store.ts index 5cc2383..8c5c7fb 100644 --- a/src/entities/Font/model/types/store.ts +++ b/src/entities/Font/model/types/store.ts @@ -7,7 +7,6 @@ import type { FontCategory, FontProvider, - FontSubset, } from './common'; import type { UnifiedFont } from './normalize'; diff --git a/src/features/FetchFonts/model/services/fetchFontshareFonts.ts b/src/features/FetchFonts/model/services/fetchFontshareFonts.ts index d9310b3..e7e0e8b 100644 --- a/src/features/FetchFonts/model/services/fetchFontshareFonts.ts +++ b/src/features/FetchFonts/model/services/fetchFontshareFonts.ts @@ -113,7 +113,7 @@ export const fetchFontshareFontsQuery: QueryFunction< export function useFontshareFontsQuery( params: FontshareQueryParams = {}, ) { - const queryClient = useQueryClient(); + useQueryClient(); const query = createQuery(() => ({ queryKey: getFontshareQueryKey(params), diff --git a/src/features/FetchFonts/model/services/fetchGoogleFonts.ts b/src/features/FetchFonts/model/services/fetchGoogleFonts.ts index 2259980..df346ca 100644 --- a/src/features/FetchFonts/model/services/fetchGoogleFonts.ts +++ b/src/features/FetchFonts/model/services/fetchGoogleFonts.ts @@ -115,7 +115,7 @@ export const fetchGoogleFontsQuery: QueryFunction< * ``` */ export function useGoogleFontsQuery(params: GoogleFontsQueryParams = {}) { - const queryClient = useQueryClient(); + useQueryClient(); const query = createQuery(() => ({ queryKey: getGoogleFontsQueryKey(params), -- 2.49.1 From 10b7457f21a827ac038ef525006da2c5c3be3f5b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 18:55:07 +0300 Subject: [PATCH 07/76] refactor(virtual): use store pattern instead of hook, fix styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store Pattern Migration: - Created createVirtualizerStore using Svelte stores (writable/derived) - Replaced useVirtualList hook with createVirtualizerStore - Matches existing store patterns (createFilterStore, createControlStore) - More Svelte-idiomatic than React-inspired hook pattern Component Refactoring: - Renamed FontVirtualList.svelte → VirtualList.svelte - Moved component from shared/virtual/ → shared/ui/ - Updated to use store pattern instead of hook - Removed pixel values from style tags (uses Tailwind CSS) - Height now configurable via Tailwind classes (e.g., 'h-96', 'h-[500px]') - Props changed from shorthand {fonts} to explicit items prop File Changes: - Deleted: useVirtualList.ts (replaced by store pattern) - Deleted: FontVirtualList.svelte (renamed and moved) - Deleted: useVirtualList.test.ts (updated to test store pattern) - Updated: README.md with store pattern usage examples - Updated: index.ts with migration guide - Created: createVirtualizerStore.ts in shared/store/ - Created: VirtualList.svelte in shared/ui/ - Created: createVirtualizerStore.test.ts - Created: barrel exports (shared/store/index.ts, shared/ui/index.ts) Styling Improvements: - All pixel values removed from