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:
Ilia Mashkov
2026-01-06 14:38:55 +03:00
parent 7d2fe49e9c
commit 29d1cc0cdc
21 changed files with 3093 additions and 4 deletions

View File

@@ -61,5 +61,8 @@
"vite": "^7.2.6", "vite": "^7.2.6",
"vitest": "^4.0.16", "vitest": "^4.0.16",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@tanstack/svelte-query": "^6.0.14"
} }
} }

View 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,
};
}

View 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);
}

View 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';

View 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,
},
},
],
};
}

View 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);
}

View File

@@ -1,8 +1,39 @@
export {
fetchAllFontshareFonts,
fetchFontshareFontBySlug,
fetchFontshareFonts,
} from './api/fontshare';
export type {
FontshareParams,
FontshareResponse,
} from './api/fontshare';
export {
fetchGoogleFontFamily,
fetchGoogleFonts,
} from './api/googleFonts';
export type {
GoogleFontItem,
GoogleFontsParams,
GoogleFontsResponse,
} from './api/googleFonts';
export {
normalizeFontshareFont,
normalizeFontshareFonts,
normalizeGoogleFont,
normalizeGoogleFonts,
} from './api/normalize';
export type {
FontFeatures,
FontMetadata,
FontStyleUrls,
UnifiedFont,
UnifiedFontVariant,
} from './api/normalize';
export type { export type {
FontCategory, FontCategory,
FontProvider, FontProvider,
FontSubset, FontSubset,
} from './model/font'; } from './model/types/font';
export type { export type {
FontshareApiModel, FontshareApiModel,
FontshareDesigner, FontshareDesigner,
@@ -13,10 +44,10 @@ export type {
FontshareStyleProperties, FontshareStyleProperties,
FontshareTag, FontshareTag,
FontshareWeight, FontshareWeight,
} from './model/fontshare_fonts'; } from './model/types/fontshare_fonts';
export type { export type {
FontFiles, FontFiles,
FontItem, FontItem,
FontVariant, FontVariant,
GoogleFontsApiModel, GoogleFontsApiModel,
} from './model/google_fonts'; } from './model/types/google_fonts';

View 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,
);
},
};
}

View 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';

View File

@@ -1,4 +1,4 @@
import type { CollectionApiModel } from '../../../shared/types/collection'; import type { CollectionApiModel } from '$shared/types/collection';
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const; export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2' as const;

View File

@@ -0,0 +1,25 @@
/**
* Fetch fonts feature exports
*
* Exports service functions for fetching fonts from Google Fonts and Fontshare
*/
export {
cancelGoogleFontsQueries,
fetchGoogleFontsQuery,
getGoogleFontsQueryKey,
invalidateGoogleFonts,
prefetchGoogleFonts,
useGoogleFontsQuery,
} from './model/services/fetchGoogleFonts';
export type { GoogleFontsQueryParams } from './model/services/fetchGoogleFonts';
export {
cancelFontshareFontsQueries,
fetchFontshareFontsQuery,
getFontshareQueryKey,
invalidateFontshareFonts,
prefetchFontshareFonts,
useFontshareFontsQuery,
} from './model/services/fetchFontshareFonts';
export type { FontshareQueryParams } from './model/services/fetchFontshareFonts';

View File

