which-dialect 0.3.0 → 0.5.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
@@ -111,44 +111,47 @@ The cases involving Spanish were removed with it. The held-out set has since bee
111
111
 
112
112
  ### `createChecker({ baseUrl?, load?, dictionary? })` → `checker.check(text, options)`
113
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.
114
+ A spellchecker for learners: it gives a correction for every word it can, with the exact place in the text and a short reason. Words written in the learner's own language (English by default) get the target-language word. Further checks (regions, pronouns, politeness) can be turned on. It runs in the browser like the rest of the library: no API calls, no cost.
115
115
 
116
116
  ```ts
117
117
  import { createChecker } from 'which-dialect'
118
118
 
119
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',
120
+ const { text, issues, parts } = await checker.check('Toi muon an the ice cream but it was very expensive.', {
121
+ lang: 'vi', region: 'Southern',
122
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)
123
+ // Toi → Tôi · muon → muốn · an → ăn (accents)
124
+ // the → (left out: Vietnamese has no "the") · ice cream → kem · but → nhưng · it → nó
125
+ // was → là · very → rất · expensive → mắc (English words, in Southern Vietnamese)
127
126
  ```
128
127
 
129
128
  | Option | Meaning |
130
129
  |---|---|
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 }`. |
130
+ | `lang` | The text's language (the target). |
131
+ | `base` | The learner's own language, whose words get translated (default `'en'`). |
132
+ | `region` | The region's words are used for translations (*expensive* → Southern *mắc*, Northern *đắt*), and the `dialect` check, when on, flags other regions' words. |
133
+ | `register` | `'casual'` allows colloquial translations; otherwise plain ones come first. |
134
+ | `rules` | Turn checks on or off. By default only `spelling` and `foreign-word` run; e.g. `{ dialect: true, 'pronoun-relationship': true }`. |
135
+ | `listener`, `about`, `speaker` | For the pronoun and polite-ending checks: who you're talking to and about (pronoun-table row ids), and the speaker's gender. |
137
136
 
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).
137
+ The result has `text` (the input in Unicode NFC form), `issues` and `parts` (the text split by language). Each issue has `rule`, `severity` (`'error'`: not a word of the language; `'warning'`: probably wrong, or wrong for the region or relationship; `'suggestion'`: often better), `start`/`end` (offsets into `text`), `text`, `message` and `suggestions` (replacements for `text`, best first; `''` means leave it out).
139
138
 
140
- The checks:
139
+ **On by default (the spellchecker):**
141
140
 
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).
141
+ - **`spelling`**: a doubtful syllable is first tried with its neighbors: if fixing its accents makes a dictionary word with the syllables next to it, that word is suggested (*hom nay* → *hôm nay* "today", where *nay* alone would become *này* "this"; *an com* → *ăn cơm*; *Chung toi* → *Chúng tôi*), changing only the doubtful syllables. Otherwise, syllables that aren't in the language get the accented forms they could be (*khong* → *không*); rare plain-letter words next to a far more common accented form too (*toi* → *tôi*, *hom* → *hôm*), and, in a sentence typed without accents, any more common accented form (*an* → *ăn*, *di* → *đi*; not *con* → *còn*, about as common). Names (capitalized mid-sentence), the language's listed pronouns (*tui*) and plain-letter words with no accented form (*email*) are left alone. Languages written in syllables only (Vietnamese).
142
+ - **`foreign-word`**: words in the learner's own language get the target-language word, in the region and register (*market* → *chợ*, *ice cream* → *kem*). Phrases the base dictionary knows are translated as one, falling back to single words. Words the language has no word for are suggested to be left out (`leaveOut` in its checker settings: Vietnamese has no *the*, *a*, *an*). Which language each word is in comes from the two dictionaries: an English word that's Vietnamese only with accents added (*but*: *bút*) is English; Vietnamese typed without accents (*khong*) is Vietnamese; words both languages have go by how common they are in each (*em*, *di*, *tui* are Vietnamese even between English words; *the* is English), and close calls (*an*, *to*) follow their neighbors, leaning to the target language.
143
+
144
+ **Opt-in (`rules`):**
145
+
146
+ - **`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*). Without `region`, words that don't fit the rest of the text's region are pointed out. Words with an untagged main meaning are never flagged (*má* is "cheek" everywhere).
144
147
  - **`pronoun-relationship`**: pronouns that don't fit the listener (*tôi* to a parent → *con*; to a teacher → *em*).
145
148
  - **`pronoun-pair`**: "I" and "you" pronouns that never go together (*tao* goes with *mày*, not *bạn*), when no listener is given.
146
149
  - **`pronoun-consistency`**: switching words for the same person within one text (*tôi* in one sentence, *tui* in the next): keep the first.
147
150
  - **`polite-ending`**: a suggestion to end sentences with *ạ* when talking to parents, elders and teachers (not for exclamations).
148
151
 
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.
152
+ Corrections are word by word: they don't fix word order or grammar, so English translated word by word reads like English in Vietnamese words (*it was very expensive* → *nó là rất mắc*). Whole English sentences or clauses that follow a [sentence frame](#sentence-frames) get the frame's natural wording instead in the [journal review](#reviewing-journal-entries). 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**.
150
153
 
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.
154
+ **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, words with no equivalent), so a check whose data a language doesn't have is skipped. A language without checker settings is read as space-separated words: English words in it are still translated, and the `dialect` check works.
152
155
 
153
156
  ## Sentence frames
154
157
 
@@ -166,13 +169,13 @@ await pb.match('Do you want coffee?', { lang: 'vi' }) //
166
169
  await pb.matchFrames('Bạn có đi?', { lang: 'vi' }) // → 'Bạn có đi không?' (missing "không")
167
170
  ```
168
171
 
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.
172
+ 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 (the journal review uses it to list the frames a sentence follows) and returns the changes the frame would suggest: 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
173
 
171
174
  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
175
 
173
176
  ## Reviewing journal entries
174
177
 
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:
178
+ `createReviewer().review(entry, { lang, base?, region?, register?, checks?, listener?, speaker? })` 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, in the target language, sentence by sentence:
176
179
 
177
180
  ```ts
178
181
  import { createReviewer } from 'which-dialect'
@@ -182,20 +185,23 @@ const review = await createReviewer().review(
182
185
  { lang: 'vi', region: 'Southern' },
183
186
  )
184
187
  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?'
188
+ // 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt lợn nhưng mắc quá. Ngày mai tui đi lại không?'
186
189
  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”.' }]
