which-dialect 0.5.6 → 0.5.9
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 +14 -13
- package/dist/check.js +56 -0
- package/dist/detect.js +11 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.js +56 -7
- package/dist/translate.js +19 -5
- package/dist/types.d.ts +30 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Translate words **between languages and between dialects**, meaning by meaning: Northern Vietnamese *ngô* into Southern *bắp*, English *cool* (as in "awesome") into Southern Vietnamese *ngầu*, English "I" into the right Vietnamese pronoun for who you're talking to, US *truck* into UK *lorry*. Dictionary data comes from [Wiktionary](https://en.wiktionary.org/) (via [Kaikki.org](https://kaikki.org/)) and loads only when you need it.
|
|
4
4
|
|
|
5
|
-
**Status:** which-dialect is starting with **Vietnamese**, with English as the bridge language, and the current focus is cleaning up Vietnamese translations. **More languages are planned.** The translator and data format are language-general, so adding one is a config file plus a data package (see [Adding a language](#adding-a-language)). Spanish
|
|
5
|
+
**Status:** which-dialect is starting with **Vietnamese**, with English as the bridge language, and the current focus is cleaning up Vietnamese translations. **More languages are planned.** The translator and data format are language-general, so adding one is a config file plus a data package (see [Adding a language](#adding-a-language)). **Spanish** is supported too, by country and region group (Spain, Mexico, Latin America, Río de la Plata, …).
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { createTranslator } from 'which-dialect'
|
|
@@ -18,7 +18,7 @@ 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
|
|
21
|
+
**Data:** Wiktionary data of 2026-09-25 (Vietnamese: 2026-09-28). See [`DATA_UPDATES.md`](DATA_UPDATES.md) for each language's dates and update history.
|
|
22
22
|
|
|
23
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.
|
|
24
24
|
|
|
@@ -27,9 +27,10 @@ You install one small package. Dictionary data is **not bundled**: each lookup f
|
|
|
27
27
|
| Language | Code | Data package | Regions | Entries | Region-tagged senses |
|
|
28
28
|
|---|---|---|---|---|---|
|
|
29
29
|
| Vietnamese | `vi` | [`which-dialect-vi`](packages/vi) | Northern, Central, Southern | 35,394 | 1,330 of 42,419 (3.1%) |
|
|
30
|
+
| Spanish | `es` | [`which-dialect-es`](packages/es) | 23 countries (Spain, Mexico, Argentina, Cuba, …), plus groups: Latin America, Central America, Caribbean, South America, Río de la Plata | 123,995 | 11,940 of 153,963 (7.8%) |
|
|
30
31
|
| English | `en` | [`which-dialect-en`](packages/en) | 15 countries/areas (US, UK, Australia, India, …), plus groups: North America, British Isles, Commonwealth, … | 594,544 | 37,339 of 776,085 (4.8%) |
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
Any of these languages can be translated into any other, in both directions and between any of their regions, including between two dialects of one language (Northern → Southern Vietnamese, US → UK English, Spain → Mexico Spanish) and dialect to dialect across languages (Mexican Spanish → Southern Vietnamese). English is also the bridge every language's definitions are written in.
|
|
33
34
|
|
|
34
35
|
More languages are planned. Each one is a config file in [`languages/`](languages) plus a data package in `packages/<code>`; see [Adding a language](#adding-a-language).
|
|
35
36
|
|
|
@@ -92,20 +93,20 @@ Meanings are ordered by `meaning` (if given), then by being tagged for `fromRegi
|
|
|
92
93
|
|
|
93
94
|
### Hand-picked words
|
|
94
95
|
|
|
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
|
+
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. A pick can be a phrase the dictionary doesn't list (*chưa bao giờ*, "never"; marked `phrase: true`), and a row can `exclude` words that should never be given for its meaning (*mát mặt* for *well* "in good health").
|
|
96
97
|
|
|
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
|
+
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*; a few grammar words; and 54 meanings from a native speaker's review of the 300 most common English words (*can* "know how to" → *biết*; *how* → *như thế nào*, Southern *làm sao*; *life* "a living being" → *mạng*).
|
|
98
99
|
|
|
99
100
|
### How accurate is it?
|
|
100
101
|
|
|
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:
|
|
102
|
+
`npm run evaluate` runs known-correct translations across language pairs and dialect pairs (English → Vietnamese by region, including pronouns, English → Spanish by country, Spanish ↔ Vietnamese, Northern → Southern Vietnamese, Spain ↔ Mexico, US ↔ UK, Vietnamese → English). A case passes when a correct word is in the top 3 of the first meaning:
|
|
102
103
|
|
|
103
104
|
| Set | Correct word in top 3 | Correct word first |
|
|
104
105
|
|---|---|---|
|
|
105
|
-
| Tuning set (used while developing) |
|
|
106
|
-
| Held-out set (written afterwards, not tuned against) |
|
|
106
|
+
| Tuning set (used while developing) | 88 / 91 (97%) | 82 / 91 (90%); 81 / 91 without picks |
|
|
107
|
+
| Held-out set (written afterwards, not tuned against) | 29 / 30 (97%) | 28 / 30 (93%) |
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
With Spanish back (2026-10-29), its cases are back too; the misses are Spanish (*cerdo* without a part of speech, and Mexican "popcorn" and "cat"; see [`FUTURE_IMPROVEMENTS.md`](FUTURE_IMPROVEMENTS.md)). 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)).
|
|
109
110
|
|
|
110
111
|
## Checking text
|
|
111
112
|
|
|
@@ -208,7 +209,7 @@ Each sentence has `original`, `parts`, `corrected`, `changes` (`{ from, to, why,
|
|
|
208
209
|
|
|
209
210
|
### `createDictionary({ lang, baseUrl?, load? })`
|
|
210
211
|
|
|
211
|
-
One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/which-dialect-<lang>@0.
|
|
212
|
+
One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/which-dialect-<lang>@0.2/data`.
|
|
212
213
|
|
|
213
214
|
- `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`). The word as written, else lowercase, else capitalized, so names written in lowercase are found (*nhật* → *Nhật*, Japan; *nhật bản* → *Nhật Bản*).
|
|
214
215
|
- `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.
|
|
@@ -216,7 +217,7 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
|
|
|
216
217
|
- `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.
|
|
217
218
|
- `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).
|
|
218
219
|
- `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
|
|
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).
|
|
220
|
+
- `dictionary.picks()`: the language's [hand-picked words](#hand-picked-words), `{ word, pos, gloss, picks: [{ word, tags? }], exclude?, first? }` per English meaning (`gloss` is the sense's own definition: the last one, for a sense nested under a heading) (empty for languages without any).
|
|
220
221
|
- `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
|
|
221
222
|
- `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`.
|
|
222
223
|
|
|
@@ -311,7 +312,7 @@ Generated data isn't committed (it would bloat git history); it's built before p
|
|
|
311
312
|
|
|
312
313
|
### Adding a language
|
|
313
314
|
|
|
314
|
-
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).
|
|
315
|
+
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). [`languages/es.ts`](languages/es.ts) shows countries with region groups (Latin America, Río de la Plata) and `skipFormOf`. 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`, …).
|
|
315
316
|
2. Copy a data package (`packages/vi`) to `packages/<code>` and update its `package.json` and `README.md`.
|
|
316
317
|
3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
|
|
317
318
|
4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
|
|
@@ -330,4 +331,4 @@ npm publish -w which-dialect-vi
|
|
|
330
331
|
npm publish -w which-dialect
|
|
331
332
|
```
|
|
332
333
|
|
|
333
|
-
The API loads data versions matching `DATA_VERSION` in `packages/core/src/index.ts` (currently `0.1`). Data-only updates can publish new 0.
|
|
334
|
+
The API loads data versions matching `DATA_VERSION` in `packages/core/src/index.ts` (currently `0.2`; `0.1` up to which-dialect 0.5.6). Data-only updates can publish new 0.2.x data versions without touching the API; bump `DATA_VERSION` when the data format changes.
|
package/dist/check.js
CHANGED
|
@@ -82,6 +82,8 @@ const PLAIN_TYPO_MIN_ZIPF = 4;
|
|
|
82
82
|
// Candidates kept per misspelled syllable.
|
|
83
83
|
const SPELLING_CANDIDATES = 3;
|
|
84
84
|
async function spelling(ctx) {
|
|
85
|
+
if (ctx.config.units === 'words')
|
|
86
|
+
return wordSpelling(ctx);
|
|
85
87
|
if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
|
|
86
88
|
return [];
|
|
87
89
|
const syllables = await ctx.dict.syllables();
|
|
@@ -222,6 +224,60 @@ async function spelling(ctx) {
|
|
|
222
224
|
}
|
|
223
225
|
return issues;
|
|
224
226
|
}
|
|
227
|
+
// Wiktionary's own misspelling entries ("misspelling of también", "common misspelling of …"). A word counts
|
|
228
|
+
// as misspelt when it's a misspelling and otherwise only an out-of-use spelling ("tambien": misspelling, and
|
|
229
|
+
// obsolete spelling, of también).
|
|
230
|
+
const MISSPELLING = /\bmisspelling of ([\p{L}\p{M}'-]+)/iu;
|
|
231
|
+
const NOT_IN_USE_SPELLING = /\b(misspelling|obsolete spelling|archaic spelling|dated spelling|obsolete form|archaic form) of\b/i;
|
|
232
|
+
// Zipf points below the most common look-alike beyond which another isn't suggested.
|
|
233
|
+
const RARE_VARIANT_GAP = 2;
|
|
234
|
+
// Spelling and accents for languages written in words (Spanish), from the dictionary alone: a word it doesn't
|
|
235
|
+
// know, as a headword or an inflected form (the forms index), that matches known words once accents are
|
|
236
|
+
// dropped gets the most common of them ("cancion" → "canción", "tambien" → "también"). Known words are left
|
|
237
|
+
// alone even when an accented one is more
|
|
238
|
+
// common ("esta" is a word, as "está" is), and so are unknown words with no such match (names, other
|
|
239
|
+
// languages, typos this can't fix), and words capitalized mid-sentence (names).
|
|
240
|
+
async function wordSpelling(ctx) {
|
|
241
|
+
const issues = [];
|
|
242
|
+
for (const sentence of ctx.sentences) {
|
|
243
|
+
for (const w of sentence.words) {
|
|
244
|
+
if (w.units.length !== 1)
|
|
245
|
+
continue;
|
|
246
|
+
// Words the dictionary lists only as misspellings ("tambien": "misspelling of también") count as unknown,
|
|
247
|
+
// with what they're a misspelling of.
|
|
248
|
+
const senses = w.entries.flatMap((e) => e.senses);
|
|
249
|
+
const misspelt = senses.some((s) => MISSPELLING.test(s.glosses[0] ?? '')) && senses.every((s) => NOT_IN_USE_SPELLING.test(s.glosses[0] ?? ''));
|
|
250
|
+
if (w.entries.length && !misspelt)
|
|
251
|
+
continue;
|
|
252
|
+
const u = w.units[0];
|
|
253
|
+
if (ctx.inBase(u) || !/\p{L}/u.test(u.text))
|
|
254
|
+
continue;
|
|
255
|
+
if (u !== sentence.units[0] && /\p{Lu}/u.test(u.text.charAt(0)))
|
|
256
|
+
continue;
|
|
257
|
+
const variants = await ctx.dict.variants(u.text, { limit: 3 });
|
|
258
|
+
// Rare look-alikes don't crowd out the word meant ("nino": niño, not also ñiño).
|
|
259
|
+
const best = variants[0]?.frequency;
|
|
260
|
+
const found = unique([
|
|
261
|
+
...(misspelt ? senses.map((s) => s.altOf ?? MISSPELLING.exec(s.glosses[0] ?? '')?.[1]) : []).filter((x) => Boolean(x)),
|
|
262
|
+
...variants.filter((v) => best === undefined || (v.frequency ?? -Infinity) >= best - RARE_VARIANT_GAP).map((v) => v.word),
|
|
263
|
+
]).slice(0, 3);
|
|
264
|
+
if (!found.length)
|
|
265
|
+
continue;
|
|
266
|
+
// Missing accents is the common learner slip; a wrong accent is more surely wrong.
|
|
267
|
+
const accented = plain(u.text) !== u.text;
|
|
268
|
+
issues.push({
|
|
269
|
+
rule: 'spelling',
|
|
270
|
+
severity: accented ? 'error' : 'warning',
|
|
271
|
+
start: u.start,
|
|
272
|
+
end: u.end,
|
|
273
|
+
text: u.text,
|
|
274
|
+
message: `${quote(u.text)} isn't a ${ctx.meta.name} word. Did you mean ${listOf(found.map(quote), 'or')}?`,
|
|
275
|
+
suggestions: found.map((c) => matchCase(u.text, c)),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return issues;
|
|
280
|
+
}
|
|
225
281
|
// Words in the learner's own language (English by default), with the target-language word to use: "market"
|
|
226
282
|
// → "chợ". Runs of base units are split into the longest phrases the base dictionary knows ("ice cream"),
|
|
227
283
|
// each translated in the region and register. Words the language usually has no word for
|
package/dist/detect.js
CHANGED
|
@@ -12,7 +12,8 @@ import { plain } from "./text.js";
|
|
|
12
12
|
* way, using its accented form's frequency: "rat" is "rất" (far more common than English "rat"); close
|
|
13
13
|
* calls ("met": mệt or English met) go to the target only when their neighbors are the target's ("Tôi met
|
|
14
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.
|
|
15
|
+
* knows without its accents ("khong") is still the target's. Languages written in words do the same with
|
|
16
|
+
* whole words and their inflected forms (Spanish "cancion" → canción, "tambien" → también). The base is known through the translator, so
|
|
16
17
|
* inflected forms count ("went", "cooked").
|
|
17
18
|
*/
|
|
18
19
|
// Zipf points by which a word both languages know must be more common in one to count as that language's
|
|
@@ -29,8 +30,12 @@ export async function markLanguages(list, o) {
|
|
|
29
30
|
// 'exact': a target word as written; 'accents': one only with accents added ("khong" → "không").
|
|
30
31
|
const targetKnows = async (u) => {
|
|
31
32
|
const lower = u.text.toLowerCase();
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// Languages written in words compare whole words: "cancion" is Spanish "canción" without its accent.
|
|
34
|
+
if (!syllableLanguage) {
|
|
35
|
+
if ((await o.target.lookup(lower)).length > 0)
|
|
36
|
+
return 'exact';
|
|
37
|
+
return (await o.target.variants(lower, { limit: 1 })).length > 0 ? 'accents' : false;
|
|
38
|
+
}
|
|
34
39
|
if (lower in syllables || lower !== plain(lower))
|
|
35
40
|
return 'exact';
|
|
36
41
|
return plainSyllables.has(lower) ? 'accents' : false;
|
|
@@ -47,7 +52,9 @@ export async function markLanguages(list, o) {
|
|
|
47
52
|
return t === 'accents' ? o.lang : '?';
|
|
48
53
|
}));
|
|
49
54
|
// The most common form a plain-letter unit takes once accents are added ("rat" → rất, 6.4).
|
|
50
|
-
const accentedFrequency = (u) => {
|
|
55
|
+
const accentedFrequency = async (u) => {
|
|
56
|
+
if (!syllableLanguage)
|
|
57
|
+
return (await o.target.variants(u.text.toLowerCase(), { limit: 1 }))[0]?.frequency ?? 0;
|
|
51
58
|
const bare = plain(u.text.toLowerCase());
|
|
52
59
|
let best = 0;
|
|
53
60
|
for (const [s, f] of Object.entries(syllables))
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export * from './pos.ts';
|
|
|
5
5
|
* Data packages this version of the API reads by default, as a jsDelivr version range.
|
|
6
6
|
* Bump it when the data format changes.
|
|
7
7
|
*/
|
|
8
|
-
export declare const DATA_VERSION = "0.
|
|
8
|
+
export declare const DATA_VERSION = "0.2";
|
|
9
9
|
/** Labels left out of search results unless `exclude` is passed. */
|
|
10
10
|
export declare const DEFAULT_EXCLUDED_LABELS: string[];
|
|
11
11
|
/** Loads one data file by its path inside the data folder, e.g. "meta.json" or "en/no.json". */
|
|
@@ -99,8 +99,9 @@ export type Dictionary = {
|
|
|
99
99
|
/**
|
|
100
100
|
* Other words written with the same letters but other accents, that the dictionary has, most common
|
|
101
101
|
* first: "muộn" → muốn, mượn, …; for words of several syllables, every syllable varies ("hom nay" →
|
|
102
|
-
* "hôm nay").
|
|
103
|
-
* itself isn't
|
|
102
|
+
* "hôm nay"). Languages written in words (with no syllable list) compare whole words, headwords and
|
|
103
|
+
* inflected forms alike: Spanish "cancion" → canción, "esta" → está, ésta. The word itself isn't
|
|
104
|
+
* included.
|
|
104
105
|
*/
|
|
105
106
|
variants(word: string, options?: {
|
|
106
107
|
limit?: number;
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { PERSONAL_NAME, PRONOUN_PERSONS, fromStored, normalizeEnglish, shardKey, } from "./types.js";
|
|
2
|
-
import { accentCombinations, segment as segmentUnits, syllablesByPlain, units } from "./text.js";
|
|
2
|
+
import { accentCombinations, plain, segment as segmentUnits, syllablesByPlain, units } from "./text.js";
|
|
3
3
|
export * from "./types.js";
|
|
4
4
|
export * from "./pos.js";
|
|
5
5
|
/**
|
|
6
6
|
* Data packages this version of the API reads by default, as a jsDelivr version range.
|
|
7
7
|
* Bump it when the data format changes.
|
|
8
8
|
*/
|
|
9
|
-
export const DATA_VERSION = '0.
|
|
9
|
+
export const DATA_VERSION = '0.2';
|
|
10
10
|
/** Labels left out of search results unless `exclude` is passed. */
|
|
11
11
|
export const DEFAULT_EXCLUDED_LABELS = [
|
|
12
12
|
'vulgar', 'offensive', 'derogatory', 'archaic', 'obsolete', 'dated', 'historical', 'rare', 'abbreviation',
|
|
@@ -67,16 +67,40 @@ export function createDictionary(options) {
|
|
|
67
67
|
async function shard(kind, term) {
|
|
68
68
|
const m = await meta();
|
|
69
69
|
const key = shardKey(term, m.shardLength);
|
|
70
|
-
return m.shards[kind]
|
|
70
|
+
return m.shards[kind]?.includes(key) ? loadOnce(`${kind}/${key}.json`) : null;
|
|
71
|
+
}
|
|
72
|
+
// An inflected form from the forms index as an entry: one sense, "first-person singular present
|
|
73
|
+
// indicative of hablar", pointing at its lemma (altOf), used in every region like the lemma.
|
|
74
|
+
function formEntries(m, word, forms) {
|
|
75
|
+
const descriptions = m.forms?.descriptions ?? [];
|
|
76
|
+
return forms.map(([lemma, pos, description, frequency]) => ({
|
|
77
|
+
word,
|
|
78
|
+
pos,
|
|
79
|
+
senses: [
|
|
80
|
+
{
|
|
81
|
+
glosses: [(descriptions[description] ?? 'form of {}').replace('{}', lemma)],
|
|
82
|
+
regions: m.regions,
|
|
83
|
+
regionTagged: false,
|
|
84
|
+
labels: [],
|
|
85
|
+
altOf: lemma,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
...(frequency !== undefined ? { frequency } : {}),
|
|
89
|
+
}));
|
|
71
90
|
}
|
|
72
91
|
async function lookup(word) {
|
|
73
92
|
const w = word.trim();
|
|
74
|
-
const [m, data] = await Promise.all([meta(), shard('words', w)]);
|
|
93
|
+
const [m, data, forms] = await Promise.all([meta(), shard('words', w), shard('forms', w)]);
|
|
75
94
|
// The word as written, else lowercase ("Mẹ" → "mẹ"), else capitalized for names written in lowercase
|
|
76
95
|
// ("nhật" → "Nhật", Japan; "nhật bản" → "Nhật Bản"), which spellchecker suggestions and learners do.
|
|
77
96
|
const titled = w.toLowerCase().replace(/(^|[\s-])(\p{Ll})/gu, (_, sep, c) => sep + c.toUpperCase());
|
|
78
|
-
const
|
|
79
|
-
|
|
97
|
+
const key = [w, w.toLowerCase(), titled].find((k) => data?.[k] || forms?.[k]);
|
|
98
|
+
if (!key)
|
|
99
|
+
return [];
|
|
100
|
+
// Headword entries first, then the word as an inflected form of others ("música": the noun, then the
|
|
101
|
+
// feminine of "músico").
|
|
102
|
+
const entries = (data?.[key] ?? []).map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
|
|
103
|
+
return [...entries, ...formEntries(m, key, forms?.[key] ?? [])];
|
|
80
104
|
}
|
|
81
105
|
const checker = async () => {
|
|
82
106
|
const m = await meta();
|
|
@@ -86,6 +110,31 @@ export function createDictionary(options) {
|
|
|
86
110
|
const m = await meta();
|
|
87
111
|
return m.syllables ? loadOnce('syllables.json') : {};
|
|
88
112
|
};
|
|
113
|
+
// Variants for languages written in words: other headwords and inflected forms with the same letters
|
|
114
|
+
// once accents are dropped. Shards are keyed without accents, so they're all in the word's own shards.
|
|
115
|
+
async function wordVariants(word, limit) {
|
|
116
|
+
const written = word.trim().normalize('NFC').toLowerCase();
|
|
117
|
+
const target = plain(written);
|
|
118
|
+
if (!target)
|
|
119
|
+
return [];
|
|
120
|
+
const [data, forms] = await Promise.all([shard('words', written), shard('forms', written)]);
|
|
121
|
+
const candidates = new Set();
|
|
122
|
+
for (const key of [...Object.keys(data ?? {}), ...Object.keys(forms ?? {})]) {
|
|
123
|
+
const lower = key.toLowerCase();
|
|
124
|
+
if (lower !== written && plain(lower) === target)
|
|
125
|
+
candidates.add(lower);
|
|
126
|
+
}
|
|
127
|
+
const found = await Promise.all([...candidates].map(async (c) => ({ word: c, entries: await lookup(c) })));
|
|
128
|
+
const frequency = (entries) => {
|
|
129
|
+
const known = entries.map((e) => e.frequency).filter((f) => f !== undefined);
|
|
130
|
+
return known.length ? Math.max(...known) : undefined;
|
|
131
|
+
};
|
|
132
|
+
return found
|
|
133
|
+
.filter((v) => v.entries.length)
|
|
134
|
+
.map((v) => ({ ...v, frequency: frequency(v.entries) }))
|
|
135
|
+
.sort((a, b) => (b.frequency ?? -1) - (a.frequency ?? -1))
|
|
136
|
+
.slice(0, limit);
|
|
137
|
+
}
|
|
89
138
|
return {
|
|
90
139
|
meta,
|
|
91
140
|
lookup,
|
|
@@ -100,7 +149,7 @@ export function createDictionary(options) {
|
|
|
100
149
|
async variants(word, { limit = 5 } = {}) {
|
|
101
150
|
const list = await syllables();
|
|
102
151
|
if (!Object.keys(list).length)
|
|
103
|
-
return
|
|
152
|
+
return wordVariants(word, limit);
|
|
104
153
|
const written = word.trim().normalize('NFC').toLowerCase();
|
|
105
154
|
const parts = written.split(/\s+/);
|
|
106
155
|
const combos = accentCombinations(parts, syllablesByPlain(list), {
|
package/dist/translate.js
CHANGED
|
@@ -445,8 +445,9 @@ export function createTranslator(options = {}) {
|
|
|
445
445
|
return hits;
|
|
446
446
|
}
|
|
447
447
|
async function englishMeanings(sense, from, bridge) {
|
|
448
|
+
// A meaning's gloss is its own (last) definition: a nested sense's first is the heading it shares.
|
|
448
449
|
if (from === 'en')
|
|
449
|
-
return [{ word: sense.lemma, pos: sense.pos, gloss: sense.glosses
|
|
450
|
+
return [{ word: sense.lemma, pos: sense.pos, gloss: sense.glosses.at(-1) ?? '', translations: sense.translations }];
|
|
450
451
|
const found = [];
|
|
451
452
|
for (const term of bridge.slice(0, 3)) {
|
|
452
453
|
for (const entry of await dict('en').lookup(term)) {
|
|
@@ -454,8 +455,9 @@ export function createTranslator(options = {}) {
|
|
|
454
455
|
const own = s.translations?.[from];
|
|
455
456
|
if (!own?.some((t) => t.word === sense.lemma || t.word === sense.word))
|
|
456
457
|
continue;
|
|
457
|
-
|
|
458
|
-
|
|
458
|
+
const gloss = s.glosses.at(-1) ?? '';
|
|
459
|
+
if (!found.some((f) => f.word === entry.word && f.gloss === gloss)) {
|
|
460
|
+
found.push({ word: entry.word, pos: entry.pos, gloss, translations: s.translations });
|
|
459
461
|
}
|
|
460
462
|
}
|
|
461
463
|
}
|
|
@@ -522,6 +524,13 @@ export function createTranslator(options = {}) {
|
|
|
522
524
|
...(entry.frequency !== undefined ? { frequency: entry.frequency } : {}),
|
|
523
525
|
};
|
|
524
526
|
}
|
|
527
|
+
// A hand-picked phrase the dictionary doesn't list: used everywhere, or where its tags say.
|
|
528
|
+
function phraseHit(word, sense, places, meta) {
|
|
529
|
+
return {
|
|
530
|
+
word, pos: sense.pos, gloss: sense.glosses.at(-1) ?? '', regions: places ? [...places] : meta.regions,
|
|
531
|
+
regionTagged: Boolean(places), labels: [], senseIndex: 0, senses: 1, primary: true, phrase: true,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
525
534
|
// The source word's uses in a pronoun table, with the definition each came from.
|
|
526
535
|
function pronounUses(rows, word) {
|
|
527
536
|
const w = word.trim();
|
|
@@ -722,7 +731,8 @@ export function createTranslator(options = {}) {
|
|
|
722
731
|
// Hand-picked words for this meaning go first, in the listed order: a speaker's judgment of the
|
|
723
732
|
// natural word where the ranking puts another first. With a target region, picks tagged for it
|
|
724
733
|
// lead, and picks tagged only for other regions (or words not used there) are left to the
|
|
725
|
-
// ranking. The ranked words follow. (A pronoun-table row, below, still comes before them.)
|
|
734
|
+
// ranking. The ranked words follow. (A pronoun-table row, below, still comes before them.) A pick
|
|
735
|
+
// that isn't a headword is a phrase ("bữa tối"), given as is. Words a row excludes are dropped.
|
|
726
736
|
const rows = pickRows.length
|
|
727
737
|
? 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)))
|
|
728
738
|
: [];
|
|
@@ -737,13 +747,17 @@ export function createTranslator(options = {}) {
|
|
|
737
747
|
continue;
|
|
738
748
|
if (to === from && t.word === sense.lemma && !places?.size)
|
|
739
749
|
continue;
|
|
740
|
-
const hit = scored.get(t.word) ??
|
|
750
|
+
const hit = scored.get(t.word) ??
|
|
751
|
+
(await tableHit(t.word, to, posList, toWanted, exclude, bridge)) ??
|
|
752
|
+
(t.word.includes(' ') ? phraseHit(t.word, sense, places, toMeta) : null);
|
|
741
753
|
if (!hit || hit.labels.some((l) => exclude.includes(l)))
|
|
742
754
|
continue;
|
|
743
755
|
if (!places && !intersects(hit.regions, toWanted))
|
|
744
756
|
continue;
|
|
745
757
|
picked.push({ hit, tagged: Boolean(toWanted && places) });
|
|
746
758
|
}
|
|
759
|
+
for (const w of rows.flatMap((r) => r.exclude ?? []))
|
|
760
|
+
scored.delete(w);
|
|
747
761
|
if (picked.length) {
|
|
748
762
|
const ordered = [...picked.filter((p) => p.tagged), ...picked.filter((p) => !p.tagged)];
|
|
749
763
|
const top = Math.max(0, ...[...scored.values()].map((t) => t.score));
|
package/dist/types.d.ts
CHANGED
|
@@ -63,6 +63,8 @@ export type Hit = {
|
|
|
63
63
|
frequency?: number;
|
|
64
64
|
/** true when the English term is the first meaning listed in the gloss (e.g. "now" in "now, today"). */
|
|
65
65
|
primary: boolean;
|
|
66
|
+
/** true for a hand-picked phrase that isn't a dictionary headword ("bữa tối"); its details are the pick's. */
|
|
67
|
+
phrase?: boolean;
|
|
66
68
|
};
|
|
67
69
|
export type LanguageMeta = {
|
|
68
70
|
/** ISO 639 code, e.g. "vi". */
|
|
@@ -99,10 +101,11 @@ export type LanguageMeta = {
|
|
|
99
101
|
regionTaggedSenses: number;
|
|
100
102
|
englishTerms: number;
|
|
101
103
|
};
|
|
102
|
-
/** Shard file names (without .json) in words/ and en
|
|
104
|
+
/** Shard file names (without .json) in words/ and en/, and forms/ when the language has a forms index. */
|
|
103
105
|
shards: {
|
|
104
106
|
words: string[];
|
|
105
107
|
en: string[];
|
|
108
|
+
forms?: string[];
|
|
106
109
|
};
|
|
107
110
|
/** Letters per shard key; see shardKey. Omitted means 2. */
|
|
108
111
|
shardLength?: number;
|
|
@@ -116,6 +119,16 @@ export type LanguageMeta = {
|
|
|
116
119
|
syllables?: boolean;
|
|
117
120
|
/** true when the language has sentence frames (frames.json, see FramesData). */
|
|
118
121
|
frames?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The language's inflected forms are in a compact index (forms/<shard>.json, see FormShard) rather than
|
|
124
|
+
* stored as entries: Spanish's conjugations and plurals are most of its source and would triple its
|
|
125
|
+
* data. `descriptions` are what each form is, with "{}" where the lemma goes ("first-person singular
|
|
126
|
+
* present indicative of {}"); a stored form points into it.
|
|
127
|
+
*/
|
|
128
|
+
forms?: {
|
|
129
|
+
descriptions: string[];
|
|
130
|
+
count: number;
|
|
131
|
+
};
|
|
119
132
|
};
|
|
120
133
|
/**
|
|
121
134
|
* Pronoun slots in sentence frames ({I}, {YOU}, …) and the pronoun-table column each one fills from, for
|
|
@@ -213,12 +226,18 @@ export type CheckerConfig = {
|
|
|
213
226
|
* translating between two other languages reaches it through the English meaning.
|
|
214
227
|
*/
|
|
215
228
|
export type PickRow = {
|
|
216
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* The English headword, part of speech and definition of the meaning, as in the English data. For a
|
|
231
|
+
* nested sense (a heading plus its own definition: "old", "Of a living being…") it's the sense's own,
|
|
232
|
+
* last definition, so senses under one heading are told apart.
|
|
233
|
+
*/
|
|
217
234
|
word: string;
|
|
218
235
|
pos: string;
|
|
219
236
|
gloss: string;
|
|
220
|
-
/** Best first. `tags` name regions like translation-table tags ("Southern"). */
|
|
237
|
+
/** Best first. `tags` name regions like translation-table tags ("Southern"). A pick needn't be a headword ("bữa tối"). */
|
|
221
238
|
picks: TableTranslation[];
|
|
239
|
+
/** Words never given for this meaning (a speaker removed them: wrong or unnatural here). */
|
|
240
|
+
exclude?: string[];
|
|
222
241
|
/** Put this meaning first when the caller gives no `pos` or `meaning` ("got" → "Have/has."). */
|
|
223
242
|
first?: boolean;
|
|
224
243
|
};
|
|
@@ -300,6 +319,14 @@ export type StoredHit = Omit<Hit, 'regions' | 'regionTagged' | 'labels'> & {
|
|
|
300
319
|
};
|
|
301
320
|
/** words/<shard>.json: entries keyed by headword. */
|
|
302
321
|
export type WordShard = Record<string, StoredEntry[]>;
|
|
322
|
+
/**
|
|
323
|
+
* One inflected form in forms/<shard>.json: [lemma, part of speech, index into meta.forms.descriptions,
|
|
324
|
+
* Zipf frequency (omitted when unknown)]. lookup() turns it into an entry with one sense whose gloss is
|
|
325
|
+
* the description and whose `altOf` is the lemma, so the translator follows it to the lemma's meanings.
|
|
326
|
+
*/
|
|
327
|
+
export type StoredForm = [lemma: string, pos: string, description: number, frequency?: number];
|
|
328
|
+
/** forms/<shard>.json: a language's inflected forms keyed by form ("hablo" → hablar). */
|
|
329
|
+
export type FormShard = Record<string, StoredForm[]>;
|
|
303
330
|
/** en/<shard>.json: hits keyed by lowercase English term. */
|
|
304
331
|
export type EnglishShard = Record<string, StoredHit[]>;
|
|
305
332
|
/** Converts a full sense or hit to its stored form. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "which-dialect",
|
|
3
|
-
"version": "0.5.
|
|
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.",
|
|
3
|
+
"version": "0.5.9",
|
|
4
|
+
"description": "Translate words between languages and dialects, meaning by meaning, starting with Vietnamese (Northern, Central, Southern), Spanish (by country) and English, with Wiktionary data loaded on demand. More languages are planned.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"translation",
|
|
27
27
|
"language-learning",
|
|
28
28
|
"wiktionary",
|
|
29
|
+
"spanish",
|
|
29
30
|
"vietnamese"
|
|
30
31
|
],
|
|
31
32
|
"license": "MIT",
|