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 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 was supported early on and has been removed for now so the work can focus on Vietnamese.
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 for both languages. See [`DATA_UPDATES.md`](DATA_UPDATES.md) for each language's dates and update history.
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
- Vietnamese and English can be translated in both directions, and between any of their regions, including between two dialects of one language (Northern → Southern Vietnamese, US → UK English). English is also the bridge every language's definitions are written in.
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) | 49 / 49 (100%) | 49 / 49 (100%); 48 / 49 without picks |
106
- | Held-out set (written afterwards, not tuned against) | 17 / 17 (100%) | 16 / 17 (94%) |
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
- 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)).
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.1/data`.
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). 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`, …).
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.1.x data versions without touching the API; bump `DATA_VERSION` when the data format changes.
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. The base is known through the translator, so
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
- if (!syllableLanguage)
33
- return (await o.target.lookup(lower)).length > 0 ? 'exact' : false;
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.1";
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"). For languages written in syllables (with a syllable list); `[]` otherwise. The word
103
- * itself isn't included.
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.1';
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].includes(key) ? loadOnce(`${kind}/${key}.json`) : null;
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 stored = data?.[w] ?? data?.[w.toLowerCase()] ?? data?.[titled] ?? [];
79
- return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
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[0] ?? '', translations: sense.translations }];
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
- if (!found.some((f) => f.word === entry.word && f.gloss === s.glosses[0])) {
458
- found.push({ word: entry.word, pos: entry.pos, gloss: s.glosses[0], translations: s.translations });
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) ?? (await tableHit(t.word, to, posList, toWanted, exclude, bridge));
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
- /** The English headword, part of speech and first definition of the meaning, as in the English data. */
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.6",
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",