Files
Hankan/tools/dict/build.mjs
MechaCat02 0b04931699 fix: a NUL byte was hiding two source files from grep, git and diff
TaskHost.tsx used a literal NUL as the delimiter in its drag-and-drop
payload, and tools/dict/build.mjs used one to join headword and part of
speech. Both work at runtime. Both also make the file *binary* to every
text tool: git shows "Bin 12259 bytes" instead of a diff, and grep prints
nothing at all for a match.

That is not hypothetical. Searching TaskHost.tsx for "<input" came back
empty three times while reviewing it, which is how its four exercise
inputs came to be reported as absent -- and why the accessibility defect
in them went unseen. Written as the escape \u0000 the value is identical
and the file stays text.

test/source-hygiene.test.ts fails on any control byte in a source file, so
this cannot come back quietly.

With the files readable again, the sweep the NUL had been hiding: eleven
form controls had no accessible name. The exercise blanks announced only
an ellipsis, and the part-of-speech select announced nothing. A
placeholder is not a label -- it disappears the moment you type. All
eleven now carry one, named after the thing they answer.

`npm run lint` gains --max-warnings 0. exhaustive-deps is configured as a
warning, so a hooks-dependency bug would have passed CI silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:06:39 +02:00

398 lines
14 KiB
JavaScript

