which-dialect 0.1.0 → 0.2.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,69 @@ 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
+ - **`polite-ending`**: a suggestion to end sentences with *ạ* when talking to parents, elders and teachers (not for exclamations).
147
+
148
+ 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.
149
+
150
+ **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.
151
+
99
152
  ## Looking words up
100
153
 
101
154
  ### `createDictionary({ lang, baseUrl?, load? })`
@@ -104,6 +157,8 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
104
157
 
105
158
  - `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
106
159
  - `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.
160
+ - `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
161
+ - `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
162
  - `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
108
163
  - `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
164
 
@@ -172,17 +227,26 @@ If you **redistribute modified data**, it has to stay under CC BY-SA 4.0. Using
172
227
 
173
228
  Requires Node 22.6+ (TypeScript scripts run with `--experimental-strip-types`).
174
229
 
230
+ Generated data (`packages/<code>/data`) isn't committed, so a fresh clone needs it first. Two ways:
231
+
232
+ - **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.
233
+ - **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.
234
+
175
235
  ```sh
176
236
  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
237
+ npm run fetch:data # quick start: the published data (-- --force replaces data you already have)
238
+ npm run build:data:all # or build every language from Kaikki, English first (-- --refresh re-downloads)
239
+ npm run build:data -- vi # build one language (after English); -- vi --refresh re-downloads first
179
240
  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)
241
+ npm run evaluate # translation accuracy on known-correct cases, with and without picks (-- --verbose to see every case)
242
+ npm run build:picks -- vi # rebuild only a language's hand-picked words (after editing `picks` in languages/vi.ts)
243
+ npm run picks-sheet -- vi # review sheet (.cache/picks-vi.csv) of the top English words' meanings; -- --words file.txt, -- --top 500
244
+ npm run picks-sheet -- vi --apply sheet.csv # config rows for the rows whose your_pick is filled in
181
245
  npm run typecheck
182
246
  npm run build # compile the API to packages/core/dist
183
247
  ```
184
248
 
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).
249
+ 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
250
 
187
251
  ### Adding a language
188
252
 
@@ -190,13 +254,15 @@ Generated data isn't committed (it would bloat git history); it's built before p
190
254
  2. Copy a data package (`packages/vi`) to `packages/<code>` and update its `package.json` and `README.md`.
191
255
  3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
192
256
  4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
257
+ 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.
258
+ 6. 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
259
 
194
260
  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
261
 
196
262
  ### Publishing
197
263
 
