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
This commit is contained in:
187
src/entities/Font/api/fontshare.ts
Normal file
187
src/entities/Font/api/fontshare.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Fontshare API client
|
||||
*
|
||||
* Handles API requests to Fontshare API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* @see https://fontshare.com
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontshareApiModel,
|
||||
FontshareFont,
|
||||
} from '$entities/Font';
|
||||
import { api } from '$shared/api/api';
|
||||
|
||||
/**
|
||||
* Fontshare API parameters
|
||||
*/
|
||||
export interface FontshareParams {
|
||||
/**
|
||||
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
|
||||
*/
|
||||
categories?: string[];
|
||||
/**
|
||||
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Page number for pagination (1-indexed)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Search query to filter fonts
|
||||
*/
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fontshare API response wrapper
|
||||
* Extends collection model with additional metadata
|
||||
*/
|
||||
export interface FontshareResponse extends FontshareApiModel {
|
||||
// Response structure matches FontshareApiModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: FontshareParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.categories?.length) {
|
||||
searchParams.append('categories', params.categories.join(','));
|
||||
}
|
||||
|
||||
if (params.tags?.length) {
|
||||
searchParams.append('tags', params.tags.join(','));
|
||||
}
|
||||
|
||||
if (params.page) {
|
||||
searchParams.append('page', String(params.page));
|
||||
}
|
||||
|
||||
if (params.limit) {
|
||||
searchParams.append('limit', String(params.limit));
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
searchParams.append('search', params.search);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fonts from Fontshare API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Fontshare API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all Sans category fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* categories: ['Sans'],
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with specific tags
|
||||
* const response = await fetchFontshareFonts({
|
||||
* tags: ['Branding', 'Logos']
|
||||
* });
|
||||
*
|
||||
* // Search fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* search: 'Satoshi'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `https://api.fontshare.com/v2${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<FontshareResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by slug
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param slug - Font slug (e.g., "satoshi", "general-sans")
|
||||
* @returns Promise resolving to Fontshare font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = await fetchFontshareFontBySlug('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFontBySlug(
|
||||
slug: string,
|
||||
): Promise<FontshareFont | undefined> {
|
||||
const response = await fetchFontshareFonts();
|
||||
return response.items.find(font => font.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all fonts from Fontshare
|
||||
* Convenience function for fetching all available fonts
|
||||
* Uses pagination to get all items
|
||||
*
|
||||
* @returns Promise resolving to all Fontshare fonts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const allFonts = await fetchAllFontshareFonts();
|
||||
* console.log(`Found ${allFonts.items.length} fonts`);
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const allFonts: FontshareFont[] = [];
|
||||
let page = 1;
|
||||
const limit = 100; // Max items per page
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFontshareFonts({
|
||||
...params,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
allFonts.push(...response.items);
|
||||
|
||||
// Check if we've fetched all items
|
||||
if (response.items.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
// Return first response with all items combined
|
||||
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
|
||||
|
||||
return {
|
||||
...firstResponse,
|
||||
items: allFonts,
|
||||
};
|
||||
}
|
||||
159
src/entities/Font/api/googleFonts.ts
Normal file
159
src/entities/Font/api/googleFonts.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Google Fonts API client
|
||||
*
|
||||
* Handles API requests to Google Fonts API for fetching font metadata.
|
||||
* Provides error handling, retry logic, and type-safe responses.
|
||||
*
|
||||
* @see https://developers.google.com/fonts/docs/developer_api
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
|
||||
/**
|
||||
* Google Fonts API parameters
|
||||
*/
|
||||
export interface GoogleFontsParams {
|
||||
/**
|
||||
* Google Fonts API key (optional for public endpoints)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Font family name (to fetch specific font)
|
||||
*/
|
||||
family?: string;
|
||||
/**
|
||||
* Font category filter (e.g., "sans-serif", "serif", "display")
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
|
||||
*/
|
||||
subset?: string;
|
||||
/**
|
||||
* Sort order for results
|
||||
*/
|
||||
sort?: 'popularity' | 'alpha' | 'date' | 'style';
|
||||
/**
|
||||
* Cap the number of fonts returned
|
||||
*/
|
||||
capability?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API response wrapper
|
||||
*/
|
||||
export interface GoogleFontsResponse {
|
||||
kind: string;
|
||||
items: GoogleFontItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified font item from Google Fonts API
|
||||
*/
|
||||
export interface GoogleFontItem {
|
||||
family: string;
|
||||
category: string;
|
||||
variants: string[];
|
||||
subsets: string[];
|
||||
version: string;
|
||||
lastModified: string;
|
||||
files: Record<string, string>;
|
||||
menu: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API base URL
|
||||
*/
|
||||
const GOOGLE_FONTS_API_URL = 'https://fonts.googleapis.com/v2/fonts' as const;
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: GoogleFontsParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.key) {
|
||||
searchParams.append('key', params.key);
|
||||
}
|
||||
|
||||
if (params.family) {
|
||||
searchParams.append('family', params.family);
|
||||
}
|
||||
|
||||
if (params.category) {
|
||||
searchParams.append('category', params.category);
|
||||
}
|
||||
|
||||
if (params.subset) {
|
||||
searchParams.append('subset', params.subset);
|
||||
}
|
||||
|
||||
if (params.sort) {
|
||||
searchParams.append('sort', params.sort);
|
||||
}
|
||||
|
||||
if (params.capability) {
|
||||
searchParams.append('capability', params.capability);
|
||||
}
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fonts from Google Fonts API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Google Fonts API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts sorted by popularity
|
||||
* const response = await fetchGoogleFonts({
|
||||
* category: 'sans-serif',
|
||||
* sort: 'popularity'
|
||||
* });
|
||||
*
|
||||
* // Fetch specific font family
|
||||
* const robotoResponse = await fetchGoogleFonts({
|
||||
* family: 'Roboto'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFonts(
|
||||
params: GoogleFontsParams = {},
|
||||
): Promise<GoogleFontsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<GoogleFontsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by family name
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param family - Font family name (e.g., "Roboto")
|
||||
* @returns Promise resolving to Google Font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchGoogleFontFamily('Roboto');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFontFamily(
|
||||
family: string,
|
||||
): Promise<GoogleFontItem | undefined> {
|
||||
const response = await fetchGoogleFonts({ family });
|
||||
return response.items.find(item => item.family === family);
|
||||
}
|
||||
39
src/entities/Font/api/index.ts
Normal file
39
src/entities/Font/api/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Font API clients exports
|
||||
*
|
||||
* Exports API clients and normalization utilities
|
||||
*/
|
||||
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './googleFonts';
|
||||
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './fontshare';
|
||||
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize';
|
||||
export type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './normalize';
|
||||
598
src/entities/Font/api/normalize.test.ts
Normal file
598
src/entities/Font/api/normalize.test.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
import type { FontshareFont } from '$entities/Font';
|
||||
import type { GoogleFontItem } from '$entities/Font/api/googleFonts';
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
import {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from '$entities/Font/api/normalize';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
|
||||
describe('Font Normalization', () => {
|
||||
describe('normalizeGoogleFont', () => {
|
||||
const mockGoogleFont: GoogleFontItem = {
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular', '700', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
files: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700':
|
||||
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||
'700italic':
|
||||
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||
},
|
||||
version: 'v30',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
};
|
||||
|
||||
it('normalizes Google Font to unified model', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.id).toBe('Roboto');
|
||||
expect(result.name).toBe('Roboto');
|
||||
expect(result.provider).toBe('google');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps font variants correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
|
||||
});
|
||||
|
||||
it('maps subsets correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.subsets).toContain('latin');
|
||||
expect(result.subsets).toContain('latin-ext');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.styles.regular).toBeDefined();
|
||||
expect(result.styles.bold).toBeDefined();
|
||||
expect(result.styles.italic).toBeDefined();
|
||||
expect(result.styles.boldItalic).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('v30');
|
||||
expect(result.metadata.lastModified).toBe('2022-01-01');
|
||||
});
|
||||
|
||||
it('marks Google Fonts as non-variable', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles sans-serif category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'sans-serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('handles serif category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('handles display category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'display' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('handles handwriting category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'handwriting' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles cursive category (maps to handwriting)', () => {
|
||||
const font = { ...mockGoogleFont, category: 'cursive' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles monospace category', () => {
|
||||
const font = { ...mockGoogleFont, category: 'monospace' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('filters invalid subsets', () => {
|
||||
const font = {
|
||||
...mockGoogleFont,
|
||||
subsets: ['latin', 'latin-ext', 'invalid-subset'],
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.subsets).not.toContain('invalid-subset');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps variant weights correctly', () => {
|
||||
const font = {
|
||||
...mockGoogleFont,
|
||||
variants: ['regular', '100', '400', '700', '900'],
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.variants).toContain('regular');
|
||||
expect(result.variants).toContain('100');
|
||||
expect(result.variants).toContain('400');
|
||||
expect(result.variants).toContain('700');
|
||||
expect(result.variants).toContain('900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFont', () => {
|
||||
const mockFontshareFont: FontshareFont = {
|
||||
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
|
||||
name: 'Satoshi',
|
||||
native_name: null,
|
||||
slug: 'satoshi',
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: 'Indian Type Foundry',
|
||||
email: null,
|
||||
id: 'test-id',
|
||||
links: [],
|
||||
name: 'Indian Type Foundry',
|
||||
},
|
||||
designers: [
|
||||
{
|
||||
bio: 'Designer bio',
|
||||
links: [],
|
||||
name: 'Designer Name',
|
||||
},
|
||||
],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: true,
|
||||
show_latin_metrics: false,
|
||||
license_type: 'itf_ffl',
|
||||
languages: 'Afar, Afrikaans',
|
||||
inserted_at: '2021-03-12T20:49:05Z',
|
||||
story: '<p>Font story</p>',
|
||||
version: '1.0',
|
||||
views: 10000,
|
||||
views_recent: 500,
|
||||
is_hot: true,
|
||||
is_new: false,
|
||||
is_shortlisted: false,
|
||||
is_top: true,
|
||||
axes: [],
|
||||
font_tags: [
|
||||
{ name: 'Branding' },
|
||||
{ name: 'Logos' },
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: 'Alternate t',
|
||||
on_by_default: false,
|
||||
tag: 'ss01',
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id-1',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-2',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-3',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-4',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('normalizes Fontshare font to unified model', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
expect(result.name).toBe('Satoshi');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('uses slug as unique identifier', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
});
|
||||
|
||||
it('extracts variant names from styles', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.variants).toContain('Regular');
|
||||
expect(result.variants).toContain('Bold');
|
||||
expect(result.variants).toContain('Regularitalic');
|
||||
expect(result.variants).toContain('Bolditalic');
|
||||
});
|
||||
|
||||
it('maps Fontshare Sans to sans-serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Sans' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Serif to serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Serif' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Display to display category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Display' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('maps Fontshare Script to handwriting category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Script' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('maps Fontshare Mono to monospace category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Mono' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
|
||||
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
|
||||
expect(result.styles.boldItalic).toBe(
|
||||
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles variable fonts', () => {
|
||||
const variableFont: FontshareFont = {
|
||||
...mockFontshareFont,
|
||||
axes: [
|
||||
{
|
||||
name: 'wght',
|
||||
property: 'wght',
|
||||
range_default: 400,
|
||||
range_left: 300,
|
||||
range_right: 900,
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'var-style',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
|
||||
is_italic: false,
|
||||
is_variable: true,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Variable',
|
||||
name: 'Variable',
|
||||
native_name: null,
|
||||
number: 0,
|
||||
weight: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = normalizeFontshareFont(variableFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(true);
|
||||
expect(result.features.axes).toHaveLength(1);
|
||||
expect(result.features.axes?.[0].name).toBe('wght');
|
||||
});
|
||||
|
||||
it('extracts font tags', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.features.tags).toContain('Branding');
|
||||
expect(result.features.tags).toContain('Logos');
|
||||
expect(result.features.tags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes popularity from views', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.popularity).toBe(10000);
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('1.0');
|
||||
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
|
||||
});
|
||||
|
||||
it('handles missing subsets gracefully', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
script: 'invalid-script',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.subsets).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty tags', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
font_tags: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.tags).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty axes', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
axes: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.axes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGoogleFonts', () => {
|
||||
it('normalizes array of Google Fonts', () => {
|
||||
const fonts: GoogleFontItem[] = [
|
||||
{
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
},
|
||||
{
|
||||
family: 'Open Sans',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeGoogleFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Roboto');
|
||||
expect(result[1].name).toBe('Open Sans');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeGoogleFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFonts', () => {
|
||||
it('normalizes array of Fontshare fonts', () => {
|
||||
const fonts: FontshareFont[] = [
|
||||
{
|
||||
...mockMinimalFontshareFont('font1', 'Font 1'),
|
||||
},
|
||||
{
|
||||
...mockMinimalFontshareFont('font2', 'Font 2'),
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeFontshareFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Font 1');
|
||||
expect(result[1].name).toBe('Font 2');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeFontshareFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Google Font with missing optional fields', () => {
|
||||
const font: Partial<GoogleFontItem> = {
|
||||
family: 'Test Font',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
};
|
||||
|
||||
const result = normalizeGoogleFont(font as GoogleFontItem);
|
||||
|
||||
expect(result.id).toBe('Test Font');
|
||||
expect(result.metadata.version).toBeUndefined();
|
||||
expect(result.metadata.lastModified).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles Fontshare font with minimal data', () => {
|
||||
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
|
||||
|
||||
expect(result.id).toBe('slug');
|
||||
expect(result.name).toBe('Name');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
});
|
||||
|
||||
it('handles unknown Fontshare category', () => {
|
||||
const font = {
|
||||
...mockMinimalFontshareFont('slug', 'Name'),
|
||||
category: 'Unknown Category',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif'); // fallback
|
||||
});
|
||||
|
||||
it('handles unknown Google Font category', () => {
|
||||
const font: GoogleFontItem = {
|
||||
family: 'Test',
|
||||
category: 'unknown',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Test',
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif'); // fallback
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create minimal Fontshare font mock
|
||||
*/
|
||||
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
|
||||
return {
|
||||
id: 'test-id',
|
||||
name,
|
||||
native_name: null,
|
||||
slug,
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: '',
|
||||
email: null,
|
||||
id: '',
|
||||
links: [],
|
||||
name: '',
|
||||
},
|
||||
designers: [],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: false,
|
||||
show_latin_metrics: false,
|
||||
license_type: '',
|
||||
languages: '',
|
||||
inserted_at: '',
|
||||
story: '',
|
||||
version: '1.0',
|
||||
views: 0,
|
||||
views_recent: 0,
|
||||
is_hot: false,
|
||||
is_new: false,
|
||||
is_shortlisted: null,
|
||||
is_top: false,
|
||||
axes: [],
|
||||
font_tags: [],
|
||||
features: [],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/test.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
347
src/entities/Font/api/normalize.ts
Normal file
347
src/entities/Font/api/normalize.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Normalize fonts from Google Fonts and Fontshare to unified model
|
||||
*
|
||||
* Transforms provider-specific font data into a common interface
|
||||
* for consistent handling across the application.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font';
|
||||
import type { FontshareFont } from '$entities/Font';
|
||||
import type { GoogleFontItem } from './googleFonts';
|
||||
|
||||
/**
|
||||
* Font variant types (standardized)
|
||||
*/
|
||||
export type UnifiedFontVariant = string;
|
||||
|
||||
/**
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface FontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
italic?: string;
|
||||
/** Bold weight URL */
|
||||
bold?: string;
|
||||
/** Bold italic URL */
|
||||
boldItalic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font metadata
|
||||
*/
|
||||
export interface FontMetadata {
|
||||
/** Timestamp when font was cached */
|
||||
cachedAt: number;
|
||||
/** Font version from provider */
|
||||
version?: string;
|
||||
/** Last modified date from provider */
|
||||
lastModified?: string;
|
||||
/** Popularity rank (if available from provider) */
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font features (variable fonts, axes, tags)
|
||||
*/
|
||||
export interface FontFeatures {
|
||||
/** Whether this is a variable font */
|
||||
isVariable?: boolean;
|
||||
/** Variable font axes (for Fontshare) */
|
||||
axes?: Array<{
|
||||
name: string;
|
||||
property: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}>;
|
||||
/** Usage tags (for Fontshare) */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified font model
|
||||
*
|
||||
* Combines Google Fonts and Fontshare data into a common interface
|
||||
* for consistent font handling across the application.
|
||||
*/
|
||||
export interface UnifiedFont {
|
||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
||||
id: string;
|
||||
/** Font display name */
|
||||
name: string;
|
||||
/** Font provider (google | fontshare) */
|
||||
provider: FontProvider;
|
||||
/** Font category classification */
|
||||
category: FontCategory;
|
||||
/** Supported character subsets */
|
||||
subsets: FontSubset[];
|
||||
/** Available font variants (weights, styles) */
|
||||
variants: UnifiedFontVariant[];
|
||||
/** URL mapping for font file downloads */
|
||||
styles: FontStyleUrls;
|
||||
/** Additional metadata */
|
||||
metadata: FontMetadata;
|
||||
/** Advanced font features */
|
||||
features: FontFeatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google Fonts category to unified FontCategory
|
||||
*/
|
||||
function mapGoogleCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized.includes('sans-serif')) {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized.includes('serif')) {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized.includes('display')) {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized.includes('monospace')) {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare category to unified FontCategory
|
||||
*/
|
||||
function mapFontshareCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized === 'sans' || normalized === 'sans-serif') {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized === 'serif') {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized === 'display') {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized === 'script') {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized === 'mono' || normalized === 'monospace') {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google subset to unified FontSubset
|
||||
*/
|
||||
function mapGoogleSubset(subset: string): FontSubset | null {
|
||||
const validSubsets: FontSubset[] = [
|
||||
'latin',
|
||||
'latin-ext',
|
||||
'cyrillic',
|
||||
'greek',
|
||||
'arabic',
|
||||
'devanagari',
|
||||
];
|
||||
return validSubsets.includes(subset as FontSubset)
|
||||
? (subset as FontSubset)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare script to unified FontSubset
|
||||
*/
|
||||
function mapFontshareScript(script: string): FontSubset | null {
|
||||
const normalized = script.toLowerCase();
|
||||
const mapping: Record<string, FontSubset | null> = {
|
||||
latin: 'latin',
|
||||
'latin-ext': 'latin-ext',
|
||||
cyrillic: 'cyrillic',
|
||||
greek: 'greek',
|
||||
arabic: 'arabic',
|
||||
devanagari: 'devanagari',
|
||||
};
|
||||
return mapping[normalized] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Google Font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Google Fonts API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = normalizeGoogleFont({
|
||||
* family: 'Roboto',
|
||||
* category: 'sans-serif',
|
||||
* variants: ['regular', '700'],
|
||||
* subsets: ['latin', 'latin-ext'],
|
||||
* files: { regular: '...', '700': '...' }
|
||||
* });
|
||||
*
|
||||
* console.log(roboto.id); // 'Roboto'
|
||||
* console.log(roboto.provider); // 'google'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
|
||||
const category = mapGoogleCategory(apiFont.category);
|
||||
const subsets = apiFont.subsets
|
||||
.map(mapGoogleSubset)
|
||||
.filter((subset): subset is FontSubset => subset !== null);
|
||||
|
||||
// Map variant files to style URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const [variant, url] of Object.entries(apiFont.files)) {
|
||||
if (variant === 'regular' || variant === '400') {
|
||||
styles.regular = url;
|
||||
} else if (variant === 'italic' || variant === '400italic') {
|
||||
styles.italic = url;
|
||||
} else if (variant === 'bold' || variant === '700') {
|
||||
styles.bold = url;
|
||||
} else if (variant === 'bolditalic' || variant === '700italic') {
|
||||
styles.boldItalic = url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiFont.family,
|
||||
name: apiFont.family,
|
||||
provider: 'google',
|
||||
category,
|
||||
subsets,
|
||||
variants: apiFont.variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.lastModified,
|
||||
},
|
||||
features: {
|
||||
isVariable: false, // Google Fonts doesn't expose variable font info
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Fontshare font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Fontshare API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = normalizeFontshareFont({
|
||||
* id: 'uuid',
|
||||
* name: 'Satoshi',
|
||||
* slug: 'satoshi',
|
||||
* category: 'Sans',
|
||||
* script: 'latin',
|
||||
* styles: [ ... ]
|
||||
* });
|
||||
*
|
||||
* console.log(satoshi.id); // 'satoshi'
|
||||
* console.log(satoshi.provider); // 'fontshare'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
||||
const category = mapFontshareCategory(apiFont.category);
|
||||
const subset = mapFontshareScript(apiFont.script);
|
||||
const subsets = subset ? [subset] : [];
|
||||
|
||||
// Extract variant names from styles
|
||||
const variants = apiFont.styles.map(style => {
|
||||
const weightLabel = style.weight.label;
|
||||
const isItalic = style.is_italic;
|
||||
return isItalic ? `${weightLabel}italic` : weightLabel;
|
||||
});
|
||||
|
||||
// Map styles to URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const style of apiFont.styles) {
|
||||
if (style.is_variable) {
|
||||
// Variable font - store as primary variant
|
||||
styles.regular = style.file;
|
||||
break;
|
||||
}
|
||||
|
||||
const weight = style.weight.number;
|
||||
const isItalic = style.is_italic;
|
||||
|
||||
if (weight === 400 && !isItalic) {
|
||||
styles.regular = style.file;
|
||||
} else if (weight === 400 && isItalic) {
|
||||
styles.italic = style.file;
|
||||
} else if (weight >= 700 && !isItalic) {
|
||||
styles.bold = style.file;
|
||||
} else if (weight >= 700 && isItalic) {
|
||||
styles.boldItalic = style.file;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variable font axes
|
||||
const axes = apiFont.axes.map(axis => ({
|
||||
name: axis.name,
|
||||
property: axis.property,
|
||||
default: axis.range_default,
|
||||
min: axis.range_left,
|
||||
max: axis.range_right,
|
||||
}));
|
||||
|
||||
// Extract tags
|
||||
const tags = apiFont.font_tags.map(tag => tag.name);
|
||||
|
||||
return {
|
||||
id: apiFont.slug,
|
||||
name: apiFont.name,
|
||||
provider: 'fontshare',
|
||||
category,
|
||||
subsets,
|
||||
variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.inserted_at,
|
||||
popularity: apiFont.views,
|
||||
},
|
||||
features: {
|
||||
isVariable: apiFont.axes.length > 0,
|
||||
axes: axes.length > 0 ? axes : undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Google Fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Google Font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeGoogleFonts(
|
||||
apiFonts: GoogleFontItem[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeGoogleFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Fontshare fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Fontshare font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeFontshareFonts(
|
||||
apiFonts: FontshareFont[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeFontshareFont);
|
||||
}
|
||||
@@ -1,8 +1,39 @@
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './api/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './api/fontshare';
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './api/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './api/googleFonts';
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './api/normalize';
|
||||
export type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './api/normalize';
|
||||
export type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from './model/font';
|
||||
} from './model/types/font';
|
||||
export type {
|
||||
FontshareApiModel,
|
||||
FontshareDesigner,
|
||||
@@ -13,10 +44,10 @@ export type {
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
} from './model/fontshare_fonts';
|
||||
} from './model/types/fontshare_fonts';
|
||||
export type {
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontVariant,
|
||||
GoogleFontsApiModel,
|
||||
} from './model/google_fonts';
|
||||
} from './model/types/google_fonts';
|
||||
|
||||
338
src/entities/Font/model/stores/fontCollectionStore.ts
Normal file
338
src/entities/Font/model/stores/fontCollectionStore.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Font collection store
|
||||
*
|
||||
* Main font collection cache using Svelte stores.
|
||||
* Integrates with TanStack Query for advanced caching and deduplication.
|
||||
*
|
||||
* Provides derived stores for filtered/sorted fonts.
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font/api/normalize';
|
||||
import {
|
||||
type CollectionCacheManager,
|
||||
createCollectionCache,
|
||||
} from '$shared/fetch/collectionCache';
|
||||
import type {
|
||||
Readable,
|
||||
Writable,
|
||||
} from 'svelte/store';
|
||||
import {
|
||||
derived,
|
||||
get,
|
||||
writable,
|
||||
} from 'svelte/store';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
} from '../types/font';
|
||||
|
||||
/**
|
||||
* Font collection state
|
||||
*/
|
||||
export interface FontCollectionState {
|
||||
/** All cached fonts */
|
||||
fonts: Record<string, UnifiedFont>;
|
||||
/** Active filters */
|
||||
filters: FontCollectionFilters;
|
||||
/** Sort configuration */
|
||||
sort: FontCollectionSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection filters
|
||||
*/
|
||||
export interface FontCollectionFilters {
|
||||
/** Search query */
|
||||
searchQuery?: string;
|
||||
/** Filter by provider */
|
||||
provider?: FontProvider;
|
||||
/** Filter by category */
|
||||
category?: FontCategory;
|
||||
/** Filter by subsets */
|
||||
subsets?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection sort configuration
|
||||
*/
|
||||
export interface FontCollectionSort {
|
||||
/** Sort field */
|
||||
field: 'name' | 'popularity' | 'category';
|
||||
/** Sort direction */
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Font collection store interface
|
||||
*/
|
||||
export interface FontCollectionStore {
|
||||
/** Main state store */
|
||||
state: Writable<FontCollectionState>;
|
||||
/** All fonts as array */
|
||||
fonts: Readable<UnifiedFont[]>;
|
||||
/** Filtered fonts as array */
|
||||
filteredFonts: Readable<UnifiedFont[]>;
|
||||
/** Number of fonts in collection */
|
||||
count: Readable<number>;
|
||||
/** Loading state */
|
||||
isLoading: Readable<boolean>;
|
||||
/** Error state */
|
||||
error: Readable<string | undefined>;
|
||||
/** Add fonts to collection */
|
||||
addFonts: (fonts: UnifiedFont[]) => void;
|
||||
/** Add single font to collection */
|
||||
addFont: (font: UnifiedFont) => void;
|
||||
/** Remove font from collection */
|
||||
removeFont: (fontId: string) => void;
|
||||
/** Clear all fonts */
|
||||
clear: () => void;
|
||||
/** Update filters */
|
||||
setFilters: (filters: Partial<FontCollectionFilters>) => void;
|
||||
/** Clear filters */
|
||||
clearFilters: () => void;
|
||||
/** Update sort configuration */
|
||||
setSort: (sort: FontCollectionSort) => void;
|
||||
/** Get font by ID */
|
||||
getFont: (fontId: string) => UnifiedFont | undefined;
|
||||
/** Get fonts by provider */
|
||||
getFontsByProvider: (provider: FontProvider) => UnifiedFont[];
|
||||
/** Get fonts by category */
|
||||
getFontsByCategory: (category: FontCategory) => UnifiedFont[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create font collection store
|
||||
*
|
||||
* @param initialState - Initial state for collection
|
||||
* @returns Font collection store instance
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const fontCollection = createFontCollectionStore({
|
||||
* fonts: {},
|
||||
* filters: {},
|
||||
* sort: { field: 'name', direction: 'asc' }
|
||||
* });
|
||||
*
|
||||
* // Add fonts to collection
|
||||
* fontCollection.addFonts([font1, font2]);
|
||||
*
|
||||
* // Use in component
|
||||
* $fontCollection.filteredFonts
|
||||
* ```
|
||||
*/
|
||||
export function createFontCollectionStore(
|
||||
initialState?: Partial<FontCollectionState>,
|
||||
): FontCollectionStore {
|
||||
const cache = createCollectionCache<UnifiedFont>({
|
||||
defaultTTL: 5 * 60 * 1000, // 5 minutes
|
||||
maxSize: 1000,
|
||||
});
|
||||
|
||||
const defaultState: FontCollectionState = {
|
||||
fonts: {},
|
||||
filters: {},
|
||||
sort: { field: 'name', direction: 'asc' },
|
||||
};
|
||||
|
||||
const state: Writable<FontCollectionState> = writable({
|
||||
...defaultState,
|
||||
...initialState,
|
||||
});
|
||||
|
||||
const isLoading = writable(false);
|
||||
const error = writable<string | undefined>();
|
||||
|
||||
// Derived store for fonts as array
|
||||
const fonts = derived(state, $state => {
|
||||
return Object.values($state.fonts);
|
||||
});
|
||||
|
||||
// Derived store for filtered fonts
|
||||
const filteredFonts = derived([state, fonts], ([$state, $fonts]) => {
|
||||
let filtered = [...$fonts];
|
||||
|
||||
// Apply search filter
|
||||
if ($state.filters.searchQuery) {
|
||||
const query = $state.filters.searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(font => font.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
// Apply provider filter
|
||||
if ($state.filters.provider) {
|
||||
filtered = filtered.filter(
|
||||
font => font.provider === $state.filters.provider,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if ($state.filters.category) {
|
||||
filtered = filtered.filter(
|
||||
font => font.category === $state.filters.category,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply subset filter
|
||||
if ($state.filters.subsets?.length) {
|
||||
filtered = filtered.filter(font =>
|
||||
$state.filters.subsets!.some(subset => font.subsets.includes(subset as any))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const { field, direction } = $state.sort;
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (field === 'name') {
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
} else if (field === 'popularity') {
|
||||
const aPop = a.metadata.popularity ?? 0;
|
||||
const bPop = b.metadata.popularity ?? 0;
|
||||
comparison = aPop - bPop;
|
||||
} else if (field === 'category') {
|
||||
comparison = a.category.localeCompare(b.category);
|
||||
}
|
||||
|
||||
return comparison * multiplier;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Derived store for count
|
||||
const count = derived(fonts, $fonts => $fonts.length);
|
||||
|
||||
return {
|
||||
// Expose main state
|
||||
state,
|
||||
|
||||
// Expose derived stores
|
||||
fonts,
|
||||
filteredFonts,
|
||||
count,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
/**
|
||||
* Add multiple fonts to collection
|
||||
*/
|
||||
addFonts: (newFonts: UnifiedFont[]) => {
|
||||
state.update($state => {
|
||||
const fontsMap = { ...$state.fonts };
|
||||
|
||||
for (const font of newFonts) {
|
||||
fontsMap[font.id] = font;
|
||||
cache.set(font.id, font);
|
||||
}
|
||||
|
||||
return {
|
||||
...$state,
|
||||
fonts: fontsMap,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Add single font to collection
|
||||
*/
|
||||
addFont: (font: UnifiedFont) => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
fonts: {
|
||||
...$state.fonts,
|
||||
[font.id]: font,
|
||||
},
|
||||
}));
|
||||
cache.set(font.id, font);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove font from collection
|
||||
*/
|
||||
removeFont: (fontId: string) => {
|
||||
state.update($state => {
|
||||
const { [fontId]: _, ...rest } = $state.fonts;
|
||||
return {
|
||||
...$state,
|
||||
fonts: rest,
|
||||
};
|
||||
});
|
||||
cache.remove(fontId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all fonts
|
||||
*/
|
||||
clear: () => {
|
||||
state.set({
|
||||
...get(state),
|
||||
fonts: {},
|
||||
});
|
||||
cache.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update filters
|
||||
*/
|
||||
setFilters: (filters: Partial<FontCollectionFilters>) => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
filters: {
|
||||
...$state.filters,
|
||||
...filters,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear filters
|
||||
*/
|
||||
clearFilters: () => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
filters: {},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Update sort configuration
|
||||
*/
|
||||
setSort: (sort: FontCollectionSort) => {
|
||||
state.update($state => ({
|
||||
...$state,
|
||||
sort,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get font by ID
|
||||
*/
|
||||
getFont: (fontId: string) => {
|
||||
const currentState = get(state);
|
||||
return currentState.fonts[fontId];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get fonts by provider
|
||||
*/
|
||||
getFontsByProvider: (provider: FontProvider) => {
|
||||
const currentState = get(state);
|
||||
return Object.values(currentState.fonts).filter(
|
||||
font => font.provider === provider,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get fonts by category
|
||||
*/
|
||||
getFontsByCategory: (category: FontCategory) => {
|
||||
const currentState = get(state);
|
||||
return Object.values(currentState.fonts).filter(
|
||||
font => font.category === category,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
13
src/entities/Font/model/stores/index.ts
Normal file
13
src/entities/Font/model/stores/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Font collection store exports
|
||||
*
|
||||
* Exports font collection store types and factory function
|
||||
*/
|
||||
|
||||
export { createFontCollectionStore } from './fontCollectionStore';
|
||||
export type {
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontCollectionStore,
|
||||
} from './fontCollectionStore';
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CollectionApiModel } from '../../../shared/types/collection';
|
||||
import type { CollectionApiModel } from '$shared/types/collection';
|
||||
|
||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;
|
||||
|
||||
Reference in New Issue
Block a user