/* The dictionary build.
dictionary source → normalize
+ frequency list → ranks, via build-time surface expansion
+ data/ → curated glosses that win over the dictionary
+ grammar lexicon → the function words a dictionary cannot supply
+ surfaceForms() → the surface index that replaces a runtime analyser
------------------------------------------------------------------
→ app/public/dict/band-N.json.gz + manifest.json + seed.sqlite3
Run: npm run dict:build
Source choice is automatic. If a KRDICT file has been vendored it wins,
because it carries curated learner glosses and a graded difficulty level;
otherwise the kaikki extract is used. Either way the manifest records
which one produced the files.
The output is committed. KRDICT cannot be re-fetched by CI, and the app
has to ship the data offline regardless, so the band files are artefacts
of the repo rather than of the build machine. */
import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { gzipSync } from "node:zlib";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { DatabaseSync } from "node:sqlite";
import { surfaceForms } from "../../lib/conjugation.js";
import { flatten } from "../../lib/gate.js";
import { bandOf, bandForUnit, REFERENCE_BAND, BANDS } from "../../shared/bands.mjs";
import { readKaikki, KAIKKI_ATTRIBUTION } from "./sources/kaikki.mjs";
import { readKrdict, findKrdict, KRDICT_ATTRIBUTION } from "./sources/krdict.mjs";
import { readFrequency, FREQUENCY_ATTRIBUTION } from "./sources/frequency.mjs";
import { rankByFrequency } from "./freq-forms.mjs";
import { ensureVendor, fetchSource } from "./fetch.mjs";
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
const VENDOR = `${ROOT}vendor/`;
const OUT = `${ROOT}app/public/dict/`;
const readJson = async (p) => JSON.parse(await readFile(p, "utf8"));
const log = (...a) => console.log(...a);
/** The dictionary-form suffix every Korean predicate ends in. */
const DICT_FORM = "\uB2E4";
/* ── merge ────────────────────────────────────────────────────────────
One entry per (headword, pos). Later sources overwrite earlier ones,
so the precedence order below is the precedence order of the glosses. */
class Lexicon {
constructor() {
this.byKey = new Map();
this.byHeadword = new Map();
}
/** Every entry for a headword, whatever its part of speech. */
forHeadword(headword) {
return [...(this.byHeadword.get(headword) ?? [])].map((k) => this.byKey.get(k));
}
key(headword, pos) {
/* Escaped, never a literal NUL: a raw one makes this file binary to
git, grep and diff. A headword cannot contain it either way. */
return `${headword}\u0000${pos}`;
}
add(entry) {
const k = this.key(entry.headword, entry.pos);
const prev = this.byKey.get(k);
// A later source wins, but never by replacing a real gloss with nothing.
if (prev && !entry.gloss_en && prev.gloss_en) entry = { ...entry, gloss_en: prev.gloss_en };
if (prev && !entry.gloss_ko && prev.gloss_ko) entry = { ...entry, gloss_ko: prev.gloss_ko };
if (prev && entry.level == null && prev.level != null) entry = { ...entry, level: prev.level };
this.byKey.set(k, entry);
let keys = this.byHeadword.get(entry.headword);
if (!keys) this.byHeadword.set(entry.headword, (keys = new Set()));
keys.add(k);
}
get size() {
return this.byKey.size;
}
entries() {
return [...this.byKey.values()];
}
}
/* ── sources ──────────────────────────────────────────────────────── */
async function loadDictionary(lex) {
const krdict = await findKrdict(VENDOR);
if (krdict) {
log(` dictionary: KRDICT — ${krdict.replace(ROOT, "")}`);
let n = 0;
for await (const e of readKrdict(krdict)) {
lex.add(e);
n++;
}
log(` ${n} entries`);
return { name: "krdict", attribution: KRDICT_ATTRIBUTION, entries: n };
}
const file = await fetchSource("kaikki");
log(` dictionary: kaikki — ${file.replace(ROOT, "")}`);
log(" (vendor a KRDICT download to prefer it — see tools/dict/sources/krdict.mjs)");
let n = 0;
for await (const e of readKaikki(file)) {
lex.add(e);
n++;
}
log(` ${n} entries`);
return { name: "kaikki", attribution: KAIKKI_ATTRIBUTION, entries: n };
}
/**
* data/gloss-extra.json — curated glosses for exactly the roadmap words the
* deck does not cover. Upstream calls these "gloss only, NOT SRS cards", so
* they are applied as an OVERRIDE on whatever the dictionary already has
* rather than as new entries: overwriting the gloss where the headword is
* already known, and only adding a row where it is not. That keeps one row
* per real word instead of shadowing every noun with a second, pos-less copy.
*/
async function loadGlossExtra(lex) {
const extra = await readJson(`${ROOT}data/gloss-extra.json`);
let overridden = 0;
let added = 0;
for (const e of extra.entries) {
const existing = lex.forHeadword(e.ko);
if (existing.length) {
for (const prev of existing) {
lex.add({ ...prev, gloss_en: e.en, note: e.note ?? "", source: "curated" });
}
overridden++;
} else {
// Unknown to the dictionary. A -다 headword is a predicate, and haeche()
// treats verbs and adjectives alike, so "verb" is safe for generating
// its surface forms; anything else is left unclassified.
lex.add({
headword: e.ko,
pos: e.ko.endsWith(DICT_FORM) ? "verb" : "word",
gloss_en: e.en,
gloss_ko: "",
note: e.note ?? "",
level: null,
source: "curated",
});
added++;
}
}
log(` gloss-extra: ${extra.entries.length} entries (${overridden} overrode a dictionary gloss, ${added} new)`);
return extra.entries.length;
}
/** deck.json — 386 curated words. These glosses beat the dictionary's. */
async function loadDeck(lex) {
const deck = await readJson(`${ROOT}data/deck.json`);
let n = 0;
for (const [topic, rows] of Object.entries(deck.topics)) {
for (const [headword, , gloss, pos] of rows) {
lex.add({
headword,
pos: pos === "phrase" ? "phrase" : pos,
gloss_en: gloss,
gloss_ko: "",
level: null,
source: "curated",
topic,
});
n++;
}
}
log(` curated deck: ${n} entries`);
return n;
}
/** Sentence chunks and sound words — glossed in context, so worth keeping. */
async function loadSentencesAndSfx(lex) {
const sentences = await readJson(`${ROOT}data/sentences.json`);
const sfx = await readJson(`${ROOT}data/sfx.json`);
let n = 0;
for (const s of sentences.sentences) {
for (const [chunk, gloss] of s.parts) {
lex.add({
headword: chunk,
pos: "chunk",
gloss_en: gloss,
gloss_ko: "",
level: null,
source: "sentence",
});
n++;
}
}
for (const item of sfx.items) {
lex.add({
headword: item.ko,
pos: "sfx",
gloss_en: item.en,
gloss_ko: "",
level: null,
source: "sfx",
topic: "의성어·의태어",
});
n++;
}
log(` sentence chunks + sound words: ${n} entries`);
return n;
}
/** The hand-written function words. Merged last, so they win outright. */
async function loadGrammar(lex) {
const g = await readJson(`${ROOT}tools/dict/grammar-lexicon.json`);
for (const e of g.entries) {
lex.add({
headword: e.headword,
pos: e.pos,
gloss_en: e.gloss_en,
gloss_ko: e.gloss_ko ?? "",
note: e.note ?? "",
level: null,
source: "grammar",
topic: "문법 Grammar",
});
}
log(` grammar lexicon: ${g.entries.length} entries`);
return g.entries.length;
}
/* ── build ────────────────────────────────────────────────────────── */
async function main() {
log("Building the dictionary\n");
await ensureVendor();
await mkdir(OUT, { recursive: true });
const lex = new Lexicon();
const dict = await loadDictionary(lex);
await loadGlossExtra(lex);
await loadDeck(lex);
await loadSentencesAndSfx(lex);
await loadGrammar(lex);
log(` merged: ${lex.size} distinct (headword, pos)\n`);
/* Frequency, via the inverted join — see freq-forms.mjs. */
const freqFile = await fetchSource("frequency");
const counts = await readFrequency(freqFile);
const entries = lex.entries();
const ranks = rankByFrequency(entries, counts);
log(` frequency: ${counts.size} surface forms → ${ranks.size} lemmas ranked\n`);
/* Which unit introduces a word, so curriculum vocabulary is admitted by
the band of its own unit rather than by its raw frequency. */
const curriculum = await readJson(`${ROOT}data/curriculum.json`);
const units = flatten(curriculum);
const introducedIn = new Map();
for (const u of units) {
for (const w of u.words ?? []) if (!introducedIn.has(w)) introducedIn.set(w, u.id);
}
/* Stable ids: sort first so a rebuild produces byte-identical output. */
entries.sort((a, b) =>
a.headword === b.headword ? a.pos.localeCompare(b.pos) : a.headword.localeCompare(b.headword),
);
const lemmas = [];
const surfaces = [];
let id = 0;
for (const e of entries) {
id++;
const rank = ranks.get(`${e.headword} ${e.pos}`) ?? null;
let band = bandOf({ source: e.source, freqRank: rank, level: e.level });
const unit = introducedIn.get(e.headword);
if (unit) band = Math.min(band, bandForUnit(unit));
lemmas.push({
id,
headword: e.headword,
pos: e.pos,
freq_rank: rank,
level: e.level ?? null,
gloss_en: e.gloss_en ?? "",
gloss_ko: e.gloss_ko ?? "",
unit_band: band,
source: e.source,
});
/* The surface index. Strictly surfaceForms() plus the headword itself —
the extra forms in freq-forms.mjs exist only to score frequency and
are deliberately NOT stored, because they are not all real words. */
surfaces.push({ form: e.headword, lemma_id: id, analysis: `headword, ${e.pos}` });
if (e.pos === "verb" || e.pos === "adj") {
for (const s of surfaceForms(e.headword, e.gloss_en ?? "")) {
if (s.form) surfaces.push({ form: s.form, lemma_id: id, analysis: s.note });
}
}
}
log(` lemmas: ${lemmas.length}`);
log(` surface forms: ${surfaces.length}\n`);
/* ── emit ── */
const byBand = new Map();
for (const l of lemmas) {
if (!byBand.has(l.unit_band)) byBand.set(l.unit_band, { lemmas: [], surfaces: [] });
byBand.get(l.unit_band).lemmas.push(l);
}
const bandOfLemma = new Map(lemmas.map((l) => [l.id, l.unit_band]));
for (const s of surfaces) {
const b = bandOfLemma.get(s.lemma_id);
byBand.get(b).surfaces.push(s);
}
const bands = [];
const allBands = [...BANDS.map((b) => b.band), REFERENCE_BAND];
for (const band of allBands) {
const data = byBand.get(band) ?? { lemmas: [], surfaces: [] };
// Arrays, not objects: the key names would otherwise be ~60% of the file.
const payload = {
band,
columns: {
lemma: ["id", "headword", "pos", "freq_rank", "level", "gloss_en", "gloss_ko", "unit_band", "source"],
surface: ["form", "lemma_id", "analysis"],
},
lemmas: data.lemmas.map((l) => [
l.id, l.headword, l.pos, l.freq_rank, l.level, l.gloss_en, l.gloss_ko, l.unit_band, l.source,
]),
surfaces: data.surfaces.map((s) => [s.form, s.lemma_id, s.analysis]),
};
const gz = gzipSync(Buffer.from(JSON.stringify(payload), "utf8"), { level: 9 });
const file = `band-${band}.json.gz`;
await writeFile(OUT + file, gz);
const sha = createHash("sha256").update(gz).digest("hex");
bands.push({
band,
file,
lemmas: data.lemmas.length,
surfaces: data.surfaces.length,
bytes: gz.length,
sha256: sha,
reference: band === REFERENCE_BAND,
});
log(
` band ${band}${band === REFERENCE_BAND ? " (reference)" : ""}: ` +
`${String(data.lemmas.length).padStart(6)} lemmas, ` +
`${String(data.surfaces.length).padStart(6)} surfaces, ` +
`${(gz.length / 1024).toFixed(0)} KB`,
);
}
/* A real SQLite file of band 0, for inspection and for anyone who wants to
open the seed with a normal client. The app loads the band files. */
await rm(`${OUT}seed.sqlite3`, { force: true });
const seed = new DatabaseSync(`${OUT}seed.sqlite3`);
seed.exec(`
CREATE TABLE lemma (id INTEGER PRIMARY KEY, headword TEXT NOT NULL, pos TEXT NOT NULL,
freq_rank INTEGER, level TEXT, gloss_en TEXT NOT NULL DEFAULT '',
gloss_ko TEXT NOT NULL DEFAULT '', unit_band INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL);
CREATE TABLE surface (form TEXT NOT NULL, lemma_id INTEGER NOT NULL, analysis TEXT NOT NULL,
PRIMARY KEY (form, lemma_id)) WITHOUT ROWID;
CREATE INDEX surface_form ON surface(form);
`);
const band0 = byBand.get(0) ?? { lemmas: [], surfaces: [] };
const li = seed.prepare("INSERT INTO lemma VALUES (?,?,?,?,?,?,?,?,?)");
const si = seed.prepare("INSERT OR IGNORE INTO surface VALUES (?,?,?)");
seed.exec("BEGIN");
for (const l of band0.lemmas)
li.run(l.id, l.headword, l.pos, l.freq_rank, l.level, l.gloss_en, l.gloss_ko, l.unit_band, l.source);
for (const s of band0.surfaces) si.run(s.form, s.lemma_id, s.analysis);
seed.exec("COMMIT");
seed.close();
const manifest = {
builtWith: {
dictionary: dict.name,
dictionaryEntries: dict.entries,
frequencyForms: counts.size,
},
totals: { lemmas: lemmas.length, surfaces: surfaces.length },
bands,
attribution: [dict.attribution, FREQUENCY_ATTRIBUTION],
notice: "See NOTICE.md. Share-alike applies to this dictionary data, not to the app code.",
};
await writeFile(OUT + "manifest.json", JSON.stringify(manifest, null, 2) + "\n");
const total = bands.reduce((a, b) => a + b.bytes, 0);
log(`\n total: ${(total / 1024 / 1024).toFixed(1)} MB across ${bands.length} band files`);
log(` written to ${OUT.replace(ROOT, "")}`);
log("\nNow run: npm run dict:assert");
}
await main();