which-dialect 0.5.1 → 0.5.4

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
@@ -80,7 +80,7 @@ The meanings of a word, following forms and variant spellings: useful for lettin
80
80
  Every language's data defines its words in English, so English is the bridge between any two languages. For each **meaning** of the source word:
81
81
 
82
82
  1. **Forms and variants go to the word they belong to:** irregular forms from the data (*said* → *say*), regular English forms by rule (*walked* → *walk*, *running* → *run*), texting spellings (*dont* → *don't*), and regional variants (*hông* → *không*, keeping its Southern region).
83
- 2. **English terms carry the meaning:** the terms in its definition ("bắp": *corn*), or for English words the word itself and its synonyms.
83
+ 2. **English terms carry the meaning:** the terms in its definition ("bắp": *corn*), or for English words the word itself and its synonyms. **Place names** carry their own name: *Japan* ↔ *Nhật Bản* (defined "Japan (a country in East Asia)"), also *Nhật*; *South Korea* ↔ *Hàn Quốc*; *Kyoto* → *Kinh Đô*. A word counts only when its definition is of the place itself, not one that mentions it (a robe "worn by the emperors of China, Japan, Korea and Vietnam" isn't Japan). Places come from Wiktionary's place categories: every place name in the target language, and in English every country plus about 4,200 common cities and regions, leaving out ones that are also ordinary words (*Reading*, *Bath*, *Well*). Given names and surnames aren't translated.
84
84
  3. **The target is searched with a compatible part of speech** (`compatiblePos`): a noun for a noun, but an English auxiliary verb may be a Vietnamese particle, and an adjective may be a verb in another language.
85
85
  4. **Wiktionary's translation tables** give the usual translation of each English meaning in each language, often with region and register tags ("I" → Vietnamese *tôi*, *tớ* (informal), *tui* (South); *cool* "mildly low temperature" → *mát*). Listed words get a strong boost, more when tagged for the target region, less when tagged only for other regions. From another language, the English meaning whose table lists the source word is the same meaning.
86
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*).
@@ -139,7 +139,8 @@ The result has `text` (the input in Unicode NFC form), `issues` and `parts` (the
139
139
  **On by default (the spellchecker):**
140
140
 
141
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. Less common words written without accents can change the same way when one dictionary word clearly wins (*mua sam* → *mua sắm* "shopping", *nha sach* → *nhà sách*), even common ones when the whole word has no accents (*nha bang* → *nhà băng* "bank"), while a common one left plain next to accented syllables is taken as meant (*tháng sau* stays), *cho con* never becomes *chó con* (not a headword) and runs that could be two words equally are left alone (*ban an*: *bản án* or *bàn ăn*). 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.
142
+ **Letter typos:** a syllable that isn't in the language also gets one-letter fixes (a letter missing, extra, wrong or two swapped) with any accents: *khôg*, *khôngg* → *không*; *họk* → *học*; *tihch* → *thích*; *muốm* → *muốn*; with its neighbors, *Cảm ơm* → *Cảm ơn*. Candidates keeping the accent marks the writer typed come first (*ngừoi* → *người*, *ơm* → *ơn* rather than *ôm*), then accent-only fixes, then letters often confused (`similarLetters` in the checker settings; Vietnamese c/k/q, s/x, i/y, m/n count as half a change), then other letter changes, then the more common syllable. Plain-letter non-words are only treated as typos when a one-letter fix is a common syllable (else they're names or foreign words). Real syllables are never letter-changed, so a typo that makes another real word (*kông*, *mún*) isn't caught. Abbreviations with inner capitals (*GĐ*, *TKiều*) and other scripts (Chinese characters) are left alone.
143
+ - **`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 also Vietnamese once accents are added is weighed by how common each is (*rat* is *rất*, far more common than English *rat*; *but* stays English, far more common than *bút*), and close calls (*met*: *mệt* or English *met*) are Vietnamese only among Vietnamese words; 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
 
144
145
  **Opt-in (`rules`):**
145
146
 
@@ -213,7 +214,7 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
213
214
  - `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.
214
215
  - `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
215
216
  - `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).
217
+ - `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*). Capitalized headwords count too, unless they're only a person's name: *nhat* → *nhất*, *Nhật* (Japan), *nhạt*…; *nhat ban* → *Nhật Bản*. 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).
217
218
  - `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
218
219
  - `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).
219
220
  - `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
@@ -251,6 +252,7 @@ This is a suggestion tool, not a curated translation dictionary. Check results b
251
252
  - **No word frequencies.** The source doesn't say how common a word or meaning is. Without `pos`/`meaning`, the first meaning follows the dictionary's order, which isn't always the most common one.
252
253
  - **English is the bridge.** Translating between two non-English languages goes through English definitions, so nuance English doesn't mark can be lost.
253
254
  - **`meaning` matches words, not ideas.** It compares your words with the dictionary's definitions (and the top translation's), so "awesome" won't find a sense defined as "Fashionable; trendy; hip." Use the definition's own wording, or let users pick from `senses()`.
