which-dialect 0.4.0 → 0.5.1

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
@@ -138,8 +138,8 @@ The result has `text` (the input in Unicode NFC form), `issues` and `parts` (the
138
138
 
139
139
  **On by default (the spellchecker):**
140
140
 
141
- - **`spelling`**: syllables that aren't in the language get the accented forms they could be (*khong* → *không*); rare plain-letter words next to a far more common accented form too (*toi* → *tôi*, *hom* → *hôm*), and, in a sentence typed without accents, any more common accented form (*an* → *ăn*, *di* → *đi*; not *con* → *còn*, about as common). Names (capitalized mid-sentence), the language's listed pronouns (*tui*) and plain-letter words with no accented form (*email*) are left alone. Languages written in syllables only (Vietnamese).
142
- - **`foreign-word`**: words in the learner's own language get the target-language word, in the region and register (*market* → *chợ*, *ice cream* → *kem*). Phrases the base dictionary knows are translated as one, falling back to single words. Words the language has no word for are suggested to be left out (`leaveOut` in its checker settings: Vietnamese has no *the*, *a*, *an*). Which language each word is in comes from the two dictionaries: an English word that's Vietnamese only with accents added (*but*: *bút*) is English; Vietnamese typed without accents (*khong*) is Vietnamese; words that could be either (*an*, *tui*) follow their neighbors, leaning to the target language.
141
+ - **`spelling`**: a doubtful syllable is first tried with its neighbors: if fixing its accents makes a dictionary word with the syllables next to it, that word is suggested (*hom nay* → *hôm nay* "today", where *nay* alone would become *này* "this"; *an com* → *ăn cơm*; *Chung toi* → *Chúng tôi*), changing only the doubtful syllables. Less common words written without accents can change the same way when one dictionary word clearly wins (*mua sam* → *mua sắm* "shopping", *nha sach* → *nhà sách*), even common ones when the whole word has no accents (*nha bang* → *nhà băng* "bank"), while a common one left plain next to accented syllables is taken as meant (*tháng sau* stays), *cho con* never becomes *chó con* (not a headword) and runs that could be two words equally are left alone (*ban an*: *bản án* or *bàn ăn*). Otherwise, syllables that aren't in the language get the accented forms they could be (*khong* → *không*); rare plain-letter words next to a far more common accented form too (*toi* → *tôi*, *hom* → *hôm*), and, in a sentence typed without accents, any more common accented form (*an* → *ăn*, *di* → *đi*; not *con* → *còn*, about as common). Names (capitalized mid-sentence), the language's listed pronouns (*tui*) and plain-letter words with no accented form (*email*) are left alone. Languages written in syllables only (Vietnamese).
142
+ - **`foreign-word`**: words in the learner's own language get the target-language word, in the region and register (*market* → *chợ*, *ice cream* → *kem*). Phrases the base dictionary knows are translated as one, falling back to single words. Words the language has no word for are suggested to be left out (`leaveOut` in its checker settings: Vietnamese has no *the*, *a*, *an*). Which language each word is in comes from the two dictionaries: an English word that's Vietnamese only with accents added (*but*: *bút*) is English; Vietnamese typed without accents (*khong*) is Vietnamese; words both languages have go by how common they are in each (*em*, *di*, *tui* are Vietnamese even between English words; *the* is English), and close calls (*an*, *to*) follow their neighbors, leaning to the target language.
143
143
 
144
144
  **Opt-in (`rules`):**
145
145
 
@@ -212,6 +212,8 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
212
212
  - `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
213
213
  - `dictionary.searchEnglish(term, { region?, pos?, exclude?, limit?, allSenses? })`: words for an English term in this language, one per word, best first (no meaning handling: use the translator for that). `allSenses: true` returns every matching sense of those words instead.
214
214
  - `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
215
+ - `dictionary.segment(text)`: the text split into the language's words, longest dictionary match first (up to the checker's `maxWordUnits`, 3 for Vietnamese): `Hôm nay tôi ăn cơm.` → *Hôm nay* · *tôi* · *ăn cơm*, each `{ text, start, end, entries }` (offsets into the NFC text; `entries` empty for words the dictionary doesn't have). Use it to treat a word of several syllables as one, e.g. to show its meaning.
216
+ - `dictionary.variants(word, { limit? })`: other words with the same letters but other accents that the dictionary has, most common first, each `{ word, entries, frequency? }`: *muộn* → *muốn*, *mượn*, *muôn*…; for words of several syllables every syllable varies (*hom nay* → *hôm nay*). For apps that let learners pick the word they meant: a real word with the wrong tone passes the spellchecker. Languages written in syllables only (`[]` otherwise).
215
217
  - `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
216
218
  - `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).