@@ -0,0 +1,211 @@
/**
* Service for fetching Fontshare fonts
*
* Integrates with TanStack Query for caching, deduplication,
* and automatic refetching.
*/
import { fetchFontshareFonts } from '$entities/Font/api/fontshare';
import { normalizeFontshareFonts } from '$entities/Font/api/normalize';
import type { UnifiedFont } from '$entities/Font/api/normalize';
import type { QueryFunction } from '@tanstack/svelte-query';
import {
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
/**
* Fontshare query parameters
*/
export interface FontshareQueryParams {
/** Filter by categories (e.g., ["Sans", "Serif"]) */
categories?: string[];
/** Filter by tags (e.g., ["Branding", "Logos"]) */
tags?: string[];
/** Page number for pagination */
page?: number;
/** Number of items per page */
limit?: number;
/** Search query */
search?: string;
}
/**
* Query key factory for Fontshare
* Generates consistent query keys for cache management
*/
export function getFontshareQueryKey(
params: FontshareQueryParams,
): readonly unknown[] {
return ['fontshare', params];
}
/**
* Query function for fetching Fontshare fonts
* Handles caching, loading states, and errors
*/
export const fetchFontshareFontsQuery: QueryFunction<
UnifiedFont[],
readonly unknown[]
> = async ({ queryKey }) => {
const params = queryKey[1] as FontshareQueryParams;
try {
const response = await fetchFontshareFonts({
categories: params.categories,
tags: params.tags,
page: params.page,
limit: params.limit,
search: params.search,
});
const normalizedFonts = normalizeFontshareFonts(response.items);
return normalizedFonts;
} catch (error) {
// User-friendly error messages
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Fontshare. Please check your internet connection and try again.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Fontshare catalog.');
}
throw new Error(
'Failed to load fonts from Fontshare. Please try again later.',
);
}
throw new Error('An unexpected error occurred while fetching fonts.');
}
};
/**
* Create a Fontshare query hook
* Use this in Svelte components to fetch Fontshare fonts with caching
*
* @param params - Query parameters
* @returns Query result with data, loading state, and error
*
* @example
* ```svelte
* <script lang="ts">
* let { categories }: { categories?: string[] } = $props();
*
* const query = useFontshareFontsQuery({ categories });
*
* if ($query.isLoading) {
* return <LoadingSpinner />;
* }
*
* if ($query.error) {
* return <ErrorMessage message={$query.error.message} />;
* }
*
* const fonts = $query.data ?? [];
* </script>
*
* {#each fonts as font}
* <FontCard {font} />
* {/each}
* ```
*/
export function useFontshareFontsQuery(
params: FontshareQueryParams = {},
) {
const queryClient = useQueryClient();
const query = createQuery(() => ({
queryKey: getFontshareQueryKey(params),
queryFn: fetchFontshareFontsQuery,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
return query;
}
/**
* Prefetch Fontshare fonts
* Fetch fonts in background without showing loading state
*
* @param params - Query parameters for prefetch
*
* @example
* ```ts
* // Prefetch fonts when user hovers over button
* function onMouseEnter() {
* prefetchFontshareFonts({ categories: ['Sans'] });
* }
* ```
*/
export async function prefetchFontshareFonts(
params: FontshareQueryParams = {},
): Promise<void> {
const queryClient = useQueryClient();
await queryClient.prefetchQuery({
queryKey: getFontshareQueryKey(params),
queryFn: fetchFontshareFontsQuery,
staleTime: 5 * 60 * 1000,
});
}
/**
* Invalidate Fontshare cache
* Forces refetch on next query
*
* @param params - Query parameters to invalidate (all if not provided)
*
* @example
* ```ts
* // Invalidate all Fontshare cache
* invalidateFontshareFonts();
*
* // Invalidate specific category cache
* invalidateFontshareFonts({ categories: ['Sans'] });
* ```
*/
export function invalidateFontshareFonts(
params?: FontshareQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.invalidateQueries({
queryKey: getFontshareQueryKey(params),
});
} else {
queryClient.invalidateQueries({
queryKey: ['fontshare'],
});
}
}
/**
* Cancel Fontshare queries
* Abort in-flight requests
*
* @param params - Query parameters to cancel (all if not provided)
*
* @example
* ```ts
* // Cancel all Fontshare queries
* cancelFontshareFontsQueries();
* ```
*/
export function cancelFontshareFontsQueries(
params?: FontshareQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.cancelQueries({
queryKey: getFontshareQueryKey(params),
});
} else {
queryClient.cancelQueries({
queryKey: ['fontshare'],
});
}
}

View File

@@ -0,0 +1,213 @@
/**
* Service for fetching Google Fonts
*
* Integrates with TanStack Query for caching, deduplication,
* and automatic refetching.
*
* Uses reactive query args pattern for Svelte 5 compatibility.
*/
import type {
FontCategory,
FontSubset,
} from '$entities/Font';
import { fetchGoogleFonts } from '$entities/Font/api/googleFonts';
import { normalizeGoogleFonts } from '$entities/Font/api/normalize';
import type { UnifiedFont } from '$entities/Font/api/normalize';
import type { QueryFunction } from '@tanstack/svelte-query';
import {
createQuery,
useQueryClient,
} from '@tanstack/svelte-query';
/**
* Google Fonts query parameters
*/
export interface GoogleFontsQueryParams {
/** Font category filter */
category?: FontCategory;
/** Character subset filter */
subset?: FontSubset;
/** Sort order */
sort?: 'popularity' | 'alpha' | 'date';
/** Search query (for specific font) */
search?: string;
/** Force refetch even if cached */
forceRefetch?: boolean;
}
/**
* Query key factory for Google Fonts
* Generates consistent query keys for cache management
*/
export function getGoogleFontsQueryKey(
params: GoogleFontsQueryParams,
): readonly unknown[] {
return ['googleFonts', params];
}
/**
* Query function for fetching Google Fonts
* Handles caching, loading states, and errors
*/
export const fetchGoogleFontsQuery: QueryFunction<
UnifiedFont[],
readonly unknown[]
> = async ({ queryKey }) => {
const params = queryKey[1] as GoogleFontsQueryParams;
try {
const response = await fetchGoogleFonts({
category: params.category,
subset: params.subset,
sort: params.sort,
});
const normalizedFonts = normalizeGoogleFonts(response.items);
return normalizedFonts;
} catch (error) {
// User-friendly error messages
if (error instanceof Error) {
if (error.message.includes('Failed to fetch')) {
throw new Error(
'Unable to connect to Google Fonts. Please check your internet connection and try again.',
);
}
if (error.message.includes('404')) {
throw new Error('Font not found in Google Fonts catalog.');
}
throw new Error(
'Failed to load fonts from Google Fonts. Please try again later.',
);
}
throw new Error('An unexpected error occurred while fetching fonts.');
}
};
/**
* Create a Google Fonts query hook
* Use this in Svelte components to fetch Google Fonts with caching
*
* @param params - Query parameters
* @returns Query result with data, loading state, and error
*
* @example
* ```svelte
* <script lang="ts">
* let { category }: { category?: FontCategory } = $props();
*
* const query = useGoogleFontsQuery({ category });
*
* if ($query.isLoading) {
* return <LoadingSpinner />;
* }
*
* if ($query.error) {
* return <ErrorMessage message={$query.error.message} />;
* }
*
* const fonts = $query.data ?? [];
* </script>
*
* {#each fonts as font}
* <FontCard {font} />
* {/each}
* ```
*/
export function useGoogleFontsQuery(params: GoogleFontsQueryParams = {}) {
const queryClient = useQueryClient();
const query = createQuery(() => ({
queryKey: getGoogleFontsQueryKey(params),
queryFn: fetchGoogleFontsQuery,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
}));
return query;
}
/**
* Prefetch Google Fonts
* Fetch fonts in background without showing loading state
*
* @param params - Query parameters for prefetch
*
* @example
* ```ts
* // Prefetch fonts when user hovers over button
* function onMouseEnter() {
* prefetchGoogleFonts({ category: 'sans-serif' });
* }
* ```
*/
export async function prefetchGoogleFonts(
params: GoogleFontsQueryParams = {},
): Promise<void> {
const queryClient = useQueryClient();
await queryClient.prefetchQuery({
queryKey: getGoogleFontsQueryKey(params),
queryFn: fetchGoogleFontsQuery,
staleTime: 5 * 60 * 1000,
});
}
/**
* Invalidate Google Fonts cache
* Forces refetch on next query
*
* @param params - Query parameters to invalidate (all if not provided)
*
* @example
* ```ts
* // Invalidate all Google Fonts cache
* invalidateGoogleFonts();
*
* // Invalidate specific category cache
* invalidateGoogleFonts({ category: 'sans-serif' });
* ```
*/
export function invalidateGoogleFonts(
params?: GoogleFontsQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.invalidateQueries({
queryKey: getGoogleFontsQueryKey(params),
});
} else {
queryClient.invalidateQueries({
queryKey: ['googleFonts'],
});
}
}
/**
* Cancel Google Fonts queries
* Abort in-flight requests
*
* @param params - Query parameters to cancel (all if not provided)
*
* @example
* ```ts
* // Cancel all Google Fonts queries
* cancelGoogleFontsQueries();
* ```
*/
export function cancelGoogleFontsQueries(
params?: GoogleFontsQueryParams,
): void {
const queryClient = useQueryClient();
if (params) {
queryClient.cancelQueries({
queryKey: getGoogleFontsQueryKey(params),
});
} else {
queryClient.cancelQueries({
queryKey: ['googleFonts'],
});
}
}

