spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1/**2 * /finder URL contract, shared by the page (server + client) and the API helpers. The site URL and the API use3 * the SAME encoding: repeated `f=slug:op:value` (between → `slug:between:a..b`), `mode=and|or`, `region`, `income`,4 * `sort=slug:asc|desc`.5 */6export type FinderOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'between';7export const FINDER_OPS: readonly FinderOp[] = ['gt', 'gte', 'lt', 'lte', 'eq', 'between'];89export interface FinderFilterInput {10 slug: string;11 op: FinderOp;12 value: number;13 value2?: number | null;14}1516export interface FinderState {17 filters: FinderFilterInput[];18 mode: 'and' | 'or';19 region: string | null;20 income: string | null;21 sort: string | null;22}2324const SLUG = /^[a-z0-9][a-z0-9-]*$/;2526export function finderFilterParam(f: FinderFilterInput): string {27 return f.op === 'between' ? `${f.slug}:between:${f.value}..${f.value2 ?? f.value}` : `${f.slug}:${f.op}:${f.value}`;28}2930export function parseFinderFilter(raw: string): FinderFilterInput | null {31 const m = /^([a-z0-9][a-z0-9-]*):(gt|gte|lt|lte|eq|between):(.+)$/.exec(raw.trim().toLowerCase());32 if (!m) return null;33 const [, slug, op, rest] = m;34 if (op === 'between') {35 const [a, b] = rest!.split('..');36 const v1 = Number(a);37 const v2 = Number(b);38 if (!Number.isFinite(v1) || !Number.isFinite(v2)) return null;39 return { slug: slug!, op: 'between', value: Math.min(v1, v2), value2: Math.max(v1, v2) };40 }41 const v = Number(rest);42 if (!Number.isFinite(v)) return null;43 return { slug: slug!, op: op as FinderOp, value: v };44}4546export function finderQuery(filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}): string {47 const p = new URLSearchParams();48 for (const f of filters) p.append('f', finderFilterParam(f));49 if (q.mode && q.mode !== 'and') p.set('mode', q.mode);50 if (q.region) p.set('region', q.region);51 if (q.income) p.set('income', q.income);52 if (q.sort) p.set('sort', q.sort);53 if (q.limit) p.set('limit', String(q.limit));54 const s = p.toString();55 return s ? `?${s}` : '';56}5758type ParamsLike = { getAll(name: string): string[]; get(name: string): string | null } | Record<string, string | string[] | undefined>;5960function all(params: ParamsLike, key: string): string[] {61 if (typeof (params as { getAll?: unknown }).getAll === 'function') return (params as { getAll(name: string): string[] }).getAll(key);62 const v = (params as Record<string, string | string[] | undefined>)[key];63 return Array.isArray(v) ? v : v != null ? [v] : [];64}65function one(params: ParamsLike, key: string): string | null {66 return all(params, key)[0] ?? null;67}6869export function parseFinderState(params: ParamsLike): FinderState {70 const filters = all(params, 'f').map(parseFinderFilter).filter((f): f is FinderFilterInput => !!f).slice(0, 8);71 const mode = one(params, 'mode') === 'or' ? 'or' : 'and';72 const clean = (v: string | null) => (v && SLUG.test(v.toLowerCase()) ? v.toLowerCase() : null);73 const sortRaw = one(params, 'sort');74 const sort = sortRaw && /^[a-z0-9][a-z0-9-]*:(asc|desc)$/.test(sortRaw.toLowerCase()) ? sortRaw.toLowerCase() : null;75 return { filters, mode, region: clean(one(params, 'region')), income: clean(one(params, 'income')), sort };76}7778export function finderStateQuery(s: FinderState): string {79 return finderQuery(s.filters, { mode: s.mode, region: s.region, income: s.income, sort: s.sort });80}81