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>
398 lines
14 KiB
JavaScript
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();
|