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/detect.js ADDED
@@ -0,0 +1,58 @@
1
+ // Which language each word of a mixed text is in: the target language or the learner's own (the base,
2
+ // English by default), from the two languages' data alone. Shared by the checker and the journal review.
3
+ import { plain } from "./text.js";
4
+ /**
5
+ * Marks each unit (of one sentence) with `lang` or `base`. A unit the target knows and the base doesn't
6
+ * is the target's, and the other way round; units both or neither know ("an", "to", names) take the
7
+ * nearest decided neighbors' language when they agree. When they don't, the target's, unless the word is
8
+ * far more common in the base (the text is mainly the target language: Vietnamese "an" stays Vietnamese
9
+ * next to English "the", and "tui" between "but" and "không"). In languages written in syllables, a base
10
+ * word that's a target syllable only once accents are added ("but": bút) is the base's; a word only the
11
+ * target knows without its accents ("khong") is still the target's. The base is known through the translator, so
12
+ * inflected forms count ("went", "cooked").
13
+ */
14
+ // Zipf points by which a word between the two languages must be more common in the base to count as base.
15
+ const BASE_MARGIN = 2;
16
+ export async function markLanguages(list, o) {
17
+ const syllables = await o.target.syllables();
18
+ const syllableLanguage = Object.keys(syllables).length > 0;
19
+ const plainSyllables = syllableLanguage ? new Set(Object.keys(syllables).map(plain)) : new Set();
20
+ // 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
21
+ const targetKnows = async (u) => {
22
+ const lower = u.text.toLowerCase();
23
+ if (!syllableLanguage)
24
+ return (await o.target.lookup(lower)).length > 0 ? 'exact' : false;
25
+ if (lower in syllables || lower !== plain(lower))
26
+ return 'exact';
27
+ return plainSyllables.has(lower) ? 'accents' : false;
28
+ };
29
+ const baseKnows = async (u) => (await o.translator.senses(u.text, { from: o.base }).catch(() => [])).length > 0;
30
+ const marks = await Promise.all(list.map(async (u) => {
31
+ if (/\d/.test(u.text))
32
+ return '?';
33
+ const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
34
+ if (t === 'exact')
35
+ return b ? '?' : o.lang;
36
+ if (b)
37
+ return o.base;
38
+ return t === 'accents' ? o.lang : '?';
39
+ }));
40
+ // How common a word is in each language (Zipf; 0 when unknown).
41
+ const targetFrequency = async (u) => {
42
+ const lower = u.text.toLowerCase();
43
+ if (syllableLanguage)
44
+ return syllables[lower] ?? 0;
45
+ return (await o.target.lookup(lower))[0]?.frequency ?? 0;
46
+ };
47
+ const baseFrequency = async (u) => (await o.baseDictionary.lookup(u.text.toLowerCase()))[0]?.frequency ?? 0;
48
+ return Promise.all(marks.map(async (m, i) => {
49
+ if (m !== '?')
50
+ return m;
51
+ const left = marks.slice(0, i).reverse().find((x) => x !== '?');
52
+ const right = marks.slice(i + 1).find((x) => x !== '?');
53
+ if (left && right && left !== right) {
54
+ return (await baseFrequency(list[i])) - (await targetFrequency(list[i])) >= BASE_MARGIN ? o.base : o.lang;
55
+ }
56
+ return left ?? right ?? o.lang;
57
+ }));
58
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type CheckerConfig, type PronounRow, type Entry, type Hit, type LanguageMeta, type PickRow } from './types.ts';
1
+ import { type CheckerConfig, type FramesData, type PronounRow, type Entry, type Hit, type LanguageMeta, type PickRow } from './types.ts';
2
2
  export * from './types.ts';
3
3
  export * from './pos.ts';
4
4
  /**
@@ -71,6 +71,8 @@ export type Dictionary = {
71
71
  checker(): Promise<CheckerConfig>;
72
72
  /** Syllable → Zipf frequency (0 when unknown), for languages written in syllables; `{}` otherwise. */
