refactor(Font): consolidate API layer and update type structure

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:21 +03:00
parent ba186d00a1
commit af4137f47f
17 changed files with 325 additions and 558 deletions

View File

@@ -1,161 +0,0 @@
/**
* Fontshare API client
*
* Handles API requests to Fontshare API for fetching font metadata.
* Provides error handling, pagination support, and type-safe responses.
*
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
* For future optimization, consider implementing incremental pagination for large datasets.
*
* @see https://fontshare.com
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontshareApiModel,
FontshareFont,
} from '../../model/types/fontshare';
/**
* Fontshare API parameters
*/
export interface FontshareParams extends QueryParams {
/**
* 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
*/
q?: string;
}
/**
* Fontshare API response wrapper
* Re-exported from model/types/fontshare for backward compatibility
*/
export type FontshareResponse = FontshareApiModel;
/**
* Fetch fonts from Fontshare API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Fontshare API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all Sans category fonts
* const response = await fetchFontshareFonts({
* categories: ['Sans'],
* limit: 50
* });
*
* // Fetch fonts with specific tags
* const response = await fetchFontshareFonts({
* tags: ['Branding', 'Logos']
* });
*
* // Search fonts
* const response = await fetchFontshareFonts({
* search: 'Satoshi'
* });
* ```
*/
export async function fetchFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const queryString = buildQueryString(params);
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
try {
const response = await api.get<FontshareResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
}
}
/**
* Fetch font by slug
* Convenience function for fetching a single font
*
* @param slug - Font slug (e.g., "satoshi", "general-sans")
* @returns Promise resolving to Fontshare font item
*
* @example
* ```ts
* const satoshi = await fetchFontshareFontBySlug('satoshi');
* ```
*/
export async function fetchFontshareFontBySlug(
slug: string,
): Promise<FontshareFont | undefined> {
const response = await fetchFontshareFonts();
return response.fonts.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.fonts.length} fonts`);
* ```
*/
export async function fetchAllFontshareFonts(
params: FontshareParams = {},
): Promise<FontshareResponse> {
const allFonts: FontshareFont[] = [];
let page = 1;
const limit = 100; // Max items per page
while (true) {
const response = await fetchFontshareFonts({
...params,
page,
limit,
});
allFonts.push(...response.fonts);
// Check if we've fetched all items
if (response.fonts.length < limit) {
break;
}
page++;
}
// Return first response with all items combined
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
return {
...firstResponse,
fonts: allFonts,
};
}

View File

@@ -1,127 +0,0 @@
/**
* Google Fonts API client
*
* Handles API requests to Google Fonts API for fetching font metadata.
* Provides error handling, retry logic, and type-safe responses.
*
* Pagination: The Google Fonts API does NOT support pagination parameters.
* All fonts matching the query are returned in a single response.
* Use category, subset, or sort filters to reduce the result set if needed.
*
* @see https://developers.google.com/fonts/docs/developer_api
*/
import { api } from '$shared/api/api';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type {
FontItem,
GoogleFontsApiModel,
} from '../../model/types/google';
/**
* Google Fonts API parameters
*/
export interface GoogleFontsParams extends QueryParams {
/**
* Google Fonts API key (required for Google Fonts API v1)
*/
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?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
/**
* Cap the number of fonts returned
*/
capability?: 'VF' | 'WOFF2';
}
/**
* Google Fonts API response wrapper
* 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/google for backward compatibility
*/
export type GoogleFontItem = FontItem;
/**
* Google Fonts API base URL
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
* fonts may not load properly.
*/
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
/**
* Fetch fonts from Google Fonts API
*
* @param params - Query parameters for filtering fonts
* @returns Promise resolving to Google Fonts API response
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all sans-serif fonts sorted by popularity
* const response = await fetchGoogleFonts({
* category: 'sans-serif',
* sort: 'popularity'
* });
*
* // Fetch specific font family
* const robotoResponse = await fetchGoogleFonts({
* family: 'Roboto'
* });
* ```
*/
export async function fetchGoogleFonts(
params: GoogleFontsParams = {},
): Promise<GoogleFontsResponse> {
const queryString = buildQueryString(params);
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
try {
const response = await api.get<GoogleFontsResponse>(url);
return response.data;
} catch (error) {
// Re-throw ApiError with context
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
}
}
/**
* Fetch font by family name
* Convenience function for fetching a single font
*
* @param family - Font family name (e.g., "Roboto")
* @returns Promise resolving to Google Font item
*
* @example
* ```ts
* const roboto = await fetchGoogleFontFamily('Roboto');
* ```
*/
export async function fetchGoogleFontFamily(
family: string,
): Promise<GoogleFontItem | undefined> {
const response = await fetchGoogleFonts({ family });
return response.items.find(item => item.family === family);
}

