feat(04-01): curated KL gazetteer + lazy three-environment loader + GPL/ODbL licensing
- 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.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user