which-dialect 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/review.js ADDED
@@ -0,0 +1,175 @@
1
+ // Reviewing a journal entry written in the target language, the learner's own language, or a mix of both
2
+ // ("Hôm nay tôi đi market với má"), and giving the corrected entry in the target language. For each sentence:
3
+ //
4
+ // 1. Each word is marked as the target or the base language (detect.ts), from the two dictionaries alone.
5
+ // 2. A whole base-language sentence or clause that follows a sentence frame is filled in ("but it was
6
+ // expensive" → "nhưng mắc quá"; "Where is the bathroom?" → "Phòng tắm ở đâu?").
7
+ // 3. The checker runs on the whole entry. By default that's the spellchecker: every misspelled word gets
8
+ // its accents ("khong" → "không") and every remaining base-language word its translation ("market" →
9
+ // "chợ"); opt-in checks (`checks`: regions, pronouns, consistency, polite endings) join it. Errors and
10
+ // warnings are applied; suggestions are returned as hints.
11
+ // 4. The sentence frames the corrected sentence follows are listed, for the learner to reuse. Frames don't
12
+ // change the sentence.
13
+ //
14
+ // Everything is rule-based and runs on the language's data: no API, and nothing language-specific here.
15
+ import { createChecker } from "./check.js";
16
+ import { markLanguages } from "./detect.js";
17
+ import { createDictionary } from "./index.js";
18
+ import { createPhrasebook } from "./phrasebook.js";
19
+ import { sentencesOf, units } from "./text.js";
20
+ import { createTranslator } from "./translate.js";
21
+ const TERMINAL = /^[.!?…]+/;
22
+ // Spaces after a word was left out: one between words, none before punctuation.
23
+ const tidy = (s) => s.replace(/[^\S\n]+/g, ' ').replace(/ ([.,!?…])/g, '$1').trim();
24
+ export function createReviewer(options = {}) {
25
+ const cache = new Map();
26
+ const dict = (lang) => {
27
+ let d = cache.get(lang);
28
+ if (!d) {
29
+ d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
30
+ cache.set(lang, d);
31
+ }
32
+ return d;
33
+ };
34
+ const translator = createTranslator({ ...options, dictionary: dict });
35
+ const phrasebook = createPhrasebook({ ...options, dictionary: dict, translator });
36
+ const checker = createChecker({ ...options, dictionary: dict });
37
+ return {
38
+ async review(entry, o) {
39
+ const text = entry.normalize('NFC');
40
+ const base = o.base ?? 'en';
41
+ // A frame as shown to the learner: its English and its pattern for these settings, slots open.
42
+ const frameInfo = new Map();
43
+ const frameOf = async (id) => {
44
+ if (!frameInfo.has(id)) {
45
+ const f = await phrasebook.render(id, o);
46
+ frameInfo.set(id, { id, en: f.en, text: f.text });
47
+ }
48
+ return frameInfo.get(id);
49
+ };
50
+ const drafts = [];
51
+ for (const list of sentencesOf(text, units(text))) {
52
+ const start = list[0].start;
53
+ const coreEnd = list[list.length - 1].end;
54
+ const end = coreEnd + (TERMINAL.exec(text.slice(coreEnd))?.[0].length ?? 0);
55
+ const langs = base === o.lang ? list.map(() => o.lang) : await markLanguages(list, { lang: o.lang, base, target: dict(o.lang), baseDictionary: dict(base), translator });
56
+ const groups = [];
57
+ list.forEach((u, i) => {
58
+ if (groups.length && groups[groups.length - 1].lang === langs[i])
59
+ groups[groups.length - 1].units.push(u);
60
+ else
61
+ groups.push({ lang: langs[i], units: [u] });
62
+ });
63
+ const draft = {
64
+ start, coreEnd, end, replacements: [], frames: [],
65
+ parts: groups.map((g) => ({ text: text.slice(g.units[0].start, g.units[g.units.length - 1].end), lang: g.lang })),
66
+ };
67
+ const whole = groups.length === 1 && groups[0].lang === base;
68
+ const fill = async (span, frame) => {
69
+ const from = text.slice(span[0].start, span[span.length - 1].end);
70
+ const info = await frameOf(frame.id);
71
+ let to = frame.text.replace(/[.!?…]+$/, '');
72
+ if (span[0].start !== start)
73
+ to = to.charAt(0).toLowerCase() + to.slice(1);
74
+ draft.replacements.push({
75
+ start: span[0].start, end: span[span.length - 1].end, to,
76
+ change: { kind: 'frame', from, to, why: `Sentence frame “${info.en}”` },
77
+ });
78
+ draft.frames.push(info);
79
+ };
80
+ for (const g of groups.filter((g) => g.lang === base)) {
81
+ // A whole sentence is matched with its own punctuation ("Where is the bathroom?").
82
+ const whole_ = whole ? text.slice(start, end) : text.slice(g.units[0].start, g.units[g.units.length - 1].end);
83
+ const frame = await phrasebook.match(whole_, o);
84
+ if (frame?.complete) {
85
+ await fill(g.units, frame);
86
+ continue;
87
+ }
88
+ // Otherwise a clause frame ending the part, longest first ("the ice cream but it was expensive":
89
+ // "but it was expensive"); the rest is translated word by word by the checker. Only spans ending
90
+ // with the part, so a clause isn't cut short ("but it was very" with "expensive" left over).
91
+ for (let len = g.units.length - 1; len >= 2; len--) {
92
+ const span = g.units.slice(g.units.length - len);
93
+ const m = await phrasebook.match(text.slice(span[0].start, span[len - 1].end), o);
94
+ if (!m?.complete || !m.clause)
95
+ continue;
96
+ await fill(span, m);
97
+ break;
98
+ }
99
+ }
100
+ drafts.push(draft);
101
+ }
102
+ // The entry with those parts filled in, keeping where each sentence now is.
103
+ let draftText = '';
104
+ let cursor = 0;
105
+ const placed = [];
106
+ for (const d of drafts) {
107
+ draftText += text.slice(cursor, d.start);
108
+ const s = draftText.length;
109
+ let core = text.slice(d.start, d.coreEnd);
110
+ for (const r of [...d.replacements].sort((a, b) => b.start - a.start)) {
111
+ core = core.slice(0, r.start - d.start) + r.to + core.slice(r.end - d.start);
112
+ }
113
+ if (/\p{Lu}/u.test(text.charAt(d.start)))
114
+ core = core.charAt(0).toUpperCase() + core.slice(1);
115
+ draftText += core + text.slice(d.coreEnd, d.end);
116
+ placed.push({ start: s, coreEnd: s + core.length, end: draftText.length });
117
+ cursor = d.end;
118
+ }
119
+ draftText += text.slice(cursor);
120
+ // 3. The checker on the whole entry; 4. the frames each corrected sentence follows.
121
+ const { issues } = await checker.check(draftText, {
122
+ lang: o.lang, base, region: o.region, listener: o.listener, speaker: o.speaker, register: o.register, rules: o.checks,
123
+ });
124
+ const sentences = [];
125
+ for (const [i, d] of drafts.entries()) {
126
+ const p = placed[i];
127
+ const mine = issues.filter((x) => x.start >= p.start && x.end <= p.coreEnd);
128
+ const changes = d.replacements.sort((a, b) => a.start - b.start).map((r) => r.change);
129
+ const hints = [];
130
+ const unchecked = [];
131
+ const applied = [];
132
+ let core = draftText.slice(p.start, p.coreEnd);
133
+ let lastStart = Infinity;
134
+ for (const x of [...mine].sort((a, b) => b.start - a.start)) {
135
+ if (!x.suggestions.length) {
136
+ if (x.rule === 'foreign-word')
137
+ unchecked.unshift(x.text);
138
+ else
139
+ hints.unshift({ rule: x.rule, text: x.text, message: x.message, suggestions: x.suggestions });
140
+ continue;
141
+ }
142
+ if (x.severity === 'suggestion' || x.end > lastStart) {
143
+ hints.unshift({ rule: x.rule, text: x.text, message: x.message, suggestions: x.suggestions });
144
+ continue;
145
+ }
146
+ core = core.slice(0, x.start - p.start) + x.suggestions[0] + core.slice(x.end - p.start);
147
+ applied.unshift({ kind: x.rule, from: x.text, to: x.suggestions[0], why: x.message });
148
+ lastStart = x.start;
149
+ }
150
+ changes.push(...applied);
151
+ core = tidy(core);
152
+ if (/\p{Lu}/u.test(draftText.charAt(p.start)))
153
+ core = core.charAt(0).toUpperCase() + core.slice(1);
154
+ const corrected = core + draftText.slice(p.coreEnd, p.end);
155
+ const frames = [...d.frames];
156
+ for (const m of await phrasebook.matchFrames(corrected, o)) {
157
+ if (!frames.some((f) => f.id === m.frame.id))
158
+ frames.push(await frameOf(m.frame.id));
159
+ }
160
+ sentences.push({
161
+ start: d.start, end: d.end, original: text.slice(d.start, d.end), parts: d.parts,
162
+ corrected, changes, hints, frames, unchecked,
163
+ });
164
+ }
165
+ let corrected = '';
166
+ cursor = 0;
167
+ for (const s of sentences) {
168
+ corrected += text.slice(cursor, s.start) + s.corrected;
169
+ cursor = s.end;
170
+ }
171
+ corrected += text.slice(cursor);
172
+ return { text, corrected, sentences };
173
+ },
174
+ };
175
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Entry } from './types.ts';
2
+ export declare const UNIT: RegExp;
3
+ export declare const SENTENCE_END: RegExp;
4
+ export type Unit = {
5
+ text: string;
6
+ start: number;
7
+ end: number;
8
+ };
9
+ export type Word = {
10
+ text: string;
11
+ lower: string;
12
+ start: number;
13
+ end: number;
14
+ units: Unit[];
15
+ entries: Entry[];
16
+ };
17
+ export declare function units(text: string): Unit[];
18
+ /** Units grouped into sentences, which end at . ! ? … or a line break. */
19
+ export declare function sentencesOf(text: string, all: Unit[]): Unit[][];
20
+ /** Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo"). */
21
+ export declare const joined: (text: string, a: Unit, b: Unit) => boolean;
22
+ /**
23
+ * Longest match against the dictionary: at each position, the longest run of units that's a headword
24
+ * ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
25
+ * no entries.
26
+ */
27
+ export declare function segment(text: string, list: Unit[], maxUnits: number, lookup: (phrase: string) => Promise<Entry[]>): Promise<Word[]>;
28
+ /** Without accents: "không" → "khong", "đi" → "di". */
29
+ export declare const plain: (s: string) => string;
30
+ /** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
31
+ export declare const matchCase: (original: string, replacement: string) => string;
package/dist/text.js ADDED
@@ -0,0 +1,57 @@
1
+ // Splitting text into units, sentences and dictionary words: shared by the checker, sentence frames and
2
+ // the journal review. Language-general: units are runs of letters and digits; a language written in
3
+ // syllables groups them into dictionary words by longest match (CheckerConfig.units / maxWordUnits).
4
+ // A run of letters (with their accents), digits and apostrophes: a word, or a syllable in syllable languages.
5
+ export const UNIT = /[\p{L}\p{M}\p{N}'’]+/gu;
6
+ export const SENTENCE_END = /[.!?…]+|\n+/g;
7
+ export function units(text) {
8
+ return [...text.matchAll(UNIT)].map((m) => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
9
+ }
10
+ /** Units grouped into sentences, which end at . ! ? … or a line break. */
11
+ export function sentencesOf(text, all) {
12
+ const ends = [...text.matchAll(SENTENCE_END)].map((m) => m.index);
13
+ const out = [[]];
14
+ let next = 0;
15
+ for (const u of all) {
16
+ while (next < ends.length && ends[next] < u.start) {
17
+ if (out[out.length - 1].length)
18
+ out.push([]);
19
+ next++;
20
+ }
21
+ out[out.length - 1].push(u);
22
+ }
23
+ return out.filter((s) => s.length);
24
+ }
25
+ /** Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo"). */
26
+ export const joined = (text, a, b) => /^[^\S\n]+$/.test(text.slice(a.end, b.start));
27
+ /**
28
+ * Longest match against the dictionary: at each position, the longest run of units that's a headword
29
+ * ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
30
+ * no entries.
31
+ */
32
+ export async function segment(text, list, maxUnits, lookup) {
33
+ const words = [];
34
+ for (let i = 0; i < list.length;) {
35
+ let taken = null;
36
+ for (let k = Math.min(maxUnits, list.length - i); k >= 1; k--) {
37
+ const span = list.slice(i, i + k);
38
+ if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
39
+ continue;
40
+ const phrase = span.map((u) => u.text).join(' ');
41
+ const entries = /\d/.test(phrase) ? [] : await lookup(phrase);
42
+ if (entries.length || k === 1) {
43
+ const start = span[0].start;
44
+ const end = span[k - 1].end;
45
+ taken = { text: text.slice(start, end), lower: phrase.toLowerCase(), start, end, units: span, entries };
46
+ break;
47
+ }
48
+ }
49
+ words.push(taken);
50
+ i += taken.units.length;
51
+ }
52
+ return words;
53
+ }
54
+ /** Without accents: "không" → "khong", "đi" → "di". */
55
+ export const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
56
+ /** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
57
+ export const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
package/dist/types.d.ts CHANGED
@@ -114,6 +114,64 @@ export type LanguageMeta = {
114
114
  checker?: boolean;
115
115
  /** true when the language has a syllable list (syllables.json: syllable → Zipf frequency, 0 when unknown). */