View File

@@ -4,7 +4,7 @@
* Exports API clients and normalization utilities * Exports API clients and normalization utilities
*/ */
// Proxy API (PRIMARY - NEW) // Proxy API (primary)
export { export {
fetchFontsByIds, fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
@@ -14,25 +14,3 @@ export type {
ProxyFontsParams, ProxyFontsParams,
ProxyFontsResponse, ProxyFontsResponse,
} from './proxy/proxyFonts'; } from './proxy/proxyFonts';
// Google Fonts API (DEPRECATED - kept for backward compatibility)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './google/googleFonts';
// Fontshare API (DEPRECATED - kept for backward compatibility)
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './fontshare/fontshare';

View File

@@ -1,83 +0,0 @@
/**
* Proxy API filters
*
* Fetches filter metadata from GlyphDiff proxy API.
* Provides type-safe response handling.
*
* @see https://api.glyphdiff.com/api/v1/filters
*/
import { api } from '$shared/api/api';
/**
* Filter metadata type from backend
*/
export interface FilterMetadata {
/** Filter ID (e.g., "providers", "categories", "subsets") */
id: string;
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
name: string;
/** Filter description */
description: string;
/** Filter type */
type: 'enum' | 'string' | 'array';
/** Available filter options */
options: FilterOption[];
}
/**
* Filter option type
*/
export interface FilterOption {
/** Option ID (e.g., "google", "serif", "latin") */
id: string;
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
name: string;
/** Option value (e.g., "google", "serif", "latin") */
value: string;
/** Number of fonts with this value */
count: number;
}
/**
* Proxy filters API response
*/
export interface ProxyFiltersResponse {
/** Array of filter metadata */
filters: FilterMetadata[];
}
/**
* Fetch filters from proxy API
*
* @returns Promise resolving to array of filter metadata
* @throws ApiError when request fails
*
* @example
* ```ts
* // Fetch all filters
* const filters = await fetchProxyFilters();
*
* console.log(filters); // [
* // { id: "providers", name: "Font Providers", options: [...] },
* // { id: "categories", name: "Categories", options: [...] },
* // { id: "subsets", name: "Character Subsets", options: [...] }
* // ]
* ```
*/
export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
const response = await api.get<FilterMetadata[]>('/api/v1/filters');
if (!response.data || !Array.isArray(response.data)) {
throw new Error('Proxy API returned invalid response');
}
return response.data;
}

View File