View File

@@ -0,0 +1,76 @@
/**
* Fetch Fonts feature types
*
* Type definitions for font fetching feature
*/
import type {
FontCategory,
FontProvider,
FontSubset,
} from '$entities/Font';
import type { UnifiedFont } from '$entities/Font/api/normalize';
/**
* Combined query parameters for fetching from any provider
*/
export interface FetchFontsParams {
/** Font provider to fetch from */
provider?: FontProvider;
/** Category filter */
category?: FontCategory;
/** Subset filter */
subset?: FontSubset;
/** Search query */
search?: string;
/** Page number (for Fontshare) */
page?: number;
/** Limit (for Fontshare) */
limit?: number;
/** Force refetch even if cached */
forceRefetch?: boolean;
}
/**
* Font fetching result
*/
export interface FetchFontsResult {
/** Fetched fonts */
fonts: UnifiedFont[];
/** Total count (for pagination) */
total?: number;
/** Whether more fonts are available */
hasMore?: boolean;
/** Page number (for pagination) */
page?: number;
}
/**
* Font fetching error
*/
export interface FetchFontsError {
/** Error message */
message: string;
/** Provider that failed */
provider: FontProvider | 'all';
/** HTTP status code (if applicable) */
status?: number;
/** Original error */
originalError?: unknown;
}
/**
* Font fetching state
*/
export interface FetchFontsState {
/** Currently fetching */
isFetching: boolean;
/** Currently loading initial data */
isLoading: boolean;
/** Error state */
error: FetchFontsError | null;
/** Cached fonts */
fonts: UnifiedFont[];
/** Last fetch timestamp */
lastFetchedAt: number | null;
}