116
116
  syllables?: boolean;
117
+ /** true when the language has sentence frames (frames.json, see FramesData). */
118
+ frames?: boolean;
119
+ };
120
+ /**
121
+ * Pronoun slots in sentence frames ({I}, {YOU}, …) and the pronoun-table column each one fills from, for
122
+ * the listener (or the table's default row).
123
+ */
124
+ export declare const FRAME_PRONOUNS: Record<string, {
125
+ person: PronounPerson;
126
+ inclusive?: boolean;
127
+ }>;
128
+ /**
129
+ * One choice for a frame word slot ({WHERE} → "đâu", "mô"): from a dictionary definition (region and
130
+ * labels from that sense) or a hand-written override with a note. `when: 'respect'` choices are used only
131
+ * when the listener's pronoun-table row is marked respect (Vietnamese "ạ").
132
+ */
133
+ export type FrameWordChoice = {
134
+ word: string;
135
+ gloss?: string;
136
+ source: 'gloss' | 'override';
137
+ regions: string[];
138
+ regionTagged: boolean;
139
+ labels: string[];
140
+ note?: string;
141
+ when?: 'respect';
142
+ };
143
+ /** A frame word slot: what it means, whether a sentence is fine without it, and its choices in order. */
144
+ export type FrameWords = {
145
+ meaning: string;
146
+ optional?: boolean;
147
+ choices: FrameWordChoice[];
148
+ };
149
+ /**
150
+ * A sentence frame in one language: the catalog's id, topic, English ways to say it and slots, and this
151
+ * language's `text` with {slot} placeholders: content slots (lowercase, from `slots`), pronoun slots
152
+ * (FRAME_PRONOUNS) and frame word slots (uppercase, from FramesData.words).
153
+ */
154
+ export type FrameData = {
155
+ id: string;
156
+ topic: string;
157
+ en: string[];
158
+ slots: Record<string, 'noun' | 'verb' | 'adj'>;
159
+ clause?: boolean;
160
+ text: string;
161
+ };
162
+ export type FramesData = {
163
+ frames: FrameData[];
164
+ words: Record<string, FrameWords>;
165
+ };
166
+ export type StoredFrameWordChoice = Omit<FrameWordChoice, 'regions' | 'regionTagged' | 'labels'> & {
167
+ regions?: string[];
168
+ labels?: string[];
169
+ };
170
+ export type StoredFramesData = {
171
+ frames: FrameData[];
172
+ words: Record<string, Omit<FrameWords, 'choices'> & {
173
+ choices: StoredFrameWordChoice[];
174
+ }>;
117
175
  };