198
264
  ```sh
199
- npm run build:data -- en --refresh && npm run build:data -- vi --refresh && npm test
265
+ npm run build:data:all -- --refresh && npm test # records the new dates in DATA_UPDATES.md: commit it
200
266
  npm publish -w which-dialect-en
201
267
  npm publish -w which-dialect-vi
202
268
  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' | '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,369 @@
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 { createTranslator } from "./translate.js";
13
+ import { PRONOUN_PERSONS, } from "./types.js";
14
+ // How each pronoun-table column is named in messages.
15
+ const PERSON_NAMES = {
16
+ self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
17
+ };
18
+ // Columns chosen by `about` (who you're talking about); the rest by `listener`.
19
+ const ABOUT_PERSONS = new Set(['third', 'thirdPlural']);
20
+ const SELF_PERSONS = new Set(['self', 'selfPlural']);
21
+ const ADDRESSEE_PERSONS = new Set(['addressee', 'addresseePlural']);
22
+ // A run of letters (with their accents), digits and apostrophes: a word, or a syllable in syllable languages.
23
+ const UNIT = /[\p{L}\p{M}\p{N}'’]+/gu;
24
+ const SENTENCE_END = /[.!?…]+|\n+/g;
25
+ const quote = (s) => `“${s}”`;
26
+ const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
27
+ const listOf = (items, conjunction = 'and') => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
28
+ // Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con").
29
+ const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
30
+ // Without accents, for finding the accented forms of a syllable: "không" → "khong", "đi" → "di".
31
+ const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
32
+ const unique = (items) => [...new Set(items)];
33
+ function units(text) {
34
+ return [...text.matchAll(UNIT)].map((m) => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
35
+ }
36
+ function sentencesOf(text, all) {
37
+ const ends = [...text.matchAll(SENTENCE_END)].map((m) => m.index);
38
+ const out = [[]];
39
+ let next = 0;
40
+ for (const u of all) {
41
+ while (next < ends.length && ends[next] < u.start) {
42
+ if (out[out.length - 1].length)
43
+ out.push([]);
44
+ next++;
45
+ }
46
+ out[out.length - 1].push(u);
47
+ }
48
+ return out.filter((s) => s.length);
49
+ }
50
+ // Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo").
51
+ const joined = (text, a, b) => /^[^\S\n]+$/.test(text.slice(a.end, b.start));
52
+ // Longest match against the dictionary: at each position, the longest run of units that's a headword
53
+ // ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
54
+ // no entries.
55
+ async function segment(text, list, maxUnits, lookup) {
56
+ const words = [];
57
+ for (let i = 0; i < list.length;) {
58
+ let taken = null;
59
+ for (let k = Math.min(maxUnits, list.length - i); k >= 1; k--) {
60
+ const span = list.slice(i, i + k);
61
+ if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
62
+ continue;
63
+ const phrase = span.map((u) => u.text).join(' ');
64
+ const entries = /\d/.test(phrase) ? [] : await lookup(phrase);
65
+ if (entries.length || k === 1) {
66
+ const start = span[0].start;
67
+ const end = span[k - 1].end;
68
+ taken = { text: text.slice(start, end), lower: phrase.toLowerCase(), start, end, units: span, entries };
69
+ break;
70
+ }
71
+ }
72
+ words.push(taken);
73
+ i += taken.units.length;
74
+ }
75
+ return words;
76
+ }
77
+ // --- Rules -----------------------------------------------------------------------------------------------
78
+ // Spelling and accents, for languages written in syllables with a syllable list: a syllable that isn't in
79
+ // the language is flagged, with the accented forms it could be ("khong" → "không"). Plain-letter
80
+ // syllables with no accented form are left alone: usually a name or a foreign word ("email").
81
+ async function spelling(ctx) {
82
+ if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
83
+ return [];
84
+ const syllables = await ctx.dict.syllables();
85
+ const byPlain = new Map();
86
+ for (const s of Object.keys(syllables))
87
+ byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
88
+ for (const list of byPlain.values())
89
+ list.sort((a, b) => syllables[b] - syllables[a]);
90
+ const issues = [];
91
+ for (const word of ctx.sentences.flatMap((s) => s.words)) {
92
+ if (word.entries.length)
93
+ continue;
94
+ for (const u of word.units) {
95
+ const lower = u.text.toLowerCase().replace(/[’']/g, '');
96
+ if (/\d/.test(lower) || lower in syllables)
97
+ continue;
98
+ const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
99
+ const accented = lower !== plain(lower);
100
+ if (!candidates.length && !accented)
101
+ continue;
102
+ issues.push({
103
+ rule: 'spelling',
104
+ // Missing accents on plain letters is the common learner slip; a wrong accent is more surely wrong.
105
+ severity: accented ? 'error' : 'warning',
106
+ start: u.start,
107
+ end: u.end,
108
+ text: u.text,
109
+ message: candidates.length
110
+ ? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or')}?`
111
+ : `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`,
112
+ suggestions: candidates.map((c) => matchCase(u.text, c)),
113
+ });
114
+ }
115
+ }
116
+ return issues;
117
+ }
118
+ // Words from another region: a word whose main meaning (its first sense) is tagged for regions that don't
119
+ // include the text's region. With `region`, against that; without, against the regions most of the text's
120
+ // regional words share (so "lợn" stands out in otherwise Southern text). Words with an untagged main
121
+ // meaning are never flagged (Vietnamese "má" is "cheek" everywhere, "mother" only in the South).
122
+ async function dialect(ctx) {
123
+ const all = new Set(ctx.meta.regions);
124
+ const regional = ctx.sentences
125
+ .flatMap((s) => s.words)
126
+ .map((word) => ({ word, sense: word.entries[0]?.senses[0] }))
127
+ .filter((x) => Boolean(x.sense?.regionTagged && x.sense.regions.length < all.size));
128
+ if (!regional.length)
129
+ return [];
130
+ const issue = (word, regions, message, suggestions, severity) => ({
131
+ rule: 'dialect',
132
+ severity,
133
+ start: word.start,
134
+ end: word.end,
135
+ text: word.text,
136
+ message: `${quote(word.text)} is ${listOf(regions)}${message}` +
137
+ // The best word only; the others (often niche: Southern "cúi", "ỉn" after "heo") are in suggestions.
138
+ (suggestions.length ? `, say ${quote(suggestions[0])}.` : '.'),
139
+ suggestions,
140
+ });
141
+ if (ctx.options.region) {
142
+ const target = resolveRegion(ctx.meta, ctx.options.region);
143
+ const where = listOf(ctx.meta.regions.filter((r) => target.has(r)));
144
+ const issues = [];
145
+ for (const { word, sense } of regional) {
146
+ if (sense.regions.some((r) => target.has(r)))
147
+ continue;
148
+ const suggestions = await ctx.suggestRegional(word, ctx.options.region);
149
+ issues.push(issue(word, sense.regions, `; in ${where} ${ctx.meta.name}`, suggestions, 'warning'));
150
+ }
151
+ return issues;
152
+ }
153
+ // No region given: the regions most regional words share are the text's region, and words that fit none
154
+ // of them stand out. When no region has a majority but some words can't go together ("lợn", Northern,
155
+ // with "nha", Central and Southern), every regional word is pointed out, without a guess.
156
+ const counts = new Map();
157
+ for (const { sense } of regional)
158
+ for (const r of sense.regions)
159
+ counts.set(r, (counts.get(r) ?? 0) + 1);
160
+ const most = Math.max(...counts.values());
161
+ const majority = new Set([...counts].filter(([, n]) => n === most).map(([r]) => r));
162
+ const outliers = regional.filter(({ sense }) => !sense.regions.some((r) => majority.has(r)));
163
+ if (outliers.length) {
164
+ const where = ctx.meta.regions.filter((r) => majority.has(r));
165
+ 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')));
166
+ }
167
+ const clash = regional.some((a) => regional.some((b) => !a.sense.regions.some((r) => b.sense.regions.includes(r))));
168
+ if (!clash)
169
+ return [];
170
+ const mixed = listOf(regional.map(({ word, sense }) => `${quote(word.text)} (${listOf(sense.regions)})`));
171
+ return regional.map(({ word, sense }) => issue(word, sense.regions, `. This text mixes regions: ${mixed}. Pick one region`, [], 'suggestion'));
172
+ }
173
+ // Pronouns that don't fit who you're talking to (`listener`) or about (`about`): "tôi" to a parent, where
174
+ // it's "con". Only the language's reliable pronouns are checked (CheckerConfig.pronouns); ones that are
175
+ // also common nouns ("bạn": friend) only get a hedged suggestion.
176
+ async function pronounRelationship(ctx) {
177
+ if (!ctx.options.listener && !ctx.options.about)
178
+ return [];
179
+ const rows = await ctx.rows();
180
+ const issues = [];
181
+ for (const m of ctx.pronounMatches()) {
182
+ const about = ABOUT_PERSONS.has(m.person);
183
+ const row = rows.find((r) => r.id === (about ? ctx.options.about : ctx.options.listener));
184
+ const fit = unique((row?.[m.person] ?? []).map((c) => c.word));
185
+ if (!row || !fit.length || fit.includes(m.lower))
186
+ continue;
187
+ const ambiguous = ctx.config.ambiguousPronouns?.includes(m.lower);
188
+ const who = `${about ? 'Talking about' : 'Talking to'} ${lowerFirst(row.label)}`;
189
+ issues.push({
190
+ rule: 'pronoun-relationship',
191
+ severity: ambiguous ? 'suggestion' : 'warning',
192
+ start: m.start,
193
+ end: m.end,
194
+ text: m.text,
195
+ message: (ambiguous ? `If ${quote(m.text)} means “${PERSON_NAMES[m.person]}” here: ${lowerFirst(who)}` : who) +
196
+ `, say ${listOf(fit.map(quote), 'or')} for “${PERSON_NAMES[m.person]}”, not ${quote(m.text)}.`,
197
+ suggestions: fit.map((w) => matchCase(m.text, w)),
198
+ });
199
+ }
200
+ return issues;
201
+ }
202
+ // "I" and "you" pronouns that never go together (Vietnamese "tao" goes with "mày", not "bạn"): no row of the
203
+ // pronoun table has both. Only when no listener is given; with one, pronoun-relationship covers it.
204
+ async function pronounPair(ctx) {
205
+ if (ctx.options.listener)
206
+ return [];
207
+ const matches = ctx.pronounMatches();
208
+ const selves = matches.filter((m) => SELF_PERSONS.has(m.person));
209
+ const yous = matches.filter((m) => ADDRESSEE_PERSONS.has(m.person));
210
+ if (!selves.length || !yous.length)
211
+ return [];
212
+ const rows = await ctx.rows();
213
+ const words = (r, persons) => [...persons].flatMap((p) => r[p].map((c) => c.word));
214
+ const issues = [];
215
+ for (const you of yous) {
216
+ for (const me of selves) {
217
+ const withMe = rows.filter((r) => words(r, SELF_PERSONS).includes(me.lower));
218
+ if (!withMe.length || withMe.some((r) => words(r, ADDRESSEE_PERSONS).includes(you.lower)))
219
+ continue;
220
+ const partners = unique(withMe.flatMap((r) => r[you.person].map((c) => c.word)));
221
+ issues.push({
222
+ rule: 'pronoun-pair',
223
+ severity: ctx.config.ambiguousPronouns?.includes(you.lower) ? 'suggestion' : 'warning',
224
+ start: you.start,
225
+ end: you.end,
226
+ text: you.text,
227
+ message: `${quote(me.text)} and ${quote(you.text)} don't go together` +
228
+ (partners.length ? `: with ${quote(me.text)}, say ${listOf(partners.map(quote), 'or')}.` : '.'),
229
+ suggestions: partners.map((w) => matchCase(you.text, w)),
230
+ });
231
+ break;
232
+ }
233
+ }
234
+ return issues;
235
+ }
236
+ // Polite endings when speaking up (the listener's row is marked `respect`): Vietnamese sentences to parents,
237
+ // elders and teachers usually end in "ạ". A suggestion, not an error: it isn't always needed.
238
+ async function politeEnding(ctx) {
239
+ const endings = ctx.config.politeEndings ?? [];
240
+ if (!ctx.options.listener || !endings.length)
241
+ return [];
242
+ const row = (await ctx.rows()).find((r) => r.id === ctx.options.listener);
243
+ if (!row?.respect)
244
+ return [];
245
+ const issues = [];
246
+ for (const s of ctx.sentences) {
247
+ const last = s.units[s.units.length - 1];
248
+ if (endings.includes(last.text.toLowerCase()))
249
+ continue;
250
+ // Exclamations ("Con nhớ má quá!") are often fine without it.
251
+ if (/^[^\p{L}\p{N}]*!/u.test(ctx.text.slice(last.end)))
252
+ continue;
253
+ issues.push({
254
+ rule: 'polite-ending',
255
+ severity: 'suggestion',
256
+ start: last.start,
257
+ end: last.end,
258
+ text: last.text,
259
+ message: `Talking to ${lowerFirst(row.label)}, end the sentence with ${listOf(endings.map(quote), 'or')} to sound polite.`,
260
+ suggestions: endings.map((e) => `${last.text} ${e}`),
261
+ });
262
+ }
263
+ return issues;
264
+ }
265
+ const RULES = {
266
+ spelling,
267
+ dialect,
268
+ 'pronoun-relationship': pronounRelationship,
269
+ 'pronoun-pair': pronounPair,
270
+ 'polite-ending': politeEnding,
271
+ };
272
+ const SEVERITY_ORDER = { error: 0, warning: 1, suggestion: 2 };
273
+ export function createChecker(options = {}) {
274
+ const cache = new Map();
275
+ const dict = (lang) => {
276
+ let d = cache.get(lang);
277
+ if (!d) {
278
+ d = options.dictionary?.(lang) ?? createDictionary({ lang, baseUrl: options.baseUrl?.(lang), load: options.load?.(lang) });
279
+ cache.set(lang, d);
280
+ }
281
+ return d;
282
+ };
283
+ const translator = createTranslator({ ...options, dictionary: dict });
284
+ const lookups = new Map();
285
+ return {
286
+ async check(input, opts) {
287
+ const text = input.normalize('NFC');
288
+ const d = dict(opts.lang);
289
+ const [meta, config] = await Promise.all([d.meta(), d.checker()]);
290
+ // Unknown listener/about ids throw, listing the valid ones (as the translator does).
291
+ if (meta.pronouns) {
292
+ if (opts.listener)
293
+ await d.pronouns({ listener: opts.listener });
294
+ if (opts.about)
295
+ await d.pronouns({ listener: opts.about });
296
+ }
297
+ const lookup = (phrase) => {
298
+ const key = `${opts.lang}\u0000${phrase}`;
299
+ let pending = lookups.get(key);
300
+ if (!pending) {
301
+ pending = d.lookup(phrase);
302
+ lookups.set(key, pending);
303
+ }
304
+ return pending;
305
+ };
306
+ const sentences = [];
307
+ for (const list of sentencesOf(text, units(text))) {
308
+ sentences.push({ units: list, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
309
+ }
310
+ let rows;
311
+ let matches;
312
+ const ctx = {
313
+ text,
314
+ options: opts,
315
+ meta,
316
+ config,
317
+ sentences,
318
+ dict: d,
319
+ rows: () => (rows ??= meta.pronouns ? d.pronouns({ region: opts.region, speaker: opts.speaker }) : Promise.resolve([])),
320
+ // The language's reliable pronouns in the text, longest first ("chúng tôi" before "tôi"), matched on
321
+ // units directly so pronouns the dictionary doesn't list as one word ("tụi mày") are found too.
322
+ pronounMatches: () => (matches ??= findPronouns(text, sentences, config)),
323
+ suggestRegional: async (word, region) => {
324
+ try {
325
+ const [group] = await translator.translate(word.lower, {
326
+ from: opts.lang, to: opts.lang, toRegion: region, pos: word.entries[0]?.pos, limit: 3,
327
+ });
328
+ return (group?.translations ?? []).map((t) => t.word).filter((w) => w !== word.lower);
329
+ }
330
+ catch {
331
+ return [];
332
+ }
333
+ },
334
+ };
335
+ const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] !== false).map((r) => RULES[r](ctx)));
336
+ const issues = results.flat().sort((a, b) => a.start - b.start || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
337
+ return { text, issues };
338
+ },
339
+ };
340
+ }
341
+ function findPronouns(text, sentences, config) {
342
+ const byWord = new Map();
343
+ for (const person of PRONOUN_PERSONS)
344
+ for (const w of config.pronouns?.[person] ?? [])
345
+ byWord.set(w.toLowerCase(), person);
346
+ if (!byWord.size)
347
+ return [];
348
+ const longest = Math.max(...[...byWord.keys()].map((w) => w.split(' ').length));
349
+ const out = [];
350
+ sentences.forEach((s, sentence) => {
351
+ for (let i = 0; i < s.units.length;) {
352
+ let step = 1;
353
+ for (let k = Math.min(longest, s.units.length - i); k >= 1; k--) {
354
+ const span = s.units.slice(i, i + k);
355
+ if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
356
+ continue;
357
+ const lower = span.map((u) => u.text.toLowerCase()).join(' ');
358
+ const person = byWord.get(lower);
359
+ if (person) {
360
+ out.push({ text: text.slice(span[0].start, span[k - 1].end), lower, start: span[0].start, end: span[k - 1].end, person, sentence });
361
+ step = k;
362
+ break;
363
+ }
364
+ }
365
+ i += step;
366
+ }
367
+ });
368
+ return out;
369
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type PronounRow, type Entry, type Hit, type LanguageMeta } from './types.ts';
1
+ import { type CheckerConfig, 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
  /**
@@ -65,6 +65,12 @@ export type Dictionary = {
65
65
  * for languages without a pronoun table.
66
66
  */
67
67
  pronouns(options?: PronounOptions): Promise<PronounRow[]>;
68
+ /** Hand-picked first choices for English meanings (see PickRow). Empty for languages without any. */
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>>;
68
74
  };