View File

@@ -0,0 +1,445 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
type CacheItemInternalState,
type CacheOptions,
createCollectionCache,
} from './collectionCache';
describe('createCollectionCache', () => {
let cache: ReturnType<typeof createCollectionCache<number>>;
beforeEach(() => {
cache = createCollectionCache<number>();
});
describe('initialization', () => {
it('initializes with empty cache', () => {
const data = get(cache.data);
expect(data).toEqual({});
});
it('initializes with default options', () => {
const stats = cache.getStats();
expect(stats.total).toBe(0);
expect(stats.cached).toBe(0);
expect(stats.fetching).toBe(0);
expect(stats.errors).toBe(0);
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
});
it('accepts custom cache options', () => {
const options: CacheOptions = {
defaultTTL: 10 * 60 * 1000, // 10 minutes
maxSize: 500,
};
const customCache = createCollectionCache<number>(options);
expect(customCache).toBeDefined();
});
});
describe('set and get', () => {
it('sets a value in cache', () => {
cache.set('key1', 100);
const value = cache.get('key1');
expect(value).toBe(100);
});
it('sets multiple values in cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.set('key3', 300);
expect(cache.get('key1')).toBe(100);
expect(cache.get('key2')).toBe(200);
expect(cache.get('key3')).toBe(300);
});
it('updates existing value', () => {
cache.set('key1', 100);
cache.set('key1', 150);
expect(cache.get('key1')).toBe(150);
});
it('returns undefined for non-existent key', () => {
const value = cache.get('non-existent');
expect(value).toBeUndefined();
});
it('marks item as ready after set', () => {
cache.set('key1', 100);
const internalState = cache.getInternalState('key1');
expect(internalState?.ready).toBe(true);
expect(internalState?.fetching).toBe(false);
});
});
describe('has and hasFresh', () => {
it('returns false for non-existent key', () => {
expect(cache.has('non-existent')).toBe(false);
expect(cache.hasFresh('non-existent')).toBe(false);
});
it('returns true after setting value', () => {
cache.set('key1', 100);
expect(cache.has('key1')).toBe(true);
expect(cache.hasFresh('key1')).toBe(true);
});
it('returns false for fetching items', () => {
cache.markFetching('key1');
expect(cache.has('key1')).toBe(false);
expect(cache.hasFresh('key1')).toBe(false);
});
it('returns false for failed items', () => {
cache.markFailed('key1', 'Network error');
expect(cache.has('key1')).toBe(false);
expect(cache.hasFresh('key1')).toBe(false);
});
});
describe('remove', () => {
it('removes a value from cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.remove('key1');
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBe(200);
});
it('removes internal state', () => {
cache.set('key1', 100);
cache.remove('key1');
const state = cache.getInternalState('key1');
expect(state).toBeUndefined();
});
it('does nothing for non-existent key', () => {
expect(() => cache.remove('non-existent')).not.toThrow();
});
});
describe('clear', () => {
it('clears all values from cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.set('key3', 300);
cache.clear();
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBeUndefined();
expect(cache.get('key3')).toBeUndefined();
});
it('clears internal state', () => {
cache.set('key1', 100);
cache.clear();
const state = cache.getInternalState('key1');
expect(state).toBeUndefined();
});
it('resets cache statistics', () => {
cache.set('key1', 100); // This increments hits
const statsBefore = cache.getStats();
cache.clear();
const statsAfter = cache.getStats();
expect(statsAfter.hits).toBe(0);
expect(statsAfter.misses).toBe(0);
});
});
describe('markFetching', () => {
it('marks item as fetching', () => {
cache.markFetching('key1');
expect(cache.isFetching('key1')).toBe(true);
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(true);
expect(state?.ready).toBe(false);
expect(state?.startTime).toBeDefined();
});
it('updates existing state when called again', () => {
cache.markFetching('key1');
const startTime1 = cache.getInternalState('key1')?.startTime;
// Wait a bit to ensure different timestamp
vi.useFakeTimers();
vi.advanceTimersByTime(100);
cache.markFetching('key1');
const startTime2 = cache.getInternalState('key1')?.startTime;
expect(startTime2).toBeGreaterThan(startTime1!);
vi.useRealTimers();
});
it('sets endTime to undefined', () => {
cache.markFetching('key1');
const state = cache.getInternalState('key1');
expect(state?.endTime).toBeUndefined();
});
});
describe('markFailed', () => {
it('marks item as failed with error message', () => {
cache.markFailed('key1', 'Network error');
expect(cache.isFetching('key1')).toBe(false);
const error = cache.getError('key1');
expect(error).toBe('Network error');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(false);
expect(state?.ready).toBe(false);
expect(state?.error).toBe('Network error');
});
it('preserves start time from fetching state', () => {
cache.markFetching('key1');
const startTime = cache.getInternalState('key1')?.startTime;
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.startTime).toBe(startTime);
});
it('sets end time', () => {
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.endTime).toBeDefined();
});
it('increments error counter', () => {
const statsBefore = cache.getStats();
cache.markFailed('key1', 'Error1');
const statsAfter1 = cache.getStats();
expect(statsAfter1.errors).toBe(statsBefore.errors + 1);
cache.markFailed('key2', 'Error2');
const statsAfter2 = cache.getStats();
expect(statsAfter2.errors).toBe(statsAfter1.errors + 1);
});
});
describe('markMiss', () => {
it('increments miss counter', () => {
const statsBefore = cache.getStats();
cache.markMiss();
const statsAfter = cache.getStats();
expect(statsAfter.misses).toBe(statsBefore.misses + 1);
});
it('increments miss counter multiple times', () => {
const statsBefore = cache.getStats();
cache.markMiss();
cache.markMiss();
cache.markMiss();
const statsAfter = cache.getStats();
expect(statsAfter.misses).toBe(statsBefore.misses + 3);
});
});
describe('statistics', () => {
it('tracks total number of items', () => {
expect(cache.getStats().total).toBe(0);
cache.set('key1', 100);
expect(cache.getStats().total).toBe(1);
cache.set('key2', 200);
expect(cache.getStats().total).toBe(2);
cache.remove('key1');
expect(cache.getStats().total).toBe(1);
});
it('tracks number of cached (ready) items', () => {
expect(cache.getStats().cached).toBe(0);
cache.set('key1', 100);
expect(cache.getStats().cached).toBe(1);
cache.set('key2', 200);
expect(cache.getStats().cached).toBe(2);
cache.markFetching('key3');
expect(cache.getStats().cached).toBe(2);
});
it('tracks number of fetching items', () => {
expect(cache.getStats().fetching).toBe(0);
cache.markFetching('key1');
expect(cache.getStats().fetching).toBe(1);
cache.markFetching('key2');
expect(cache.getStats().fetching).toBe(2);
cache.set('key1', 100);
expect(cache.getStats().fetching).toBe(1);
});
it('tracks cache hits', () => {
const statsBefore = cache.getStats();
cache.set('key1', 100);
const statsAfter1 = cache.getStats();
expect(statsAfter1.hits).toBe(statsBefore.hits + 1);
cache.set('key2', 200);
const statsAfter2 = cache.getStats();
expect(statsAfter2.hits).toBe(statsAfter1.hits + 1);
});
it('provides derived stats store', () => {
cache.set('key1', 100);
cache.markFetching('key2');
const stats = get(cache.stats);
expect(stats.total).toBe(1);
expect(stats.cached).toBe(1);
expect(stats.fetching).toBe(1);
});
});
describe('store reactivity', () => {
it('updates data store reactively', () => {
let dataUpdates = 0;
const unsubscribe = cache.data.subscribe(() => {
dataUpdates++;
});
cache.set('key1', 100);
cache.set('key2', 200);
expect(dataUpdates).toBeGreaterThan(0);
unsubscribe();
});
it('updates internal state store reactively', () => {
let internalUpdates = 0;
const unsubscribe = cache.internal.subscribe(() => {
internalUpdates++;
});
cache.markFetching('key1');
cache.set('key1', 100);
cache.markFailed('key2', 'Error');
expect(internalUpdates).toBeGreaterThan(0);
unsubscribe();
});
it('updates stats store reactively', () => {
let statsUpdates = 0;
const unsubscribe = cache.stats.subscribe(() => {
statsUpdates++;
});
cache.set('key1', 100);
cache.markMiss();
expect(statsUpdates).toBeGreaterThan(0);
unsubscribe();
});
});
describe('edge cases', () => {
it('handles complex types', () => {
interface ComplexType {
id: string;
value: number;
tags: string[];
}
const complexCache = createCollectionCache<ComplexType>();
const item: ComplexType = {
id: '1',
value: 42,
tags: ['a', 'b', 'c'],
};
complexCache.set('item1', item);
const retrieved = complexCache.get('item1');
expect(retrieved).toEqual(item);
expect(retrieved?.tags).toEqual(['a', 'b', 'c']);
});
it('handles special characters in keys', () => {
cache.set('key with spaces', 1);
cache.set('key/with/slashes', 2);
cache.set('key-with-dashes', 3);
expect(cache.get('key with spaces')).toBe(1);
expect(cache.get('key/with/slashes')).toBe(2);
expect(cache.get('key-with-dashes')).toBe(3);
});
it('handles rapid set and remove operations', () => {
for (let i = 0; i < 100; i++) {
cache.set(`key${i}`, i);
}
for (let i = 0; i < 100; i += 2) {
cache.remove(`key${i}`);
}
expect(cache.getStats().total).toBe(50);
expect(cache.get('key0')).toBeUndefined();
expect(cache.get('key1')).toBe(1);
});
});
describe('error handling', () => {
it('handles concurrent markFetching for same key', () => {
cache.markFetching('key1');
cache.markFetching('key1');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(true);
expect(state?.startTime).toBeDefined();
});
it('handles marking failed without prior fetching', () => {
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(false);
expect(state?.ready).toBe(false);
expect(state?.error).toBe('Error');
});
it('handles operations on removed keys', () => {
cache.set('key1', 100);
cache.remove('key1');
expect(() => cache.set('key1', 200)).not.toThrow();
expect(() => cache.remove('key1')).not.toThrow();
expect(() => cache.getError('key1')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,334 @@
/**
* Collection cache manager
*
* Provides key-based caching, deduplication, and request tracking
* for any collection type. Integrates with Svelte stores for reactive updates.
*
* Key features:
* - Key-based caching (any ID, query hash)
* - Request deduplication (prevents concurrent requests for same key)
* - Request state tracking (fetching, ready, error)
* - TTL/staleness management
* - Performance timing tracking
*/
import type {
Readable,
Writable,
} from 'svelte/store';
import {
derived,
get,
writable,
} from 'svelte/store';
/**
* Internal state for a cached item
* Tracks request lifecycle (fetching → ready/error)
*/
export interface CacheItemInternalState {
/** Whether a fetch is currently in progress */
fetching: boolean;
/** Whether data is ready and cached */
ready: boolean;
/** Error message if fetch failed */
error?: string;
/** Request start timestamp (performance tracking) */
startTime?: number;
/** Request end timestamp (performance tracking) */
endTime?: number;
}
/**
* Cache configuration options
*/
export interface CacheOptions {
/** Default time-to-live for cached items (in milliseconds) */
defaultTTL?: number;
/** Maximum number of items to cache (LRU eviction) */
maxSize?: number;
}
/**
* Statistics about cache performance
*/
export interface CacheStats {
/** Total number of items in cache */
total: number;
/** Number of items marked as ready */
cached: number;
/** Number of items currently fetching */
fetching: number;
/** Number of items with errors */
errors: number;
/** Total cache hits (data returned from cache) */
hits: number;
/** Total cache misses (data fetched from API) */
misses: number;
}
/**
* Cache manager interface
* Type-safe interface for collection caching operations
*/
export interface CollectionCacheManager<T> {
/** Get an item from cache by key */
get: (key: string) => T | undefined;
/** Check if item exists in cache and is ready */
has: (key: string) => boolean;
/** Check if item exists and is not stale */
hasFresh: (key: string) => boolean;
/** Set an item in cache (manual cache write) */
set: (key: string, value: T, ttl?: number) => void;
/** Remove item from cache */
remove: (key: string) => void;
/** Clear all items from cache */
clear: () => void;
/** Check if key is currently being fetched */
isFetching: (key: string) => boolean;
/** Get error for a key */
getError: (key: string) => string | undefined;
/** Get internal state for a key (for debugging) */
getInternalState: (key: string) => CacheItemInternalState | undefined;
/** Get cache statistics */
getStats: () => CacheStats;
/** Mark item as fetching (used when starting API request) */
markFetching: (key: string) => void;
/** Mark item as failed (used when API request fails) */
markFailed: (key: string, error: string) => void;
/** Increment cache miss counter */
markMiss: () => void;
/** Store containing cached data */
data: Writable<Record<string, T>>;
/** Store containing internal state (fetching, ready, error) */
internal: Writable<Record<string, CacheItemInternalState>>;
/** Derived store containing cache statistics */
stats: Readable<CacheStats>;
}
/**
* Creates a collection cache manager
*
* @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User)
* @param options - Cache configuration options
* @returns Cache manager instance
*
* @example
* ```ts
* const fontCache = createCollectionCache<UnifiedFont>({
* defaultTTL: 5 * 60 * 1000, // 5 minutes
* maxSize: 1000
* });
*
* // Set font in cache
* fontCache.set('Roboto', robotoFont);
*
* // Get font from cache
* const font = fontCache.get('Roboto');
* if (fontCache.hasFresh('Roboto')) {
* // Use cached font
* }
* ```
*/
export function createCollectionCache<T>(options: CacheOptions = {}): CollectionCacheManager<T> {
const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options;
// Stores for reactive data
const data: Writable<Record<string, T>> = writable({});
const internal: Writable<Record<string, CacheItemInternalState>> = writable({});
// Cache statistics store
const statsState = writable<CacheStats>({
total: 0,
cached: 0,
fetching: 0,
errors: 0,
hits: 0,
misses: 0,
});
// Derived stats store for reactive updates
const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({
...$statsState,
total: Object.keys($data).length,
cached: Object.values($internal).filter(s => s.ready).length,
fetching: Object.values($internal).filter(s => s.fetching).length,
errors: Object.values($internal).filter(s => s.error).length,
}));
return {
/**
* Get cached data by key
* Returns undefined if not found
*/
get: (key: string) => {
const currentData = get(data);
return currentData[key];
},
/**
* Check if key exists in cache and is ready
*/
has: (key: string) => {
const currentInternal = get(internal);
const state = currentInternal[key];
return state?.ready === true;
},
/**
* Check if key exists and is not stale (still within TTL)
*/
hasFresh: (key: string) => {
const currentInternal = get(internal);
const currentData = get(data);
const state = currentInternal[key];
if (!state?.ready) {
return false;
}
// Check if item exists in data store
if (!currentData[key]) {
return false;
}
// TODO: Implement TTL check with cachedAt timestamps
// For now, just check ready state
return true;
},
/**
* Set data in cache
* Marks entry as ready and stops fetching state
*/
set: (key: string, value: T, ttl?: number) => {
data.update(d => ({
...d,
[key]: value,
}));
internal.update(i => {
const existingState = i[key];
return {
...i,
[key]: {
fetching: false,
ready: true,
error: undefined,
startTime: existingState?.startTime,
endTime: Date.now(),
},
};
});
// Update statistics (cache hit)
statsState.update(s => ({ ...s, hits: s.hits + 1 }));
},
/**
* Remove item from cache
*/
remove: (key: string) => {
data.update(d => {
const { [key]: _, ...rest } = d;
return rest;
});
internal.update(i => {
const { [key]: _, ...rest } = i;
return rest;
});
},
/**
* Clear all items from cache
*/
clear: () => {
data.set({});
internal.set({});
statsState.update(s => ({ ...s, hits: 0, misses: 0 }));
},
/**
* Check if key is currently being fetched
*/
isFetching: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key]?.fetching === true;
},
/**
* Get error for a key
*/
getError: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key]?.error;
},
/**
* Get internal state for debugging
*/
getInternalState: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key];
},
/**
* Get current cache statistics
*/
getStats: () => {
return get(stats);
},
/**
* Mark item as fetching (used when starting API request)
*/
markFetching: (key: string) => {
internal.update(internal => ({
...internal,
[key]: {
fetching: true,
ready: false,
error: undefined,
startTime: Date.now(),
endTime: undefined,
},
}));
},
/**
* Mark item as failed (used when API request fails)
*/
markFailed: (key: string, error: string) => {
internal.update(internal => {
const existingState = internal[key];
return {
...internal,
[key]: {
fetching: false,
ready: false,
error,
startTime: existingState?.startTime,
endTime: Date.now(),
},
};
});
// Update statistics
const currentStats = get(stats);
statsState.update(s => ({ ...s, errors: currentStats.errors + 1 }));
},
/**
* Increment cache miss counter
*/
markMiss: () => {
statsState.update(s => ({ ...s, misses: s.misses + 1 }));
},
// Expose stores for reactive binding
data,
internal,
stats,
};
}