190
+ // [{ from: 'but it was expensive', to: 'nhưng mắc quá', kind: 'frame', why: 'Sentence frame “but it was {quality}”' }]
191
+
192
+ // With the regional and consistency checks on:
193
+ await createReviewer().review(entry, { lang: 'vi', region: 'Southern', checks: { dialect: true, 'pronoun-consistency': true } })
194
+ // '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?'
189
195
  ```
190
196
 
191
197
  For each sentence:
192
198
 
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.
199
+ 1. **Each word is marked as the target or the base language** (`parts`), as in the checker's `foreign-word` check.
200
+ 2. **A whole base-language sentence or clause that follows a sentence frame is filled in** with the frame's wording (*but it was expensive* → *nhưng mắc quá*; *Where is the bathroom?* → *Phòng tắm ở đâu?*).
201
+ 3. **The checker runs on the whole entry**: by default the spellchecker, so every remaining English word gets its translation and every misspelled word its accents; `checks` turns on more (regions, pronouns, consistency across sentences, polite endings). Its errors and warnings are applied (`changes`); its suggestions, like ending with *ạ*, are returned as `hints`.
202
+ 4. **The frames the corrected sentence follows are listed** (`frames`), for suggesting patterns to reuse. Frames don't change the sentence.
197
203
 
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.
204
+ Each sentence has `original`, `parts`, `corrected`, `changes` (`{ from, to, why, kind }`; `to` is `''` when a word is left out), `hints`, `frames` (`{ id, en, text }`, the frame's English and its pattern) and `unchecked` (English words with no translation, left as written). For a journal, leave `listener` out: people written about (*má*, *thầy*) aren't treated as the listener. Corrections are word by word except for frames: word order and grammar aren't fixed (*I am very happy* → *Tôi là rất mừng*).
199
205
 
200
206
  ## Looking words up
201
207
 
@@ -206,6 +212,8 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
206
212
  - `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
207
213
  - `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
214
  - `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
215
+ - `dictionary.segment(text)`: the text split into the language's words, longest dictionary match first (up to the checker's `maxWordUnits`, 3 for Vietnamese): `Hôm nay tôi ăn cơm.` → *Hôm nay* · *tôi* · *ăn cơm*, each `{ text, start, end, entries }` (offsets into the NFC text; `entries` empty for words the dictionary doesn't have). Use it to treat a word of several syllables as one, e.g. to show its meaning.
216
+ - `dictionary.variants(word, { limit? })`: other words with the same letters but other accents that the dictionary has, most common first, each `{ word, entries, frequency? }`: *muộn* → *muốn*, *mượn*, *muôn*…; for words of several syllables every syllable varies (*hom nay* → *hôm nay*). For apps that let learners pick the word they meant: a real word with the wrong tone passes the spellchecker. Languages written in syllables only (`[]` otherwise).
209
217
  - `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
210
218
  - `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).