69
75
  /**
70
76
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
@@ -74,3 +80,4 @@ export declare function resolveRegion(meta: LanguageMeta, region: string | undef
74
80
  export declare function dataUrl(lang: string): string;
75
81
  export declare function createDictionary(options: DictionaryOptions): Dictionary;
76
82
  export * from './translate.ts';
83
+ export * from './check.ts';
package/dist/index.js CHANGED
@@ -103,6 +103,18 @@ export function createDictionary(options) {
103
103
  const kept = new Set(words.map((h) => h.word));
104
104
  return ranked.filter((h) => kept.has(h.word));
105
105
  },
106
+ async picks() {
107
+ const m = await meta();
108
+ return m.picks ? loadOnce('picks.json') : [];
109
+ },
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
+ },
106
118
  async pronouns({ listener, region, speaker, exclude = DEFAULT_EXCLUDED_LABELS } = {}) {
107
119
  const m = await meta();
108
120
  if (!m.pronouns)
@@ -128,3 +140,4 @@ export function createDictionary(options) {
128
140
  };
129
141
  }
130
142
  export * from "./translate.js";
143
+ export * from "./check.js";
@@ -27,8 +27,9 @@ export type Translation = Hit & {
27
27
  score: number;
28
28
  /**
29
29
  * How this word was found: the English term that linked it, "synonym" for a same-language synonym,
30
- * "table" for Wiktionary's translation table, or "pronouns" for a pronoun-table row's words (the
31
- * `listener`'s or `about`'s row, or the default row).
30
+ * "table" for Wiktionary's translation table, "picked" for the target language's hand-picked words
31
+ * for this meaning (see PickRow), or "pronouns" for a pronoun-table row's words (the `listener`'s or
32
+ * `about`'s row, or the default row).
32
33
  */
