which-dialect 0.1.1 → 0.3.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/README.md CHANGED
@@ -18,6 +18,8 @@ await tr.translate('cool', { from: 'en', to: 'vi', toRegion: 'Southern', meaning
18
18
 
19
19
  Each result is a list of **groups, one per meaning** of the source word, each with its part of speech, its definitions and its translations, so a word's meanings never get mixed up.
20
20
 
21
+ **Data:** Wiktionary data of 2026-09-25 for both languages. See [`DATA_UPDATES.md`](DATA_UPDATES.md) for each language's dates and update history.
22
+
21
23
  You install one small package. Dictionary data is **not bundled**: each lookup fetches one small file (usually 5–100 kB compressed) from the language's data package on the jsDelivr CDN, and caches it.
22
24
 
23
25
  ## Languages
@@ -60,11 +62,12 @@ Loads each language's data on demand. The options are only needed to self-host t
60
62
  | `exclude` | Labels to leave out (default: vulgar, offensive, derogatory, archaic, obsolete, dated, historical, rare, abbreviation). |
61
63
  | `limit` | Translations per meaning (default 5). |
62
64
  | `allSenses` | Return every meaning, including ones with no translation, instead of only useful ones. For letting users pick a meaning. |
65
+ | `picks` | Use the target language's [hand-picked words](#hand-picked-words) (default `true`). `false` returns the ranking alone. |
63
66
 
64
67
  It returns `TranslationGroup[]`, most relevant meaning first. Each group has:
65
68
 
66
69
  - `source`: the meaning being translated: `lemma` (the base or standard word, e.g. *say* for *said*), `pos`, `glosses` (its English definitions), `regions`, `labels`, and `via` when the word led there ("simple past of say", "Southern Vietnam form of không").
67
- - `translations`: target words, best first, each with its own `pos`, `gloss`, `regions`, `labels`, a `score`, and `bridge` (how it was found; `'pronouns'` for the listener's words, which also carry `relationship`, e.g. "Your parents").
70
+ - `translations`: target words, best first, each with its own `pos`, `gloss`, `regions`, `labels`, a `score`, and `bridge` (how it was found; `'picked'` for [hand-picked words](#hand-picked-words); `'pronouns'` for the listener's words, which also carry `relationship`, e.g. "Your parents").
68
71
  - `pronounUses` (source words in the source language's pronoun table): the relationships the word is used in with this meaning, `{ id, label, person: 'self' | 'addressee', speaker?, regions, note?, warning? }`. Uses no definition covers get a group of their own, translated as "I" or "you": Vietnamese *em* → "I/me, when talking to: someone a bit older (man); …; your teacher" and "you, when talking to: someone younger; …"; *ba* → "you, when talking to: your parents". Pass `pos: 'pron'` to get only these.
69
72
  - `relationships` (singular "I"/"you" meanings into a language with a pronoun table): the word for each relationship, `{ id, label, words, warning? }`, filtered by `toRegion`, `speaker` and `exclude`, for showing "it depends on who you're talking to".
70
73
 
@@ -83,19 +86,117 @@ Every language's data defines its words in English, so English is the bridge bet
83
86
  5. **Within one language, synonyms are direct equivalents.** Wiktionary lists dialect words as synonyms (*ngô* → *bắp*, *lợn* → *heo*, *lift* → *elevator*), in either direction (*dạ* lists *vâng*).
84
87
  6. **Ranking** favors **common words** ([word frequencies](#word-frequencies): *anh* over the niche *cô nương* for "you"), target words whose definition shares the source definition's details, whose *main* meaning is the match, that are tagged for the target region, and that keep the **register**: a polite word translates to a polite word (Northern *vâng* → Southern *dạ*), slang to slang.
85
88
 
89
+ **Hand-picked words** come before all of these for the meanings that have them (see below).
90
+
86
91
  Meanings are ordered by `meaning` (if given), then by being tagged for `fromRegion`, then the dictionary's own order, which lists main meanings first. That order is a guess when a word has several parts of speech: English *just* is listed as an adjective ("fair") before the adverb. Pass `pos` or `meaning` when you know which one you want.
87
92
 
93
+ ### Hand-picked words
94
+
95
+ For common words, a speaker of the target language knows the natural word even where the data ranks another first: *get* "To fetch, bring, take" ranks Vietnamese *đưa* ("to hand, to bring") above *lấy*, because *đưa*'s definition matches more of the English words and no *lấy* definition says "fetch". Each language can list **hand-picked words for English meanings** (`picks` in `languages/<code>.ts`, built into `picks.json`). In a meaning that has picks, the picked words come first (`bridge: 'picked'`), then the ranked words; other meanings are unchanged. Picks can carry a region tag, like translation tables: with `toRegion`, picks tagged for it come first and picks tagged only for other regions are left to the ranking. A pick can also mark its meaning as the one to put first when no `pos` or `meaning` is given.
96
+
97
+ Picks are keyed by English meaning, so each language needs one list however many languages there are: translating from any other language reaches them through the English meaning (as with translation tables). They're optional: a language without picks uses the ranking alone. `picks: false` shows the ranking alone, and `npm run evaluate` reports both. Vietnamese picks so far: *get* "fetch" → *lấy*, *mang*; *get* "obtain" → *lấy*.
98
+
88
99
  ### How accurate is it?
89
100
 
90
101
  `npm run evaluate` runs known-correct translations across language pairs and dialect pairs (English → Vietnamese by region, including pronouns, Northern → Southern Vietnamese, US ↔ UK, Vietnamese → English). A case passes when a correct word is in the top 3 of the first meaning:
91
102
 
92
103
  | Set | Correct word in top 3 | Correct word first |
93
104
  |---|---|---|
94
- | Tuning set (used while developing) | 42 / 42 (100%) | 42 / 42 (100%) |
105
+ | Tuning set (used while developing) | 49 / 49 (100%) | 49 / 49 (100%); 48 / 49 without picks |
95
106
  | Held-out set (written afterwards, not tuned against) | 17 / 17 (100%) | 16 / 17 (94%) |
96
107
 
97
108
  The cases involving Spanish were removed with it. The held-out set has since been looked at, so new held-out cases are needed before relying on it (see [`FUTURE_IMPROVEMENTS.md`](FUTURE_IMPROVEMENTS.md)).
98
109
 
110
+ ## Checking text
111
+
112
+ ### `createChecker({ baseUrl?, load?, dictionary? })` → `checker.check(text, options)`
113
+
114
+ Checks a learner's sentence in one language and returns issues with the exact place in the text, a short explanation and suggested fixes. It runs in the browser like the rest of the library: no API calls, no cost.
115
+
116
+ ```ts
117
+ import { createChecker } from 'which-dialect'
118
+
119
+ const checker = createChecker()
120
+ const { text, issues } = await checker.check('Tôi muốn ăn thịt lợn và bắp khong?', {
121
+ lang: 'vi', region: 'Southern', listener: 'parent',
122
+ })
123
+ // “Tôi”: Talking to your parents, say “con” for “I”, not “Tôi”. → Con
124
+ // “lợn”: “lợn” is Northern; in Southern Vietnamese, say “heo”. → heo, cúi, ỉn
125
+ // “khong”: “khong” isn't a Vietnamese syllable. Did you mean “không”, “khổng” or “khống”?
126
+ // “khong”: Talking to your parents, end the sentence with “ạ” to sound polite. (suggestion)
127
+ ```
128
+
129
+ | Option | Meaning |
130
+ |---|---|
131
+ | `lang` | The text's language. |
132
+ | `region` | The region the text should be in. Without it, words that don't fit the rest of the text's region are pointed out (*lợn*, Northern, next to *nha*, Southern). |
133
+ | `listener` | Who you're talking to, as a [pronoun-table](#looking-words-up) row id (`'parent'`, `'teacher'`, `'friend'`, …): pronouns and polite endings are checked against it. |
134
+ | `about` | Who you're talking about, for "he/she" and "they" pronouns. |
135
+ | `speaker` | `'male'` or `'female'`, for pronouns that depend on it. |
136
+ | `rules` | Turn checks off, e.g. `{ 'polite-ending': false }`. |
137
+
138
+ Each issue has `rule`, `severity` (`'error'`: not a word of the language; `'warning'`: wrong for the chosen region or relationship; `'suggestion'`: often better), `start`/`end` (offsets into the returned `text`, the input in Unicode NFC form), `text`, `message` and `suggestions` (replacements for `text`, best first).
139
+
140
+ The checks:
141
+
142
+ - **`spelling`**: syllables that aren't in the language, with the accented forms they could be (*khong* → *không*). Plain-letter words with no accented form are left alone (names, foreign words). Languages written in syllables only (Vietnamese).
143
+ - **`dialect`**: words whose main meaning is tagged for another region (*lợn* in Southern text → *heo*; *muỗng* in Northern → *thìa*; in English, US *apartment* in UK text → *flat*). Words with an untagged main meaning are never flagged (*má* is "cheek" everywhere).
144
+ - **`pronoun-relationship`**: pronouns that don't fit the listener (*tôi* to a parent → *con*; to a teacher → *em*).
145
+ - **`pronoun-pair`**: "I" and "you" pronouns that never go together (*tao* goes with *mày*, not *bạn*), when no listener is given.
146
+ - **`pronoun-consistency`**: switching words for the same person within one text (*tôi* in one sentence, *tui* in the next): keep the first.
147
+ - **`polite-ending`**: a suggestion to end sentences with *ạ* when talking to parents, elders and teachers (not for exclamations).
148
+
149
+ The checker only speaks up when it's confident, so **no issues means none of the checks found anything, not that the text is correct**. It doesn't check word order, classifiers or whether a sentence sounds natural yet. Only reliable pronouns are checked: kinship words like *con* (also "child", and a classifier in *con chó*) aren't, and *bạn* (also "friend") only gets suggestions.
150
+
151
+ **Every language uses the same checks.** They read only the language's data and its `checker` settings in `languages/<code>.ts` (how text splits into words, which pronouns are reliable, polite endings), so a check whose data a language doesn't have is skipped. A language without checker settings still gets the `dialect` check.
152
+
153
+ ## Sentence frames
154
+
155
+ Common sentences with slots, filled for a region, a listener and a register: *Where is {place}?* is *{place} ở **đâu**?*, and in Central Vietnamese *{place} ở **mô**?*; *Have you eaten yet?* to a parent in the South is *Ba ăn cơm chưa ạ?*, to a friend *Bạn ăn cơm chưa?*.
156
+
157
+ ```ts
158
+ import { createPhrasebook } from 'which-dialect'
159
+
160
+ const pb = createPhrasebook()
161
+ await pb.frames({ lang: 'vi', region: 'Southern', topic: 'questions' }) // every frame, slots open
162
+ await pb.render('ask-where', { lang: 'vi', region: 'Central', slots: { place: 'bathroom' } })
163
+ // → { text: 'Phòng tắm ở mô?', parts: [{ text: 'Phòng tắm', kind: 'content', from: 'bathroom' }, …,
164
+ // { text: 'mô', kind: 'word', why: 'where (Central)' }, …], complete: true }
165
+ await pb.match('Do you want coffee?', { lang: 'vi' }) // → 'Bạn có muốn cà phê không?'
166
+ await pb.matchFrames('Bạn có đi?', { lang: 'vi' }) // → 'Bạn có đi không?' (missing "không")
167
+ ```
168
+
169
+ Options: `lang`, `region`, `listener` (a pronoun-table row id; without it, the neutral default row), `speaker`, `register` (`'casual'` allows colloquial words like Southern *hông*, *tui*). Content slots take English (translated for you, in the region) or `{ text }` in the language itself. `parts` says why each word was chosen, for highlighting. `matchFrames` recognizes a frame in a sentence in the language itself and returns the changes the frame suggests: a missing required word (*không*), a word from another region (*mô* in Southern text → *đâu*), missing accents in a frame position (*Toi* → *Tôi*), and, only when a listener is given, a pronoun that doesn't fit them.
170
+
171
+ The frames themselves are written once, in English, in [`languages/frames.ts`](languages/frames.ts) (34 so far: journal sentences, feelings, questions, requests, greetings). Each language says how it expresses them (`frames` in `languages/<code>.ts`), with pronoun slots (`{I}`, `{YOU}`, `{WE_INCL}`, …, filled from the pronoun table) and **frame words** for what changes by region (`frameWords`: Vietnamese `{WHERE}` *đâu* / Central *mô*, `{HOW}` Northern *thế nào* / *sao* / Central *răng*, `{SOFT}` Northern *nhé* / Central and Southern *nha*, `{Q_END}` *không* / Southern casual *hông*, `{POLITE}` *ạ* when speaking up). Each frame word comes from a dictionary definition, keeping its region and labels, or from an override with a note, like the pronoun table. A language without frames returns none.
172
+
173
+ ## Reviewing journal entries
174
+
175
+ `createReviewer().review(entry, { lang, base?, region?, listener?, speaker?, register? })` reviews an entry written in the target language, the learner's own language (`base`, default `'en'`), or a mix of both, and returns the corrected entry sentence by sentence:
176
+
177
+ ```ts
178
+ import { createReviewer } from 'which-dialect'
179
+
180
+ const review = await createReviewer().review(
181
+ 'Hôm nay tôi đi market với má. Tôi muốn ăn thịt lợn but it was expensive. Ngày mai tui đi lại khong?',
182
+ { lang: 'vi', region: 'Southern' },
183
+ )
184
+ review.corrected
185
+ // 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt heo nhưng mắc quá. Ngày mai tôi đi lại không?'
186
+ review.sentences[1].changes
187
+ // [{ from: 'but it was expensive', to: 'nhưng mắc quá', kind: 'frame', why: 'Sentence frame “but it was {quality}”' },
188
+ // { from: 'lợn', to: 'heo', kind: 'dialect', why: '“lợn” is Northern; in Southern Vietnamese, say “heo”.' }]
189
+ ```
190
+
191
+ For each sentence:
192
+
193
+ 1. **Each word is marked as the target or the base language** (`parts`), from the two dictionaries alone. Words that could be either (*an*, *to*) take their neighbors' language; an English word that's Vietnamese only with accents added (*but*: *bút*) stays English, while Vietnamese typed without accents (*khong*) stays Vietnamese.
194
+ 2. **Base-language parts are replaced:** a whole sentence or clause that follows a sentence frame is filled in (*but it was expensive* → *nhưng mắc quá*; *Where is the bathroom?* → *Phòng tắm ở đâu?*), and words or short phrases (up to 3 words) are translated in the region (*market* → *chợ*). Anything longer is left as written and listed in `unchecked`: the review doesn't guess.
195
+ 3. **The grammar checker runs on the whole entry**, so consistency checks see every sentence (*tôi* earlier, *tui* later). Its errors and warnings are applied (`changes`); its suggestions, like ending with *ạ*, are returned as `hints`.
196
+ 4. **The corrected sentence is checked against sentence frames** (*Bạn có đi chợ?* → *Bạn có đi chợ không?*), and the frames it follows are listed (`frames`), for suggesting patterns to reuse.
197
+
198
+ Each sentence has `original`, `parts`, `corrected`, `changes` (`{ from, to, why, kind }`), `hints`, `frames` (`{ id, en, text }`, the frame's English and its pattern) and `unchecked`. For a journal, leave `listener` out: people written about (*má*, *thầy*) aren't treated as the listener. Rules can't fix everything: scrambled word order, missing verbs, or English sentences no frame covers come back as written, and plain-letter words that are also Vietnamese words (*toi*, *di*) are only fixed when a sentence frame shows what they should be.
199
+
99
200
  ## Looking words up
100
201
 
101
202
  ### `createDictionary({ lang, baseUrl?, load? })`
@@ -104,6 +205,9 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
104
205
 
105
206
  - `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
106
207
  - `dictionary.searchEnglish(term, { region?, pos?, exclude?, limit?, allSenses? })`: words for an English term in this language, one per word, best first (no meaning handling: use the translator for that). `allSenses: true` returns every matching sense of those words instead.
208
+ - `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
209
+ - `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
210
+ - `dictionary.picks()`: the language's [hand-picked words](#hand-picked-words), `{ word, pos, gloss, picks: [{ word, tags? }], first? }` per English meaning (empty for languages without any).
107
211
  - `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
108
212
  - `dictionary.pronouns({ listener?, region?, speaker?, exclude? })`: how to say "I", "you", "he/she", "we", plural "you" and "they" depending on who you're talking to (or about), one row per relationship, for languages whose pronouns depend on it (so far Vietnamese; empty for others). Each row has the columns `self`, `addressee`, `third`, `selfPlural`, `addresseePlural` and `thirdPlural`. Each word says where it comes from: a dictionary definition (`source: 'gloss'`, with the `gloss`), a grammar rule for a regular compound the dictionary doesn't list (`source: 'rule'`, e.g. *các anh*, with a `note`), or a hand-written override (`source: 'override'`, with a `note`); and, when it has one, the `gender` of the person it refers to and whether a "we" is `inclusive`.
109
213
 
@@ -172,17 +276,26 @@ If you **redistribute modified data**, it has to stay under CC BY-SA 4.0. Using
172
276
 
173
277
  Requires Node 22.6+ (TypeScript scripts run with `--experimental-strip-types`).
174
278
 
279
+ Generated data (`packages/<code>/data`) isn't committed, so a fresh clone needs it first. Two ways:
280
+
281
+ - **Quick start: `npm run fetch:data`.** Downloads the published data packages from npm (a few seconds), then rebuilds hand-picked words from the repo's configs. You get exactly the published data, so tests and `npm run evaluate` match the numbers in this README. Use this unless you're changing how data is built.
282
+ - **Build from Wiktionary: `npm run build:data:all`.** Downloads Kaikki's files (English 3.3 GB, Vietnamese 79 MB; cached in `.cache/`) and builds every language, **English first**: English is the bridge between languages, and other languages' hand-picked words are checked against it. Kaikki always serves its latest dump, so this can be newer than the published data; the build records the dates in [`DATA_UPDATES.md`](DATA_UPDATES.md). Building one language alone (`npm run build:data -- vi`) also works once English is built.
283
+
175
284
  ```sh
176
285
  npm install
177
- npm run build:data -- vi # download Kaikki's file for a language (cached in .cache/) and build packages/<code>/data
178
- npm run build:data -- vi --refresh # re-download first
286
+ npm run fetch:data # quick start: the published data (-- --force replaces data you already have)
287
+ npm run build:data:all # or build every language from Kaikki, English first (-- --refresh re-downloads)
288
+ npm run build:data -- vi # build one language (after English); -- vi --refresh re-downloads first
179
289
  npm test # unit tests, plus checks against whichever languages are built
180
- npm run evaluate # translation accuracy on known-correct cases (-- --verbose to see every case)
290
+ npm run evaluate # translation accuracy on known-correct cases, with and without picks (-- --verbose to see every case)
291
+ npm run build:picks -- vi # rebuild only a language's hand-picked words (after editing `picks` in languages/vi.ts)
292
+ npm run picks-sheet -- vi # review sheet (.cache/picks-vi.csv) of the top English words' meanings; -- --words file.txt, -- --top 500
293
+ npm run picks-sheet -- vi --apply sheet.csv # config rows for the rows whose your_pick is filled in
181
294
  npm run typecheck
182
295
  npm run build # compile the API to packages/core/dist
183
296
  ```
184
297
 
185
- Generated data isn't committed (it would bloat git history); it's built before publishing. Downloads: Vietnamese 79 MB, English 3.3 GB. Builds take seconds (English: about 25 s and 1.4 GB of memory).
298
+ Generated data isn't committed (it would bloat git history); it's built before publishing. Builds take seconds (English: about 25 s and 1.4 GB of memory).
186
299
 
187
300
  ### Adding a language
188
301
 
@@ -190,13 +303,16 @@ Generated data isn't committed (it would bloat git history); it's built before p
190
303
  2. Copy a data package (`packages/vi`) to `packages/<code>` and update its `package.json` and `README.md`.
191
304
  3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
192
305
  4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
306
+ 5. Optional: [checker](#checking-text) settings (`checker` in `languages/<code>.ts`): `units: 'syllables'` for languages written in syllables (also builds the syllable list for accent suggestions), `maxWordUnits`, the reliable `pronouns` per pronoun-table column, `ambiguousPronouns`, and `politeEndings`. Without them, the language is read as space-separated words and gets the region check.
307
+ 6. Optional: [sentence frames](#sentence-frames): `frames` (how the language says each frame in `languages/frames.ts`) and `frameWords` (the words that change by region, each from a definition or an override with a note). Frames reach other languages through the shared catalog; a new frame goes in the catalog once.
308
+ 7. Optional, later: [hand-picked words](#hand-picked-words). A speaker runs `npm run picks-sheet -- <code>`, fills in `your_pick` only where the first choice is wrong, and pastes the `--apply` output into `picks`.
193
309
 
194
310
  Languages in non-Latin scripts (Chinese, Arabic, Russian, …) will need a script-aware version of `shardKey` first: today files are split by the first two Latin letters.
195
311
 
196
312
  ### Publishing
197
313
 
198
314
  ```sh
199
- npm run build:data -- en --refresh && npm run build:data -- vi --refresh && npm test
315
+ npm run build:data:all -- --refresh && npm test # records the new dates in DATA_UPDATES.md: commit it
200
316
  npm publish -w which-dialect-en
201
317
  npm publish -w which-dialect-vi
202
318
  npm publish -w which-dialect
@@ -0,0 +1,45 @@
1
+ import { type TranslatorOptions } from './translate.ts';
2
+ export type CheckRule = 'spelling' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
3
+ export type CheckIssue = {
4
+ rule: CheckRule;
5
+ /**
6
+ * 'error': almost certainly wrong (not a word of the language). 'warning': wrong for the chosen region
7
+ * or relationship. 'suggestion': often better; worth a look.
8
+ */
9
+ severity: 'error' | 'warning' | 'suggestion';
10
+ /** Character offsets of `text` in the checked text (`CheckResult.text`, the input in Unicode NFC form). */
11
+ start: number;
12
+ end: number;
13
+ /** The text the issue is about. */
14
+ text: string;
15
+ /** One sentence for the learner, e.g. “khong” isn't a Vietnamese syllable. Did you mean “không”? */
16
+ message: string;
17
+ /** Replacements for `text`, best first; empty when there's nothing specific to suggest. */
18
+ suggestions: string[];
19
+ };
20
+ export type CheckOptions = {
21
+ /** Language code of the text, e.g. "vi". */
22
+ lang: string;
23
+ /** Region or region group the text should be in, e.g. "Southern". Without it, mixed regions are flagged. */
24
+ region?: string;
25
+ /** Who you're talking to, as a pronoun-table row id (see Dictionary.pronouns), e.g. "parent". */
26
+ listener?: string;
27
+ /** Who you're talking about, as a pronoun-table row id, for "he/she" and "they" pronouns. */
28
+ about?: string;
29
+ /** The speaker's gender, for pronouns that depend on it. */
30
+ speaker?: 'male' | 'female';
31
+ /** Turn rules off, e.g. { 'polite-ending': false }. All are on by default. */
32
+ rules?: Partial<Record<CheckRule, boolean>>;
33
+ };
34
+ export type CheckResult = {
35
+ /** The checked text: the input in Unicode NFC form, which the issues' offsets refer to. */
36
+ text: string;
37
+ /** Sorted by position. */
38
+ issues: CheckIssue[];
39
+ };
40
+ export type Checker = {
41
+ check(text: string, options: CheckOptions): Promise<CheckResult>;
42
+ };
43
+ /** Same as the translator's: where each language's data comes from. */
44
+ export type CheckerOptions = TranslatorOptions;
45
+ export declare function createChecker(options?: CheckerOptions): Checker;
package/dist/check.js ADDED
@@ -0,0 +1,347 @@
1
+ // Checking text in one language: spelling and accents, words from another region, and pronouns and
2
+ // polite endings that fit who you're talking to.
3
+ //
4
+ // Every rule reads only the language's data: its dictionary, pronoun table, checker.json and
5
+ // syllables.json (see CheckerConfig). Adding a language means adding data and settings in
6
+ // languages/<lang>.ts, not code here. A rule whose data a language doesn't have is skipped, so a language
7
+ // without checker settings is still checked for words from another region.
8
+ //
9
+ // The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
10
+ // the checks found anything, not that the text is correct.
11
+ import { createDictionary, resolveRegion } from "./index.js";
12
+ import { joined, matchCase, plain, segment, sentencesOf, units } from "./text.js";
13
+ import { createTranslator } from "./translate.js";
14
+ import { PRONOUN_PERSONS, } from "./types.js";
15
+ // How each pronoun-table column is named in messages.
16
+ const PERSON_NAMES = {
17
+ self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
18
+ };
19
+ // Columns chosen by `about` (who you're talking about); the rest by `listener`.
20
+ const ABOUT_PERSONS = new Set(['third', 'thirdPlural']);
21
+ const SELF_PERSONS = new Set(['self', 'selfPlural']);
22
+ const ADDRESSEE_PERSONS = new Set(['addressee', 'addresseePlural']);
23
+ const quote = (s) => `“${s}”`;
24
+ const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
25
+ const listOf = (items, conjunction = 'and') => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
26
+ const unique = (items) => [...new Set(items)];
27
+ // --- Rules -----------------------------------------------------------------------------------------------
28
+ // Spelling and accents, for languages written in syllables with a syllable list: a syllable that isn't in
29
+ // the language is flagged, with the accented forms it could be ("khong" → "không"). Plain-letter
30
+ // syllables with no accented form are left alone: usually a name or a foreign word ("email").
31
+ async function spelling(ctx) {
32
+ if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
33
+ return [];
34
+ const syllables = await ctx.dict.syllables();
35
+ const byPlain = new Map();
36
+ for (const s of Object.keys(syllables))
37
+ byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
38
+ for (const list of byPlain.values())
39
+ list.sort((a, b) => syllables[b] - syllables[a]);
40
+ const issues = [];
41
+ for (const word of ctx.sentences.flatMap((s) => s.words)) {
42
+ if (word.entries.length)
43
+ continue;
44
+ for (const u of word.units) {
45
+ const lower = u.text.toLowerCase().replace(/[’']/g, '');
46
+ if (/\d/.test(lower) || lower in syllables)
47
+ continue;
48
+ const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
49
+ const accented = lower !== plain(lower);
50
+ if (!candidates.length && !accented)
51
+ continue;
52
+ issues.push({
53
+ rule: 'spelling',
54
+ // Missing accents on plain letters is the common learner slip; a wrong accent is more surely wrong.
55
+ severity: accented ? 'error' : 'warning',
56
+ start: u.start,
57
+ end: u.end,
58
+ text: u.text,
59
+ message: candidates.length
60
+ ? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or')}?`
61
+ : `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`,
62
+ suggestions: candidates.map((c) => matchCase(u.text, c)),
63
+ });
64
+ }
65
+ }
66
+ return issues;
67
+ }
68
+ // Words from another region: a word whose main meaning (its first sense) is tagged for regions that don't
69
+ // include the text's region. With `region`, against that; without, against the regions most of the text's
70
+ // regional words share (so "lợn" stands out in otherwise Southern text). Words with an untagged main
71
+ // meaning are never flagged (Vietnamese "má" is "cheek" everywhere, "mother" only in the South).
72
+ async function dialect(ctx) {
73
+ const all = new Set(ctx.meta.regions);
74
+ const regional = ctx.sentences
75
+ .flatMap((s) => s.words)
76
+ .map((word) => ({ word, sense: word.entries[0]?.senses[0] }))
77
+ .filter((x) => Boolean(x.sense?.regionTagged && x.sense.regions.length < all.size));
78
+ if (!regional.length)
79
+ return [];
80
+ const issue = (word, regions, message, suggestions, severity) => ({
81
+ rule: 'dialect',
82
+ severity,
83
+ start: word.start,
84
+ end: word.end,
85
+ text: word.text,
86
+ message: `${quote(word.text)} is ${listOf(regions)}${message}` +
87
+ // The best word only; the others (often niche: Southern "cúi", "ỉn" after "heo") are in suggestions.
88
+ (suggestions.length ? `, say ${quote(suggestions[0])}.` : '.'),
89
+ suggestions,
90
+ });
91
+ if (ctx.options.region) {
92
+ const target = resolveRegion(ctx.meta, ctx.options.region);
93
+ const where = listOf(ctx.meta.regions.filter((r) => target.has(r)));
94
+ const issues = [];
95
+ for (const { word, sense } of regional) {
96
+ if (sense.regions.some((r) => target.has(r)))
97
+ continue;
98
+ const suggestions = await ctx.suggestRegional(word, ctx.options.region);
99
+ issues.push(issue(word, sense.regions, `; in ${where} ${ctx.meta.name}`, suggestions, 'warning'));
100
+ }
101
+ return issues;
102
+ }
103
+ // No region given: the regions most regional words share are the text's region, and words that fit none
104
+ // of them stand out. When no region has a majority but some words can't go together ("lợn", Northern,
105
+ // with "nha", Central and Southern), every regional word is pointed out, without a guess.
106
+ const counts = new Map();
107
+ for (const { sense } of regional)
108
+ for (const r of sense.regions)
109
+ counts.set(r, (counts.get(r) ?? 0) + 1);
110
+ const most = Math.max(...counts.values());
111
+ const majority = new Set([...counts].filter(([, n]) => n === most).map(([r]) => r));
112
+ const outliers = regional.filter(({ sense }) => !sense.regions.some((r) => majority.has(r)));
113
+ if (outliers.length) {
114
+ const where = ctx.meta.regions.filter((r) => majority.has(r));
115
+ return Promise.all(outliers.map(async ({ word, sense }) => issue(word, sense.regions, `, but the rest of the text is ${listOf(where)}; there`, await ctx.suggestRegional(word, where[0]), 'suggestion')));
116
+ }
117
+ const clash = regional.some((a) => regional.some((b) => !a.sense.regions.some((r) => b.sense.regions.includes(r))));
118
+ if (!clash)
119
+ return [];
120
+ const mixed = listOf(regional.map(({ word, sense }) => `${quote(word.text)} (${listOf(sense.regions)})`));
121
+ return regional.map(({ word, sense }) => issue(word, sense.regions, `. This text mixes regions: ${mixed}. Pick one region`, [], 'suggestion'));
122
+ }
123
+ // Pronouns that don't fit who you're talking to (`listener`) or about (`about`): "tôi" to a parent, where
124
+ // it's "con". Only the language's reliable pronouns are checked (CheckerConfig.pronouns); ones that are
125
+ // also common nouns ("bạn": friend) only get a hedged suggestion.
126
+ async function pronounRelationship(ctx) {
127
+ if (!ctx.options.listener && !ctx.options.about)
128
+ return [];
129
+ const rows = await ctx.rows();
130
+ const issues = [];
131
+ for (const m of ctx.pronounMatches()) {
132
+ const about = ABOUT_PERSONS.has(m.person);
133
+ const row = rows.find((r) => r.id === (about ? ctx.options.about : ctx.options.listener));
134
+ const fit = unique((row?.[m.person] ?? []).map((c) => c.word));
135
+ if (!row || !fit.length || fit.includes(m.lower))
136
+ continue;
137
+ const ambiguous = ctx.config.ambiguousPronouns?.includes(m.lower);
138
+ const who = `${about ? 'Talking about' : 'Talking to'} ${lowerFirst(row.label)}`;
139
+ issues.push({
140
+ rule: 'pronoun-relationship',
141
+ severity: ambiguous ? 'suggestion' : 'warning',
142
+ start: m.start,
143
+ end: m.end,
144
+ text: m.text,
145
+ message: (ambiguous ? `If ${quote(m.text)} means “${PERSON_NAMES[m.person]}” here: ${lowerFirst(who)}` : who) +
146
+ `, say ${listOf(fit.map(quote), 'or')} for “${PERSON_NAMES[m.person]}”, not ${quote(m.text)}.`,
147
+ suggestions: fit.map((w) => matchCase(m.text, w)),
148
+ });
149
+ }
150
+ return issues;
151
+ }
152
+ // "I" and "you" pronouns that never go together (Vietnamese "tao" goes with "mày", not "bạn"): no row of the
153
+ // pronoun table has both. Only when no listener is given; with one, pronoun-relationship covers it.
154
+ async function pronounPair(ctx) {
155
+ if (ctx.options.listener)
156
+ return [];
157
+ const matches = ctx.pronounMatches();
158
+ const selves = matches.filter((m) => SELF_PERSONS.has(m.person));
159
+ const yous = matches.filter((m) => ADDRESSEE_PERSONS.has(m.person));
160
+ if (!selves.length || !yous.length)
161
+ return [];
162
+ const rows = await ctx.rows();
163
+ const words = (r, persons) => [...persons].flatMap((p) => r[p].map((c) => c.word));
164
+ const issues = [];
165
+ for (const you of yous) {
166
+ for (const me of selves) {
167
+ const withMe = rows.filter((r) => words(r, SELF_PERSONS).includes(me.lower));
168
+ if (!withMe.length || withMe.some((r) => words(r, ADDRESSEE_PERSONS).includes(you.lower)))
169
+ continue;
170
+ const partners = unique(withMe.flatMap((r) => r[you.person].map((c) => c.word)));
171
+ issues.push({
172
+ rule: 'pronoun-pair',
173
+ severity: ctx.config.ambiguousPronouns?.includes(you.lower) ? 'suggestion' : 'warning',
174
+ start: you.start,
175
+ end: you.end,
176
+ text: you.text,
177
+ message: `${quote(me.text)} and ${quote(you.text)} don't go together` +
178
+ (partners.length ? `: with ${quote(me.text)}, say ${listOf(partners.map(quote), 'or')}.` : '.'),
179
+ suggestions: partners.map((w) => matchCase(you.text, w)),
180
+ });
181
+ break;
182
+ }
183
+ }
184
+ return issues;
185
+ }
186
+ // Switching words for the same person within one text ("tôi" in one sentence, "tui" in the next): the first
187
+ // one used is kept. Useful for journals and longer messages. Ambiguous pronouns ("bạn": also "friend") are
188
+ // never the ones flagged as a warning.
189
+ async function pronounConsistency(ctx) {
190
+ const first = new Map();
191
+ const issues = [];
192
+ for (const m of ctx.pronounMatches()) {
193
+ const earlier = first.get(m.person);
194
+ if (!earlier) {
195
+ first.set(m.person, m);
196
+ continue;
197
+ }
198
+ if (earlier.lower === m.lower)
199
+ continue;
200
+ const ambiguous = ctx.config.ambiguousPronouns?.includes(m.lower) || ctx.config.ambiguousPronouns?.includes(earlier.lower);
201
+ issues.push({
202
+ rule: 'pronoun-consistency',
203
+ severity: ambiguous ? 'suggestion' : 'warning',
204
+ start: m.start,
205
+ end: m.end,
206
+ text: m.text,
207
+ message: `You used ${quote(earlier.text)} for “${PERSON_NAMES[m.person]}” earlier; keep one word: ${quote(earlier.lower)}.`,
208
+ suggestions: [matchCase(m.text, earlier.lower)],
209
+ });
210
+ }
211
+ return issues;
212
+ }
213
+ // Polite endings when speaking up (the listener's row is marked `respect`): Vietnamese sentences to parents,
214
+ // elders and teachers usually end in "ạ". A suggestion, not an error: it isn't always needed.
215
+ async function politeEnding(ctx) {
216
+ const endings = ctx.config.politeEndings ?? [];
217
+ if (!ctx.options.listener || !endings.length)
218
+ return [];
219
+ const row = (await ctx.rows()).find((r) => r.id === ctx.options.listener);
220
+ if (!row?.respect)
221
+ return [];
222
+ const issues = [];
223
+ for (const s of ctx.sentences) {
224
+ const last = s.units[s.units.length - 1];
225
+ if (endings.includes(last.text.toLowerCase()))
226
+ continue;
227
+ // Exclamations ("Con nhớ má quá!") are often fine without it.
228
+ if (/^[^\p{L}\p{N}]*!/u.test(ctx.text.slice(last.end)))
229
+ continue;
230
+ issues.push({
231
+ rule: 'polite-ending',
232
+ severity: 'suggestion',
233
+ start: last.start,
234
+ end: last.end,
235
+ text: last.text,
236
+ message: `Talking to ${lowerFirst(row.label)}, end the sentence with ${listOf(endings.map(quote), 'or')} to sound polite.`,
237
+ suggestions: endings.map((e) => `${last.text} ${e}`),
238
+ });
239
+ }
240
+ return issues;
241
+ }
242
+ const RULES = {
243
+ spelling,
244
+ dialect,
245
+ 'pronoun-relationship': pronounRelationship,
246
+ 'pronoun-pair': pronounPair,
247
+ 'pronoun-consistency': pronounConsistency,
248
+ 'polite-ending': politeEnding,
249
+ };
250
+ const SEVERITY_ORDER = { error: 0, warning: 1, suggestion: 2 };
251
+ export function createChecker(options = {}) {
252
+ const cache = new Map();
253
+ const dict = (lang) => {
254
+ let d = cache.get(lang);
255
+ if (!d) {
256
+ d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
257
+ cache.set(lang, d);
258
+ }
259
+ return d;
260
+ };
261
+ const translator = createTranslator({ ...options, dictionary: dict });
262
+ const lookups = new Map();
263
+ return {
264
+ async check(input, opts) {
265
+ const text = input.normalize('NFC');
266
+ const d = dict(opts.lang);
267
+ const [meta, config] = await Promise.all([d.meta(), d.checker()]);
268
+ // Unknown listener/about ids throw, listing the valid ones (as the translator does).
269
+ if (meta.pronouns) {
270
+ if (opts.listener)
271
+ await d.pronouns({ listener: opts.listener });
272
+ if (opts.about)
273
+ await d.pronouns({ listener: opts.about });
274
+ }
275
+ const lookup = (phrase) => {
276
+ const key = `${opts.lang}\u0000${phrase}`;
277
+ let pending = lookups.get(key);
278
+ if (!pending) {
279
+ pending = d.lookup(phrase);
280
+ lookups.set(key, pending);
281
+ }
282
+ return pending;
283
+ };
284
+ const sentences = [];
285
+ for (const list of sentencesOf(text, units(text))) {
286
+ sentences.push({ units: list, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
287
+ }
288
+ let rows;
289
+ let matches;
290
+ const ctx = {
291
+ text,
292
+ options: opts,
293
+ meta,
294
+ config,
295
+ sentences,
296
+ dict: d,
297
+ rows: () => (rows ??= meta.pronouns ? d.pronouns({ region: opts.region, speaker: opts.speaker }) : Promise.resolve([])),
298
+ // The language's reliable pronouns in the text, longest first ("chúng tôi" before "tôi"), matched on
299
+ // units directly so pronouns the dictionary doesn't list as one word ("tụi mày") are found too.
300
+ pronounMatches: () => (matches ??= findPronouns(text, sentences, config)),
301
+ suggestRegional: async (word, region) => {
302
+ try {
303
+ const [group] = await translator.translate(word.lower, {
304
+ from: opts.lang, to: opts.lang, toRegion: region, pos: word.entries[0]?.pos, limit: 3,
305
+ });
306
+ return (group?.translations ?? []).map((t) => t.word).filter((w) => w !== word.lower);
307
+ }
308
+ catch {
309
+ return [];
310
+ }
311
+ },
312
+ };
313
+ const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] !== false).map((r) => RULES[r](ctx)));
314
+ const issues = results.flat().sort((a, b) => a.start - b.start || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
315
+ return { text, issues };
316
+ },
317
+ };
318
+ }
319
+ function findPronouns(text, sentences, config) {
320
+ const byWord = new Map();
321
+ for (const person of PRONOUN_PERSONS)
322
+ for (const w of config.pronouns?.[person] ?? [])
323
+ byWord.set(w.toLowerCase(), person);
324
+ if (!byWord.size)
325
+ return [];
326
+ const longest = Math.max(...[...byWord.keys()].map((w) => w.split(' ').length));
327
+ const out = [];
328
+ sentences.forEach((s, sentence) => {
329
+ for (let i = 0; i < s.units.length;) {
330
+ let step = 1;
331
+ for (let k = Math.min(longest, s.units.length - i); k >= 1; k--) {
332
+ const span = s.units.slice(i, i + k);
333
+ if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
334
+ continue;
335
+ const lower = span.map((u) => u.text.toLowerCase()).join(' ');
336
+ const person = byWord.get(lower);
337
+ if (person) {
338
+ out.push({ text: text.slice(span[0].start, span[k - 1].end), lower, start: span[0].start, end: span[k - 1].end, person, sentence });
339
+ step = k;
340
+ break;
341
+ }
342
+ }
343
+ i += step;
344
+ }
345
+ });
346
+ return out;
347
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { 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
  /**
@@ -67,6 +67,12 @@ export type Dictionary = {
67
67
  pronouns(options?: PronounOptions): Promise<PronounRow[]>;
68
68
  /** Hand-picked first choices for English meanings (see PickRow). Empty for languages without any. */
69
69
  picks(): Promise<PickRow[]>;
70
+ /** How the checker reads this language (see CheckerConfig); `{}` for languages without settings. */
71
+ checker(): Promise<CheckerConfig>;
72
+ /** Syllable → Zipf frequency (0 when unknown), for languages written in syllables; `{}` otherwise. */
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>;
70
76
  };
71
77
  /**
72
78
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
@@ -76,3 +82,7 @@ export declare function resolveRegion(meta: LanguageMeta, region: string | undef
76
82
  export declare function dataUrl(lang: string): string;
77
83
  export declare function createDictionary(options: DictionaryOptions): Dictionary;
78
84
  export * from './translate.ts';
85
+ export * from './check.ts';
86
+ export * from './text.ts';
87
+ export * from './phrasebook.ts';
88
+ export * from './review.ts';