211
219
  - `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
package/dist/check.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { type TranslatorOptions } from './translate.ts';
2
- export type CheckRule = 'spelling' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
2
+ export type CheckRule = 'spelling' | 'foreign-word' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
3
+ /**
4
+ * Which checks run unless `rules` says otherwise: the spellchecker (spelling, and words in the learner's
5
+ * own language, with their translation). The rest are opt-in, e.g. `rules: { dialect: true }`.
6
+ */
7
+ export declare const DEFAULT_CHECK_RULES: Record<CheckRule, boolean>;
3
8
  export type CheckIssue = {
4
9
  rule: CheckRule;
5
10
  /**
@@ -20,15 +25,28 @@ export type CheckIssue = {
20
25
  export type CheckOptions = {
21
26
  /** Language code of the text, e.g. "vi". */
22
27
  lang: string;
23
- /** Region or region group the text should be in, e.g. "Southern". Without it, mixed regions are flagged. */
28
+ /**
29
+ * The learner's own language, whose words in the text are translated (`foreign-word`). Default "en";
30
+ * none when it's the text's language.
31
+ */
32
+ base?: string;
33
+ /**
34
+ * Region or region group the text should be in, e.g. "Southern": translations use its words, and the
35
+ * `dialect` check flags other regions' words (without it, mixed regions).
36
+ */
24
37
  region?: string;
38
+ /** 'casual' allows colloquial translations (Southern "tui"); otherwise plain ones come first. */
39
+ register?: 'casual' | 'neutral' | 'polite';
25
40
  /** Who you're talking to, as a pronoun-table row id (see Dictionary.pronouns), e.g. "parent". */
26
41
  listener?: string;
27
42
  /** Who you're talking about, as a pronoun-table row id, for "he/she" and "they" pronouns. */
28
43
  about?: string;
29
44
  /** The speaker's gender, for pronouns that depend on it. */
30
45
  speaker?: 'male' | 'female';
31
- /** Turn rules off, e.g. { 'polite-ending': false }. All are on by default. */
46
+ /**
47
+ * Turn checks on or off. By default only the spellchecker runs (DEFAULT_CHECK_RULES: `spelling` and
48
+ * `foreign-word`); opt in to others, e.g. { dialect: true, 'pronoun-relationship': true }.
49
+ */
32
50
  rules?: Partial<Record<CheckRule, boolean>>;
33
51
  };
34
52
  export type CheckResult = {
@@ -36,6 +54,13 @@ export type CheckResult = {
36
54
  text: string;
37
55
  /** Sorted by position. */
38
56
  issues: CheckIssue[];
57
+ /** The text split by language (the target and the base), in order; one part when no base is given. */
58
+ parts: {
59
+ text: string;
60
+ lang: string;
61
+ start: number;
62
+ end: number;
63
+ }[];
39
64
  };
40
65
  export type Checker = {
41
66
  check(text: string, options: CheckOptions): Promise<CheckResult>;
package/dist/check.js CHANGED
@@ -1,17 +1,33 @@
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.
1
+ // Checking text in one language. By default it's a spellchecker that gives a correction for every word it
2
+ // can: syllables that aren't in the language get their accented forms ("khong" → "không"), and words in the
3
+ // learner's own language (English by default) get the target-language word ("market" → "chợ"). Further
4
+ // checks are opt-in (`rules`): words from another region, pronouns and polite endings that fit who you're
5
+ // talking to, and consistency within the text.
3
6
  //
4
7
  // Every rule reads only the language's data: its dictionary, pronoun table, checker.json and
5
8
  // 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.
9
+ // languages/<lang>.ts, not code here. A rule whose data a language doesn't have is skipped.
8
10
  //
9
11
  // The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
10
12
  // the checks found anything, not that the text is correct.
11
13
  import { createDictionary, resolveRegion } from "./index.js";
12
- import { joined, matchCase, plain, segment, sentencesOf, units } from "./text.js";
14
+ import { accentCombinations, joined, matchCase, plain, segment, sentencesOf, syllablesByPlain, units } from "./text.js";
15
+ import { markLanguages } from "./detect.js";
13
16
  import { createTranslator } from "./translate.js";
14
17
  import { PRONOUN_PERSONS, } from "./types.js";
18
+ /**
19
+ * Which checks run unless `rules` says otherwise: the spellchecker (spelling, and words in the learner's
20
+ * own language, with their translation). The rest are opt-in, e.g. `rules: { dialect: true }`.
21
+ */
22
+ export const DEFAULT_CHECK_RULES = {
23
+ spelling: true,
24
+ 'foreign-word': true,
25
+ dialect: false,
26
+ 'pronoun-relationship': false,
27
+ 'pronoun-pair': false,
28
+ 'pronoun-consistency': false,
29
+ 'polite-ending': false,
30
+ };
15
31
  // How each pronoun-table column is named in messages.
16
32
  const PERSON_NAMES = {
17
33
  self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
@@ -25,42 +41,180 @@ const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
25
41
  const listOf = (items, conjunction = 'and') => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
26
42
  const unique = (items) => [...new Set(items)];
27
43
  // --- 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").
44
+ // Spelling and accents, for languages written in syllables with a syllable list (with frequencies):
45
+ // - a syllable that isn't in the language is flagged, with the accented forms it could be ("khong" →
46
+ // "không"); plain-letter syllables with no accented form are left alone (names, foreign words: "email");
47
+ // - a plain-letter syllable that is a word but a rare one, next to a much more common accented form, is
48
+ // probably missing its accents ("toi" → "tôi", "hom" → "hôm"). In a sentence typed without accents (at
49
+ // least half plain letters and a missing-accents error already, or 3+ words with no accent at all; half,
50
+ // because filled-in sentence frames add accented words), any accented form that's more common
51
+ // counts ("an" → "ăn"; but not "con" → "còn", about as common). Left alone: capitalized words
52
+ // mid-sentence (names) and the language's listed pronouns, some written without accents ("tui").
53
+ // First, a doubtful syllable (one of the above) is tried with its neighbors: when fixing its accents makes a
54
+ // dictionary word with them, that word is suggested for the run ("hom nay" → "hôm nay", not "hôm này").
55
+ // Units inside a known word, or in the learner's own language, aren't checked.
56
+ // Zipf points by which an accented form must be more common than a plain-letter word to be suggested.
57
+ const ACCENTS_GAP = 1.5;
58
+ const ACCENTS_GAP_UNACCENTED_TEXT = 0.2;
59
+ // Accent forms tried per doubtful syllable when looking for a word with its neighbors, and how many
60
+ // combinations are looked up at most.
61
+ const COMPOUND_FORMS_PER_SYLLABLE = 5;
62
+ const COMPOUND_MAX_COMBINATIONS = 64;
31
63
  async function spelling(ctx) {
32
64
  if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
33
65
  return [];
34
66
  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]);
67
+ const byPlain = syllablesByPlain(syllables);
40
68
  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)
69
+ const suggest = (u, candidates, severity, message) => issues.push({ rule: 'spelling', severity, start: u.start, end: u.end, text: u.text, message, suggestions: candidates.map((c) => matchCase(u.text, c)) });
70
+ const options = (u, candidates) => listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or');
71
+ const pronouns = new Set(Object.values(ctx.config.pronouns ?? {}).flat().map((w) => w.toLowerCase()));
72
+ for (const sentence of ctx.sentences) {
73
+ const lone = sentence.words.filter((w) => !w.entries.length || w.units.length === 1).flatMap((w) => w.units).filter((u) => !ctx.inBase(u));
74
+ const target = sentence.units.filter((u) => !ctx.inBase(u) && /\p{L}/u.test(u.text));
75
+ const isPlain = (u) => u.text.toLowerCase() === plain(u.text.toLowerCase());
76
+ const mostlyPlain = target.filter(isPlain).length >= target.length * 0.5;
77
+ const lowerOf = (u) => u.text.toLowerCase().replace(/[’']/g, '');
78
+ const inWord = (u) => sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
79
+ // What each lone unit is: not a syllable of the language (with forms it could be), or a plain-letter
80
+ // syllable that could be missing its accents.
81
+ const checked = lone.filter((u) => !/\d/.test(lowerOf(u)) && !inWord(u));
82
+ const unknown = new Set(checked.filter((u) => !(lowerOf(u) in syllables) && ((byPlain.get(plain(lowerOf(u))) ?? []).length || !isPlain(u))));
83
+ const plainWords = new Set(checked.filter((u) => {
84
+ const lower = lowerOf(u);
85
+ return lower in syllables && lower === plain(lower) && !pronouns.has(lower) && (u === sentence.units[0] || !/\p{Lu}/u.test(u.text.charAt(0)));
86
+ }));
87
+ const missingAccents = [...unknown].some(isPlain);
88
+ // Typed without accents: mostly plain letters, and an accent error already, or 3+ words with no accent at all.
89
+ const unaccented = mostlyPlain && (missingAccents || (target.length >= 3 && target.every(isPlain)));
90
+ const gap = unaccented ? ACCENTS_GAP_UNACCENTED_TEXT : ACCENTS_GAP;
91
+ const betterForms = (u) => {
92
+ const lower = lowerOf(u);
93
+ return (byPlain.get(lower) ?? []).filter((c) => c !== lower && syllables[c] >= syllables[lower] + gap - 1e-9).slice(0, 3);
94
+ };
95
+ const suspect = (u) => unknown.has(u) || (plainWords.has(u) && betterForms(u).length > 0);
96
+ // First, runs of syllables with a doubtful one that make a word once their accents are fixed: "hom nay"
97
+ // → "hôm nay", "an com" → "ăn cơm", "hôm nây" → "hôm nay". Only the doubtful syllables change. The word
98
+ // decides between forms a syllable alone can't ("nay" alone could be này, nảy…).
99
+ const covered = new Set();
100
+ const loneSet = new Set(checked);
101
+ const maxUnits = ctx.config.maxWordUnits ?? 3;
102
+ for (let i = 0; i < sentence.units.length; i++) {
103
+ for (let k = Math.min(maxUnits, sentence.units.length - i); k >= 2; k--) {
104
+ const span = sentence.units.slice(i, i + k);
105
+ if (span.some((u, j) => !loneSet.has(u) || covered.has(u) || (j > 0 && !joined(ctx.text, span[j - 1], u))))
106
+ continue;
107
+ if (!span.some(suspect))
108
+ continue;
109
+ const written = span.map(lowerOf);
110
+ const combos = accentCombinations(written, byPlain, {
111
+ fixed: span.map((u) => !suspect(u)),
112
+ perUnit: COMPOUND_FORMS_PER_SYLLABLE,
113
+ max: COMPOUND_MAX_COMBINATIONS,
114
+ }).filter((c) => c !== written.join(' '));
115
+ const found = (await Promise.all(combos.map(async (c) => ({ word: c, entries: await ctx.dict.lookup(c) }))))
116
+ .filter((f) => f.entries.length)
117
+ .map((f) => ({ word: f.word, frequency: Math.max(-1, ...f.entries.map((e) => e.frequency ?? -1)) }))
118
+ .sort((a, b) => b.frequency - a.frequency)
119
+ .slice(0, 3);
120
+ if (!found.length)
121
+ continue;
122
+ const at = { start: span[0].start, end: span[k - 1].end, text: ctx.text.slice(span[0].start, span[k - 1].end) };
123
+ const suggestions = found.map((f) => matchCase(at.text, f.word));
124
+ const quoted = listOf(suggestions.map(quote), 'or');
125
+ issues.push({
126
+ rule: 'spelling', severity: 'warning', ...at, suggestions,
127
+ message: span.filter(suspect).every(isPlain)
128
+ ? `${quote(at.text)} looks typed without accents. Did you mean ${quoted}?`
129
+ : `${quote(at.text)} isn't spelled right. Did you mean ${quoted}?`,
130
+ });
131
+ span.forEach((u) => covered.add(u));
132
+ break;
133
+ }
134
+ }
135
+ // Then each remaining syllable on its own.
136
+ for (const u of checked) {
137
+ if (covered.has(u))
51
138
  continue;
52
- issues.push({
53
- rule: 'spelling',
139
+ const lower = lowerOf(u);
140
+ if (unknown.has(u)) {
141
+ const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
142
+ const accented = !isPlain(u);
54
143
  // 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
- });
144
+ suggest(u, candidates, accented ? 'error' : 'warning', candidates.length
145
+ ? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, candidates)}?`
146
+ : `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`);
147
+ }
148
+ else if (plainWords.has(u)) {
149
+ const better = betterForms(u);
150
+ if (!better.length)
151
+ continue;
152
+ suggest(u, better, 'warning', unaccented
153
+ ? `${quote(u.text)} looks typed without accents. Did you mean ${options(u, better)}?`
154
+ : `${quote(u.text)} is a rare word; did you mean ${options(u, better)}?`);
155
+ }
156
+ }
157
+ }
158
+ return issues;
159
+ }
160
+ // Words in the learner's own language (English by default), with the target-language word to use: "market"
161
+ // → "chợ". Runs of base units are split into the longest phrases the base dictionary knows ("ice cream"),
162
+ // each translated in the region and register. Words the language usually has no word for
163
+ // (CheckerConfig.leaveOut: Vietnamese has no articles) are suggested to be left out.
164
+ async function foreignWord(ctx) {
165
+ const base = ctx.base;
166
+ if (!base)
167
+ return [];
168
+ const leaveOut = new Set((ctx.config.leaveOut ?? []).map((w) => w.toLowerCase()));
169
+ const issues = [];
170
+ for (const s of ctx.sentences) {
171
+ const runs = [];
172
+ s.units.forEach((u, i) => {
173
+ if (!ctx.inBase(u))
174
+ return;
175
+ const last = runs[runs.length - 1];
176
+ if (last && last[last.length - 1] === s.units[i - 1] && joined(ctx.text, s.units[i - 1], u))
177
+ last.push(u);
178
+ else
179
+ runs.push([u]);
180
+ });
181
+ for (const run of runs) {
182
+ for (let i = 0; i < run.length;) {
183
+ // The longest phrase the base dictionary knows and the language has a word for; single words
184
+ // always give an issue, with or without a translation.
185
+ let found;
186
+ for (let k = Math.min(MAX_FOREIGN_UNITS, run.length - i); k >= 1 && !found; k--) {
187
+ const phrase = ctx.text.slice(run[i].start, run[i + k - 1].end);
188
+ if (k === 1 && leaveOut.has(phrase.toLowerCase())) {
189
+ found = { k, words: [], leave: true };
190
+ break;
191
+ }
192
+ if (k > 1 && !(await ctx.translator.senses(phrase, { from: base }).catch(() => [])).length)
193
+ continue;
194
+ const [group] = await ctx.translator.translate(phrase, {
195
+ from: base, to: ctx.options.lang, toRegion: ctx.options.region, register: ctx.options.register, listener: ctx.options.listener, limit: 3,
196
+ }).catch(() => []);
197
+ const words = unique((group?.translations ?? []).map((t) => t.word));
198
+ if (words.length || k === 1)
199
+ found = { k, words, leave: false };
200
+ }
201
+ const { k, words, leave } = found;
202
+ const span = run.slice(i, i + k);
203
+ const text = ctx.text.slice(span[0].start, span[k - 1].end);
204
+ i += k;
205
+ const at = { rule: 'foreign-word', severity: 'error', start: span[0].start, end: span[k - 1].end, text };
206
+ if (leave) {
207
+ issues.push({ ...at, message: `${quote(text)} isn't ${ctx.meta.name}; ${ctx.meta.name} usually has no word for it, so leave it out.`, suggestions: [''] });
208
+ continue;
209
+ }
210
+ issues.push({
211
+ ...at,
212
+ message: words.length
213
+ ? `${quote(text)} isn't ${ctx.meta.name}: say ${listOf(words.map(quote), 'or')}.`
214
+ : `${quote(text)} isn't ${ctx.meta.name}, and no translation was found.`,
215
+ suggestions: words.map((w) => (span[0].start === s.units[0].start ? matchCase(text, w) : w)),
216
+ });
217
+ }
64
218
  }
