- kl-gazetteer.json: 30 hand-curated KL-metro landmarks (10 required + 20
more) with locals' aliases as native data (D-08) — canonical OSM names
as name, shorthand ('KLCC', 'DUKE', 'Sg Wang') as aliases — plus the
ODbL-1.0 attribution header traveling inside the JSON (D-06/D-07)
- gazetteer.ts: the three laws — lazy memoized index never at module
scope (Workers startup, T-04-04), static JSON import surviving
wrangler/Vite/bare-Node (D-07), zero logging/storage (D-15); aliases
normalized once at index-build (D-09 symmetry)
- source.ts: the D-03 SearchSource — async search() + synchronous byId()
(slug-validated, honest null); tiered admission (fuzzy only at sim
>= 0.72) so 'Kuching'/'Penang' refuse as [] — never a fabricated pin
- index.ts: the named-export public API (no default, subpath+extension
imports per the @araharah/theme precedent)
- LICENSE: the verbatim GPL-3.0 text (D-04); LICENSES.md: the dual-licence
note (code GPL-3.0, data ODbL-1.0 + OSM attribution); README: corrected
licence section + search/ in the layout, four-package table, commands
G1-G11 + L1-L3 green (39/39); typecheck clean; esbuild bundle carries the
suria-klcc marker; wrangler dry-run exits 0; bot + web suites unaffected.
107 lines
3.7 KiB
TypeScript
107 lines
3.7 KiB
TypeScript
/*
|
|
* gazetteer.ts — the dataset loader + alias index (D-06, D-07, D-09).
|
|
* ------------------------------------------------------------------
|
|
* Three hard laws:
|
|
*
|
|
* LAW 1 — LAZY, MEMOIZED, NEVER AT MODULE SCOPE (RESEARCH Pitfall 8,
|
|
* Workers 1 s startup limit / "avoid expensive work in global scope").
|
|
* The index is built inside a function on first access and cached in
|
|
* module scope — the same lazy-load shape as bot/test/initdata.test.mjs.
|
|
*
|
|
* LAW 2 — THE LOADER MUST SURVIVE THREE ENVIRONMENTS: the
|
|
* wrangler/esbuild bundle (Worker), the Vite bundle (browser), and bare
|
|
* Node 24 (the corpus runner). The mechanism is the STATIC JSON IMPORT
|
|
* below — Workers have no fs and a runtime retrieval would be a hard
|
|
* failure. esbuild and Vite both inline a static JSON import at build
|
|
* time; the `with { type: "json" }` attribute is required by Node ESM
|
|
* and harmless under both bundlers (loader-env suite is the arbiter).
|
|
*
|
|
* LAW 3 — NO LOGGING AND NO STORAGE (D-15): the query text is user
|
|
* payload. This module contains no logging at all and touches no
|
|
* storage surface.
|
|
*
|
|
* The index maps each entry's NORMALIZED name and each NORMALIZED alias
|
|
* to the entry (the D-09 symmetry law — both sides of every comparison
|
|
* are canonical). Normalization happens ONCE per dataset load, never
|
|
* per query.
|
|
*/
|
|
|
|
import raw from "./data/kl-gazetteer.json" with { type: "json" };
|
|
import { normalize } from "./normalize.ts";
|
|
import type { GazetteerEntry } from "./types.ts";
|
|
|
|
/** The committed dataset's envelope (the ODbL notice travels inside it). */
|
|
interface GazetteerFile {
|
|
_license: string;
|
|
_attribution: string;
|
|
_notice: string;
|
|
version: number;
|
|
entries: GazetteerEntry[];
|
|
}
|
|
|
|
let entriesCache: readonly GazetteerEntry[] | null = null;
|
|
let byIdCache: Map<string, GazetteerEntry> | null = null;
|
|
let indexCache: Map<string, GazetteerEntry[]> | null = null;
|
|
|
|
/** The parsed dataset — one V8-native JSON parse, memoized. */
|
|
function loadEntries(): readonly GazetteerEntry[] {
|
|
if (entriesCache === null) {
|
|
const file = raw as GazetteerFile;
|
|
entriesCache = Object.freeze([...file.entries]);
|
|
}
|
|
return entriesCache;
|
|
}
|
|
|
|
/** id -> entry, built lazily. */
|
|
function loadById(): Map<string, GazetteerEntry> {
|
|
if (byIdCache === null) {
|
|
const map = new Map<string, GazetteerEntry>();
|
|
for (const entry of loadEntries()) {
|
|
map.set(entry.id, entry);
|
|
}
|
|
byIdCache = map;
|
|
}
|
|
return byIdCache;
|
|
}
|
|
|
|
/** normalized alias -> entry, built lazily and memoized (LAW 1). */
|
|
function loadIndex(): Map<string, GazetteerEntry[]> {
|
|
if (indexCache === null) {
|
|
const index = new Map<string, GazetteerEntry[]>();
|
|
for (const entry of loadEntries()) {
|
|
const keys = new Set<string>();
|
|
const nameKey = normalize(entry.name);
|
|
if (nameKey !== "") keys.add(nameKey);
|
|
for (const alias of entry.aliases) {
|
|
const aliasKey = normalize(alias);
|
|
if (aliasKey !== "") keys.add(aliasKey);
|
|
}
|
|
for (const key of keys) {
|
|
const bucket = index.get(key);
|
|
if (bucket === undefined) {
|
|
index.set(key, [entry]);
|
|
} else {
|
|
bucket.push(entry);
|
|
}
|
|
}
|
|
}
|
|
indexCache = index;
|
|
}
|
|
return indexCache;
|
|
}
|
|
|
|
/** All curated entries (frozen — the dataset is a build-time constant). */
|
|
export function getEntries(): readonly GazetteerEntry[] {
|
|
return loadEntries();
|
|
}
|
|
|
|
/** id -> entry; honest null when the id is unknown (never a fabricated entry). */
|
|
export function getEntryById(id: string): GazetteerEntry | null {
|
|
return loadById().get(id) ?? null;
|
|
}
|
|
|
|
/** The memoized normalized-alias index (normalized key -> entries). */
|
|
export function getIndex(): Map<string, GazetteerEntry[]> {
|
|
return loadIndex();
|
|
}
|