feature/fetch-fonts #14

Merged
ilia merged 76 commits from feature/fetch-fonts into main 2026-01-14 11:01:44 +00:00
2 changed files with 273 additions and 0 deletions
Showing only changes of commit 893bb02459 - Show all commits

View File

@@ -0,0 +1,194 @@
/**
* Tests for buildQueryString utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { buildQueryString } from './buildQueryString';
describe('buildQueryString', () => {
describe('basic parameter building', () => {
test('should build query string with string parameter', () => {
const result = buildQueryString({ category: 'serif' });
expect(result).toBe('?category=serif');
});
test('should build query string with number parameter', () => {
const result = buildQueryString({ limit: 50 });
expect(result).toBe('?limit=50');
});
test('should build query string with boolean parameter', () => {
const result = buildQueryString({ active: true });
expect(result).toBe('?active=true');
});
test('should build query string with multiple parameters', () => {
const result = buildQueryString({
category: 'serif',
limit: 50,
page: 1,
});
expect(result).toBe('?category=serif&limit=50&page=1');
});
});
describe('array handling', () => {
test('should handle array of strings', () => {
const result = buildQueryString({
subsets: ['latin', 'latin-ext', 'cyrillic'],
});
expect(result).toBe('?subsets=latin&subsets=latin-ext&subsets=cyrillic');
});
test('should handle array of numbers', () => {
const result = buildQueryString({ ids: [1, 2, 3] });
expect(result).toBe('?ids=1&ids=2&ids=3');
});
test('should handle mixed arrays and primitives', () => {
const result = buildQueryString({
category: 'serif',
subsets: ['latin', 'latin-ext'],
limit: 50,
});
expect(result).toBe('?category=serif&subsets=latin&subsets=latin-ext&limit=50');
});
test('should filter out null/undefined values in arrays', () => {
const result = buildQueryString({
// @ts-expect-error - Testing runtime behavior with invalid types
ids: [1, null, 3, undefined],
});
expect(result).toBe('?ids=1&ids=3');
});
});
describe('optional values', () => {
test('should exclude undefined values', () => {
const result = buildQueryString({
category: 'serif',
search: undefined,
});
expect(result).toBe('?category=serif');
});
test('should exclude null values', () => {
const result = buildQueryString({
category: 'serif',
search: null,
});
expect(result).toBe('?category=serif');
});
test('should handle all undefined/null values', () => {
const result = buildQueryString({
category: undefined,
search: null,
});
expect(result).toBe('');
});
});
describe('URL encoding', () => {
test('should encode spaces', () => {
const result = buildQueryString({ search: 'hello world' });
expect(result).toBe('?search=hello+world');
});
test('should encode special characters', () => {
const result = buildQueryString({ query: 'a&b=c+d' });
expect(result).toBe('?query=a%26b%3Dc%2Bd');
});
test('should encode Unicode characters', () => {
const result = buildQueryString({ text: 'café' });
expect(result).toBe('?text=caf%C3%A9');
});
test('should encode reserved URL characters', () => {
const result = buildQueryString({ url: 'https://example.com' });
expect(result).toBe('?url=https%3A%2F%2Fexample.com');
});
});
describe('edge cases', () => {
test('should return empty string for empty object', () => {
const result = buildQueryString({});
expect(result).toBe('');
});
test('should return empty string when all values are excluded', () => {
const result = buildQueryString({
a: undefined,
b: null,
});
expect(result).toBe('');
});
test('should handle empty arrays', () => {
const result = buildQueryString({ tags: [] });
expect(result).toBe('');
});
test('should handle zero values', () => {
const result = buildQueryString({ page: 0, count: 0 });
expect(result).toBe('?page=0&count=0');
});
test('should handle false boolean', () => {
const result = buildQueryString({ active: false });
expect(result).toBe('?active=false');
});
test('should handle empty string', () => {
const result = buildQueryString({ search: '' });
expect(result).toBe('?search=');
});
});
describe('parameter order', () => {
test('should maintain parameter order from input object', () => {
const result = buildQueryString({
a: '1',
b: '2',
c: '3',
});
expect(result).toBe('?a=1&b=2&c=3');
});
});
describe('real-world examples', () => {
test('should handle Google Fonts API parameters', () => {
const result = buildQueryString({
category: 'sans-serif',
sort: 'popularity',
subset: 'latin',
});
expect(result).toBe('?category=sans-serif&sort=popularity&subset=latin');
});
test('should handle Fontshare API parameters', () => {
const result = buildQueryString({
categories: ['Sans', 'Serif'],
page: 1,
limit: 50,
search: 'satoshi',
});
expect(result).toBe('?categories=Sans&categories=Serif&page=1&limit=50&search=satoshi');
});
test('should handle pagination parameters', () => {
const result = buildQueryString({
page: 2,
per_page: 20,
sort: 'name',
order: 'desc',
});
expect(result).toBe('?page=2&per_page=20&sort=name&order=desc');
});
});
});

View File

@@ -0,0 +1,79 @@
/**
* Build query string from URL parameters
*
* Generic, type-safe function to build properly encoded query strings
* from URL parameters. Supports primitives, arrays, and optional values.
*
* @param params - Object containing query parameters
* @returns Encoded query string (empty string if no parameters)
*
* @example
* ```ts
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
* // Returns: "category=serif&subsets=latin&subsets=latin-ext"
*
* buildQueryString({ limit: 50, page: 1 })
* // Returns: "limit=50&page=1"
*
* buildQueryString({})
* // Returns: ""
*
* buildQueryString({ search: 'hello world', active: true })
* // Returns: "search=hello%20world&active=true"
* ```
*/
/**
* Query parameter value type
* Supports primitives, arrays, and excludes null/undefined
*/
export type QueryParamValue = string | number | boolean | string[] | number[];
/**
* Query parameters object
*/
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
/**
* Build query string from URL parameters
*
* Handles:
* - Primitive values (string, number, boolean)
* - Arrays (multiple values with same key)
* - Optional values (excludes undefined/null)
* - Proper URL encoding
*
* Edge cases:
* - Empty object → empty string
* - No parameters → empty string
* - Nested objects → flattens to string representation
* - Special characters → proper encoding
*
* @param params - Object containing query parameters
* @returns Encoded query string (with "?" prefix if non-empty)
*/
export function buildQueryString(params: QueryParams): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
// Skip undefined/null values
if (value === undefined || value === null) {
continue;
}
// Handle arrays (multiple values with same key)
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null) {
searchParams.append(key, String(item));
}
}
} else {
// Handle primitives
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}