65
219
  }
66
220
  return issues;
@@ -73,6 +227,7 @@ async function dialect(ctx) {
73
227
  const all = new Set(ctx.meta.regions);
74
228
  const regional = ctx.sentences
75
229
  .flatMap((s) => s.words)
230
+ .filter((word) => !word.units.some((u) => ctx.inBase(u)))
76
231
  .map((word) => ({ word, sense: word.entries[0]?.senses[0] }))
77
232
  .filter((x) => Boolean(x.sense?.regionTagged && x.sense.regions.length < all.size));
78
233
  if (!regional.length)
@@ -239,8 +394,11 @@ async function politeEnding(ctx) {
239
394
  }
240
395
  return issues;
241
396
  }
397
+ // The longest base-language phrase looked up as one ("ice cream", "take care of").
398
+ const MAX_FOREIGN_UNITS = 3;
242
399
  const RULES = {
243
400
  spelling,
401
+ 'foreign-word': foreignWord,
244
402
  dialect,
245
403
  'pronoun-relationship': pronounRelationship,
246
404
  'pronoun-pair': pronounPair,
@@ -281,10 +439,13 @@ export function createChecker(options = {}) {
281
439
  }
282
440
  return pending;
283
441
  };
442
+ const base = (opts.base ?? 'en') === opts.lang ? undefined : (opts.base ?? 'en');
284
443
  const sentences = [];
285
444
  for (const list of sentencesOf(text, units(text))) {
286
- sentences.push({ units: list, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
445
+ const langs = base ? await markLanguages(list, { lang: opts.lang, base, target: d, baseDictionary: dict(base), translator }) : list.map(() => opts.lang);
446
+ sentences.push({ units: list, langs, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
287
447
  }
448
+ const baseUnits = new Set(sentences.flatMap((s) => s.units.filter((_, i) => s.langs[i] !== opts.lang).map((u) => u.start)));
288
449
  let rows;
289
450
  let matches;
290
451
  const ctx = {
@@ -298,6 +459,9 @@ export function createChecker(options = {}) {
298
459
  // The language's reliable pronouns in the text, longest first ("chúng tôi" before "tôi"), matched on
299
460
  // units directly so pronouns the dictionary doesn't list as one word ("tụi mày") are found too.
300
461
  pronounMatches: () => (matches ??= findPronouns(text, sentences, config)),
462
+ base,
463
+ inBase: (u) => baseUnits.has(u.start),
464
+ translator,
301
465
  suggestRegional: async (word, region) => {
302
466
  try {
303
467
  const [group] = await translator.translate(word.lower, {
@@ -310,9 +474,22 @@ export function createChecker(options = {}) {
310
474
  }
311
475
  },
312
476
  };
313
- const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] !== false).map((r) => RULES[r](ctx)));
477
+ const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] ?? DEFAULT_CHECK_RULES[r]).map((r) => RULES[r](ctx)));
314
478
  const issues = results.flat().sort((a, b) => a.start - b.start || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
315
- return { text, issues };
479
+ // The text split by language: consecutive units in one language, with the text between them.
480
+ const parts = [];
481
+ for (const s of sentences) {
482
+ s.units.forEach((u, i) => {
483
+ const last = parts[parts.length - 1];
484
+ if (last && last.lang === s.langs[i])
485
+ last.end = u.end;
486
+ else
487
+ parts.push({ text: '', lang: s.langs[i], start: u.start, end: u.end });
488
+ });
489
+ }
490
+ for (const p of parts)
491
+ p.text = text.slice(p.start, p.end);
492
+ return { text, issues, parts };
316
493
  },
317
494
  };
318
495
  }