217
219
  - `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
package/dist/check.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
12
12
  // the checks found anything, not that the text is correct.
13
13
  import { createDictionary, resolveRegion } from "./index.js";
14
- import { joined, matchCase, plain, segment, sentencesOf, units } from "./text.js";
14
+ import { accentCombinations, joined, matchCase, plain, segment, sentencesOf, syllablesByPlain, units } from "./text.js";
15
15
  import { markLanguages } from "./detect.js";
16
16
  import { createTranslator } from "./translate.js";
17
17
  import { PRONOUN_PERSONS, } from "./types.js";
@@ -50,19 +50,30 @@ const unique = (items) => [...new Set(items)];
50
50
  // because filled-in sentence frames add accented words), any accented form that's more common
51
51
  // counts ("an" → "ăn"; but not "con" → "còn", about as common). Left alone: capitalized words
52
52
  // mid-sentence (names) and the language's listed pronouns, some written without accents ("tui").
53
+ // First, a doubtful syllable (one of the above) is tried with its neighbors: when fixing its accents makes a
54
+ // dictionary word with them, that word is suggested for the run ("hom nay" → "hôm nay", not "hôm này").
53
55
  // Units inside a known word, or in the learner's own language, aren't checked.
54
56
  // Zipf points by which an accented form must be more common than a plain-letter word to be suggested.
55
57
  const ACCENTS_GAP = 1.5;
56
58
  const ACCENTS_GAP_UNACCENTED_TEXT = 0.2;
59
+ // Accent forms tried per doubtful syllable when looking for a word with its neighbors, and how many
60
+ // combinations are looked up at most.
61
+ const COMPOUND_FORMS_PER_SYLLABLE = 5;
62
+ const COMPOUND_MAX_COMBINATIONS = 64;
63
+ // When none of a run's syllables is doubtful on its own (all real words written without accents, "mua sam"),
64
+ // its accented form is suggested only if one dictionary word clearly wins: ahead of the next by this many
65
+ // Zipf points ("ban an" could be "bản án" or "bàn ăn", equally common, so it's left alone).
66
+ const PLAIN_RUN_MARGIN = 0.5;
67
+ // In a run that has accents elsewhere, only syllables less common than this (Zipf) may change that way: a
68
+ // common word left without accents next to accented ones is usually meant ("tháng sau", next month, isn't
69
+ // "tháng sáu", June). A run with no accents at all ("nha bang") was probably typed without them, so any of
70
+ // its syllables may change ("nhà băng", bank), still only when one word clearly wins.
71
+ const PLAIN_RUN_MAX_ZIPF = 5;
57
72
  async function spelling(ctx) {
58
73
  if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
59
74
  return [];
60
75
  const syllables = await ctx.dict.syllables();
61
- const byPlain = new Map();
62
- for (const s of Object.keys(syllables))
63
- byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
64
- for (const list of byPlain.values())
65
- list.sort((a, b) => syllables[b] - syllables[a]);
76
+ const byPlain = syllablesByPlain(syllables);
66
77
  const issues = [];
67
78
  const suggest = (u, candidates, severity, message) => issues.push({ rule: 'spelling', severity, start: u.start, end: u.end, text: u.text, message, suggestions: candidates.map((c) => matchCase(u.text, c)) });
68
79
  const options = (u, candidates) => listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or');
@@ -70,44 +81,97 @@ async function spelling(ctx) {
70
81
  for (const sentence of ctx.sentences) {
71
82
  const lone = sentence.words.filter((w) => !w.entries.length || w.units.length === 1).flatMap((w) => w.units).filter((u) => !ctx.inBase(u));
72
83
  const target = sentence.units.filter((u) => !ctx.inBase(u) && /\p{L}/u.test(u.text));
73
- const mostlyPlain = target.filter((u) => u.text.toLowerCase() === plain(u.text.toLowerCase())).length >= target.length * 0.5;
74
- let missingAccents = false;
75
- const rare = [];
76
- for (const u of lone) {
77
- const lower = u.text.toLowerCase().replace(/[’']/g, '');
78
- if (/\d/.test(lower))
79
- continue;
80
- const inWord = sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
81
- if (inWord)
84
+ const isPlain = (u) => u.text.toLowerCase() === plain(u.text.toLowerCase());
85
+ const mostlyPlain = target.filter(isPlain).length >= target.length * 0.5;
86
+ const lowerOf = (u) => u.text.toLowerCase().replace(/[’']/g, '');
87
+ const inWord = (u) => sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
88
+ // What each lone unit is: not a syllable of the language (with forms it could be), or a plain-letter
89
+ // syllable that could be missing its accents.
90
+ const checked = lone.filter((u) => !/\d/.test(lowerOf(u)) && !inWord(u));
91
+ const unknown = new Set(checked.filter((u) => !(lowerOf(u) in syllables) && ((byPlain.get(plain(lowerOf(u))) ?? []).length || !isPlain(u))));
92
+ const plainWords = new Set(checked.filter((u) => {
93
+ const lower = lowerOf(u);
94
+ return lower in syllables && lower === plain(lower) && !pronouns.has(lower) && (u === sentence.units[0] || !/\p{Lu}/u.test(u.text.charAt(0)));
95
+ }));
96
+ const missingAccents = [...unknown].some(isPlain);
97
+ // Typed without accents: mostly plain letters, and an accent error already, or 3+ words with no accent at all.
98
+ const unaccented = mostlyPlain && (missingAccents || (target.length >= 3 && target.every(isPlain)));
99
+ const gap = unaccented ? ACCENTS_GAP_UNACCENTED_TEXT : ACCENTS_GAP;
100
+ const betterForms = (u) => {
101
+ const lower = lowerOf(u);
102
+ return (byPlain.get(lower) ?? []).filter((c) => c !== lower && syllables[c] >= syllables[lower] + gap - 1e-9).slice(0, 3);
103
+ };
104
+ const suspect = (u) => unknown.has(u) || (plainWords.has(u) && betterForms(u).length > 0);
105
+ // First, runs of syllables with a doubtful one that make a word once their accents are fixed: "hom nay"
106
+ // → "hôm nay", "an com" → "ăn cơm", "hôm nây" → "hôm nay". The word decides between forms a syllable
107
+ // alone can't ("nay" alone could be này, nảy…). Syllables written without accents may change too
108
+ // ("mua sam" → "mua sắm": "sam" is a word, but "mua sam" isn't, and "mua sắm" is); when none of them is
109
+ // doubtful on its own, only if one word clearly wins. Syllables written with accents never change
110
+ // unless they're doubtful.
111
+ const covered = new Set();
112
+ const loneSet = new Set(checked);
113
+ const maxUnits = ctx.config.maxWordUnits ?? 3;
114
+ for (let i = 0; i < sentence.units.length; i++) {
115
+ for (let k = Math.min(maxUnits, sentence.units.length - i); k >= 2; k--) {
116
+ const span = sentence.units.slice(i, i + k);
117
+ if (span.some((u, j) => !loneSet.has(u) || covered.has(u) || (j > 0 && !joined(ctx.text, span[j - 1], u))))
118
+ continue;
119
+ const doubtful = span.some(suspect);
120
+ const allPlain = span.every(isPlain);
121
+ const rarePlain = (u) => plainWords.has(u) && (allPlain || (syllables[lowerOf(u)] ?? 0) < PLAIN_RUN_MAX_ZIPF);
122
+ const mayChange = (u) => suspect(u) || rarePlain(u);
123
+ if (!doubtful && !span.some(rarePlain))
124
+ continue;
125
+ const written = span.map(lowerOf);
126
+ const combos = accentCombinations(written, byPlain, {
127
+ fixed: span.map((u) => !mayChange(u)),
128
+ perUnit: COMPOUND_FORMS_PER_SYLLABLE,
129
+ max: COMPOUND_MAX_COMBINATIONS,
130
+ }).filter((c) => c !== written.join(' '));
131
+ const found = (await Promise.all(combos.map(async (c) => ({ word: c, entries: await ctx.dict.lookup(c) }))))
132
+ .filter((f) => f.entries.length)
133
+ .map((f) => ({ word: f.word, frequency: Math.max(-1, ...f.entries.map((e) => e.frequency ?? -1)) }))
134
+ .sort((a, b) => b.frequency - a.frequency)
135
+ .slice(0, 3);
136
+ if (!found.length)
137
+ continue;
138
+ if (!doubtful && found.length > 1 && found[0].frequency - found[1].frequency < PLAIN_RUN_MARGIN)
139
+ continue;
140
+ const at = { start: span[0].start, end: span[k - 1].end, text: ctx.text.slice(span[0].start, span[k - 1].end) };
141
+ const suggestions = found.map((f) => matchCase(at.text, f.word));
142
+ const quoted = listOf(suggestions.map(quote), 'or');
143
+ issues.push({
144
+ rule: 'spelling', severity: 'warning', ...at, suggestions,
145
+ message: span.filter(mayChange).every(isPlain)
146
+ ? `${quote(at.text)} looks typed without accents. Did you mean ${quoted}?`
147
+ : `${quote(at.text)} isn't spelled right. Did you mean ${quoted}?`,
148
+ });
149
+ span.forEach((u) => covered.add(u));
150
+ break;
151
+ }
152
+ }
153
+ // Then each remaining syllable on its own.
154
+ for (const u of checked) {
155
+ if (covered.has(u))
82
156
  continue;
83
- if (!(lower in syllables)) {
157
+ const lower = lowerOf(u);
158
+ if (unknown.has(u)) {
84
159
  const candidates = (byPlain.get(plain(lower)) ?? []).slice(0, 3);
85
- const accented = lower !== plain(lower);
86
- if (!candidates.length && !accented)
87
- continue;
88
- if (!accented)
89
- missingAccents = true;
160
+ const accented = !isPlain(u);
90
161
  // Missing accents on plain letters is the common learner slip; a wrong accent is more surely wrong.
91
162
  suggest(u, candidates, accented ? 'error' : 'warning', candidates.length
92
163
  ? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, candidates)}?`
93
164
  : `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`);
94
165
  }
95
- else if (lower === plain(lower) && !pronouns.has(lower) && (u === sentence.units[0] || !/\p{Lu}/u.test(u.text.charAt(0)))) {
96
- rare.push(u);
166
+ else if (plainWords.has(u)) {
167
+ const better = betterForms(u);
168
+ if (!better.length)
169
+ continue;
170
+ suggest(u, better, 'warning', unaccented
171
+ ? `${quote(u.text)} looks typed without accents. Did you mean ${options(u, better)}?`
172
+ : `${quote(u.text)} is a rare word; did you mean ${options(u, better)}?`);
97
173
  }
98
174
  }
99
- // Typed without accents: mostly plain letters, and an accent error already, or 3+ words with no accent at all.
100
- const unaccented = mostlyPlain && (missingAccents || (target.length >= 3 && target.every((u) => u.text.toLowerCase() === plain(u.text.toLowerCase()))));
101
- for (const u of rare) {
102
- const lower = u.text.toLowerCase();
103
- const gap = unaccented ? ACCENTS_GAP_UNACCENTED_TEXT : ACCENTS_GAP;
104
- const better = (byPlain.get(lower) ?? []).filter((c) => c !== lower && syllables[c] >= syllables[lower] + gap - 1e-9).slice(0, 3);
105
- if (!better.length)
106
- continue;
107
- suggest(u, better, 'warning', unaccented
108
- ? `${quote(u.text)} looks typed without accents. Did you mean ${options(u, better)}?`
109
- : `${quote(u.text)} is a rare word; did you mean ${options(u, better)}?`);
110
- }
111
175
  }
112
176
  return issues;
113
177
  }
package/dist/detect.js CHANGED
@@ -3,15 +3,18 @@
3
3
  import { plain } from "./text.js";
4
4
  /**
5
5
  * Marks each unit (of one sentence) with `lang` or `base`. A unit the target knows and the base doesn't
6
- * is the target's, and the other way round; units both or neither know ("an", "to", names) take the
7
- * nearest decided neighbors' language when they agree. When they don't, the target's, unless the word is
8
- * far more common in the base (the text is mainly the target language: Vietnamese "an" stays Vietnamese
9
- * next to English "the", and "tui" between "but" and "không"). In languages written in syllables, a base
6
+ * is the target's, and the other way round. A unit both know ("em", "an", "to") goes by how common it is
7
+ * in each: clearly more common in the target, the target's (Vietnamese "em", "di" next to English
8
+ * "hello", "shopping": English has rare "em", "di"); far more common in the base, the base's ("the");
9
+ * otherwise, and for units neither knows (names), the nearest decided neighbors' language when they agree,
10
+ * else the target's (the text is mainly the target language: "an" next to English "the"). In languages written in syllables, a base
10
11
  * word that's a target syllable only once accents are added ("but": bút) is the base's; a word only the
11
12
  * target knows without its accents ("khong") is still the target's. The base is known through the translator, so
12
13
  * inflected forms count ("went", "cooked").
13
14
  */
14
- // Zipf points by which a word between the two languages must be more common in the base to count as base.
15
+ // Zipf points by which a word both languages know must be more common in one to count as that language's
16
+ // whatever its neighbors: less for the target, since the text is mainly the target language.
17
+ const TARGET_MARGIN = 1;
15
18
  const BASE_MARGIN = 2;
16
19
  export async function markLanguages(list, o) {
17
20
  const syllables = await o.target.syllables();
@@ -32,7 +35,7 @@ export async function markLanguages(list, o) {
32
35
  return '?';
33
36
  const [t, b] = await Promise.all([targetKnows(u), baseKnows(u)]);
34
37
  if (t === 'exact')
35
- return b ? '?' : o.lang;
38
+ return b ? 'both' : o.lang;
36
39
  if (b)
37
40
  return o.base;
38
41
  return t === 'accents' ? o.lang : '?';
@@ -45,14 +48,18 @@ export async function markLanguages(list, o) {
45
48
  return (await o.target.lookup(lower))[0]?.frequency ?? 0;
46
49
  };
47
50
  const baseFrequency = async (u) => (await o.baseDictionary.lookup(u.text.toLowerCase()))[0]?.frequency ?? 0;
48
- return Promise.all(marks.map(async (m, i) => {
49
- if (m !== '?')
51
+ // Words both languages know, decided by frequency when it's clear.
52
+ const decided = await Promise.all(marks.map(async (m, i) => {
53
+ if (m !== 'both')
50
54
  return m;
51
- const left = marks.slice(0, i).reverse().find((x) => x !== '?');
52
- const right = marks.slice(i + 1).find((x) => x !== '?');
53
- if (left && right && left !== right) {
54
- return (await baseFrequency(list[i])) - (await targetFrequency(list[i])) >= BASE_MARGIN ? o.base : o.lang;
55
- }
56
- return left ?? right ?? o.lang;
55
+ const [t, b] = await Promise.all([targetFrequency(list[i]), baseFrequency(list[i])]);
56
+ return t - b >= TARGET_MARGIN ? o.lang : b - t >= BASE_MARGIN ? o.base : '?';
57
57
  }));
58
+ return decided.map((m, i) => {
59
+ if (m !== '?')
60
+ return m;
61
+ const left = decided.slice(0, i).reverse().find((x) => x !== '?');
62
+ const right = decided.slice(i + 1).find((x) => x !== '?');
63
+ return left && right && left !== right ? o.lang : left ?? right ?? o.lang;
64
+ });
58
65
  }
package/dist/index.d.ts CHANGED
@@ -49,6 +49,22 @@ export type PronounOptions = {
49
49
  /** Labels to leave out. Defaults to DEFAULT_EXCLUDED_LABELS; pass [] to include everything. */
50
50
  exclude?: string[];
51
51
  };
52
+ /** One word of a text, as the dictionary splits it (see Dictionary.segment). */
53
+ export type WordSegment = {
54
+ /** The word as written; offsets are into the text in Unicode NFC form. */
55
+ text: string;
56
+ start: number;
57
+ end: number;
58
+ /** The word's dictionary entries; empty for words the dictionary doesn't have (names, typos). */
59
+ entries: Entry[];
60
+ };
61
+ /** Another way to write a word with the same letters (see Dictionary.variants). */
62
+ export type WordVariant = {
63
+ word: string;
64
+ entries: Entry[];
65
+ /** Zipf frequency (see Entry.frequency), or the syllable's for one syllable; undefined when unknown. */
66
+ frequency?: number;
67
+ };
52
68
  export type Dictionary = {
53
69
  /** The language's name, regions, source, license and counts. */
54
70
  meta(): Promise<LanguageMeta>;
@@ -73,6 +89,22 @@ export type Dictionary = {
73
89
  syllables(): Promise<Record<string, number>>;
74
90
  /** The language's sentence frames and frame words, unfilled (see FramesData); empty without frames. */
75
91
  frames(): Promise<FramesData>;
92
+ /**
93
+ * The text split into the language's words: at each point, the longest run of units (syllables or
94
+ * words, up to the checker's `maxWordUnits`, default 3) that's a headword, so Vietnamese "hôm nay" is one
95
+ * word ("today"), not "hôm" + "nay". Units join only across spaces. Units the dictionary doesn't know
96
+ * are words of their own with no entries. Punctuation isn't returned.
97
+ */
98
+ segment(text: string): Promise<WordSegment[]>;
99
+ /**
100
+ * Other words written with the same letters but other accents, that the dictionary has, most common
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.
104
+ */
105
+ variants(word: string, options?: {
106
+ limit?: number;
107
+ }): Promise<WordVariant[]>;
76
108
  };
77
109
  /**
78
110
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PRONOUN_PERSONS, fromStored, normalizeEnglish, shardKey, } from "./types.js";
2
+ import { accentCombinations, segment as segmentUnits, syllablesByPlain, units } from "./text.js";
2
3
  export * from "./types.js";
3
4
  export * from "./pos.js";
4
5
  /**
@@ -14,6 +15,11 @@ export const DEFAULT_EXCLUDED_LABELS = [
14
15
  // just a particular register); the second set costs more (specialized, playful or uncommon).
15
16
  const MILD_LABELS = new Set(['colloquial', 'informal', 'familiar', 'polite', 'formal', 'endearing', 'honorific']);
16
17
  const LABEL_PENALTY = (h) => h.labels.reduce((sum, l) => sum + (MILD_LABELS.has(l) ? 1 : 2), 0);
18
+ // Accent forms tried per syllable: more for one syllable, fewer for longer words, where every
19
+ // combination is looked up (at most MAX_VARIANT_COMBINATIONS).
20
+ const VARIANT_FORMS_ONE_SYLLABLE = 12;
21
+ const VARIANT_FORMS_PER_SYLLABLE = 5;
22
+ const MAX_VARIANT_COMBINATIONS = 64;
17
23
  /**
18
24
  * The regions a region or region-group name stands for (e.g. English "North America" -> US, Canada),
19
25
  * or null when no region is given. Throws for names the language doesn't have, listing the valid ones.
@@ -63,13 +69,52 @@ export function createDictionary(options) {
63
69
  const key = shardKey(term, m.shardLength);
64
70
  return m.shards[kind].includes(key) ? loadOnce(`${kind}/${key}.json`) : null;
65
71
  }
72
+ async function lookup(word) {
73
+ const w = word.trim();
74
+ const [m, data] = await Promise.all([meta(), shard('words', w)]);
75
+ const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? [];
76
+ return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
77
+ }
78
+ const checker = async () => {
79
+ const m = await meta();
80
+ return m.checker ? loadOnce('checker.json') : {};
81
+ };
82
+ const syllables = async () => {
83
+ const m = await meta();
84
+ return m.syllables ? loadOnce('syllables.json') : {};
85
+ };
66
86
  return {
67
87
  meta,
68
- async lookup(word) {
69
- const w = word.trim();
70
- const [m, data] = await Promise.all([meta(), shard('words', w)]);
71
- const stored = data?.[w] ?? data?.[w.toLowerCase()] ?? [];
72
- return stored.map((e) => ({ ...e, senses: e.senses.map((s) => fromStored(s, m.regions)) }));
88
+ lookup,
89
+ checker,
90
+ syllables,
91
+ async segment(input) {
92
+ const text = input.normalize('NFC');
93
+ const config = await checker();
94
+ const words = await segmentUnits(text, units(text), config.maxWordUnits ?? 3, lookup);
95
+ return words.map(({ text: t, start, end, entries }) => ({ text: t, start, end, entries }));
96
+ },
97
+ async variants(word, { limit = 5 } = {}) {
98
+ const list = await syllables();
99
+ if (!Object.keys(list).length)
100
+ return [];
101
+ const written = word.trim().normalize('NFC').toLowerCase();
102
+ const parts = written.split(/\s+/);
103
+ const combos = accentCombinations(parts, syllablesByPlain(list), {
104
+ perUnit: parts.length === 1 ? VARIANT_FORMS_ONE_SYLLABLE : VARIANT_FORMS_PER_SYLLABLE,
105
+ max: MAX_VARIANT_COMBINATIONS,
106
+ }).filter((c) => c !== written);
107
+ const found = await Promise.all(combos.map(async (c) => ({ word: c, entries: await lookup(c) })));
108
+ const frequency = (v) => {
109
+ const known = v.entries.map((e) => e.frequency).filter((f) => f !== undefined);
110
+ return known.length ? Math.max(...known) : parts.length === 1 ? list[v.word] || undefined : undefined;
111
+ };
112
+ // Most common first; ties (and unknown frequencies) keep the order of more common syllable forms.
113
+ return found
114
+ .filter((v) => v.entries.length)
115
+ .map((v) => ({ ...v, frequency: frequency(v) }))
116
+ .sort((a, b) => (b.frequency ?? -1) - (a.frequency ?? -1))
117
+ .slice(0, limit);
73
118
  },
74
119
  async searchEnglish(term, { region, pos, exclude = DEFAULT_EXCLUDED_LABELS, limit = 10, allSenses = false } = {}) {
75
120
  const key = normalizeEnglish(term);
@@ -107,14 +152,6 @@ export function createDictionary(options) {
107
152
  const m = await meta();
108
153
  return m.picks ? loadOnce('picks.json') : [];
109
154
  },
110
- async checker() {
111
- const m = await meta();
112
- return m.checker ? loadOnce('checker.json') : {};
113
- },
114
- async syllables() {
115
- const m = await meta();
116
- return m.syllables ? loadOnce('syllables.json') : {};
117
- },
118
155
  async frames() {
119
156
  const m = await meta();
120
157
  if (!m.frames)
package/dist/text.d.ts CHANGED
@@ -29,3 +29,16 @@ export declare function segment(text: string, list: Unit[], maxUnits: number, lo
29
29
  export declare const plain: (s: string) => string;
30
30
  /** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
31
31
  export declare const matchCase: (original: string, replacement: string) => string;
32
+ /** A language's syllables grouped by their form without accents, most common first. */
33
+ export declare function syllablesByPlain(syllables: Record<string, number>): Map<string, string[]>;
34
+ /**
35
+ * Ways to write a run of syllables with other accents ("hom nay" → "hôm nay", "hơm nay", …): each
36
+ * syllable's forms that share its letters (the most common `perUnit`, plus the syllable as written), in
37
+ * every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
38
+ * change (`fixed[i]`) keep their written form. The written run itself is included.
39
+ */
40
+ export declare function accentCombinations(written: string[], byPlain: Map<string, string[]>, { fixed, perUnit, max }: {
41
+ fixed?: boolean[];
42
+ perUnit: number;
43
+ max: number;
44
+ }): string[];
package/dist/text.js CHANGED
@@ -55,3 +55,43 @@ export async function segment(text, list, maxUnits, lookup) {
55
55
  export const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
56
56
  /** Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con"). */
57
57
  export const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
58
+ // Syllables grouped by their plain form, most common first: "khong" → ["không", "khống", …]. Built once
59
+ // per syllable list.
60
+ const byPlainCache = new WeakMap();
61
+ /** A language's syllables grouped by their form without accents, most common first. */
62
+ export function syllablesByPlain(syllables) {
63
+ let byPlain = byPlainCache.get(syllables);
64
+ if (!byPlain) {
65
+ byPlain = new Map();
66
+ for (const s of Object.keys(syllables))
67
+ byPlain.set(plain(s), [...(byPlain.get(plain(s)) ?? []), s]);
68
+ for (const list of byPlain.values())
69
+ list.sort((a, b) => syllables[b] - syllables[a]);
70
+ byPlainCache.set(syllables, byPlain);
71
+ }
72
+ return byPlain;
73
+ }
74
+ /**
75
+ * Ways to write a run of syllables with other accents ("hom nay" → "hôm nay", "hơm nay", …): each
76
+ * syllable's forms that share its letters (the most common `perUnit`, plus the syllable as written), in
77
+ * every combination, the ones made of more common forms first, at most `max`. Syllables that mustn't
78
+ * change (`fixed[i]`) keep their written form. The written run itself is included.
79
+ */
80
+ export function accentCombinations(written, byPlain, { fixed = [], perUnit, max }) {
81
+ const choices = written.map((w, i) => {
82
+ const lower = w.toLowerCase();
83
+ if (fixed[i])
84
+ return [lower];
85
+ const forms = (byPlain.get(plain(lower)) ?? []).slice(0, perUnit);
86
+ return forms.includes(lower) ? forms : [...forms, lower];
87
+ });
88
+ // Every combination with the sum of its forms' ranks: lower sums are made of more common forms.
89
+ let combos = [{ words: [], rank: 0 }];
90
+ for (const options of choices) {
91
+ combos = combos.flatMap((c) => options.map((o, r) => ({ words: [...c.words, o], rank: c.rank + r })));
92
+ }
93
+ return combos
94
+ .sort((a, b) => a.rank - b.rank)
95
+ .slice(0, max)
96
+ .map((c) => c.words.join(' '));
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Translate words between languages and dialects, meaning by meaning, starting with Vietnamese (Northern, Central, Southern) and English, with Wiktionary data loaded on demand. More languages are planned.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",