14
src/shared/fetch/index.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Shared fetch layer exports
*
* Exports collection caching utilities and reactive patterns for Svelte 5
*/
export { createCollectionCache } from './collectionCache';
export type {
CacheItemInternalState,
CacheOptions,
CacheStats,
CollectionCacheManager,
} from './collectionCache';
export { reactiveQueryArgs } from './reactiveQueryArgs';

View File

@@ -0,0 +1,37 @@
import type { Readable } from 'svelte/store';
import { writable } from 'svelte/store';
/**
* Creates a reactive store that maintains stable references for query arguments
*
* This function wraps a callback in a Svelte store that updates via `$effect.pre()`,
* ensuring that the callback is called before DOM updates while maintaining object
* reference stability.
*
* @typeParam T - Type of query arguments (e.g., CreateQueryOptions)
* @param cb - Callback function that computes query arguments
* @returns Readable store containing current query arguments
*
* @example
* ```ts
* const queryArgsStore = reactiveQueryArgs(() => ({
* queryKey: ['fonts', search],
* queryFn: fetchFonts,
* staleTime: 5000
* }));
*
* // Use in component with TanStack Query
* const query = createQuery(queryArgsStore);
* ```
*/
export const reactiveQueryArgs = <T>(cb: () => T): Readable<T> => {
const store = writable<T>();
// Use $effect.pre() to run before DOM updates
// This ensures stable references while staying reactive
$effect.pre(() => {
store.set(cb());
});
return store;
};