@@ -0,0 +1,10 @@
1
+ import type { Dictionary } from './index.ts';
2
+ import { type Unit } from './text.ts';
3
+ import type { Translator } from './translate.ts';
4
+ export declare function markLanguages(list: Unit[], o: {
5
+ lang: string;
6
+ base: string;
7
+ target: Dictionary;
8
+ baseDictionary: Dictionary;
9
+ translator: Translator;
10
+ }): Promise<string[]>;
package/dist/detect.js ADDED
@@ -0,0 +1,65 @@
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. A unit both know ("em", "an", "to") goes by how common it is
7
+ * in each: clearly more common in the target, the target's (Vietnamese "em", "di" next to English
8
+ * "hello", "shopping": English has rare "em", "di"); far more common in the base, the base's ("the");
9
+ * otherwise, and for units neither knows (names), the nearest decided neighbors' language when they agree,
10
+ * else the target's (the text is mainly the target language: "an" next to English "the"). In languages written in syllables, a base
11
+ * word that's a target syllable only once accents are added ("but": bút) is the base's; a word only the
12
+ * target knows without its accents ("khong") is still the target's. The base is known through the translator, so
13
+ * inflected forms count ("went", "cooked").
14
+ */
15
+ // Zipf points by which a word both languages know must be more common in one to count as that language's
16
+ // whatever its neighbors: less for the target, since the text is mainly the target language.
17
+ const TARGET_MARGIN = 1;
18
+ const BASE_MARGIN = 2;
19
+ export async function markLanguages(list, o) {
20
+ const syllables = await o.target.syllables();
21
+ const syllableLanguage = Object.keys(syllables).length > 0;
22
+ const plainSyllables = syllableLanguage ? new Set(Object.keys(syllables).map(plain)) : new Set();
23
+ // 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
24
+ const targetKnows = async (u) => {
25
+ const lower = u.text.toLowerCase();
26
+ if (!syllableLanguage)
27
+ return (await o.target.lookup(lower)).length > 0 ? 'exact' : false;
28
+ if (lower in syllables || lower !== plain(lower))
29
+ return 'exact';
30
+ return plainSyllables.has(lower) ? 'accents' : false;
31
+ };
32
+ const baseKnows = async (u) => (await o.translator.senses(u.text, { from: o.base }).catch(() => [])).length > 0;
33
+ const marks = await Promise.all(list.map(async (u) => {
34
+ if (/\d/.test(u.text))
35
+ return '?';
36
+ const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
37
+ if (t === 'exact')
38
+ return b ? 'both' : o.lang;
39
+ if (b)
40
+ return o.base;
41
+ return t === 'accents' ? o.lang : '?';
42
+ }));
43
+ // How common a word is in each language (Zipf; 0 when unknown).
44
+ const targetFrequency = async (u) => {
45
+ const lower = u.text.toLowerCase();
46
+ if (syllableLanguage)
47
+ return syllables[lower] ?? 0;
48
+ return (await o.target.lookup(lower))[0]?.frequency ?? 0;
49
+ };
50
+ const baseFrequency = async (u) => (await o.baseDictionary.lookup(u.text.toLowerCase()))[0]?.frequency ?? 0;
51
+ // Words both languages know, decided by frequency when it's clear.
52
+ const decided = await Promise.all(marks.map(async (m, i) => {
53
+ if (m !== 'both')
54
+ return m;
55
+ const [t, b] = await Promise.all([targetFrequency(list[i]), baseFrequency(list[i])]);
56
+ return t - b >= TARGET_MARGIN ? o.lang : b - t >= BASE_MARGIN ? o.base : '?';
57
+ }));
58
+ return decided.map((m, i) => {
59
+ if (m !== '?')
60
+ return m;
61
+ const left = decided.slice(0, i).reverse().find((x) => x !== '?');
62
+ const right = decided.slice(i + 1).find((x) => x !== '?');
63
+ return left && right && left !== right ? o.lang : left ?? right ?? o.lang;
64
+ });
65
+ }
package/dist/index.d.ts CHANGED
@@ -49,6 +49,22 @@ export type PronounOptions = {
49
49
  /** Labels to leave out. Defaults to DEFAULT_EXCLUDED_LABELS; pass [] to include everything. */
50
50
  exclude?: string[];
51
51
  };