@@ -0,0 +1,171 @@
/**
* Tests for proxy API client
*/
import {
beforeEach,
describe,
expect,
test,
vi,
} from 'vitest';
import type { UnifiedFont } from '../../model/types';
import type { ProxyFontsResponse } from './proxyFonts';
vi.mock('$shared/api/api', () => ({
api: {
get: vi.fn(),
},
}));
import { api } from '$shared/api/api';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
return {
id: 'roboto',
family: 'Roboto',
provider: 'google',
category: 'sans-serif',
variants: [],
subsets: [],
...overrides,
} as UnifiedFont;
}
function mockApiGet<T>(data: T) {
vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 });
}
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
});
describe('fetchProxyFonts', () => {
test('should fetch fonts with no params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 50,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFonts();
expect(api.get).toHaveBeenCalledWith(PROXY_API_URL);
expect(result).toEqual(mockResponse);
});
test('should build URL with query params', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 20,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 });
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('provider=google');
expect(calledUrl).toContain('category=sans-serif');
expect(calledUrl).toContain('limit=20');
expect(calledUrl).toContain('offset=0');
});
test('should throw on invalid response (missing fonts array)', async () => {
mockApiGet({ total: 0 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
test('should throw on null response data', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
});
});
describe('fetchProxyFontById', () => {
test('should return font matching the ID', async () => {
const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' });
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont(), targetFont],
total: 2,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('satoshi');
expect(result).toEqual(targetFont);
});
test('should return undefined when font not found', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [createMockFont()],
total: 1,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
const result = await fetchProxyFontById('nonexistent');
expect(result).toBeUndefined();
});
test('should search with the ID as query param', async () => {
const mockResponse: ProxyFontsResponse = {
fonts: [],
total: 0,
limit: 1000,
offset: 0,
};
mockApiGet(mockResponse);
await fetchProxyFontById('Roboto');
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
expect(calledUrl).toContain('limit=1000');
expect(calledUrl).toContain('q=Roboto');
});
});
describe('fetchFontsByIds', () => {
test('should return empty array for empty input', async () => {
const result = await fetchFontsByIds([]);
expect(result).toEqual([]);
expect(api.get).not.toHaveBeenCalled();
});
test('should call batch endpoint with comma-separated IDs', async () => {
const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })];
mockApiGet(fonts);
const result = await fetchFontsByIds(['roboto', 'satoshi']);
expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`);
expect(result).toEqual(fonts);
});
test('should return empty array when response data is nullish', async () => {
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
const result = await fetchFontsByIds(['roboto']);
expect(result).toEqual([]);
});
});
});

View File

@@ -1,4 +1,4 @@
// Proxy API (PRIMARY) // Proxy API (primary)
export { export {
fetchFontsByIds, fetchFontsByIds,
fetchProxyFontById, fetchProxyFontById,
@@ -9,32 +9,9 @@ export type {
ProxyFontsResponse, ProxyFontsResponse,
} from './api/proxy/proxyFonts'; } from './api/proxy/proxyFonts';
// Fontshare API (DEPRECATED)
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './api/fontshare/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './api/fontshare/fontshare';
// Google Fonts API (DEPRECATED)
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './api/google/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './api/google/googleFonts';
export { export {
normalizeFontshareFont, normalizeFontshareFont,
normalizeFontshareFonts, normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './lib/normalize/normalize'; } from './lib/normalize/normalize';
export type { export type {
// Domain types // Domain types
@@ -65,8 +42,6 @@ export type {
FontVariant, FontVariant,
FontWeight, FontWeight,
FontWeightItalic, FontWeightItalic,
// Google Fonts API types
GoogleFontsApiModel,
// Normalization types // Normalization types
UnifiedFont, UnifiedFont,
UnifiedFontVariant, UnifiedFontVariant,

View File

@@ -3,13 +3,31 @@ import type {
UnifiedFont, UnifiedFont,
} from '../../model'; } from '../../model';
/** Valid font weight values (100-900 in increments of 100) */
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900]; const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/** /**
* Constructs a URL for a font based on the provided font and weight. * Gets the URL for a font file at a specific weight
* @param font - The font object. *
* @param weight - The weight of the font. * Constructs the appropriate URL for loading a font file based on
* @returns The URL for the font. * the font object and requested weight. Handles variable fonts and
* provides fallbacks for static fonts.
*
* @param font - Unified font object containing style URLs
* @param weight - Font weight (100-900)
* @returns URL string for the font file, or undefined if not found
* @throws Error if weight is not a valid value (100-900)
*
* @example
* ```ts
* const url = getFontUrl(roboto, 700); // Returns URL for Roboto Bold
*
* // Variable fonts: backend maps weight to VF URL
* const vfUrl = getFontUrl(inter, 450); // Returns variable font URL
*
* // Fallback for missing weights
* const fallback = getFontUrl(font, 900); // Falls back to regular/400 if 900 missing
* ```
*/ */
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined { export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
if (!SIZES.includes(weight)) { if (!SIZES.includes(weight)) {
@@ -18,12 +36,11 @@ export function getFontUrl(font: UnifiedFont, weight: number): string | undefine
const weightKey = weight.toString() as FontWeight; const weightKey = weight.toString() as FontWeight;
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable) // Try exact match (backend maps weight to VF URL for variable fonts)
if (font.styles.variants?.[weightKey]) { if (font.styles.variants?.[weightKey]) {
return font.styles.variants[weightKey]; return font.styles.variants[weightKey];
} }
// 2. Fallbacks for Static Fonts (if exact weight missing) // Fallbacks for static fonts when exact weight is missing
// Try 'regular' or '400' as safe defaults
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular']; return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
} }

View File

@@ -1,7 +1,5 @@
/** /**
* ============================================================================ * Mock font filter data
* MOCK FONT FILTER DATA
* ============================================================================
* *
* Factory functions and preset mock data for font-related filters. * Factory functions and preset mock data for font-related filters.
* Used in Storybook stories for font filtering components. * Used in Storybook stories for font filtering components.
@@ -36,9 +34,7 @@ import type {
import type { Property } from '$shared/lib'; import type { Property } from '$shared/lib';
import { createFilter } from '$shared/lib'; import { createFilter } from '$shared/lib';
// ============================================================================
// TYPE DEFINITIONS // TYPE DEFINITIONS
// ============================================================================
/** /**
* Options for creating a mock filter * Options for creating a mock filter
@@ -60,9 +56,7 @@ export interface MockFilters {
subsets: ReturnType<typeof createFilter<FontSubset>>; subsets: ReturnType<typeof createFilter<FontSubset>>;
} }
// ============================================================================
// FONT CATEGORIES // FONT CATEGORIES
// ============================================================================
/** /**
* Google Fonts categories * Google Fonts categories
@@ -98,9 +92,7 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
{ id: 'monospace', name: 'Monospace', value: 'monospace' }, { id: 'monospace', name: 'Monospace', value: 'monospace' },
]; ];
// ============================================================================
// FONT SUBSETS // FONT SUBSETS
// ============================================================================
/** /**
* Common font subsets * Common font subsets
@@ -114,9 +106,7 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' }, { id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
]; ];
// ============================================================================
// FONT PROVIDERS // FONT PROVIDERS
// ============================================================================
/** /**
* Font providers * Font providers
@@ -126,9 +116,7 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' }, { id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
]; ];
// ============================================================================
// FILTER FACTORIES // FILTER FACTORIES
// ============================================================================
/** /**
* Create a mock filter from properties * Create a mock filter from properties
@@ -172,9 +160,7 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
return createFilter<FontProvider>({ properties }); return createFilter<FontProvider>({ properties });
} }
// ============================================================================
// PRESET FILTERS // PRESET FILTERS
// ============================================================================
/** /**
* Preset mock filters - use these directly in stories * Preset mock filters - use these directly in stories
@@ -251,9 +237,7 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
}), }),
}; };
// ============================================================================
// GENERIC FILTER MOCKS // GENERIC FILTER MOCKS
// ============================================================================
/** /**
* Create a mock filter with generic string properties * Create a mock filter with generic string properties

View File

@@ -50,9 +50,7 @@ import type {
UnifiedFont, UnifiedFont,
} from '$entities/Font/model/types'; } from '$entities/Font/model/types';
// ============================================================================
// GOOGLE FONTS MOCKS // GOOGLE FONTS MOCKS
// ============================================================================
/** /**
* Options for creating a mock Google Font * Options for creating a mock Google Font
@@ -186,9 +184,7 @@ export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
}), }),
}; };
// ============================================================================
// FONTHARE MOCKS // FONTHARE MOCKS
// ============================================================================
/** /**
* Options for creating a mock Fontshare font * Options for creating a mock Fontshare font
@@ -399,9 +395,7 @@ export const FONTHARE_FONTS: Record<string, FontshareFont> = {
}), }),
}; };
// ============================================================================
// UNIFIED FONT MOCKS // UNIFIED FONT MOCKS
// ============================================================================
/** /**
* Options for creating a mock UnifiedFont * Options for creating a mock UnifiedFont

View File

@@ -35,9 +35,7 @@ import {
generateMockFonts, generateMockFonts,
} from './fonts.mock'; } from './fonts.mock';
// ============================================================================
// TANSTACK QUERY MOCK TYPES // TANSTACK QUERY MOCK TYPES
// ============================================================================
/** /**
* Mock TanStack Query state * Mock TanStack Query state
@@ -83,9 +81,7 @@ export interface MockQueryObserverResult<TData = unknown, TError = Error> {
isPaused?: boolean; isPaused?: boolean;
} }
// ============================================================================
// TANSTACK QUERY MOCK FACTORIES // TANSTACK QUERY MOCK FACTORIES
// ============================================================================
/** /**
* Create a mock query state for TanStack Query * Create a mock query state for TanStack Query
@@ -142,9 +138,7 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
return createMockQueryState<TData>({ status: 'success', data, error: undefined }); return createMockQueryState<TData>({ status: 'success', data, error: undefined });
} }
// ============================================================================
// FONT STORE MOCKS // FONT STORE MOCKS
// ============================================================================
/** /**
* Mock UnifiedFontStore state * Mock UnifiedFontStore state
@@ -332,9 +326,7 @@ export const MOCK_FONT_STORE_STATES = {
}), }),
}; };
// ============================================================================
// MOCK STORE OBJECT // MOCK STORE OBJECT
// ============================================================================
/** /**
* Create a mock store object that mimics TanStack Query behavior * Create a mock store object that mimics TanStack Query behavior
@@ -469,9 +461,7 @@ export const MOCK_STORES = {
}, },
}; };
// ============================================================================
// REACTIVE STATE MOCKS // REACTIVE STATE MOCKS
// ============================================================================
/** /**
* Create a reactive state object using Svelte 5 runes pattern * Create a reactive state object using Svelte 5 runes pattern
@@ -525,9 +515,7 @@ export function createMockComparisonStore(config: {
}; };
} }
// ============================================================================
// MOCK DATA GENERATORS // MOCK DATA GENERATORS
// ============================================================================
/** /**
* Generate paginated font data * Generate paginated font data

View File

@@ -7,41 +7,64 @@ import {
} from '@tanstack/query-core'; } from '@tanstack/query-core';
import type { UnifiedFont } from '../types'; import type { UnifiedFont } from '../types';
/** */ /**
* Base class for font stores using TanStack Query
*
* Provides reactive font data fetching with caching, automatic refetching,
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
* font fetching.
*
* @template TParams - Type of query parameters
*/
export abstract class BaseFontStore<TParams extends Record<string, any>> { export abstract class BaseFontStore<TParams extends Record<string, any>> {
/**
* Cleanup function for effects
* Call destroy() to remove effects and prevent memory leaks
*/
cleanup: () => void; cleanup: () => void;
/** Reactive parameter bindings from external sources */
#bindings = $state<(() => Partial<TParams>)[]>([]); #bindings = $state<(() => Partial<TParams>)[]>([]);
/** Internal parameter state */
#internalParams = $state<TParams>({} as TParams); #internalParams = $state<TParams>({} as TParams);
/**
* Merged params from internal state and all bindings
* Automatically updates when bindings or internal params change
*/
params = $derived.by(() => { params = $derived.by(() => {
let merged = { ...this.#internalParams }; let merged = { ...this.#internalParams };
// Loop through every "Cable" plugged into the store // Merge all binding results into params
// Loop through every "Cable" plugged into the store
for (const getter of this.#bindings) { for (const getter of this.#bindings) {
const bindingResult = getter(); const bindingResult = getter();
merged = { ...merged, ...bindingResult }; merged = { ...merged, ...bindingResult };
} }
return merged as TParams; return merged as TParams;
}); });
/** TanStack Query result state */
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any); protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
/** TanStack Query observer instance */
protected observer: QueryObserver<UnifiedFont[], Error>; protected observer: QueryObserver<UnifiedFont[], Error>;
/** Shared query client */
protected qc = queryClient; protected qc = queryClient;
/**
* Creates a new base font store
* @param initialParams - Initial query parameters
*/
constructor(initialParams: TParams) { constructor(initialParams: TParams) {
this.#internalParams = initialParams; this.#internalParams = initialParams;
this.observer = new QueryObserver(this.qc, this.getOptions()); this.observer = new QueryObserver(this.qc, this.getOptions());
// Sync TanStack -> Svelte State // Sync TanStack Query state -> Svelte state
this.observer.subscribe(r => { this.observer.subscribe(r => {
this.result = r; this.result = r;
}); });
// Sync Svelte State -> TanStack Options // Sync Svelte state changes -> TanStack Query options
this.cleanup = $effect.root(() => { this.cleanup = $effect.root(() => {
$effect(() => { $effect(() => {
this.observer.setOptions(this.getOptions()); this.observer.setOptions(this.getOptions());
@@ -50,11 +73,21 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
} }
/** /**
* Mandatory: Child must define how to fetch data and what the key is. * Must be implemented by child class
* Returns the query key for TanStack Query caching
*/ */
protected abstract getQueryKey(params: TParams): QueryKey; protected abstract getQueryKey(params: TParams): QueryKey;
/**
* Must be implemented by child class
* Fetches font data from API
*/
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>; protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
/**
* Gets TanStack Query options
* @param params - Query parameters (defaults to current params)
*/
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> { protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
return { return {
queryKey: this.getQueryKey(params), queryKey: this.getQueryKey(params),
@@ -64,25 +97,36 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
}; };
} }
// --- Common Getters --- /** Array of fonts (empty array if loading/error) */
get fonts() { get fonts() {
return this.result.data ?? []; return this.result.data ?? [];
} }
/** Whether currently fetching initial data */
get isLoading() { get isLoading() {
return this.result.isLoading; return this.result.isLoading;
} }
/** Whether any fetch is in progress (including refetches) */
get isFetching() { get isFetching() {
return this.result.isFetching; return this.result.isFetching;
} }
/** Whether last fetch resulted in an error */
get isError() { get isError() {
return this.result.isError; return this.result.isError;
} }
/** Whether no fonts are loaded (not loading and empty array) */
get isEmpty() { get isEmpty() {
return !this.isLoading && this.fonts.length === 0; return !this.isLoading && this.fonts.length === 0;
} }
// --- Common Actions --- /**
* Add a reactive parameter binding
* @param getter - Function that returns partial params to merge
* @returns Unbind function to remove the binding
*/
addBinding(getter: () => Partial<TParams>) { addBinding(getter: () => Partial<TParams>) {
this.#bindings.push(getter); this.#bindings.push(getter);
@@ -91,9 +135,14 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
}; };
} }
/**
* Update query parameters
* @param newParams - Partial params to merge with existing
*/
setParams(newParams: Partial<TParams>) { setParams(newParams: Partial<TParams>) {
this.#internalParams = { ...this.params, ...newParams }; this.#internalParams = { ...this.params, ...newParams };
} }
/** /**
* Invalidate cache and refetch * Invalidate cache and refetch
*/ */
@@ -101,19 +150,22 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) }); this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
} }
/**
* Clean up effects and observers
*/
destroy() { destroy() {
this.cleanup(); this.cleanup();
} }
/** /**
* Manually refetch * Manually trigger a refetch
*/ */
async refetch() { async refetch() {
await this.observer.refetch(); await this.observer.refetch();
} }
/** /**
* Prefetch with different params (for hover states, pagination, etc.) * Prefetch data with different parameters
*/ */
async prefetch(params: TParams) { async prefetch(params: TParams) {
await this.qc.prefetchQuery(this.getOptions(params)); await this.qc.prefetchQuery(this.getOptions(params));

View File

@@ -1,43 +0,0 @@
/**
* ============================================================================
* UNIFIED FONT STORE TYPES
* ============================================================================
*
* Type definitions for the unified font store infrastructure.
* Provides types for filters, sorting, and fetch parameters.
*/
import type {
FontshareParams,
GoogleFontsParams,
} from '$entities/Font/api';
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font/model/types/common';
/**
* Sort configuration
*/
export interface FontSort {
field: 'name' | 'popularity' | 'category' | 'date';
direction: 'asc' | 'desc';
}
/**
* Fetch params for unified API
*/
export interface FetchFontsParams {
providers?: FontProvider[];
categories?: FontCategory[];
subsets?: FontSubset[];
search?: string;
sort?: FontSort;
forceRefetch?: boolean;
}
/**
* Provider-specific params union
*/
export type ProviderParams = GoogleFontsParams | FontshareParams;

View File

@@ -43,7 +43,7 @@ import { BaseFontStore } from './baseFontStore.svelte';
* }); * });
* *
* // Update parameters * // Update parameters
* store.setCategory('serif'); * store.setCategories(['serif']);
* store.nextPage(); * store.nextPage();
* ``` * ```
*/ */
@@ -108,16 +108,20 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
this.#filterCleanup = $effect.root(() => { this.#filterCleanup = $effect.root(() => {
$effect(() => { $effect(() => {
const filterParams = JSON.stringify({ const filterParams = JSON.stringify({
provider: this.params.provider, providers: this.params.providers,
category: this.params.category, categories: this.params.categories,
subset: this.params.subset, subsets: this.params.subsets,
q: this.params.q, q: this.params.q,
}); });
// If filters changed, reset offset to 0 // If filters changed, reset offset and invalidate cache
if (filterParams !== this.#previousFilterParams) { if (filterParams !== this.#previousFilterParams) {
if (this.#previousFilterParams && this.params.offset !== 0) { if (this.#previousFilterParams) {
this.setParams({ offset: 0 }); if (this.params.offset !== 0) {
this.setParams({ offset: 0 });
}
this.#accumulatedFonts = [];
this.invalidate();
} }
this.#previousFilterParams = filterParams; this.#previousFilterParams = filterParams;
} }
@@ -170,7 +174,7 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
} }
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> { protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
const hasFilters = !!(params.q || params.provider || params.category || params.subset); const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
return { return {
queryKey: this.getQueryKey(params), queryKey: this.getQueryKey(params),
queryFn: () => this.fetchFn(params), queryFn: () => this.fetchFn(params),
@@ -221,8 +225,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
return response.fonts; return response.fonts;
} }
// --- Getters (proxied from BaseFontStore) ---
/** /**
* Get all accumulated fonts (for infinite scroll) * Get all accumulated fonts (for infinite scroll)
*/ */
@@ -258,27 +260,25 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
return !this.isLoading && this.fonts.length === 0; return !this.isLoading && this.fonts.length === 0;
} }
// --- Provider-specific shortcuts ---
/** /**
* Set provider filter * Set providers filter
*/ */
setProvider(provider: 'google' | 'fontshare' | undefined) { setProviders(providers: ProxyFontsParams['providers']) {
this.setParams({ provider }); this.setParams({ providers });
} }
/** /**
* Set category filter * Set categories filter
*/ */
setCategory(category: ProxyFontsParams['category']) { setCategories(categories: ProxyFontsParams['categories']) {
this.setParams({ category }); this.setParams({ categories });
} }
/** /**
* Set subset filter * Set subsets filter
*/ */
setSubset(subset: ProxyFontsParams['subset']) { setSubsets(subsets: ProxyFontsParams['subsets']) {
this.setParams({ subset }); this.setParams({ subsets });
} }
/** /**
@@ -295,8 +295,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
this.setParams({ sort }); this.setParams({ sort });
} }
// --- Pagination methods ---
/** /**
* Go to next page * Go to next page
*/ */
@@ -337,8 +335,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
this.setParams({ limit }); this.setParams({ limit });
} }
// --- Category shortcuts (for convenience) ---
get sansSerifFonts() { get sansSerifFonts() {
return this.fonts.filter(f => f.category === 'sans-serif'); return this.fonts.filter(f => f.category === 'sans-serif');
} }

View File

@@ -1,50 +1,60 @@
/** /**
* ============================================================================ * Common font domain types
* DOMAIN TYPES *
* ============================================================================ * Shared types for font entities across providers (Google, Fontshare).
* Includes categories, subsets, weights, and filter types.
*/ */
import type { FontCategory as FontshareFontCategory } from './fontshare'; import type { FontCategory as FontshareFontCategory } from './fontshare';
import type { FontCategory as GoogleFontCategory } from './google'; import type { FontCategory as GoogleFontCategory } from './google';
/** /**
* Font category * Unified font category across all providers
*/ */
export type FontCategory = GoogleFontCategory | FontshareFontCategory; export type FontCategory = GoogleFontCategory | FontshareFontCategory;
/** /**
* Font provider * Font provider identifier
*/ */
export type FontProvider = 'google' | 'fontshare'; export type FontProvider = 'google' | 'fontshare';
/** /**
* Font subset * Character subset support
*/ */
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari'; export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
/** /**
* Filter state * Combined filter state for font queries
*/ */
export interface FontFilters { export interface FontFilters {
/** Selected font providers */
providers: FontProvider[]; providers: FontProvider[];
/** Selected font categories */
categories: FontCategory[]; categories: FontCategory[];
/** Selected character subsets */
subsets: FontSubset[]; subsets: FontSubset[];
} }
/** Filter group identifier */
export type FilterGroup = 'providers' | 'categories' | 'subsets'; export type FilterGroup = 'providers' | 'categories' | 'subsets';
/** Filter type including search query */
export type FilterType = FilterGroup | 'searchQuery'; export type FilterType = FilterGroup | 'searchQuery';
/** /**
* Standard font weights * Numeric font weights (100-900)
*/ */
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
/** /**
* Italic variant format: e.g., "100italic", "400italic", "700italic" * Italic variant with weight: "100italic", "400italic", "700italic", etc.
*/ */
export type FontWeightItalic = `${FontWeight}italic`; export type FontWeightItalic = `${FontWeight}italic`;
/** /**
* All possible font variants * All possible font variant identifiers
*
* Includes:
* - Numeric weights: "400", "700", etc. * - Numeric weights: "400", "700", etc.
* - Italic variants: "400italic", "700italic", etc. * - Italic variants: "400italic", "700italic", etc.
* - Legacy names: "regular", "italic", "bold", "bolditalic" * - Legacy names: "regular", "italic", "bold", "bolditalic"

View File

@@ -46,6 +46,13 @@ export interface FontMetadata {
lastModified?: string; lastModified?: string;
/** Popularity rank (if available from provider) */ /** Popularity rank (if available from provider) */
popularity?: number; popularity?: number;
/**
* Normalized popularity score (0-100)
*
* Normalized across all fonts for consistent ranking
* Higher values indicate more popular fonts
*/
popularityScore?: number;
} }
/** /**
@@ -79,6 +86,13 @@ export interface UnifiedFont {
name: string; name: string;
/** Font provider (google | fontshare) */ /** Font provider (google | fontshare) */
provider: FontProvider; provider: FontProvider;
/**
* Provider badge display name
*
* Human-readable provider name for UI display
* e.g., "Google Fonts" or "Fontshare"
*/
providerBadge?: string;
/** Font category classification */ /** Font category classification */
category: FontCategory; category: FontCategory;
/** Supported character subsets */ /** Supported character subsets */

View File

@@ -16,19 +16,20 @@ import {
interface Props { interface Props {
/** /**
* Applied font * Font to apply
*/ */
font: UnifiedFont; font: UnifiedFont;
/** /**
* Font weight * Font weight
* @default 400
*/ */
weight?: number; weight?: number;
/** /**
* Additional classes * CSS classes
*/ */
className?: string; className?: string;
/** /**
* Children * Content snippet
*/ */
children?: Snippet; children?: Snippet;
} }
@@ -44,7 +45,7 @@ const status = $derived(
appliedFontsManager.getFontStatus( appliedFontsManager.getFontStatus(
font.id, font.id,
weight, weight,
font.features.isVariable, font.features?.isVariable,
), ),
); );

View File

@@ -28,11 +28,11 @@ interface Props extends
> >
{ {
/** /**
* Callback for when visible items change * Visible items callback
*/ */
onVisibleItemsChange?: (items: UnifiedFont[]) => void; onVisibleItemsChange?: (items: UnifiedFont[]) => void;
/** /**
* Weight of the font * Font weight
*/ */
weight: number; weight: number;
/** /**
@@ -69,6 +69,7 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
}); });
} }
}); });
// Auto-register fonts with the manager // Auto-register fonts with the manager
appliedFontsManager.touch(configs); appliedFontsManager.touch(configs);