SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
3.4 KB · 81 lines typescript
Raw Blame History
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