The export bundle is the input to this port, not a sketch: the curriculum, the tutor prompt and the five logic modules are finished and tested. They land here byte-identical and stay that way. diff -r export/data data && diff -r export/lib lib diff -r export/prompt prompt && diff export/validate.mjs validate.mjs data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs runs verbatim with no path edits. All four are excluded from lint and formatting — they are not ours to restyle. Types for lib/ live alongside in types/ rather than as sibling .d.ts files, so the verbatim check stays a plain directory diff. CI runs the curriculum gate first, before anything else can pass: node validate.mjs PASS — 0 blocking, 0 advisory Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
5.0 KiB
JavaScript
126 lines
5.0 KiB
JavaScript
/* 한글 — decomposition, composition, and a 두벌식 input method.
|
|
No dependencies. Lifted from the artifact; the IME is unit-tested
|
|
against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */
|
|
|
|
export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
|
|
export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
|
|
export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ";
|
|
|
|
const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"};
|
|
const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ",
|
|
"ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"};
|
|
const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]]));
|
|
|
|
/** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */
|
|
export function decompose(ch) {
|
|
const c = ch.charCodeAt(0) - 0xAC00;
|
|
if (c < 0 || c > 11171) return null;
|
|
return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28];
|
|
}
|
|
export function compose(i, m, f = 0) {
|
|
return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f);
|
|
}
|
|
export const isJamo = {
|
|
initial: c => CHO.includes(c),
|
|
medial: c => JUNG.includes(c),
|
|
final: c => c && JONG.indexOf(c) > 0,
|
|
};
|
|
|
|
/* ── 두벌식 IME ───────────────────────────────────────────
|
|
Hold one composing buffer per input. Feed jamo with key(),
|
|
punctuation and spaces with text(), and Backspace with back().
|
|
Each call returns the full new value. */
|
|
export class Composer {
|
|
constructor() { this.reset(); }
|
|
reset() { this.cho = null; this.jung = null; this.jong = null; }
|
|
get empty() { return !this.cho && !this.jung && !this.jong; }
|
|
|
|
/** The syllable currently being assembled, as text. */
|
|
render() {
|
|
if (this.cho && this.jung) {
|
|
const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung);
|
|
const f = this.jong ? JONG.indexOf(this.jong) : 0;
|
|
if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f);
|
|
}
|
|
return (this.cho || "") + (this.jung || "") + (this.jong || "");
|
|
}
|
|
/** Strip the composing tail off a value so it can be rebuilt. */
|
|
_base(value) {
|
|
const cur = this.render();
|
|
return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value;
|
|
}
|
|
|
|
key(value, j) {
|
|
let base = this._base(value);
|
|
if (isJamo.medial(j)) {
|
|
if (this.jong) { // final splits off to start a new block
|
|
const parts = FSPLIT[this.jong];
|
|
let moved;
|
|
if (parts) { this.jong = parts[0]; moved = parts[1]; }
|
|
else { moved = this.jong; this.jong = null; }
|
|
base += this.render();
|
|
this.cho = moved; this.jung = j; this.jong = null;
|
|
} else if (this.jung) {
|
|
const join = VJOIN[this.jung + j];
|
|
if (join) this.jung = join;
|
|
else { base += this.render(); this.reset(); this.jung = j; }
|
|
} else this.jung = j;
|
|
} else {
|
|
if (this.cho && this.jung) {
|
|
if (this.jong) {
|
|
const join = FJOIN[this.jong + j];
|
|
if (join) this.jong = join;
|
|
else { base += this.render(); this.reset(); this.cho = j; }
|
|
} else if (isJamo.final(j)) this.jong = j;
|
|
else { base += this.render(); this.reset(); this.cho = j; }
|
|
} else {
|
|
if (!this.empty) base += this.render();
|
|
this.reset(); this.cho = j;
|
|
}
|
|
}
|
|
return base + this.render();
|
|
}
|
|
|
|
back(value) {
|
|
let base = this._base(value);
|
|
if (this.jong) {
|
|
const parts = FSPLIT[this.jong];
|
|
this.jong = parts ? parts[0] : null;
|
|
} else if (this.jung) {
|
|
let peeled = null;
|
|
for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0];
|
|
this.jung = peeled;
|
|
} else if (this.cho) {
|
|
this.cho = null;
|
|
} else { // pull a finished block back in
|
|
const last = base.slice(-1);
|
|
base = base.slice(0, -1);
|
|
const d = last ? decompose(last) : null;
|
|
if (d) {
|
|
this.cho = CHO[d[0]]; this.jung = JUNG[d[1]];
|
|
this.jong = d[2] ? JONG[d[2]] : null;
|
|
if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; }
|
|
else this.jung = null;
|
|
}
|
|
}
|
|
return base + this.render();
|
|
}
|
|
|
|
/** Commit the buffer and append literal text (space, punctuation). */
|
|
text(value, t) {
|
|
const base = this._base(value) + this.render();
|
|
this.reset();
|
|
return base + t;
|
|
}
|
|
}
|
|
|
|
/** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */
|
|
export const KEYBOARD = {
|
|
rows: [
|
|
["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"],
|
|
["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"],
|
|
["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"],
|
|
],
|
|
shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
|
|
};
|