which-dialect 0.5.8 → 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/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
@@ -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,5 +1,5 @@
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
  /**
@@ -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/types.d.ts CHANGED
@@ -101,10 +101,11 @@ export type LanguageMeta = {
101
101
  regionTaggedSenses: number;
102
102
  englishTerms: number;
103
103
  };
104
- /** 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. */
105
105
  shards: {
106
106
  words: string[];
107
107
  en: string[];
108
+ forms?: string[];
108
109
  };
109
110
  /** Letters per shard key; see shardKey. Omitted means 2. */
110
111
  shardLength?: number;
@@ -118,6 +119,16 @@ export type LanguageMeta = {
118
119
  syllables?: boolean;
119
120
  /** true when the language has sentence frames (frames.json, see FramesData). */
120
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
+ };
121
132
  };
122
133
  /**
123
134
  * Pronoun slots in sentence frames ({I}, {YOU}, …) and the pronoun-table column each one fills from, for
@@ -308,6 +319,14 @@ export type StoredHit = Omit<Hit, 'regions' | 'regionTagged' | 'labels'> & {
308
319
  };
309
320
  /** words/<shard>.json: entries keyed by headword. */
310
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[]>;
311
330
  /** en/<shard>.json: hits keyed by lowercase English term. */
312
331
  export type EnglishShard = Record<string, StoredHit[]>;
313
332
  /** Converts a full sense or hit to its stored form. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "which-dialect",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
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",