255
+ - **Nationality phrases aren't built yet.** *Japan* → *Nhật Bản* works, but "Japanese food" → *món Nhật* (the thing, then the country's short name) doesn't; see FUTURE_IMPROVEMENTS.md.
254
256
  - **Grammar words translate poorly.** Words defined by their function ("marks the future tense" for Vietnamese *sẽ*) aren't reached from English *will*; *the*, *is* or *gonna* may give nothing useful.
255
257
 
256
258
  ## Word frequencies
@@ -307,11 +309,11 @@ Generated data isn't committed (it would bloat git history); it's built before p
307
309
 
308
310
  ### Adding a language
309
311
 
310
- 1. Add `languages/<code>.ts`: the Kaikki language name, the language's regions (and groups, if any), and how to read regions from Wiktionary's tags. See [`languages/vi.ts`](languages/vi.ts) (dialect areas and a pronoun table) and [`languages/en.ts`](languages/en.ts) (countries with region groups, and trimming a very large language). Spanish was built this way too (countries and region groups; `languages/es.ts` in the git history). Set `skipFormOf` for heavily inflected languages; for very large ones, see the trimming options in [`scripts/language-config.ts`](scripts/language-config.ts) (`dropTechnical`, `formOfPos`, `keepFormOf`, `maxSensesPerEntry`, `shardLength`, …).
312
+ 1. Add `languages/<code>.ts`: the Kaikki language name, the language's regions (and groups, if any), and how to read regions from Wiktionary's tags. See [`languages/vi.ts`](languages/vi.ts) (dialect areas and a pronoun table) and [`languages/en.ts`](languages/en.ts) (countries with region groups, and trimming a very large language). Spanish was built this way too (countries and region groups; `languages/es.ts` in the git history). Set `placeNames: {}` so its place names translate ("Japan" ↔ its name for Japan). Set `skipFormOf` for heavily inflected languages; for very large ones, see the trimming options in [`scripts/language-config.ts`](scripts/language-config.ts) (`dropTechnical`, `formOfPos`, `keepFormOf`, `maxSensesPerEntry`, `shardLength`, …).
311
313
  2. Copy a data package (`packages/vi`) to `packages/<code>` and update its `package.json` and `README.md`.
312
314
  3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
313
315
  4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