33
34
  bridge: string;
34
35
  /** Example sentences for this word in the matched sense, when the dictionary has them. */
@@ -124,6 +125,11 @@ export type TranslateOptions = {
124
125
  * translations repeat another sense's. Default false. Useful for letting users pick a meaning.
125
126
  */
126
127
  allSenses?: boolean;
128
+ /**
129
+ * Use the target language's hand-picked words (default true). false returns the ranking alone, e.g. to
130
+ * see what picks change.
131
+ */
132
+ picks?: boolean;
127
133
  };
128
134
  export type Translator = {
129
135
  /** The senses of a word, following forms and variant spellings to the words they belong to. */
package/dist/translate.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // for those terms with a compatible part of speech, in the target region. Each source sense is translated
6
6
  // on its own, so a word's meanings don't get mixed up ("cool" the temperature vs. "cool" the compliment).
7
7
  import { compatiblePos } from "./pos.js";
8
- import { displayGloss, glossTerms, normalizeEnglish, PRONOUN_PERSONS, } from "./types.js";
8
+ import { displayGloss, glossTerms, normalizeEnglish, PRONOUN_PERSONS, TRAILING_PARTICLE, } from "./types.js";
9
9
  import { createDictionary, DEFAULT_EXCLUDED_LABELS, resolveRegion, } from "./index.js";
10
10
  /**
11
11
  * Possible base words for a regularly inflected English word, most likely first: "walked" -> walk,
@@ -118,9 +118,61 @@ const MIN_SCORE = 1;
118
118
  // A variant that's only another spelling of a different word ("yeah": pronunciation spelling of "year")
119
119
  // ranks below the word's own meanings.
120
120
  const SPELLING_VIA = /pronunciation spelling|misspelling|eye dialect|nonstandard spelling|dated spelling|informal spelling/i;
121
+ // Among a form's own meanings, one that needs a following construction ("Must; have/has (to).", "used
122
+ // with have", "followed by …") is narrower than a standalone one ("got": "Have/has." comes first). Only
123
+ // there: function words' main meanings are constructions ("would": "Past tense of will; usually
124
+ // followed by a bare infinitive"), and demoting them everywhere put "would" → "hoàn cảnh" first.
125
+ const CONSTRUCTION = /\((?:to|with [^)]*|followed by [^)]*)\)|\bused with\b|\bfollowed by\b/i;
121
126
  // Senses whose labels are all in this set rank after mainstream senses.
122
127
  const MARGINAL_LABELS = new Set(['informal', 'slang', 'colloquial', 'dialectal', 'rare', 'nonstandard', 'Internet', 'humorous']);
128
+ // How a form reached its base word, when it's an inflection ("simple past of get", "form of run").
129
+ const INFLECTION_VIA = /\b(?:past|participle|plural|singular|present|third-person|comparative|superlative|gerund)\b|^form of /i;
130
+ // Everyday speech, not slang: a form's own colloquial meaning ("got": "Have/has.") isn't marginal.
131
+ const EVERYDAY_LABELS = new Set(['informal', 'colloquial']);
123
132
  const LABEL_PENALTY = new Set(['slang', 'Internet', 'humorous', 'literary', 'dialectal', 'uncommon', 'rare', 'euphemistic']);
133
+ // A target gloss that lists the bridge term itself as an equivalent ("muốn": "to want"; "má": "mother;
134
+ // mom") is a plain translation, but a short gloss can't earn definition overlap, since the term's own
135
+ // words don't count. Wordier glosses did ("mắc": "to want to (go to the bathroom, laugh, etc.); to have
136
+ // the desire to" overlapped "desire"), so a narrow use beat the everyday word. An exact equivalent
137
+ // scores like one overlapping word, for main terms only: a side term's exact match ("corn" → "cereal" →
138
+ // "ngũ cốc") is a looser equivalent.
139
+ const EXACT_GLOSS_BONUS = 3;
140
+ // The term narrowed by a parenthetical right after it ("to want to (go to the bathroom …)") is a
141
+ // restricted use of the word, not its general meaning (see narrows()).
142
+ const NARROWED_PENALTY = 1.5;
143
+ // A parenthetical narrows a term when it names something the source meaning doesn't: "to want to (go to
144
+ // the bathroom, laugh, etc.)", "in/at (an outdoor or external location)". Restatements ("ngầu": "cool
145
+ // (awesome; fantastic; great)" for cool = awesome), grammar ("to wait (for)") and usage notes, which say
146
+ // how the word is used rather than what it means ("dạ": "yes (answers a question)"), don't narrow.
147
+ const USAGE_NOTE = /^\s*(?:used|said|\w+s\s+(?:a|an|the|that|for|to|with|about)\b)/i;
148
+ function narrows(parenthetical, context) {
149
+ return contentWords(parenthetical).size > 0 && !USAGE_NOTE.test(parenthetical) && overlap(context, parenthetical) === 0;
150
+ }
151
+ // A target sense marked passive ("được": "to obtain, to get (passively)") is a different voice from an
152
+ // active source meaning ("get": "To obtain; to acquire", where "lấy" is the everyday word). Sized to
153
+ // offset a very common word's frequency: "được" is common mostly as a helper verb ("được đi", "làm
154
+ // được"), and frequency is per word, not per meaning.
155
+ const PASSIVE = /\bpassive(?:ly)?\b/i;
156
+ const VOICE_MISMATCH_PENALTY = 3;
157
+ function exactGloss(gloss, term, context) {
158
+ // "to wait for" is plain "wait", but a part with a narrowing parenthetical isn't ("to want to (…)").
159
+ // Like glossTerms, only the text after a colon is the meaning ("Used as an answer …: no"). A question
160
+ // ("dạ": "yes? (asks for more information)") isn't the plain word.
161
+ let cleaned = gloss.replace(/\(([^)]*)\)/g, (_, inner) => (narrows(inner, context) ? '(' : ' '));
162
+ if (cleaned.includes(':'))
163
+ cleaned = cleaned.slice(cleaned.lastIndexOf(':') + 1);
164
+ return cleaned.split(/[;,/]/).some((part) => {
165
+ if (part.includes('('))
166
+ return false;
167
+ const t = normalizeEnglish(part.replace(/[.!]+\s*$/, ''));
168
+ return t === term || t.replace(TRAILING_PARTICLE, '') === term;
169
+ });
170
+ }
171
+ function narrowed(gloss, term, context) {
172
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
173
+ const m = new RegExp(`\\b${escaped}\\b(?:\\s+to)?\\s*\\(([^)]*)`, 'i').exec(gloss);
174
+ return m !== null && narrows(m[1], context);
175
+ }
124
176
  // Register: a polite source word should translate to a polite word, a casual one to a casual one.
125
177
  const POLITE = new Set(['polite', 'formal', 'honorific', 'literary']);
126
178
  const CASUAL = new Set(['informal', 'colloquial', 'slang', 'familiar', 'vulgar', 'Internet']);
@@ -147,16 +199,25 @@ function registerFit(source, target, wanted) {
147
199
  // what the word means. Without the jargon, Vietnamese "con" ("used when talking to someone older than
148
200
  // the speaker") matched English "I" ("the speaker or writer") on "speaker".
149
201
  const STOPWORDS = new Set(('a an the to of or and in on for with by as at from that this be is are was it its used use any some one ' +
150
- 'something someone very more most usually especially often also who which what when being having into ' +
202
+ 'something someone very more most usually especially often also who which what when being have has having into ' +
151
203
  'speaker speakers writer referred refer refers referring grammatical subject object sentence clause ' +
152
204
  'word words form forms term expression phrase talking speaking addressed addressing person people ' +
153
205
  'particular certain various generally sense usage meaning').split(' '));
154
- function contentWords(text) {
206
+ function contentWords(text, keep) {
155
207
  return new Set(text
156
208
  .toLowerCase()
157
209
  .replace(/[^a-z' ]+/g, ' ')
158
210
  .split(' ')
159
- .filter((w) => w.length > 2 && !STOPWORDS.has(w)));
211
+ .filter((w) => w.length > 2 && (!STOPWORDS.has(w) || keep?.has(w))));
212
+ }
213
+ // The caller's `meaning` words all count, even ones too common to count in definitions ("have" picks
214
+ // "got" = "To have", not "To obtain").
215
+ function meaningOverlap(words, text) {
216
+ let n = 0;
217
+ for (const w of contentWords(text, words))
218
+ if (words.has(w))
219
+ n++;
220
+ return n;
160
221
  }
161
222
  function overlap(a, text) {
162
223
  let n = 0;
@@ -187,10 +248,15 @@ function bridgeTerms(sense, from) {
187
248
  }
188
249
  // Headings ("As a copulative verb:") carry no meaning, so the specific definitions are used.
189
250
  const glosses = sense.glosses.filter((g) => !g.trim().endsWith(':'));
251
+ // A first definition that is only a short list of equivalents ("To fetch, bring, take.") names the
252
+ // meaning with every item equally, so all of them are main terms ("take" → "lấy" scored no position
253
+ // bonus, and words found only through the ambiguous "get" itself came first: hóng, bắt, ra khỏi).
190
254
  glosses.slice(0, 2).forEach((gloss, gi) => {
191
- for (const [term, position] of glossTerms(gloss)) {
255
+ const found = glossTerms(gloss);
256
+ const list = gi === 0 && isTermList(gloss, found.length);
257
+ for (const [term, position] of found) {
192
258
  terms.push(term);
193
- if (gi === 0 && position === 0)
259
+ if (gi === 0 && (position === 0 || list))
194
260
  main.add(term);
195
261
  }
196
262
  });
@@ -206,6 +272,11 @@ function bridgeTerms(sense, from) {
206
272
  }
207
273
  return { terms: unique.slice(0, 8), synonymsFrom, main };
208
274
  }
275
+ // Every comma/semicolon part of the definition became a term, and each is at most three words.
276
+ function isTermList(gloss, termCount) {
277
+ const parts = gloss.replace(/\.\s*$/, '').split(/[;,]/);
278
+ return termCount >= 2 && parts.length === termCount && parts.every((p) => p.trim().split(/\s+/).length <= 3);
279
+ }
209
280
  export function createTranslator(options = {}) {
210
281
  const cache = new Map();
211
282
  const dict = (lang) => {
@@ -351,34 +422,37 @@ export function createTranslator(options = {}) {
351
422
  }
352
423
  return hits;
353
424
  }
354
- // The translation-table words for a source sense in the target language. English senses carry their
355
- // table; for other languages, the English sense whose table lists the source word (in the source
356
- // language) is the same meaning, and its target-language list applies.
357
- async function tableFor(sense, from, to, bridge) {
425
+ async function englishMeanings(sense, from, bridge) {
358
426
  if (from === 'en')
359
- return to === 'en' ? [] : (sense.translations?.[to] ?? []);
427
+ return [{ word: sense.lemma, pos: sense.pos, gloss: sense.glosses[0] ?? '', translations: sense.translations }];
360
428
  const found = [];
361
- const add = (list) => {
362
- for (const t of list ?? [])
363
- if (!found.some((f) => f.word === t.word))
364
- found.push(t);
365
- };
366
429
  for (const term of bridge.slice(0, 3)) {
367
430
  for (const entry of await dict('en').lookup(term)) {
368
431
  for (const s of entry.senses) {
369
432
  const own = s.translations?.[from];
370
433
  if (!own?.some((t) => t.word === sense.lemma || t.word === sense.word))
371
434
  continue;
372
- // Into English, the English word whose table lists the source word is itself the translation.
373
- if (to === 'en')
374
- add([{ word: entry.word }]);
375
- else
376
- add(s.translations?.[to]);
435
+ if (!found.some((f) => f.word === entry.word && f.gloss === s.glosses[0])) {
436
+ found.push({ word: entry.word, pos: entry.pos, gloss: s.glosses[0], translations: s.translations });
437
+ }
377
438
  }
378
439
  }
379
440
  }
380
441
  return found;
381
442
  }
443
+ async function tableFor(sense, from, to, bridge) {
444
+ if (from === 'en')
445
+ return to === 'en' ? [] : (sense.translations?.[to] ?? []);
446
+ const found = [];
447
+ for (const m of await englishMeanings(sense, from, bridge)) {
448
+ // Into English, the English word whose table lists the source word is itself the translation.
449
+ const list = to === 'en' ? [{ word: m.word }] : (m.translations?.[to] ?? []);
450
+ for (const t of list)
451
+ if (!found.some((f) => f.word === t.word))
452
+ found.push(t);
453
+ }
454
+ return found;
455
+ }
382
456
  // Which of the target's regions a table tag names ("Latin-America" -> its countries), if any.
383
457
  function tagRegions(tags, meta) {
384
458
  const out = new Set();
@@ -480,8 +554,12 @@ export function createTranslator(options = {}) {
480
554
  }
481
555
  const api = {
482
556
  senses,
483
- async translate(word, { from, to, fromRegion, toRegion, pos, meaning, register: wantedRegister, listener, about, speaker, exclude = DEFAULT_EXCLUDED_LABELS, limit = 5, allSenses = false }) {
557
+ async translate(word, { from, to, fromRegion, toRegion, pos, meaning, register: wantedRegister, listener, about, speaker, exclude = DEFAULT_EXCLUDED_LABELS, limit = 5, allSenses = false, picks: usePicks = true }) {
484
558
  const [sourceSenses, toMeta, fromMeta] = await Promise.all([senses(word, { from, fromRegion, pos }), dict(to).meta(), dict(from).meta()]);
559
+ // The target language's hand-picked words for English meanings (picks.json), when it has any.
560
+ const pickRows = usePicks && to !== 'en' && toMeta.picks ? await dict(to).picks() : [];
561
+ // Senses whose pick row asks to come first when the caller gives no pos or meaning.
562
+ const pickedFirst = new Set();
485
563
  // The target's pronoun table, for personal-pronoun senses (throws for an unknown listener/about).
486
564
  const pronounRows = toMeta.pronouns && sourceSenses.some((s) => personOf(s, from))
487
565
  ? await dict(to).pronouns({ region: toRegion, speaker, exclude })
@@ -492,7 +570,7 @@ export function createTranslator(options = {}) {
492
570
  await dict(to).pronouns({ listener: about });
493
571
  const toWanted = resolveRegion(toMeta, toRegion);
494
572
  const hasFrequencies = Boolean(toMeta.frequencySource);
495
- const meaningWords = meaning ? contentWords(meaning) : null;
573
+ const meaningWords = meaning ? contentWords(meaning, new Set(meaning.toLowerCase().split(/[^a-z']+/))) : null;
496
574
  const groups = [];
497
575
  for (const sense of sourceSenses) {
498
576
  const { terms: bridge, synonymsFrom, main } = bridgeTerms(sense, from);
@@ -523,6 +601,7 @@ export function createTranslator(options = {}) {
523
601
  // The term's own words say nothing about which meaning matched, so they don't count as overlap.
524
602
  const termWords = contentWords(term);
525
603
  const ctx = new Set([...context].filter((w) => !termWords.has(w)));
604
+ const isMain = main.has(term) || termIndex === 0;
526
605
  const hits = to === 'en'
527
606
  ? await englishCandidates(term, posList, toMeta, toRegion, exclude)
528
607
  // A generous limit: the dictionary's quick ranking puts region-tagged words first, and the
@@ -546,7 +625,10 @@ export function createTranslator(options = {}) {
546
625
  // less (common words win anyway), and kinship words list "you" after "I/me" (Vietnamese "anh"),
547
626
  // so the penalty is small then.
548
627
  const score = 3 * overlap(ctx, hit.gloss) +
549
- (main.has(term) || termIndex === 0 ? 2 : termIndex === 1 ? 1 : 0) +
628
+ (isMain && exactGloss(hit.gloss, term, ctx) ? EXACT_GLOSS_BONUS : 0) -
629
+ (narrowed(hit.gloss, term, ctx) ? NARROWED_PENALTY : 0) -
630
+ (PASSIVE.test(hit.gloss) && !PASSIVE.test(sense.glosses.join(' ')) ? VOICE_MISMATCH_PENALTY : 0) +
631
+ (isMain ? 2 : termIndex === 1 ? 1 : 0) +
550
632
  (hit.primary ? 1 : 0) +
551
633
  // Tagged for the target region: a full bonus when the match is the word's main meaning, a
552
634
  // small one otherwise ("bá cháy" is Southern for "awesome", not for "cool" the temperature).
@@ -608,6 +690,36 @@ export function createTranslator(options = {}) {
608
690
  scored.set(t.word, { ...t, score: t.score + 4, bridge: 'synonym' });
609
691
  }
610
692
  }
693
+ // Hand-picked words for this meaning go first, in the listed order: a speaker's judgment of the
694
+ // natural word where the ranking puts another first. With a target region, picks tagged for it
695
+ // lead, and picks tagged only for other regions (or words not used there) are left to the
696
+ // ranking. The ranked words follow. (A pronoun-table row, below, still comes before them.)
697
+ const rows = pickRows.length
698
+ ? await englishMeanings(sense, from, bridge).then((ms) => pickRows.filter((r) => ms.some((m) => m.word === r.word && m.pos === r.pos && m.gloss === r.gloss)))
699
+ : [];
700
+ if (rows.some((r) => r.first))
701
+ pickedFirst.add(sense);
702
+ const picked = [];
703
+ for (const t of rows.flatMap((r) => r.picks)) {
704
+ if (picked.some((p) => p.hit.word === t.word))
705
+ continue;
706
+ const places = tagRegions(t.tags, toMeta);
707
+ if (toWanted && places && ![...places].some((r) => toWanted.has(r)))
708
+ continue;
709
+ if (to === from && t.word === sense.lemma && !places?.size)
710
+ continue;
711
+ const hit = scored.get(t.word) ?? (await tableHit(t.word, to, posList, toWanted, exclude, bridge));
712
+ if (!hit || hit.labels.some((l) => exclude.includes(l)))
713
+ continue;
714
+ if (!places && !intersects(hit.regions, toWanted))
715
+ continue;
716
+ picked.push({ hit, tagged: Boolean(toWanted && places) });
717
+ }
718
+ if (picked.length) {
719
+ const ordered = [...picked.filter((p) => p.tagged), ...picked.filter((p) => !p.tagged)];
720
+ const top = Math.max(0, ...[...scored.values()].map((t) => t.score));
721
+ ordered.forEach(({ hit }, i) => scored.set(hit.word, { ...hit, score: top + 1 + (ordered.length - i) * 0.1, bridge: 'picked' }));
722
+ }
611
723
  // Personal pronouns: the chosen row's words go first (the `listener`'s for I/you/we, the `about`'s
612
724
  // for he/she/they, else the default row's), fitted to the pronoun's gender (he/she) and
613
725
  // inclusiveness (we). Words tagged for the target region, then those fitting the register, lead.
@@ -706,15 +818,34 @@ export function createTranslator(options = {}) {
706
818
  const chosen = ABOUT_PERSONS.has(p.person) ? about : listener;
707
819
  return (chosen ? 10 : 0) - (hasSingular && !SINGULAR_PERSONS.has(p.person) ? 3 : 0);
708
820
  };
821
+ // A form with its own entries ("got": "Have/has.", colloquial) as well as its base word's ("get"):
822
+ // the form's own meanings are why it has entries at all, so they stay ahead of the base word's, and
823
+ // a colloquial/informal label doesn't push them behind (slang and Internet labels still do: "felt"
824
+ // "To thoroughly defeat", Internet). A construction ("Must; have/has (to).") then only reorders
825
+ // them among themselves.
826
+ // Only inflections ("got": simple past of get), not contractions ("kinda") or spelling variants.
827
+ const formWithBase = ownSenses && sourceSenses.some((s) => s.via && INFLECTION_VIA.test(s.via));
828
+ const labelRelevance = (g) => {
829
+ const { labels } = g.source;
830
+ const marginal = labels.length > 0 && labels.every((l) => MARGINAL_LABELS.has(l));
831
+ const everyday = labels.length > 0 && labels.every((l) => EVERYDAY_LABELS.has(l));
832
+ if (formWithBase && !g.source.via && (!marginal || everyday)) {
833
+ return 1 - (CONSTRUCTION.test(g.source.glosses.join(' ')) ? 0.5 : 0);
834
+ }
835
+ return marginal ? -1 : 0;
836
+ };
709
837
  const relevance = (g) => (meaningWords
710
- ? 10 * overlap(meaningWords, g.source.glosses.join(' ')) + 5 * overlap(meaningWords, g.translations[0]?.gloss ?? '')
838
+ ? 10 * meaningOverlap(meaningWords, g.source.glosses.join(' ')) + 5 * meaningOverlap(meaningWords, g.translations[0]?.gloss ?? '')
711
839
  : 0) -
712
840
  (ownSenses && g.source.via && SPELLING_VIA.test(g.source.via) ? 2 : 0) +
713
841
  (fromRegion && g.source.regionTagged ? 3 : 0) +
714
- pronounRelevance(g) -
715
- (g.source.labels.length && g.source.labels.every((l) => MARGINAL_LABELS.has(l)) ? 1 : 0);
842
+ pronounRelevance(g) +
843
+ labelRelevance(g) +
844
+ (!meaning && pickedFirst.has(g.source) ? 5 : 0);
845
+ // A sense with no translations says nothing, so it goes after the ones that do ("got": "Expressing
846
+ // obligation; used with have." finds nothing; "Must; have/has (to)." gives "phải").
716
847
  const ordered = groups
717
- .map((g, i) => ({ g, i, r: relevance(g) }))
848
+ .map((g, i) => ({ g, i, r: relevance(g) - (g.translations.length ? 0 : 100) }))
718
849
  .sort((a, b) => b.r - a.r || a.i - b.i)
719
850
  .map((x) => x.g);
720
851
  if (allSenses)
package/dist/types.d.ts CHANGED
@@ -108,6 +108,51 @@ export type LanguageMeta = {
108
108
  shardLength?: number;
109
109
  /** true when the language has a pronoun table (pronouns.json, see PronounRow). */
110
110
  pronouns?: boolean;
111
+ /** true when the language has hand-picked translations (picks.json, see PickRow). */
112
+ picks?: boolean;
113
+ /** true when the language has checker settings (checker.json, see CheckerConfig). */
114
+ checker?: boolean;
115
+ /** true when the language has a syllable list (syllables.json: syllable → Zipf frequency, 0 when unknown). */
116
+ syllables?: boolean;
117
+ };
118
+ /**
119
+ * How the checker reads one language (languages/<lang>.ts `checker`, stored as data/checker.json). Every
120
+ * field is optional: a language without it is read as space-separated words, and gets the checks that
121
+ * only need its dictionary (dialect mixing).
122
+ */
123
+ export type CheckerConfig = {
124
+ /**
125
+ * What spaces separate: 'words' (English), or 'syllables' that group into dictionary words (Vietnamese
126
+ * "thịt heo", "kết quả"). With 'syllables' the build also writes syllables.json for accent suggestions.
127
+ */
128
+ units?: 'words' | 'syllables';
129
+ /** The longest dictionary word to look for, in units (default 3). */
130
+ maxWordUnits?: number;
131
+ /**
132
+ * Pronouns that are reliably pronouns, by pronoun-table column ("tôi", "tao", "mày"). The pronoun checks
133
+ * look only at these: kinship words like Vietnamese "con" (also "child", a classifier) aren't listed.
134
+ */
135
+ pronouns?: Partial<Record<PronounPerson, string[]>>;
136
+ /** Listed pronouns that are also common nouns ("bạn": you, or friend); checked, but only as suggestions. */
137
+ ambiguousPronouns?: string[];
138
+ /** Sentence endings expected when speaking up (pronoun-table rows marked `respect`), e.g. Vietnamese "ạ". */
139
+ politeEndings?: string[];
140
+ };
141
+ /**
142
+ * Hand-picked first choices for one English meaning, in one target language (picks.json). A speaker's
143
+ * judgment of the natural word where the data ranks another first ("get", "To fetch, bring, take" →
144
+ * Vietnamese "lấy", not "đưa"). Keyed by English meaning, so each language needs one list, and
145
+ * translating between two other languages reaches it through the English meaning.
146
+ */
147
+ export type PickRow = {
148
+ /** The English headword, part of speech and first definition of the meaning, as in the English data. */
149
+ word: string;
150
+ pos: string;
151
+ gloss: string;
152
+ /** Best first. `tags` name regions like translation-table tags ("Southern"). */
153
+ picks: TableTranslation[];
154
+ /** Put this meaning first when the caller gives no `pos` or `meaning` ("got" → "Have/has."). */
155
+ first?: boolean;
111
156
  };
