Files
Hankan/lib/hangul.js
MechaCat02 b80deb2f6f chore: scaffold, CI, and the export bundle verbatim
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>
2026-09-08 19:11:40 +02:00

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: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
};