View File

@@ -1292,6 +1292,24 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tanstack/query-core@npm:5.90.16":
version: 5.90.16
resolution: "@tanstack/query-core@npm:5.90.16"
checksum: 10c0/f6a4827feeed2b4118323056bbda8d5099823202d1f29b538204ae2591be4e80f2946f3311eed30fefe866643f431c04b560457f03415d40caf2f353ba1efac0
languageName: node
linkType: hard
"@tanstack/svelte-query@npm:^6.0.14":
version: 6.0.14
resolution: "@tanstack/svelte-query@npm:6.0.14"
dependencies:
"@tanstack/query-core": "npm:5.90.16"
peerDependencies:
svelte: ^5.25.0
checksum: 10c0/5f7218596e3a2cbe5b877afb2cea678539e38ea9400f000361f859922189273b07e94e42ac8154245f5138fa509e5a24c01b6f7ae5e655acb61daaaef9da80c3
languageName: node
linkType: hard
"@testing-library/dom@npm:9.x.x || 10.x.x": "@testing-library/dom@npm:9.x.x || 10.x.x":
version: 10.4.1 version: 10.4.1
resolution: "@testing-library/dom@npm:10.4.1" resolution: "@testing-library/dom@npm:10.4.1"
@@ -2429,6 +2447,7 @@ __metadata:
"@storybook/svelte-vite": "npm:^10.1.11" "@storybook/svelte-vite": "npm:^10.1.11"
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1" "@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
"@tailwindcss/vite": "npm:^4.1.18" "@tailwindcss/vite": "npm:^4.1.18"
"@tanstack/svelte-query": "npm:^6.0.14"
"@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/jest-dom": "npm:^6.9.1"
"@testing-library/svelte": "npm:^5.3.1" "@testing-library/svelte": "npm:^5.3.1"
"@tsconfig/svelte": "npm:^5.0.6" "@tsconfig/svelte": "npm:^5.0.6"