314
- 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.
316
+ 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`, `similarLetters` (groups of letters often confused, for typo suggestions). Without them, the language is read as space-separated words and gets the region check.
315
317
  6. Optional: [sentence frames](#sentence-frames): `frames` (how the language says each frame in `languages/frames.ts`) and `frameWords` (the words that change by region, each from a definition or an override with a note). Frames reach other languages through the shared catalog; a new frame goes in the catalog once.
316
318
  7. Optional, later: [hand-picked words](#hand-picked-words). A speaker runs `npm run picks-sheet -- <code>`, fills in `your_pick` only where the first choice is wrong, and pastes the `--apply` output into `picks`.
317
319
 
package/dist/check.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
12
12
  // the checks found anything, not that the text is correct.
13
13
  import { createDictionary, resolveRegion } from "./index.js";
14
- import { accentCombinations, joined, matchCase, plain, segment, sentencesOf, syllablesByPlain, units } from "./text.js";
14
+ import { accentCombinations, joined, matchCase, plain, segment, sentencesOf, spellingCandidates, syllablesByPlain, units } from "./text.js";
15
15
  import { markLanguages } from "./detect.js";
16
16
  import { createTranslator } from "./translate.js";
17
17
  import { PRONOUN_PERSONS, } from "./types.js";
@@ -50,8 +50,15 @@ const unique = (items) => [...new Set(items)];
50
50
  // because filled-in sentence frames add accented words), any accented form that's more common
51
51
  // counts ("an" → "ăn"; but not "con" → "còn", about as common). Left alone: capitalized words
52
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").
53
+ // - a syllable that isn't in the language may also be a letter typo: one letter missing, extra, wrong or two
54
+ // swapped ("khôg", "khôngg" → "không"; "họk" → "học"), with any accents (spellingCandidates). Plain-letter
55
+ // ones count only when a one-letter fix gives a common syllable ("tihch" → "thích"); real syllables are
56
+ // never letter-changed, so correct words stay.
57
+ // Not checked: abbreviations and names with a capital after the first letter ("GĐ", "TKiều"), and words
58
+ // with letters the language's syllables don't use (Chinese characters in Vietnamese text).
59
+ // First, a doubtful syllable (one of the above) is tried with its neighbors: when fixing its accents (or a
60
+ // letter) makes a dictionary word with them, that word is suggested for the run ("hom nay" → "hôm nay", not
61
+ // "hôm này"; "cảm ơm" → "cảm ơn", not "cảm ôm").
55
62
  // Units inside a known word, or in the learner's own language, aren't checked.
56
63
  // Zipf points by which an accented form must be more common than a plain-letter word to be suggested.
57
64
  const ACCENTS_GAP = 1.5;
@@ -69,6 +76,11 @@ const PLAIN_RUN_MARGIN = 0.5;
69
76
  // "tháng sáu", June). A run with no accents at all ("nha bang") was probably typed without them, so any of
70
77
  // its syllables may change ("nhà băng", bank), still only when one word clearly wins.
71
78
  const PLAIN_RUN_MAX_ZIPF = 5;
79
+ // A plain-letter non-syllable with no accented form is checked as a typo only if a one-letter fix gives a
80
+ // syllable at least this common (Zipf); otherwise it's probably a name or a foreign word.
81
+ const PLAIN_TYPO_MIN_ZIPF = 4;
82
+ // Candidates kept per misspelled syllable.
83
+ const SPELLING_CANDIDATES = 3;
72
84
  async function spelling(ctx) {
73
85
  if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
74
86
  return [];
@@ -78,6 +90,10 @@ async function spelling(ctx) {
78
90
  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)) });
79
91
  const options = (u, candidates) => listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or');
80
92
  const pronouns = new Set(Object.values(ctx.config.pronouns ?? {}).flat().map((w) => w.toLowerCase()));
93
+ // The language's letters (without accents), from its syllables: words using others aren't its own.
94
+ const alphabet = new Set([...byPlain.keys()].join(''));
95
+ const ownLetters = (u) => [...plain(u.text.toLowerCase()).replace(/[’']/g, '')].every((c) => alphabet.has(c));
96
+ const abbreviation = (u) => /\p{Lu}/u.test(u.text.slice(1));
81
97
  for (const sentence of ctx.sentences) {
82
98
  const lone = sentence.words.filter((w) => !w.entries.length || w.units.length === 1).flatMap((w) => w.units).filter((u) => !ctx.inBase(u));
83
99
  const target = sentence.units.filter((u) => !ctx.inBase(u) && /\p{L}/u.test(u.text));
@@ -87,13 +103,38 @@ async function spelling(ctx) {
87
103
  const inWord = (u) => sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
88
104
  // What each lone unit is: not a syllable of the language (with forms it could be), or a plain-letter
89
105
  // syllable that could be missing its accents.
90
- const checked = lone.filter((u) => !/\d/.test(lowerOf(u)) && !inWord(u));
91
- const unknown = new Set(checked.filter((u) => !(lowerOf(u) in syllables) && ((byPlain.get(plain(lowerOf(u))) ?? []).length || !isPlain(u))));
106
+ const checked = lone.filter((u) => !/\d/.test(lowerOf(u)) && !inWord(u) && !abbreviation(u) && ownLetters(u));
107
+ const name = (u) => u !== sentence.units[0] && /\p{Lu}/u.test(u.text.charAt(0));
108
+ // Each non-syllable's candidates: accents and one-letter changes, the writer's own accents first.
109
+ const candidatesOf = new Map();
110
+ const similar = ctx.config.similarLetters ?? [];
111
+ const candidates = (u, limit = SPELLING_CANDIDATES) => spellingCandidates(lowerOf(u), syllables, byPlain, { limit, similar }).map((c) => c.word);
112
+ // For words with neighbors, only the best-ranked candidates: "muốm ăn" mustn't become "muối ăn" (salt)
113
+ // through a worse fix of "muốm" than "muốn" just because "muối ăn" is a dictionary word.
114
+ const bestCandidates = (u, limit) => {
115
+ const all = spellingCandidates(lowerOf(u), syllables, byPlain, { limit: 50, similar });
116
+ return all.filter((c) => c.marks === all[0]?.marks && c.edits === all[0]?.edits).slice(0, limit).map((c) => c.word);
117
+ };
118
+ const unknown = new Set(checked.filter((u) => {
119
+ const lower = lowerOf(u);
120
+ if (lower in syllables)
121
+ return false;
122
+ if ((byPlain.get(plain(lower)) ?? []).length || !isPlain(u))
123
+ return true;
124
+ // Plain letters, no accented form: a typo only if a one-letter fix is a common syllable, and not a name.
125
+ if (name(u))
126
+ return false;
127
+ const best = spellingCandidates(lower, syllables, byPlain, { limit: 1, minZipf: PLAIN_TYPO_MIN_ZIPF, similar })[0];
128
+ return Boolean(best);
129
+ }));
130
+ for (const u of unknown)
131
+ candidatesOf.set(u, candidates(u));
92
132
  const plainWords = new Set(checked.filter((u) => {
93
133
  const lower = lowerOf(u);
94
134
  return lower in syllables && lower === plain(lower) && !pronouns.has(lower) && (u === sentence.units[0] || !/\p{Lu}/u.test(u.text.charAt(0)));
95
135
  }));
96
- const missingAccents = [...unknown].some(isPlain);
136
+ // Only accent slips count here ("khong"), not letter typos ("tihch").
137
+ const missingAccents = [...unknown].some((u) => isPlain(u) && (byPlain.get(lowerOf(u)) ?? []).length > 0);
97
138
  // Typed without accents: mostly plain letters, and an accent error already, or 3+ words with no accent at all.
98
139
  const unaccented = mostlyPlain && (missingAccents || (target.length >= 3 && target.every(isPlain)));
99
140
  const gap = unaccented ? ACCENTS_GAP_UNACCENTED_TEXT : ACCENTS_GAP;
@@ -125,6 +166,8 @@ async function spelling(ctx) {
125
166
  const written = span.map(lowerOf);
126
167
  const combos = accentCombinations(written, byPlain, {
127
168
  fixed: span.map((u) => !mayChange(u)),
169
+ // Misspelled syllables may change letters too ("cảm ơm" → "cảm ơn").
170
+ forms: span.map((u) => (unknown.has(u) ? bestCandidates(u, COMPOUND_FORMS_PER_SYLLABLE) : undefined)),
128
171
  perUnit: COMPOUND_FORMS_PER_SYLLABLE,
129
172
  max: COMPOUND_MAX_COMBINATIONS,
130
173
  }).filter((c) => c !== written.join(' '));
@@ -156,11 +199,11 @@ async function spelling(ctx) {
156
199
  continue;
157
200
  const lower = lowerOf(u);
158
201
  if (unknown.has(u)) {
159
- const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
202
+ const found = candidatesOf.get(u) ?? [];
160
203
  const accented = !isPlain(u);
161
204
  // Missing accents on plain letters is the common learner slip; a wrong accent is more surely wrong.
162
- suggest(u, candidates, accented ? 'error' : 'warning', candidates.length
163
- ? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, candidates)}?`
205
+ suggest(u, found, accented ? 'error' : 'warning', found.length
206
+ ? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, found)}?`
164
207
  : `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`);
165
208
  }
166
209
  else if (plainWords.has(u)) {
package/dist/detect.js CHANGED
@@ -7,15 +7,21 @@ import { plain } from "./text.js";
7
7
  * in each: clearly more common in the target, the target's (Vietnamese "em", "di" next to English
8
8
  * "hello", "shopping": English has rare "em", "di"); far more common in the base, the base's ("the");
9
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
10
+ * else the target's (the text is mainly the target language: "an" next to English "the"). In languages
11
+ * written in syllables, a base word that's a target word only once accents are added is weighed the same
12
+ * way, using its accented form's frequency: "rat" is "rất" (far more common than English "rat"); close
13
+ * calls ("met": mệt or English met) go to the target only when their neighbors are the target's ("Tôi met
14
+ * quá"), so English stays English among English ("kem but it was": but, not bút). A word only the target
15
+ * knows without its accents ("khong") is still the target's. The base is known through the translator, so
13
16
  * inflected forms count ("went", "cooked").
14
17
  */
15
18
  // Zipf points by which a word both languages know must be more common in one to count as that language's
16
19
  // whatever its neighbors: less for the target, since the text is mainly the target language.
17
20
  const TARGET_MARGIN = 1;
18
21
  const BASE_MARGIN = 2;
22
+ // For a base word that's a target word only with accents added, a smaller lead keeps it the base's: it was
23
+ // typed exactly as the base word ("but" is 1.9 more common in English than "bút" in Vietnamese).
24
+ const BASE_MARGIN_ACCENTS = 1;
19
25
  export async function markLanguages(list, o) {
20
26
  const syllables = await o.target.syllables();
21
27
  const syllableLanguage = Object.keys(syllables).length > 0;
@@ -37,9 +43,18 @@ export async function markLanguages(list, o) {
37
43
  if (t === 'exact')
38
44
  return b ? 'both' : o.lang;
39
45
  if (b)
40
- return o.base;
46
+ return t === 'accents' ? 'accents' : o.base;
41
47
  return t === 'accents' ? o.lang : '?';
42
48
  }));
49
+ // The most common form a plain-letter unit takes once accents are added ("rat" → rất, 6.4).
50
+ const accentedFrequency = (u) => {
51
+ const bare = plain(u.text.toLowerCase());
52
+ let best = 0;
53
+ for (const [s, f] of Object.entries(syllables))
54
+ if (f > best && plain(s) === bare)
55
+ best = f;
56
+ return best;
57
+ };
43
58
  // How common a word is in each language (Zipf; 0 when unknown).
44
59
  const targetFrequency = async (u) => {
45
60
  const lower = u.text.toLowerCase();
@@ -48,18 +63,29 @@ export async function markLanguages(list, o) {
48
63
  return (await o.target.lookup(lower))[0]?.frequency ?? 0;
49
64
  };
50
65
  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.
66
+ // Words both languages know, decided by frequency when it's clear. Base words that are target words with
67
+ // accents added use that form's frequency, and close calls are marked '?accents' (decided by neighbors,
68
+ // more strictly).
52
69
  const decided = await Promise.all(marks.map(async (m, i) => {
53
- if (m !== 'both')
70
+ if (m !== 'both' && m !== 'accents')
54
71
  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 : '?';
72
+ const [t, b] = await Promise.all([m === 'accents' ? accentedFrequency(list[i]) : targetFrequency(list[i]), baseFrequency(list[i])]);
73
+ if (t - b >= TARGET_MARGIN)
74
+ return o.lang;
75
+ if (b - t >= (m === 'accents' ? BASE_MARGIN_ACCENTS : BASE_MARGIN))
76
+ return o.base;
77
+ return m === 'accents' ? '?accents' : '?';
57
78
  }));
79
+ const known = (x) => x !== '?' && x !== '?accents';
58
80
  return decided.map((m, i) => {
59
- if (m !== '?')
81
+ if (known(m))
60
82
  return m;
61
- const left = decided.slice(0, i).reverse().find((x) => x !== '?');
62
- const right = decided.slice(i + 1).find((x) => x !== '?');
83
+ const left = decided.slice(0, i).reverse().find(known);
84
+ const right = decided.slice(i + 1).find(known);
85
+ // A word written as typed in the base language needs every decided neighbor to be the target's to be
86
+ // read as the target's without its accents ("Tôi met quá": mệt); otherwise it's the base's.
87
+ if (m === '?accents')
88
+ return (left ?? o.lang) === o.lang && (right ?? o.lang) === o.lang && (left || right) ? o.lang : o.base;
63
89
  return left && right && left !== right ? o.lang : left ?? right ?? o.lang;
64
90
  });
65
91
  }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PRONOUN_PERSONS, fromStored, normalizeEnglish, shardKey, } from "./types.js";
1
+ import { PERSONAL_NAME, PRONOUN_PERSONS, fromStored, normalizeEnglish, shardKey, } from "./types.js";
2
2
  import { accentCombinations, segment as segmentUnits, syllablesByPlain, units } from "./text.js";
3
3
  export * from "./types.js";
4
4
  export * from "./pos.js";
@@ -104,10 +104,17 @@ export function createDictionary(options) {
104
104
  perUnit: parts.length === 1 ? VARIANT_FORMS_ONE_SYLLABLE : VARIANT_FORMS_PER_SYLLABLE,
105
105
  max: MAX_VARIANT_COMBINATIONS,
106
106
  }).filter((c) => c !== written);
107
- const found = await Promise.all(combos.map(async (c) => ({ word: c, entries: await lookup(c) })));
107
+ // Each form as written in lowercase and capitalized, for names ("nhat" → "Nhật", Japan; "nhat ban" →
108
+ // "Nhật Bản").
109
+ const forms = [...new Set(combos.flatMap((c) => [c, c.replace(/(^|\s)(\S)/gu, (_, sep, ch) => sep + ch.toUpperCase())]))];
110
+ // A capitalized form counts when it's its own headword (lookup ignores case) and more than a person's
111
+ // name (a place, a people), so given names don't crowd out words.
112
+ const found = (await Promise.all(forms.map(async (c) => ({ word: c, entries: await lookup(c) })))).map((v) => v.word === v.word.toLowerCase()
113
+ ? v
114
+ : { ...v, entries: v.entries.filter((e) => e.word === v.word && e.senses.some((s) => !PERSONAL_NAME.test(s.glosses[0] ?? ''))) });
108
115
  const frequency = (v) => {
109
116
  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;
117
+ return known.length ? Math.max(...known) : parts.length === 1 ? list[v.word.toLowerCase()] || undefined : undefined;
111
118
  };
112
119
  // Most common first; ties (and unknown frequencies) keep the order of more common syllable forms.
113
120
  return found
package/dist/text.d.ts CHANGED
@@ -37,8 +37,28 @@ export declare function syllablesByPlain(syllables: Record<string, number>): Map
37
37
  * every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
38
38
  * change (`fixed[i]`) keep their written form. The written run itself is included.
39
39
  */
40
- export declare function accentCombinations(written: string[], byPlain: Map<string, string[]>, { fixed, perUnit, max }: {
40
+ export declare function accentCombinations(written: string[], byPlain: Map<string, string[]>, { fixed, forms: given, perUnit, max }: {
41
41
  fixed?: boolean[];
42
+ forms?: (string[] | undefined)[];
42
43
  perUnit: number;
43
44
  max: number;
44
45
  }): string[];
46
+ /**
47
+ * The syllables a misspelled one could be, best first: the same letters with other accents ("khong" →
48
+ * "không"), and one letter changed: missing, extra, wrong or two swapped ("khôg", "khôngg" → "không";
49
+ * "tihch" → "thích"; "ơm" → "ơn"), each with any accents. First, same-letter fixes keeping every accent mark
50
+ * the writer typed ("ngừoi" → "người": the marks were on the wrong letter). Then candidates keeping more of
51
+ * the writer's marks ("muốm" → "muốn", not "muỗm"; "ơm" → "ơn", not "ôm"); then accent-only fixes, then changes between
52
+ * letters often confused (`similar`: groups like "ckq", counted as half a change: "họk" → "học", not "họ"),
53
+ * then other letter changes, then the more common syllable. Letter changes only give syllables at least
54
+ * `minZipf` common. `edits` is 0, 0.5 or 1; `marks` how well the writer's accent marks are kept.
55
+ */
56
+ export declare function spellingCandidates(written: string, syllables: Record<string, number>, byPlain: Map<string, string[]>, { limit, minZipf, similar }: {
57
+ limit: number;
58
+ minZipf?: number;
59
+ similar?: string[];
60
+ }): {
61
+ word: string;
62
+ edits: number;
63
+ marks: number;
64
+ }[];
package/dist/text.js CHANGED
@@ -77,12 +77,13 @@ export function syllablesByPlain(syllables) {
77
77
  * every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
78
78
  * change (`fixed[i]`) keep their written form. The written run itself is included.
79
79
  */
80
- export function accentCombinations(written, byPlain, { fixed = [], perUnit, max }) {
80
+ export function accentCombinations(written, byPlain, { fixed = [], forms: given = [], perUnit, max }) {
81
81
  const choices = written.map((w, i) => {
82
82
  const lower = w.toLowerCase();
83
83
  if (fixed[i])
84
84
  return [lower];
85
- const forms = (byPlain.get(plain(lower)) ?? []).slice(0, perUnit);
85
+ // `forms[i]`, when given, replaces the accent forms (e.g. spellingCandidates, with letter changes too).
86
+ const forms = (given[i] ?? byPlain.get(plain(lower)) ?? []).slice(0, perUnit);
86
87
  return forms.includes(lower) ? forms : [...forms, lower];
87
88
  });
88
89
  // Every combination with the sum of its forms' ranks: lower sums are made of more common forms.
@@ -95,3 +96,74 @@ export function accentCombinations(written, byPlain, { fixed = [], perUnit, max
95
96
  .slice(0, max)
96
97
  .map((c) => c.words.join(' '));
97
98
  }
99
+ // The letters a plain-letter typo can be changed to (accents come from the syllable list afterwards).
100
+ const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
101
+ // A syllable's accent marks, as a multiset of combining characters ("muốn": circumflex, acute).
102
+ const marksOf = (s) => [...s.normalize('NFD')].filter((c) => /\p{M}/u.test(c));
103
+ /**
104
+ * The syllables a misspelled one could be, best first: the same letters with other accents ("khong" →
105
+ * "không"), and one letter changed: missing, extra, wrong or two swapped ("khôg", "khôngg" → "không";
106
+ * "tihch" → "thích"; "ơm" → "ơn"), each with any accents. First, same-letter fixes keeping every accent mark
107
+ * the writer typed ("ngừoi" → "người": the marks were on the wrong letter). Then candidates keeping more of
108
+ * the writer's marks ("muốm" → "muốn", not "muỗm"; "ơm" → "ơn", not "ôm"); then accent-only fixes, then changes between
109
+ * letters often confused (`similar`: groups like "ckq", counted as half a change: "họk" → "học", not "họ"),
110
+ * then other letter changes, then the more common syllable. Letter changes only give syllables at least
111
+ * `minZipf` common. `edits` is 0, 0.5 or 1; `marks` how well the writer's accent marks are kept.
112
+ */
113
+ export function spellingCandidates(written, syllables, byPlain, { limit, minZipf = 3, similar = [] }) {
114
+ const lower = written.toLowerCase();
115
+ const base = plain(lower);
116
+ const alike = (x, y) => similar.some((g) => g.includes(x) && g.includes(y));
117
+ // Each one-letter change of the plain letters, with its cost: half for letters often confused.
118
+ const variants = new Map();
119
+ const add = (v, cost) => {
120
+ if (v !== base && cost < (variants.get(v) ?? Infinity))
121
+ variants.set(v, cost);
122
+ };
123
+ for (let i = 0; i <= base.length; i++) {
124
+ const [a, b] = [base.slice(0, i), base.slice(i)];
125
+ if (b)
126
+ add(a + b.slice(1), 1);
127
+ if (b.length > 1)
128
+ add(a + b[1] + b[0] + b.slice(2), 1);
129
+ for (const c of LETTERS) {
130
+ if (b)
131
+ add(a + c + b.slice(1), alike(b[0], c) ? 0.5 : 1);
132
+ add(a + c + b, 1);
133
+ }
134
+ }
135
+ const found = new Map();
136
+ for (const w of byPlain.get(base) ?? [])
137
+ if (w !== lower)
138
+ found.set(w, 0);
139
+ for (const [v, cost] of variants) {
140
+ for (const w of byPlain.get(v) ?? []) {
141
+ if ((syllables[w] ?? 0) >= minZipf && cost < (found.get(w) ?? Infinity))
142
+ found.set(w, cost);
143
+ }
144
+ }
145
+ const mine = marksOf(lower);
146
+ // How a candidate keeps the writer's marks: how many it shares, and a score where missing or extra marks
147
+ // count down (0 when the writer typed none, so plain-letter writing isn't held against any candidate).
148
+ const keep = (w) => {
149
+ const left = marksOf(w);
150
+ let shared = 0;
151
+ for (const m of mine) {
152
+ const at = left.indexOf(m);
153
+ if (at >= 0) {
154
+ shared++;
155
+ left.splice(at, 1);
156
+ }
157
+ }
158
+ return { all: shared === mine.length, score: mine.length ? shared - (mine.length - shared) - left.length : 0 };
159
+ };
160
+ return [...found]
161
+ .map(([word, edits]) => {
162
+ const k = keep(word);
163
+ // Tier 0: same letters, every typed mark kept (accents only moved or added).
164
+ return { word, edits, tier: edits === 0 && k.all ? 0 : 1, marks: k.score, zipf: syllables[word] ?? 0 };
165
+ })
166
+ .sort((a, b) => a.tier - b.tier || b.marks - a.marks || a.edits - b.edits || b.zipf - a.zipf)
167
+ .slice(0, limit)
168
+ .map(({ word, edits, tier, marks }) => ({ word, edits, marks: marks - tier * 100 }));
169
+ }
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, TRAILING_PARTICLE, } from "./types.js";
8
+ import { displayGloss, glossTerms, normalizeEnglish, PRONOUN_PERSONS, TRAILING_PARTICLE, withoutNotes, } 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,
@@ -238,6 +238,13 @@ function bridgeTerms(sense, from) {
238
238
  // Main terms get the full "main meaning" bonus: the English word itself, and the first term of the
239
239
  // definition ("will": "Used to express the future tense" → "future tense", which is what "sẽ" means).
240
240
  const main = new Set();
241
+ // An English place name is its own bridge: "Japan" finds "Nhật Bản" (defined "Japan (a country in East
242
+ // Asia)"). Its definition describes it ("A country in East Asia. Capital and largest city: Tokyo.") and
243
+ // would find other places.
244
+ if (from === 'en' && sense.pos === 'name') {
245
+ const name = normalizeEnglish(sense.lemma);
246
+ return { terms: [name], synonymsFrom: 1, main: new Set([name]) };
247
+ }
241
248
  if (from === 'en') {
242
249
  terms.push(normalizeEnglish(sense.lemma));
243
250
  main.add(normalizeEnglish(sense.lemma));
@@ -272,6 +279,14 @@ function bridgeTerms(sense, from) {
272
279
  }
273
280
  return { terms: unique.slice(0, 8), synonymsFrom, main };
274
281
  }
282
+ // Whether a definition is of the place `name` itself: it starts with the name, notes aside ("Japan (a
283
+ // country in East Asia)"), or is a short list of equivalents including it ("Japano-; Nippo-; Japan").
284
+ function namesPlace(gloss, name) {
285
+ const text = normalizeEnglish(withoutNotes(gloss));
286
+ if (text === name || new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=[\\s,;:.]|$)`).test(text))
287
+ return true;
288
+ return isTermList(gloss, glossTerms(gloss).length) && glossTerms(gloss).some(([t]) => t === name);
289
+ }
275
290
  // Every comma/semicolon part of the definition became a term, and each is at most three words.