52
+ /** One word of a text, as the dictionary splits it (see Dictionary.segment). */
53
+ export type WordSegment = {
54
+ /** The word as written; offsets are into the text in Unicode NFC form. */
55
+ text: string;
56
+ start: number;
57
+ end: number;
58
+ /** The word's dictionary entries; empty for words the dictionary doesn't have (names, typos). */
59
+ entries: Entry[];
60
+ };
61
+ /** Another way to write a word with the same letters (see Dictionary.variants). */
62
+ export type WordVariant = {
63
+ word: string;
64
+ entries: Entry[];
65
+ /** Zipf frequency (see Entry.frequency), or the syllable's for one syllable; undefined when unknown. */
66
+ frequency?: number;
67
+ };
52
68
  export type Dictionary = {
53
69
  /** The language's name, regions, source, license and counts. */
54
70
  meta(): Promise<LanguageMeta>;
@@ -73,6 +89,22 @@ export type Dictionary = {
73
89
  syllables(): Promise<Record<string, number>>;
74
90
  /** The language's sentence frames and frame words, unfilled (see FramesData); empty without frames. */
75
91
  frames(): Promise<FramesData>;
92
+ /**
93
+ * The text split into the language's words: at each point, the longest run of units (syllables or
94
+ * words, up to the checker's `maxWordUnits`, default 3) that's a headword, so Vietnamese "hôm nay" is one
95
+ * word ("today"), not "hôm" + "nay". Units join only across spaces. Units the dictionary doesn't know
96
+ * are words of their own with no entries. Punctuation isn't returned.
97
+ */
98
+ segment(text: string): Promise<WordSegment[]>;
99
+ /**
100
+ * Other words written with the same letters but other accents, that the dictionary has, most common
101
+ * first: "muộn" → muốn, mượn, …; for words of several syllables, every syllable varies ("hom nay" →
102
+ * "hôm nay"). For languages written in syllables (with a syllable list); `[]` otherwise. The word
103
+ * itself isn't included.
104
+ */
105
+ variants(word: string, options?: {
106
+ limit?: number;
107
+ }): Promise<WordVariant[]>;
76
108
  };
77
109
  /**
78
110
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
@@ -86,3 +118,4 @@ export * from './check.ts';
86
118
  export * from './text.ts';
87
119
  export * from './phrasebook.ts';
88
120
  export * from './review.ts';
121
+ export * from './detect.ts';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PRONOUN_PERSONS, fromStored, normalizeEnglish, shardKey, } from "./types.js";
2
+ import { accentCombinations, segment as segmentUnits, syllablesByPlain, units } from "./text.js";
2
3
  export * from "./types.js";
3
4
  export * from "./pos.js";
4
5
  /**
@@ -14,6 +15,11 @@ export const DEFAULT_EXCLUDED_LABELS = [
14
15
  // just a particular register); the second set costs more (specialized, playful or uncommon).
15
16
  const MILD_LABELS = new Set(['colloquial', 'informal', 'familiar', 'polite', 'formal', 'endearing', 'honorific']);
16
17
  const LABEL_PENALTY = (h) => h.labels.reduce((sum, l) => sum + (MILD_LABELS.has(l) ? 1 : 2), 0);
18
+ // Accent forms tried per syllable: more for one syllable, fewer for longer words, where every
19
+ // combination is looked up (at most MAX_VARIANT_COMBINATIONS).
20
+ const VARIANT_FORMS_ONE_SYLLABLE = 12;
21
+ const VARIANT_FORMS_PER_SYLLABLE = 5;
22
+ const MAX_VARIANT_COMBINATIONS = 64;
17
23
  /**
18
24
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
19
25
  * or null when no region is given. Throws for names the language doesn't have, listing the valid ones.
@@ -63,13 +69,52 @@ export function createDictionary(options) {
63
69
  const key = shardKey(term, m.shardLength);
64
70
  return m.shards[kind].includes(key) ? loadOnce(`${kind}/${key}.json`) : null;
65
71
  }
72
+ async function lookup(word) {
73
+ const w = word.trim();
74
+ const [m, data] = await Promise.all([meta(), shard('words', w)]);
75
+ const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? [];
76
+ return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
77
+ }
78
+ const checker = async () => {
79
+ const m = await meta();
80
+ return m.checker ? loadOnce('checker.json') : {};
81
+ };
82
+ const syllables = async () => {
83
+ const m = await meta();
84
+ return m.syllables ? loadOnce('syllables.json') : {};
85
+ };
66
86
  return {
67
87
  meta,
68
- async lookup(word) {
69
- const w = word.trim();
70
- const [m, data] = await Promise.all([meta(), shard('words', w)]);
71
- const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? [];
72
- return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
88
+ lookup,
89
+ checker,
90
+ syllables,
91
+ async segment(input) {
92
+ const text = input.normalize('NFC');
93
+ const config = await checker();
94
+ const words = await segmentUnits(text, units(text), config.maxWordUnits ?? 3, lookup);
95
+ return words.map(({ text: t, start, end, entries }) => ({ text: t, start, end, entries }));
96
+ },
97
+ async variants(word, { limit = 5 } = {}) {
98
+ const list = await syllables();
99
+ if (!Object.keys(list).length)
100
+ return [];
101
+ const written = word.trim().normalize('NFC').toLowerCase();
102
+ const parts = written.split(/\s+/);
103
+ const combos = accentCombinations(parts, syllablesByPlain(list), {
104
+ perUnit: parts.length === 1 ? VARIANT_FORMS_ONE_SYLLABLE : VARIANT_FORMS_PER_SYLLABLE,
105
+ max: MAX_VARIANT_COMBINATIONS,
106
+ }).filter((c) => c !== written);
107
+ const found = await Promise.all(combos.map(async (c) => ({ word: c, entries: await lookup(c) })));
108
+ const frequency = (v) => {
109
+ const known = v.entries.map((e) => e.frequency).filter((f) => f !== undefined);
110
+ return known.length ? Math.max(...known) : parts.length === 1 ? list[v.word] || undefined : undefined;
111
+ };
112
+ // Most common first; ties (and unknown frequencies) keep the order of more common syllable forms.
113
+ return found
114
+ .filter((v) => v.entries.length)
115
+ .map((v) => ({ ...v, frequency: frequency(v) }))
116
+ .sort((a, b) => (b.frequency ?? -1) - (a.frequency ?? -1))
117
+ .slice(0, limit);
73
118
  },
74
119
  async searchEnglish(term, { region, pos, exclude = DEFAULT_EXCLUDED_LABELS, limit = 10, allSenses = false } = {}) {
75
120
  const key = normalizeEnglish(term);
@@ -107,14 +152,6 @@ export function createDictionary(options) {
107
152
  const m = await meta();
108
153
  return m.picks ? loadOnce('picks.json') : [];
109
154
  },
110
- async checker() {
111
- const m = await meta();
112
- return m.checker ? loadOnce('checker.json') : {};
113
- },
114
- async syllables() {
115
- const m = await meta();
116
- return m.syllables ? loadOnce('syllables.json') : {};
117
- },
118
155
  async frames() {
119
156
  const m = await meta();
120
157
  if (!m.frames)
@@ -154,3 +191,4 @@ export * from "./check.js";
154
191
  export * from "./text.js";
155
192
  export * from "./phrasebook.js";
156
193
  export * from "./review.js";
194
+ export * from "./detect.js";
@@ -39,6 +39,8 @@ export type FilledFrame = {
39
39
  }[];
40
40
  /** true when every content slot is filled (and translated). */
41
41
  complete: boolean;
42
+ /** true for frames that can be part of a sentence ("but it was {quality}"). */
43
+ clause?: boolean;
42
44
  };
43
45
  /** A content slot value: English (translated for you) or `{ text }` in the target language. */
