which-dialect 0.5.5 → 0.5.8

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,15 +209,15 @@ 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
- - `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
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.
215
216
  - `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
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
 
@@ -301,6 +302,8 @@ npm run evaluate # translation accuracy on known-correct cas
301
302
  npm run build:picks -- vi # rebuild only a language's hand-picked words (after editing `picks` in languages/vi.ts)
302
303
  npm run picks-sheet -- vi # review sheet (.cache/picks-vi.csv) of the top English words' meanings; -- --words file.txt, -- --top 500
303
304
  npm run picks-sheet -- vi --apply sheet.csv # config rows for the rows whose your_pick is filled in
305
+ npm run picks-sheet -- vi --top 300 --drafts reviews/vi-top300.json --html page.html # the same rows as a review page
306
+ npm run picks-sheet -- vi --apply-decisions <dir> --drafts reviews/vi-top300.json # config rows from the page's decisions
304
307
  npm run typecheck
305
308
  npm run build # compile the API to packages/core/dist
306
309
  ```
@@ -309,13 +312,13 @@ Generated data isn't committed (it would bloat git history); it's built before p
309
312
 
310
313
  ### Adding a language
311
314
 
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`, …).
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`, …).
313
316
  2. Copy a data package (`packages/vi`) to `packages/<code>` and update its `package.json` and `README.md`.
314
317
  3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
315
318
  4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
316
319
  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.
317
320
  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.
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`.
321
+ 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`. Or as a **review page**: draft suggestions for the meanings whose first word is wrong in `reviews/<code>-top300.json` (picks, confidence, why, an example sentence), build the page with `--html`, publish it as a claude.ai Artifact (it saves each decision in its database), and once a speaker has gone through it, export the decisions and turn them into config rows with `--apply-decisions`. In Claude Code, the project skill `/word-review <language> <N>` does all of this (and `/word-review <language> <N> apply` afterwards).
319
322
 
320
323
  Languages in non-Latin scripts (Chinese, Arabic, Russian, …) will need a script-aware version of `shardKey` first: today files are split by the first two Latin letters.
321
324
 
@@ -328,4 +331,4 @@ npm publish -w which-dialect-vi
328
331
  npm publish -w which-dialect
329
332
  ```
330
333
 
331
- 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
@@ -172,8 +172,12 @@ async function spelling(ctx) {
172
172
  max: COMPOUND_MAX_COMBINATIONS,
173
173
  }).filter((c) => c !== written.join(' '));
174
174
  const found = (await Promise.all(combos.map(async (c) => ({ word: c, entries: await ctx.dict.lookup(c) }))))
175
+ // Names (found through a capitalized headword) only for a misspelled run: "nhat ban" → "Nhật Bản",
176
+ // but "hoa lan" (orchid, every syllable a word) isn't "Hòa Lan" (the Netherlands).
177
+ .map((f) => ({ ...f, entries: doubtful ? f.entries : f.entries.filter((e) => e.word === e.word.toLowerCase()) }))
175
178
  .filter((f) => f.entries.length)
176
- .map((f) => ({ word: f.word, frequency: Math.max(-1, ...f.entries.map((e) => e.frequency ?? -1)) }))
179
+ // Spelled as the headword: names keep their capitals ("nhat ban" → "Nhật Bản").
180
+ .map((f) => ({ word: f.entries[0].word.toLowerCase() === f.word ? f.entries[0].word : f.word, frequency: Math.max(-1, ...f.entries.map((e) => e.frequency ?? -1)) }))
177
181
  .sort((a, b) => b.frequency - a.frequency)
178
182
  .slice(0, 3);
179
183
  if (!found.length)
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". */
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ export * from "./pos.js";
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',
@@ -72,7 +72,10 @@ export function createDictionary(options) {
72
72
  async function lookup(word) {
73
73
  const w = word.trim();
74
74
  const [m, data] = await Promise.all([meta(), shard('words', w)]);
75
- const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? [];
75
+ // The word as written, else lowercase ("Mẹ" → "mẹ"), else capitalized for names written in lowercase
76
+ // ("nhật" → "Nhật", Japan; "nhật bản" → "Nhật Bản"), which spellchecker suggestions and learners do.
77
+ const titled = w.toLowerCase().replace(/(^|[\s-])(\p{Ll})/gu, (_, sep, c) => sep + c.toUpperCase());
78
+ const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? data?.[titled] ?? [];
76
79
  return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
77
80
  }
78
81
  const checker = async () => {
@@ -107,11 +110,15 @@ export function createDictionary(options) {
107
110
  // Each form as written in lowercase and capitalized, for names ("nhat" → "Nhật", Japan; "nhat ban" →
108
111
  // "Nhật Bản").
109
112
  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] ?? ''))) });
113
+ // Each form counts only as its own headword (lookup also finds other cases: "nhật" finds "Nhật"), and a
114
+ // capitalized one only when it's more than a person's name (a place, a people), so given names don't
115
+ // crowd out words.
116
+ const found = (await Promise.all(forms.map(async (c) => ({ word: c, entries: await lookup(c) })))).map((v) => ({
117
+ ...v,
118
+ entries: v.entries.filter((e) => v.word === v.word.toLowerCase()
119
+ ? !(e.word !== v.word && e.word.toLowerCase() === v.word)
120
+ : e.word === v.word && e.senses.some((s) => !PERSONAL_NAME.test(s.glosses[0] ?? ''))),
121
+ }));
115
122
  const frequency = (v) => {
116
123
  const known = v.entries.map((e) => e.frequency).filter((f) => f !== undefined);
117
124
  return known.length ? Math.max(...known) : parts.length === 1 ? list[v.word.toLowerCase()] || undefined : undefined;
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". */
@@ -213,12 +215,18 @@ export type CheckerConfig = {
213
215
  * translating between two other languages reaches it through the English meaning.
214
216
  */
215
217
  export type PickRow = {
216
- /** The English headword, part of speech and first definition of the meaning, as in the English data. */
218
+ /**
219
+ * The English headword, part of speech and definition of the meaning, as in the English data. For a
220
+ * nested sense (a heading plus its own definition: "old", "Of a living being…") it's the sense's own,
221
+ * last definition, so senses under one heading are told apart.
222
+ */
217
223
  word: string;
218
224
  pos: string;
219
225
  gloss: string;
220
- /** Best first. `tags` name regions like translation-table tags ("Southern"). */
226
+ /** Best first. `tags` name regions like translation-table tags ("Southern"). A pick needn't be a headword ("bữa tối"). */
221
227
  picks: TableTranslation[];
228
+ /** Words never given for this meaning (a speaker removed them: wrong or unnatural here). */
229
+ exclude?: string[];
222
230
  /** Put this meaning first when the caller gives no `pos` or `meaning` ("got" → "Have/has."). */
223
231
  first?: boolean;
224
232
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.5.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.8",
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",