276
291
  function isTermList(gloss, termCount) {
277
292
  const parts = gloss.replace(/\.\s*$/, '').split(/[;,]/);
@@ -361,8 +376,11 @@ export function createTranslator(options = {}) {
361
376
  const hits = [];
362
377
  // Terms are lowercase, so the capitalized word too ("i" is the letter; the pronoun is "I"). When the
363
378
  // exact part of speech is there, other compatible ones are left out (the letter "i" for pronoun "I").
379
+ // Names of several words are capitalized throughout ("South Korea", "United Kingdom").
364
380
  const capitalized = term.charAt(0).toUpperCase() + term.slice(1);
365
- let entries = [...(await en.lookup(term)), ...(capitalized !== term ? await en.lookup(capitalized) : [])];
381
+ const titled = term.replace(/(^|[ -])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
382
+ const forms = [...new Set([term, capitalized, titled])];
383
+ let entries = (await Promise.all(forms.map((f) => en.lookup(f)))).flat();
366
384
  const usable = (s) => !s.altOf && intersects(s.regions, wanted) && !s.labels.some((l) => exclude.includes(l));
367
385
  if (entries.some((e) => e.pos === posList[0] && e.senses.some(usable)))
368
386
  entries = entries.filter((e) => e.pos === posList[0]);
@@ -580,6 +598,7 @@ export function createTranslator(options = {}) {
580
598
  continue;
581
599
  }
582
600
  const posList = compatiblePos(sense.pos);
601
+ const placeName = from === 'en' && sense.pos === 'name' && to !== 'en';
583
602
  const context = contentWords(`${sense.glosses.join(' ')} ${meaning ?? ''}`);
584
603
  const scored = new Map();
585
604
  const keep = (hit, score, bridgeTerm) => {
@@ -616,6 +635,12 @@ export function createTranslator(options = {}) {
616
635
  if (!wordRank.has(h.word))
617
636
  wordRank.set(h.word, wordRank.size);
618
637
  hits.forEach((hit) => {
638
+ // An English place name matches a word that means that place: a definition starting with the
639
+ // name ("Japan (a country in East Asia)"), a short list ("Nhật": "Japano-; Nippo-; Japan"), or a
640
+ // spelling of such a word; not a definition that mentions it ("hoàng bào": a robe worn by the
641
+ // emperors of China, Japan, Korea and Vietnam; "Vinh": the provincial capital of …, Vietnam).
642
+ if (placeName && !hit.altOf && !namesPlace(hit.gloss, term))
643
+ return;
619
644
  const rank = wordRank.get(hit.word);
620
645
  // Translating between dialects of one language: the word itself only counts if it's tagged for the target region.
621
646
  if (to === from && hit.word === sense.lemma && toWanted && !hit.regionTagged)
package/dist/types.d.ts CHANGED
@@ -200,6 +200,11 @@ export type CheckerConfig = {
200
200
  * leaving them out rather than translating them: Vietnamese has no articles ("the", "a", "an").
201
201
  */
202
202
  leaveOut?: string[];
203
+ /**
204
+ * Groups of letters often confused, by sound or by neighboring keys ("ckq": Vietnamese c, k and q sound
205
+ * alike). The spellchecker counts swapping within a group as half a change, so "họk" → "học" before "họ".
206
+ */
207
+ similarLetters?: string[];
203
208
  };
204
209
  /**
205
210
  * Hand-picked first choices for one English meaning, in one target language (picks.json). A speaker's
@@ -329,6 +334,13 @@ export declare function normalizeEnglish(term: string): string;
329
334
  */
330
335
  export declare function displayGloss(glosses: string[]): string;
331
336
  export declare const TRAILING_PARTICLE: RegExp;
337
+ /** Definitions of personal names ("a male given name from Chinese", "a surname"), which aren't translated. */
338
+ export declare const PERSONAL_NAME: RegExp;
339
+ /**
340
+ * A definition without its parenthesized notes, nested ones too ("South Korea (a country …; official name:
341
+ * Đại Hàn Dân Quốc (“Republic of Korea”))" → "South Korea"), and a note left open by a cut-off definition.
342
+ */
343
+ export declare function withoutNotes(gloss: string): string;
332
344
  /**
333
345
  * Splits an English gloss into search terms, main meaning first: "now, today, this time" ->
334
346
  * [["now", 0], ["today", 1], ["this time", 2]]. Parenthesized notes and final punctuation are dropped.
package/dist/types.js CHANGED
@@ -69,6 +69,20 @@ export function displayGloss(glosses) {
69
69
  }
70
70
  // Trailing words dropped to also match the bare verb: "wait for" is found by "wait" too.
71
71
  export const TRAILING_PARTICLE = / (for|to|at|on|with|about|of|in|into|up|out|off|over)$/;
72
+ /** Definitions of personal names ("a male given name from Chinese", "a surname"), which aren't translated. */
73
+ export const PERSONAL_NAME = /\b(?:given name|surname|family name|patronymic|nickname)\b/i;
74
+ /**
75
+ * A definition without its parenthesized notes, nested ones too ("South Korea (a country …; official name:
76
+ * Đại Hàn Dân Quốc (“Republic of Korea”))" → "South Korea"), and a note left open by a cut-off definition.
77
+ */
78
+ export function withoutNotes(gloss) {
79
+ let text = gloss;
80
+ for (let previous = ''; previous !== text;) {
81
+ previous = text;
82
+ text = text.replace(/\([^()]*\)/g, ' ');
83
+ }
84
+ return text.replace(/\([^)]*$/, ' ');
85
+ }
72
86
  /**
73
87
  * Splits an English gloss into search terms, main meaning first: "now, today, this time" ->
74
88
  * [["now", 0], ["today", 1], ["this time", 2]]. Parenthesized notes and final punctuation are dropped.
@@ -80,7 +94,7 @@ export function glossTerms(gloss) {
80
94
  // express the future tense" (English "will"). The phrase after marks/expresses/indicates/denotes is
81
95
  // their meaning, so it's a term too, placed after the gloss's own terms.
82
96
  const grammar = [...gloss.matchAll(GRAMMAR_PHRASE)].map((m) => normalizeEnglish(m[1])).filter((t) => t.split(' ').length <= 3);
83
- let cleaned = gloss.replace(/\([^)]*\)/g, ' ').replace(/[“”"]/g, '');
97
+ let cleaned = withoutNotes(gloss).replace(/[“”"]/g, '');
84
98
  if (cleaned.includes(':'))
85
99
  cleaned = cleaned.slice(cleaned.lastIndexOf(':') + 1);
86
100
  const terms = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.5.1",
3
+ "version": "0.5.4",
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",