112
157
  /**
113
158
  * One word in the pronoun table: how to say "I", "you", "he/she", "we", plural "you" or "they" in one
@@ -163,6 +208,8 @@ export type PronounRow = {
163
208
  thirdPlural: PronounChoice[];
164
209
  /** The row to use when the relationship isn't known (neutral words: Vietnamese "tôi", "bạn"). */
165
210
  default?: boolean;
211
+ /** Speaking up to this person (parents, elders, teachers): polite sentence endings are expected. */
212
+ respect?: boolean;
166
213
  /** A caution for the whole row, e.g. that "tao"/"mày" are rude outside close friendships. */
167
214
  warning?: string;
168
215
  };
@@ -218,6 +265,7 @@ export declare function normalizeEnglish(term: string): string;
218
265
  * headings ending in ":" are skipped in favor of the specific definition.
219
266
  */
220
267
  export declare function displayGloss(glosses: string[]): string;
268
+ export declare const TRAILING_PARTICLE: RegExp;
221
269
  /**
222
270
  * Splits an English gloss into search terms, main meaning first: "now, today, this time" ->
223
271
  * [["now", 0], ["today", 1], ["this time", 2]]. Parenthesized notes and final punctuation are dropped.
package/dist/types.js CHANGED
@@ -55,7 +55,7 @@ export function displayGloss(glosses) {
55
55
  return glosses.find((g) => !g.trim().endsWith(':')) ?? glosses[0] ?? '';
56
56
  }
57
57
  // Trailing words dropped to also match the bare verb: "wait for" is found by "wait" too.
58
- const TRAILING_PARTICLE = / (for|to|at|on|with|about|of|in|into|up|out|off|over)$/;
58
+ export const TRAILING_PARTICLE = / (for|to|at|on|with|about|of|in|into|up|out|off|over)$/;
59
59
  /**
60
60
  * Splits an English gloss into search terms, main meaning first: "now, today, this time" ->
61
61
  * [["now", 0], ["today", 1], ["this time", 2]]. Parenthesized notes and final punctuation are dropped.
@@ -72,8 +72,11 @@ export function glossTerms(gloss) {
72
72
  cleaned = cleaned.slice(cleaned.lastIndexOf(':') + 1);
73
73
  const terms = [];
74
74
  let position = 0;
75
- // "I/me" lists two meanings, like "I; me".
76
- for (const part of cleaned.split(/[;,/]/)) {
75
+ // "I/me" lists two meanings, like "I; me". Sentences too: "To have. See usage notes." means "have",
76
+ // and cross-references ("See usage notes", "Compare shall", "Synonym: …") aren't meanings.
77
+ for (const part of cleaned.split(/[;,/]|\.\s+/)) {
78
+ if (/^\s*(?:see|compare|cf|synonyms?|antonyms?)\b/i.test(part))
79
+ continue;
77
80
  // "etc." is not a meaning: "walking etc" -> "walking", and a lone "etc" is skipped.
78
81
  const t = normalizeEnglish(part.replace(/[.!?]+\s*$/, '').replace(/\betc\.?$/i, '').trim());
79
82
  if (!t || t.split(' ').length > 4 || !/^[a-z][a-z' -]*$/.test(t))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",