118
176
  /**
119
177
  * How the checker reads one language (languages/<lang>.ts `checker`, stored as data/checker.json). Every
@@ -137,6 +195,11 @@ export type CheckerConfig = {
137
195
  ambiguousPronouns?: string[];
138
196
  /** Sentence endings expected when speaking up (pronoun-table rows marked `respect`), e.g. Vietnamese "ạ". */
139
197
  politeEndings?: string[];
198
+ /**
199
+ * English words (English is the bridge) this language usually has no word for, so the checker suggests
200
+ * leaving them out rather than translating them: Vietnamese has no articles ("the", "a", "an").
201
+ */
202
+ leaveOut?: string[];
140
203
  };
141
204
  /**
142
205
  * Hand-picked first choices for one English meaning, in one target language (picks.json). A speaker's
package/dist/types.js CHANGED
@@ -1,5 +1,18 @@
1
1
  // Shapes of the data. The build script (scripts/build-language.ts) writes the Stored* types to the
2
2
  // data files; the API in index.ts reads them and returns the full Sense/Entry/Hit types.
3
+ /**
4
+ * Pronoun slots in sentence frames ({I}, {YOU}, …) and the pronoun-table column each one fills from, for
5
+ * the listener (or the table's default row).
6
+ */
7
+ export const FRAME_PRONOUNS = {
8
+ I: { person: 'self' },
9
+ YOU: { person: 'addressee' },
10
+ WE: { person: 'selfPlural', inclusive: false },
11
+ WE_INCL: { person: 'selfPlural', inclusive: true },
12
+ YOU_PL: { person: 'addresseePlural' },
13
+ HE_SHE: { person: 'third' },
14
+ THEY: { person: 'thirdPlural' },
15
+ };
3
16
  /** The pronoun table's columns: I, you, he/she, we, you (plural), they. */
4
17
  export const PRONOUN_PERSONS = ['self', 'addressee', 'third', 'selfPlural', 'addresseePlural', 'thirdPlural'];
5
18
  /** Converts a full sense or hit to its stored form. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Translate words between languages and dialects, meaning by meaning, starting with Vietnamese (Northern, Central, Southern) and English, with Wiktionary data loaded on demand. More languages are planned.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",