73
73
  syllables(): Promise<Record<string, number>>;
74
+ /** The language's sentence frames and frame words, unfilled (see FramesData); empty without frames. */
75
+ frames(): Promise<FramesData>;
74
76
  };
75
77
  /**
76
78
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
@@ -81,3 +83,7 @@ export declare function dataUrl(lang: string): string;
81
83
  export declare function createDictionary(options: DictionaryOptions): Dictionary;
82
84
  export * from './translate.ts';
83
85
  export * from './check.ts';
86
+ export * from './text.ts';
87
+ export * from './phrasebook.ts';
88
+ export * from './review.ts';
89
+ export * from './detect.ts';
package/dist/index.js CHANGED
@@ -115,6 +115,16 @@ export function createDictionary(options) {
115
115
  const m = await meta();
116
116
  return m.syllables ? loadOnce('syllables.json') : {};
117
117
  },
118
+ async frames() {
119
+ const m = await meta();
120
+ if (!m.frames)
121
+ return { frames: [], words: {} };
122
+ const stored = await loadOnce('frames.json');
123
+ return {
124
+ frames: stored.frames,
125
+ words: Object.fromEntries(Object.entries(stored.words).map(([k, w]) => [k, { ...w, choices: w.choices.map((c) => fromStored(c, m.regions)) }])),
126
+ };
127
+ },
118
128
  async pronouns({ listener, region, speaker, exclude = DEFAULT_EXCLUDED_LABELS } = {}) {
119
129
  const m = await meta();
120
130
  if (!m.pronouns)
@@ -141,3 +151,7 @@ export function createDictionary(options) {
141
151
  }
142
152
  export * from "./translate.js";
143
153
  export * from "./check.js";
154
+ export * from "./text.js";
155
+ export * from "./phrasebook.js";
156
+ export * from "./review.js";
157
+ export * from "./detect.js";
@@ -0,0 +1,82 @@
1
+ import { type Translator, type TranslatorOptions } from './translate.ts';
2
+ export type FrameOptions = {
3
+ /** The language to say it in, e.g. "vi". */
4
+ lang: string;
5
+ /** Region or region group; its words come first, and words only used elsewhere are left out. */
6
+ region?: string;
7
+ /** Who you're talking to, as a pronoun-table row id; without it, the table's default row. */
8
+ listener?: string;
9
+ /** The speaker's gender, for pronouns that depend on it. */
10
+ speaker?: 'male' | 'female';
11
+ /** 'casual' allows colloquial words (Southern "hông", "tui"); otherwise they're left out. */
12
+ register?: 'casual' | 'neutral' | 'polite';
13
+ };
14
+ /** One piece of a filled frame, with why it was chosen, for highlighting and explanations. */
15
+ export type FramePart = {
16
+ text: string;
17
+ /** Fixed wording, a frame word ({WHERE}), a pronoun ({I}) or a content slot ({place}). */
18
+ kind: 'fixed' | 'word' | 'pronoun' | 'content';
19
+ slot?: string;
20
+ /** e.g. "where (Central)", "“I”, talking to your parents". */
21
+ why?: string;
22
+ regions?: string[];
23
+ /** For content slots given in English: the English, and whether no translation was found. */
24
+ from?: string;
25
+ untranslated?: boolean;
26
+ };
27
+ export type FilledFrame = {
28
+ id: string;
29
+ topic: string;
30
+ /** The English way to say it, with slots filled when given. */
31
+ en: string;
32
+ /** The sentence; open content slots stay as {name}. */
33
+ text: string;
34
+ parts: FramePart[];
35
+ /** The content slots and their parts of speech. */
36
+ slots: {
37
+ name: string;
38
+ pos: 'noun' | 'verb' | 'adj';
39
+ }[];
40
+ /** true when every content slot is filled (and translated). */
41
+ complete: boolean;
42
+ /** true for frames that can be part of a sentence ("but it was {quality}"). */
43
+ clause?: boolean;
44
+ };
45
+ /** A content slot value: English (translated for you) or `{ text }` in the target language. */
46
+ export type SlotValue = string | {
47
+ text: string;
48
+ };
49
+ /** A frame recognized in a sentence in the language itself, and what the frame says should change. */
50
+ export type FrameMatch = {
51
+ frame: {
52
+ id: string;
53
+ en: string;
54
+ };
55
+ /** The sentence as the frame would have it, for the given region, listener and register. */
56
+ text: string;
57
+ changes: {
58
+ slot: string;
59
+ from: string;
60
+ to: string;
61
+ why: string;
62
+ }[];
63
+ };
64
+ export type Phrasebook = {
65
+ /** The language's frames, filled for the region, listener and register, with content slots open. */
66
+ frames(options: FrameOptions & {
67
+ topic?: string;
68
+ }): Promise<FilledFrame[]>;
69
+ /** One frame with its content slots filled. Unknown ids throw, listing the valid ones. */
70
+ render(id: string, options: FrameOptions & {
71
+ slots?: Record<string, SlotValue>;
72
+ }): Promise<FilledFrame>;
73
+ /** The frame an English sentence (or clause) follows, filled with its slot values; null when none. */
74
+ match(english: string, options: FrameOptions): Promise<FilledFrame | null>;
75
+ /** Frames a sentence in the language itself follows, with the changes the frame suggests. */
76
+ matchFrames(sentence: string, options: FrameOptions): Promise<FrameMatch[]>;
77
+ };
78
+ export type PhrasebookOptions = TranslatorOptions & {
79
+ /** Share a translator (and its dictionaries) instead of creating one. */
80
+ translator?: Translator;
81
+ };
82
+ export declare function createPhrasebook(options?: PhrasebookOptions): Phrasebook;
@@ -0,0 +1,254 @@
1
+ // Sentence frames: common sentences with slots ("Where is {place}?" → "{place} ở {WHERE}?"), filled for a
2
+ // region, a listener and a register. The frames, their words and the pronoun table are the language's data
3
+ // (frames.json, pronouns.json), so this file has no language-specific rules: it picks words by region and
4
+ // labels, fills pronouns from the pronoun table, and translates content slots with the translator.
5
+ //
6
+ // Three directions: list and fill frames (`frames`, `render`), recognize an English sentence as a frame
7
+ // (`match`), and recognize a frame in a sentence in the language itself (`matchFrames`), which is how the
8
+ // journal review checks a sentence against frames ("Bạn có đi?" is the yes/no frame missing "không").
9
+ import { createDictionary, resolveRegion } from "./index.js";
10
+ import { matchCase, plain } from "./text.js";
11
+ import { createTranslator } from "./translate.js";
12
+ import { FRAME_PRONOUNS, } from "./types.js";
13
+ const SLOT = /\{([A-Za-z_]+)\}/g;
14
+ // Words for a casual register only.
15
+ const CASUAL = new Set(['colloquial', 'informal', 'familiar', 'slang', 'Internet']);
16
+ const PERSON_NAMES = {
17
+ self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
18
+ };
19
+ const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
20
+ const quote = (s) => `“${s}”`;
21
+ // Words and their accent-less forms ("tôi", "toi"), longest first so "chúng tôi" wins over "tôi".
22
+ const withPlain = (words) => [...new Set(words.flatMap((w) => [w, plain(w)]))].sort((a, b) => b.length - a.length);
23
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24
+ // English sentences compare without case, curly quotes, extra spaces or the final punctuation.
25
+ const normalizeEn = (s) => s.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim().replace(/[.!?…]+$/, '').trim();
26
+ // Spaces after filling: one between words, none before punctuation or after an empty slot.
27
+ const tidy = (s) => s.replace(/\s+/g, ' ').replace(/\s+([.,!?…])/g, '$1').trim();
28
+ function choose(choices, s) {
29
+ return choices
30
+ .filter((c) => !s.wanted || c.regions.some((r) => s.wanted.has(r)))
31
+ .filter((c) => c.when !== 'respect' || s.row?.respect)
32
+ .filter((c) => s.casual || !c.labels.some((l) => CASUAL.has(l)))
33
+ .map((c, i) => ({ c, i, regional: s.wanted && c.regionTagged ? 1 : 0, casual: s.casual && c.labels.some((l) => CASUAL.has(l)) ? 1 : 0 }))
34
+ .sort((a, b) => b.casual - a.casual || b.regional - a.regional || a.i - b.i)[0]?.c;
35
+ }
36
+ function pronounChoices(row, slot) {
37
+ const p = FRAME_PRONOUNS[slot];
38
+ if (!row || !p)
39
+ return [];
40
+ return row[p.person].filter((c) => p.inclusive === undefined || c.inclusive === undefined || c.inclusive === p.inclusive);
41
+ }
42
+ export function createPhrasebook(options = {}) {
43
+ const cache = new Map();
44
+ const dict = (lang) => {
45
+ let d = cache.get(lang);
46
+ if (!d) {
47
+ d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
48
+ cache.set(lang, d);
49
+ }
50
+ return d;
51
+ };
52
+ const translator = options.translator ?? createTranslator({ ...options, dictionary: dict });
53
+ async function setting(o) {
54
+ const d = dict(o.lang);
55
+ const [meta, data] = await Promise.all([d.meta(), d.frames()]);
56
+ // An unknown listener throws, listing the valid ones (as the translator does).
57
+ if (meta.pronouns && o.listener)
58
+ await d.pronouns({ listener: o.listener });
59
+ const rows = meta.pronouns ? await d.pronouns({ region: o.region, speaker: o.speaker }) : [];
60
+ const row = o.listener ? rows.find((r) => r.id === o.listener) : rows.find((r) => r.default);
61
+ return { meta, data, rows, row, wanted: resolveRegion(meta, o.region), casual: o.register === 'casual' };
62
+ }
63
+ // The word for a frame word or pronoun slot, with why; empty when the slot has no word here (no "ạ"
64
+ // unless the listener's row is marked respect).
65
+ function slotWord(name, s) {
66
+ const words = s.data.words[name];
67
+ if (words) {
68
+ const c = choose(words.choices, s);
69
+ if (!c)
70
+ return { text: '', kind: 'word', slot: name };
71
+ return {
72
+ text: c.word, kind: 'word', slot: name, regions: c.regionTagged ? c.regions : undefined,
73
+ why: `${words.meaning}${c.regionTagged ? ` (${c.regions.join(', ')})` : ''}${c.note ? `: ${c.note}` : ''}`,
74
+ };
75
+ }
76
+ const c = choose(pronounChoices(s.row, name), s);
77
+ const person = PERSON_NAMES[FRAME_PRONOUNS[name]?.person ?? ''];
78
+ if (!c)
79
+ return { text: '', kind: 'pronoun', slot: name };
80
+ return {
81
+ text: c.word, kind: 'pronoun', slot: name, regions: c.regionTagged ? c.regions : undefined,
82
+ why: `“${person}”${s.row && !s.row.default ? `, talking to ${lowerFirst(s.row.label)}` : ''}`,
83
+ };
84
+ }
85
+ async function content(frame, name, value, o) {
86
+ if (value === undefined)
87
+ return { text: `{${name}}`, kind: 'content', slot: name };
88
+ if (typeof value !== 'string')
89
+ return { text: value.text, kind: 'content', slot: name };
90
+ const english = value.trim().replace(/^(the|a|an|some)\s+/i, '');
91
+ const [group] = await translator.translate(english, {
92
+ from: 'en', to: o.lang, toRegion: o.region, pos: frame.slots[name], register: o.register, limit: 1,
93
+ }).catch(() => []);
94
+ const word = group?.translations[0]?.word;
95
+ return word
96
+ ? { text: word, kind: 'content', slot: name, from: value, why: `“${english}”`, regions: group.translations[0].regionTagged ? group.translations[0].regions : undefined }
97
+ : { text: value, kind: 'content', slot: name, from: value, untranslated: true };
98
+ }
99
+ async function fill(frame, o, s, values = {}) {
100
+ const parts = [];
101
+ let last = 0;
102
+ for (const m of frame.text.matchAll(SLOT)) {
103
+ if (m.index > last)
104
+ parts.push({ text: frame.text.slice(last, m.index), kind: 'fixed' });
105
+ const name = m[1];
106
+ parts.push(frame.slots[name] ? await content(frame, name, values[name], o) : slotWord(name, s));
107
+ last = m.index + m[0].length;
108
+ }
109
+ if (last < frame.text.length)
110
+ parts.push({ text: frame.text.slice(last), kind: 'fixed' });
111
+ let text = tidy(parts.map((p) => p.text).join(''));
112
+ if (!frame.clause)
113
+ text = text.charAt(0).toUpperCase() + text.slice(1);
114
+ const slots = Object.entries(frame.slots).map(([name, pos]) => ({ name, pos }));
115
+ const en = Object.keys(frame.slots).reduce((s, name) => (typeof values[name] === 'string' ? s.replace(`{${name}}`, values[name]) : s), frame.en[0]);
116
+ return {
117
+ id: frame.id, topic: frame.topic, en, text, ...(frame.clause ? { clause: true } : {}),
118
+ parts: parts.filter((p) => p.text),
119
+ slots,
120
+ complete: slots.every(({ name }) => values[name] !== undefined) && !parts.some((p) => p.untranslated),
121
+ };
122
+ }
123
+ async function frameById(id, o) {
124
+ const s = await setting(o);
125
+ const frame = s.data.frames.find((f) => f.id === id);
126
+ if (!frame) {
127
+ throw new Error(`which-dialect: unknown frame "${id}" for ${s.meta.name}. Use one of: ${s.data.frames.map((f) => f.id).join(', ') || '(none)'}`);
128
+ }
129
+ return { frame, s };
130
+ }
131
+ return {
132
+ async frames(o) {
133
+ const s = await setting(o);
134
+ return Promise.all(s.data.frames.filter((f) => !o.topic || f.topic === o.topic).map((f) => fill(f, o, s)));
135
+ },
136
+ async render(id, o) {
137
+ const { frame, s } = await frameById(id, o);
138
+ return fill(frame, o, s, o.slots);
139
+ },
140
+ async match(english, o) {
141
+ const s = await setting(o);
142
+ const sentence = normalizeEn(english);
143
+ for (const frame of s.data.frames) {
144
+ for (const template of frame.en) {
145
+ const names = [];
146
+ const pattern = escape(normalizeEn(template)).replace(/\\\{([A-Za-z_]+)\\\}/g, (_, name) => {
147
+ names.push(name);
148
+ return '(.+?)';
149
+ });
150
+ const m = new RegExp(`^${pattern}$`, 'u').exec(sentence);
151
+ if (!m)
152
+ continue;
153
+ const values = Object.fromEntries(names.map((n, i) => [n, m[i + 1]]));
154
+ const filled = await fill(frame, o, s, values);
155
+ return { ...filled, en: english.trim() };
156
+ }
157
+ }
158
+ return null;
159
+ },
160
+ async matchFrames(sentence, o) {
161
+ const s = await setting(o);
162
+ const d = dict(o.lang);
163
+ // Every pronoun of a column, in every row and region: a frame position decides it's a pronoun.
164
+ const allRows = s.meta.pronouns ? await d.pronouns({ exclude: [] }) : [];
165
+ const terminal = /[?]\s*$/.test(sentence) ? '?' : '';
166
+ const core = sentence.trim().replace(/[.!?…]+$/, '').trim();
167
+ const out = [];
168
+ for (const frame of s.data.frames) {
169
+ if (frame.text.trim().endsWith('?') !== (terminal === '?'))
170
+ continue;
171
+ const tokens = [...frame.text.replace(/[.!?…]+\s*$/, '').matchAll(/\{([A-Za-z_]+)\}|[^{]+/g)].map((m) => m[1] ? { slot: m[1] } : { fixed: m[0] });
172
+ // Frames without fixed words ("{thing} {HOW}?") match only with every word present; frames with
173
+ // fixed words can spot a missing required word ("Bạn có đi?" → "… không?"). Frames with neither
174
+ // fixed words nor frame words ("{I} {quality}.") would match almost anything, so they're skipped.
175
+ const anchored = tokens.some((t) => t.fixed && /\p{L}/u.test(t.fixed));
176
+ if (!anchored && !tokens.some((t) => t.slot && s.data.words[t.slot]))
177
+ continue;
178
+ const slots = [];
179
+ let pattern = '';
180
+ for (const t of tokens) {
181
+ if (t.fixed !== undefined) {
182
+ pattern += t.fixed.trim() ? `\\s*${escape(t.fixed.trim()).replace(/\s+/g, '\\s+')}\\s*` : '\\s*';
183
+ continue;
184
+ }
185
+ const name = t.slot;
186
+ if (frame.slots[name]) {
187
+ slots.push({ name, kind: 'content', optional: false });
188
+ pattern += '(.+?)';
189
+ }
190
+ else if (s.data.words[name]) {
191
+ const alts = withPlain(s.data.words[name].choices.map((c) => c.word)).map(escape);
192
+ const optional = Boolean(s.data.words[name].optional) || anchored;
193
+ slots.push({ name, kind: 'word', optional });
194
+ pattern += `(${alts.join('|')})${optional ? '?' : ''}`;
195
+ }
196
+ else {
197
+ const alts = withPlain(allRows.flatMap((r) => pronounChoices(r, name)).map((c) => c.word));
198
+ if (!alts.length)
199
+ continue;
200
+ slots.push({ name, kind: 'pronoun', optional: false });
201
+ pattern += `(${alts.map(escape).join('|')})`;
202
+ }
203
+ }
204
+ const m = new RegExp(`^${pattern}$`, 'iu').exec(core);
205
+ if (!m)
206
+ continue;
207
+ const changes = [];
208
+ const filled = {};
209
+ slots.forEach((slot, i) => {
210
+ let captured = m[i + 1] ?? '';
211
+ filled[slot.name] = captured;
212
+ if (slot.kind === 'content')
213
+ return;
214
+ // A word missing its accents in a frame position ("Toi" where the frame has a pronoun) is that word.
215
+ const known = slot.kind === 'word'
216
+ ? s.data.words[slot.name].choices.map((c) => c.word)
217
+ : allRows.flatMap((r) => pronounChoices(r, slot.name)).map((c) => c.word);
218
+ if (captured && !known.includes(captured.toLowerCase())) {
219
+ const accented = known.find((w) => plain(w) === captured.toLowerCase());
220
+ if (accented) {
221
+ const fixed = matchCase(captured, accented);
222
+ changes.push({ slot: slot.name, from: captured, to: fixed, why: `${quote(fixed)} needs its accents` });
223
+ filled[slot.name] = captured = fixed;
224
+ }
225
+ }
226
+ const expected = slotWord(slot.name, s);
227
+ if (slot.kind === 'pronoun') {
228
+ // Pronouns change only for a chosen listener: in a journal, "Má" may be who you write about.
229
+ const fits = pronounChoices(s.row, slot.name).some((c) => c.word === captured.toLowerCase());
230
+ if (o.listener && expected.text && !fits) {
231
+ filled[slot.name] = matchCase(captured, expected.text);
232
+ changes.push({ slot: slot.name, from: captured, to: filled[slot.name], why: expected.why ?? '' });
233
+ }
234
+ return;
235
+ }
236
+ const words = s.data.words[slot.name];
237
+ const allowed = words.choices.filter((c) => choose([c], s));
238
+ if (captured && !allowed.some((c) => c.word === captured.toLowerCase()) && expected.text) {
239
+ filled[slot.name] = matchCase(captured, expected.text);
240
+ changes.push({ slot: slot.name, from: captured, to: filled[slot.name], why: expected.why ?? '' });
241
+ }
242
+ else if (!captured && !words.optional && expected.text) {
243
+ filled[slot.name] = expected.text;
244
+ changes.push({ slot: slot.name, from: '', to: expected.text, why: `${words.meaning}: this frame needs it` });
245
+ }
246
+ });
247
+ let text = tidy(frame.text.replace(/[.!?…]+\s*$/, '').replace(SLOT, (_, name) => filled[name] ?? ''));
248
+ text = matchCase(core, text) + (terminal || sentence.trim().match(/[.!?…]+$/)?.[0] || '');
249
+ out.push({ frame: { id: frame.id, en: frame.en[0] }, text, changes });
250
+ }
251
+ return out;
252
+ },
253
+ };
254
+ }
@@ -0,0 +1,59 @@
1
+ import { type CheckRule } from './check.ts';
2
+ import { type FrameOptions } from './phrasebook.ts';
3
+ import { type TranslatorOptions } from './translate.ts';
4
+ export type ReviewOptions = FrameOptions & {
5
+ /** The learner's own language, whose words are translated into the target language (default "en"). */
6
+ base?: string;
7
+ /** Checks to add to the spellchecker, e.g. { dialect: true, 'pronoun-consistency': true } (see CheckRule). */
8
+ checks?: Partial<Record<CheckRule, boolean>>;
9
+ };
10
+ export type ReviewChange = {
11
+ /** What was written. */
12
+ from: string;
13
+ /** The correction; empty when the word is left out (Vietnamese has no "the"). */
14
+ to: string;
15
+ /** One sentence for the learner. */
16
+ why: string;
17
+ /** 'frame' (a base-language sentence or clause filled into a sentence frame), or the checker rule. */
18
+ kind: 'frame' | CheckRule;
19
+ };
20
+ /** A checker suggestion that wasn't applied ("end with ạ"), for the learner to consider. */
21
+ export type ReviewHint = {
22
+ rule: CheckRule;
23
+ text: string;
24
+ message: string;
25
+ suggestions: string[];
26
+ };
27
+ export type ReviewSentence = {
28
+ /** Offsets of the sentence in Review.text, including its final punctuation. */
29
+ start: number;
30
+ end: number;
31
+ original: string;
32
+ /** The sentence split by language, e.g. [{ text: 'Hôm nay tôi đi', lang: 'vi' }, { text: 'market', lang: 'en' }, …]. */
33
+ parts: {
34
+ text: string;
35
+ lang: string;
36
+ }[];
37
+ corrected: string;
38
+ changes: ReviewChange[];
39
+ hints: ReviewHint[];
40
+ /** Sentence frames the sentence follows or was built from: the frame's English and its pattern. */
41
+ frames: {
42
+ id: string;
43
+ en: string;
44
+ text: string;
45
+ }[];
46
+ /** Words the review couldn't correct (a base-language word with no translation), left as written. */
47
+ unchecked: string[];
48
+ };
49
+ export type Review = {
50
+ /** The entry, in Unicode NFC form; sentence offsets refer to it. */
51
+ text: string;
52
+ /** The whole entry with every sentence corrected. */
53
+ corrected: string;
54
+ sentences: ReviewSentence[];
55
+ };
56
+ export type Reviewer = {
57
+ review(entry: string, options: ReviewOptions): Promise<Review>;
58
+ };
59
+ export declare function createReviewer(options?: TranslatorOptions): Reviewer;