44
46
  export type SlotValue = string | {
@@ -114,7 +114,7 @@ export function createPhrasebook(options = {}) {
114
114
  const slots = Object.entries(frame.slots).map(([name, pos]) => ({ name, pos }));
115
115
  const en = Object.keys(frame.slots).reduce((s, name) => (typeof values[name] === 'string' ? s.replace(`{${name}}`, values[name]) : s), frame.en[0]);
116
116
  return {
117
- id: frame.id, topic: frame.topic, en, text,
117
+ id: frame.id, topic: frame.topic, en, text, ...(frame.clause ? { clause: true } : {}),
118
118
  parts: parts.filter((p) => p.text),
119
119
  slots,
120
120
  complete: slots.every(({ name }) => values[name] !== undefined) && !parts.some((p) => p.untranslated),
package/dist/review.d.ts CHANGED
@@ -2,17 +2,20 @@ import { type CheckRule } from './check.ts';
2
2
  import { type FrameOptions } from './phrasebook.ts';
3
3
  import { type TranslatorOptions } from './translate.ts';
4
4
  export type ReviewOptions = FrameOptions & {
5
- /** The learner's own language, for the parts written in it (default "en"). */
5
+ /** The learner's own language, whose words are translated into the target language (default "en"). */
6
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>>;
7
9
  };
8
10
  export type ReviewChange = {
9
- /** What was written (empty when a missing word was added). */
11
+ /** What was written. */
10
12
  from: string;
13
+ /** The correction; empty when the word is left out (Vietnamese has no "the"). */
11
14
  to: string;
12
15
  /** One sentence for the learner. */
13
16
  why: string;
14
- /** 'translated' (a base-language word), 'frame' (a sentence frame), or the checker rule. */
15
- kind: 'translated' | 'frame' | CheckRule;
17
+ /** 'frame' (a base-language sentence or clause filled into a sentence frame), or the checker rule. */
18
+ kind: 'frame' | CheckRule;
16
19
  };
17
20
  /** A checker suggestion that wasn't applied ("end with ạ"), for the learner to consider. */
18
21
  export type ReviewHint = {
@@ -40,7 +43,7 @@ export type ReviewSentence = {
40
43
  en: string;
41
44
  text: string;
42
45
  }[];
43
- /** Parts the review couldn't handle (longer base-language text that follows no frame), left as written. */
46
+ /** Words the review couldn't correct (a base-language word with no translation), left as written. */
44
47
  unchecked: string[];
45
48
  };
46
49
  export type Review = {
package/dist/review.js CHANGED
@@ -1,24 +1,26 @@
1
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á"). For each sentence:
2
+ // ("Hôm nay tôi đi market với má"), and giving the corrected entry in the target language. For each sentence:
3
3
  //
4
- // 1. Each word is marked as the target or the base language, from the two dictionaries alone (accents and
5
- // syllables, headwords, inflected forms); words that could be either take their neighbors' language.
6
- // 2. Base-language parts are replaced: a whole sentence or clause that follows a sentence frame is filled
7
- // in ("but it was expensive" → "nhưng mắc quá"), short phrases are translated word by word ("market" →
8
- // "chợ"), and anything longer is listed as unchecked rather than guessed.
9
- // 3. The grammar checker runs on the whole entry, so consistency checks see every sentence ("tôi" here,
10
- // "tui" there); its errors and warnings are applied, its suggestions returned as hints.
11
- // 4. The corrected sentence is checked against sentence frames ("Bạn có đi?" → "Bạn có đi không?").
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.
12
13
  //
13
14
  // Everything is rule-based and runs on the language's data: no API, and nothing language-specific here.
14
15
  import { createChecker } from "./check.js";
16
+ import { markLanguages } from "./detect.js";
15
17
  import { createDictionary } from "./index.js";
16
18
  import { createPhrasebook } from "./phrasebook.js";
17
- import { plain, sentencesOf, units } from "./text.js";
19
+ import { sentencesOf, units } from "./text.js";
18
20
  import { createTranslator } from "./translate.js";
19
- // The longest base-language phrase translated word by word; longer ones need a frame.
20
- const MAX_TRANSLATED_UNITS = 3;
21
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();
22
24
  export function createReviewer(options = {}) {
23
25
  const cache = new Map();
24
26
  const dict = (lang) => {
@@ -32,53 +34,10 @@ export function createReviewer(options = {}) {
32
34
  const translator = createTranslator({ ...options, dictionary: dict });
33
35
  const phrasebook = createPhrasebook({ ...options, dictionary: dict, translator });
34
36
  const checker = createChecker({ ...options, dictionary: dict });
35
- // Which language each unit is in. A unit the target knows and the base doesn't is the target's, and the
36
- // other way round; units both or neither know ("an", "to", names) take the nearest decided neighbor's.
37
- // In syllable languages, a base-language word that's a target syllable only once accents are added
38
- // ("but": bút) is the base language's; a word only the target knows without its accents ("khong") is
39
- // still the target's.
40
- async function languages(list, lang, base) {
41
- const target = dict(lang);
42
- const syllables = await target.syllables();
43
- const syllableLanguage = Object.keys(syllables).length > 0;
44
- const plainSyllables = syllableLanguage ? new Set(Object.keys(syllables).map(plain)) : new Set();
45
- // 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
46
- const targetKnows = async (u) => {
47
- const lower = u.text.toLowerCase();
48
- if (!syllableLanguage)
49
- return (await target.lookup(lower)).length > 0 ? 'exact' : false;
50
- if (lower in syllables || lower !== plain(lower))
51
- return 'exact';
52
- return plainSyllables.has(lower) ? 'accents' : false;
53
- };
54
- const baseKnows = async (u) => (await translator.senses(u.text, { from: base }).catch(() => [])).length > 0;
55
- const marks = await Promise.all(list.map(async (u) => {
56
- if (/\d/.test(u.text))
57
- return '?';
58
- const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
59
- if (t === 'exact')
60
- return b ? '?' : lang;
61
- if (b)
62
- return base;
63
- return t === 'accents' ? lang : '?';
64
- }));
65
- return marks.map((m, i) => {
66
- if (m !== '?')
67
- return m;
68
- for (let d = 1; d < marks.length; d++) {
69
- if (i - d >= 0 && marks[i - d] !== '?')
70
- return marks[i - d];
71
- if (i + d < marks.length && marks[i + d] !== '?')
72
- return marks[i + d];
73
- }
74
- return lang;
75
- });
76
- }
77
37
  return {
78
38
  async review(entry, o) {
79
39
  const text = entry.normalize('NFC');
80
40
  const base = o.base ?? 'en';
81
- const meta = await dict(o.lang).meta();
82
41
  // A frame as shown to the learner: its English and its pattern for these settings, slots open.
83
42
  const frameInfo = new Map();
84
43
  const frameOf = async (id) => {
@@ -93,7 +52,7 @@ export function createReviewer(options = {}) {
93
52
  const start = list[0].start;
94
53
  const coreEnd = list[list.length - 1].end;
95
54
  const end = coreEnd + (TERMINAL.exec(text.slice(coreEnd))?.[0].length ?? 0);
96
- const langs = await languages(list, o.lang, base);
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 });
97
56
  const groups = [];
98
57
  list.forEach((u, i) => {
99
58
  if (groups.length && groups[groups.length - 1].lang === langs[i])
@@ -102,38 +61,45 @@ export function createReviewer(options = {}) {
102
61
  groups.push({ lang: langs[i], units: [u] });
103
62
  });
104
63
  const draft = {
105
- start, coreEnd, end, replacements: [], frames: [], unchecked: [],
64
+ start, coreEnd, end, replacements: [], frames: [],
106
65
  parts: groups.map((g) => ({ text: text.slice(g.units[0].start, g.units[g.units.length - 1].end), lang: g.lang })),
107
66
  };
108
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
+ };
109
80
  for (const g of groups.filter((g) => g.lang === base)) {
110
- const from = text.slice(g.units[0].start, g.units[g.units.length - 1].end);
111
- const at = { start: g.units[0].start, end: g.units[g.units.length - 1].end };
112
81
  // A whole sentence is matched with its own punctuation ("Where is the bathroom?").
113
- const frame = await phrasebook.match(whole ? text.slice(start, end) : from, o);
114
- if (frame && frame.complete) {
115
- let to = frame.text.replace(/[.!?…]+$/, '');
116
- if (!whole && g.units[0].start !== start)
117
- to = to.charAt(0).toLowerCase() + to.slice(1);
118
- const info = await frameOf(frame.id);
119
- draft.replacements.push({ ...at, to, change: { kind: 'frame', from, to, why: `Sentence frame “${info.en}”` } });
120
- draft.frames.push(info);
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);
121
86
  continue;
122
87
  }
123
- if (!whole && g.units.length <= MAX_TRANSLATED_UNITS) {
124
- const english = from.replace(/^(the|a|an)\s+/i, '');
125
- const [group] = await translator.translate(english, { from: base, to: o.lang, toRegion: o.region, register: o.register, limit: 1 }).catch(() => []);
126
- const word = group?.translations[0]?.word;
127
- if (word) {
128
- draft.replacements.push({ ...at, to: word, change: { kind: 'translated', from, to: word, why: `“${english}” in ${meta.name}` } });
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)
129
95
  continue;
130
- }
96
+ await fill(span, m);
97
+ break;
131
98
  }
132
- draft.unchecked.push(from);
133
99
  }
134
100
  drafts.push(draft);
135
101
  }
136
- // The entry with base-language parts replaced, keeping where each sentence now is.
102
+ // The entry with those parts filled in, keeping where each sentence now is.
137
103
  let draftText = '';
138
104
  let cursor = 0;
139
105
  const placed = [];
@@ -151,41 +117,49 @@ export function createReviewer(options = {}) {
151
117
  cursor = d.end;
152
118
  }
153
119
  draftText += text.slice(cursor);
154
- // 3. The checker on the whole entry; 4. frames on each corrected sentence.
155
- const { issues } = await checker.check(draftText, { lang: o.lang, region: o.region, listener: o.listener, about: undefined, speaker: o.speaker });
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
+ });
156
124
  const sentences = [];
157
125
  for (const [i, d] of drafts.entries()) {
158
126
  const p = placed[i];
159
127
  const mine = issues.filter((x) => x.start >= p.start && x.end <= p.coreEnd);
160
128
  const changes = d.replacements.sort((a, b) => a.start - b.start).map((r) => r.change);
161
129
  const hints = [];
130
+ const unchecked = [];
131
+ const applied = [];
162
132
  let core = draftText.slice(p.start, p.coreEnd);
163
133
  let lastStart = Infinity;
164
134
  for (const x of [...mine].sort((a, b) => b.start - a.start)) {
165
- const applies = (x.severity === 'error' || x.severity === 'warning') && x.suggestions.length && x.end <= lastStart;
166
- if (!applies) {
167
- hints.push({ rule: x.rule, text: x.text, message: x.message, suggestions: x.suggestions });
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 });
168
144
  continue;
169
145
  }
170
146
  core = core.slice(0, x.start - p.start) + x.suggestions[0] + core.slice(x.end - p.start);
171
- changes.push({ kind: x.rule, from: x.text, to: x.suggestions[0], why: x.message });
147
+ applied.unshift({ kind: x.rule, from: x.text, to: x.suggestions[0], why: x.message });
172
148
  lastStart = x.start;
173
149
  }
174
- const punctuation = draftText.slice(p.coreEnd, p.end);
175
- let corrected = core + punctuation;
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);
176
155
  const frames = [...d.frames];
177
156
  for (const m of await phrasebook.matchFrames(corrected, o)) {
178
157
  if (!frames.some((f) => f.id === m.frame.id))
179
158
  frames.push(await frameOf(m.frame.id));
180
- if (m.changes.length && corrected === core + punctuation) {
181
- corrected = m.text;
182
- for (const c of m.changes)
183
- changes.push({ kind: 'frame', from: c.from, to: c.to, why: `${c.why} (sentence frame “${m.frame.en}”)` });
184
- }
185
159
  }
186
160
  sentences.push({
187
161
  start: d.start, end: d.end, original: text.slice(d.start, d.end), parts: d.parts,
188
- corrected, changes, hints: hints.reverse(), frames, unchecked: d.unchecked,
162
+ corrected, changes, hints, frames, unchecked,
189
163
  });
190
164
  }
191
165
  let corrected = '';
package/dist/text.d.ts CHANGED
@@ -29,3 +29,16 @@ export declare function segment(text: string, list: Unit[], maxUnits: number, lo
29
29
  export declare const plain: (s: string) => string;
30
30
  /** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
31
31
  export declare const matchCase: (original: string, replacement: string) => string;
32
+ /** A language's syllables grouped by their form without accents, most common first. */
33
+ export declare function syllablesByPlain(syllables: Record<string, number>): Map<string, string[]>;
34
+ /**
35
+ * Ways to write a run of syllables with other accents ("hom nay" → "hôm nay", "hơm nay", …): each
36
+ * syllable's forms that share its letters (the most common `perUnit`, plus the syllable as written), in
37
+ * every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
38
+ * change (`fixed[i]`) keep their written form. The written run itself is included.
39
+ */
40
+ export declare function accentCombinations(written: string[], byPlain: Map<string, string[]>, { fixed, perUnit, max }: {
41
+ fixed?: boolean[];
42
+ perUnit: number;
43
+ max: number;
44
+ }): string[];
package/dist/text.js CHANGED
@@ -55,3 +55,43 @@ export async function segment(text, list, maxUnits, lookup) {
55
55
  export const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
56
56
  /** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
57
57
  export const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
58
+ // Syllables grouped by their plain form, most common first: "khong" → ["không", "khống", …]. Built once
59
+ // per syllable list.
60
+ const byPlainCache = new WeakMap();
61
+ /** A language's syllables grouped by their form without accents, most common first. */
62
+ export function syllablesByPlain(syllables) {
63
+ let byPlain = byPlainCache.get(syllables);
64
+ if (!byPlain) {
65
+ byPlain = new Map();
66
+ for (const s of Object.keys(syllables))
67
+ byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
68
+ for (const list of byPlain.values())
69
+ list.sort((a, b) => syllables[b] - syllables[a]);
70
+ byPlainCache.set(syllables, byPlain);
71
+ }
72
+ return byPlain;
73
+ }
74
+ /**
75
+ * Ways to write a run of syllables with other accents ("hom nay" → "hôm nay", "hơm nay", …): each
76
+ * syllable's forms that share its letters (the most common `perUnit`, plus the syllable as written), in
77
+ * every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
78
+ * change (`fixed[i]`) keep their written form. The written run itself is included.
79
+ */
80
+ export function accentCombinations(written, byPlain, { fixed = [], perUnit, max }) {
81
+ const choices = written.map((w, i) => {
82
+ const lower = w.toLowerCase();
83
+ if (fixed[i])
84
+ return [lower];
85
+ const forms = (byPlain.get(plain(lower)) ?? []).slice(0, perUnit);
86
+ return forms.includes(lower) ? forms : [...forms, lower];
87
+ });
88
+ // Every combination with the sum of its forms' ranks: lower sums are made of more common forms.
89
+ let combos = [{ words: [], rank: 0 }];
90
+ for (const options of choices) {
91
+ combos = combos.flatMap((c) => options.map((o, r) => ({ words: [...c.words, o], rank: c.rank + r })));
92
+ }
93
+ return combos
94
+ .sort((a, b) => a.rank - b.rank)
95
+ .slice(0, max)
96
+ .map((c) => c.words.join(' '));
97
+ }
package/dist/types.d.ts CHANGED
@@ -195,6 +195,11 @@ export type CheckerConfig = {
195
195
  ambiguousPronouns?: string[];
196
196
  /** Sentence endings expected when speaking up (pronoun-table rows marked `respect`), e.g. Vietnamese "ạ". */
197
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[];
198
203
  };
199
204
  /**
200
205
  * Hand-picked first choices for one English meaning, in one target language (picks.json). A speaker's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",