which-dialect 0.2.0 → 0.4.0
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 +76 -20
- package/dist/check.d.ts +28 -3
- package/dist/check.js +189 -80
- package/dist/detect.d.ts +10 -0
- package/dist/detect.js +58 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +14 -0
- package/dist/phrasebook.d.ts +82 -0
- package/dist/phrasebook.js +254 -0
- package/dist/review.d.ts +59 -0
- package/dist/review.js +175 -0
- package/dist/text.d.ts +31 -0
- package/dist/text.js +57 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,43 +111,97 @@ The cases involving Spanish were removed with it. The held-out set has since bee
|
|
|
111
111
|
|
|
112
112
|
### `createChecker({ baseUrl?, load?, dictionary? })` → `checker.check(text, options)`
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
A spellchecker for learners: it gives a correction for every word it can, with the exact place in the text and a short reason. Words written in the learner's own language (English by default) get the target-language word. Further checks (regions, pronouns, politeness) can be turned on. It runs in the browser like the rest of the library: no API calls, no cost.
|
|
115
115
|
|
|
116
116
|
```ts
|
|
117
117
|
import { createChecker } from 'which-dialect'
|
|
118
118
|
|
|
119
119
|
const checker = createChecker()
|
|
120
|
-
const { text, issues } = await checker.check('
|
|
121
|
-
lang: 'vi', region: 'Southern',
|
|
120
|
+
const { text, issues, parts } = await checker.check('Toi muon an the ice cream but it was very expensive.', {
|
|
121
|
+
lang: 'vi', region: 'Southern',
|
|
122
122
|
})
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// “khong”: Talking to your parents, end the sentence with “ạ” to sound polite. (suggestion)
|
|
123
|
+
// Toi → Tôi · muon → muốn · an → ăn (accents)
|
|
124
|
+
// the → (left out: Vietnamese has no "the") · ice cream → kem · but → nhưng · it → nó
|
|
125
|
+
// was → là · very → rất · expensive → mắc (English words, in Southern Vietnamese)
|
|
127
126
|
```
|
|
128
127
|
|
|
129
128
|
| Option | Meaning |
|
|
130
129
|
|---|---|
|
|
131
|
-
| `lang` | The text's language. |
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
130
|
+
| `lang` | The text's language (the target). |
|
|
131
|
+
| `base` | The learner's own language, whose words get translated (default `'en'`). |
|
|
132
|
+
| `region` | The region's words are used for translations (*expensive* → Southern *mắc*, Northern *đắt*), and the `dialect` check, when on, flags other regions' words. |
|
|
133
|
+
| `register` | `'casual'` allows colloquial translations; otherwise plain ones come first. |
|
|
134
|
+
| `rules` | Turn checks on or off. By default only `spelling` and `foreign-word` run; e.g. `{ dialect: true, 'pronoun-relationship': true }`. |
|
|
135
|
+
| `listener`, `about`, `speaker` | For the pronoun and polite-ending checks: who you're talking to and about (pronoun-table row ids), and the speaker's gender. |
|
|
137
136
|
|
|
138
|
-
Each issue has `rule`, `severity` (`'error'`: not a word of the language; `'warning'`: wrong for the
|
|
137
|
+
The result has `text` (the input in Unicode NFC form), `issues` and `parts` (the text split by language). Each issue has `rule`, `severity` (`'error'`: not a word of the language; `'warning'`: probably wrong, or wrong for the region or relationship; `'suggestion'`: often better), `start`/`end` (offsets into `text`), `text`, `message` and `suggestions` (replacements for `text`, best first; `''` means leave it out).
|
|
139
138
|
|
|
140
|
-
|
|
139
|
+
**On by default (the spellchecker):**
|
|
141
140
|
|
|
142
|
-
- **`spelling`**: syllables that aren't in the language
|
|
143
|
-
- **`
|
|
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.
|
|
143
|
+
|
|
144
|
+
**Opt-in (`rules`):**
|
|
145
|
+
|
|
146
|
+
- **`dialect`**: words whose main meaning is tagged for another region (*lợn* in Southern text → *heo*; *muỗng* in Northern → *thìa*; in English, US *apartment* in UK text → *flat*). Without `region`, words that don't fit the rest of the text's region are pointed out. Words with an untagged main meaning are never flagged (*má* is "cheek" everywhere).
|
|
144
147
|
- **`pronoun-relationship`**: pronouns that don't fit the listener (*tôi* to a parent → *con*; to a teacher → *em*).
|
|
145
148
|
- **`pronoun-pair`**: "I" and "you" pronouns that never go together (*tao* goes with *mày*, not *bạn*), when no listener is given.
|
|
149
|
+
- **`pronoun-consistency`**: switching words for the same person within one text (*tôi* in one sentence, *tui* in the next): keep the first.
|
|
146
150
|
- **`polite-ending`**: a suggestion to end sentences with *ạ* when talking to parents, elders and teachers (not for exclamations).
|
|
147
151
|
|
|
148
|
-
|
|
152
|
+
Corrections are word by word: they don't fix word order or grammar, so English translated word by word reads like English in Vietnamese words (*it was very expensive* → *nó là rất mắc*). Whole English sentences or clauses that follow a [sentence frame](#sentence-frames) get the frame's natural wording instead in the [journal review](#reviewing-journal-entries). The checker only speaks up when it's confident, so **no issues means none of the checks found anything, not that the text is correct**.
|
|
153
|
+
|
|
154
|
+
**Every language uses the same checks.** They read only the language's data and its `checker` settings in `languages/<code>.ts` (how text splits into words, which pronouns are reliable, polite endings, words with no equivalent), so a check whose data a language doesn't have is skipped. A language without checker settings is read as space-separated words: English words in it are still translated, and the `dialect` check works.
|
|
155
|
+
|
|
156
|
+
## Sentence frames
|
|
157
|
+
|
|
158
|
+
Common sentences with slots, filled for a region, a listener and a register: *Where is {place}?* is *{place} ở **đâu**?*, and in Central Vietnamese *{place} ở **mô**?*; *Have you eaten yet?* to a parent in the South is *Ba ăn cơm chưa ạ?*, to a friend *Bạn ăn cơm chưa?*.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { createPhrasebook } from 'which-dialect'
|
|
162
|
+
|
|
163
|
+
const pb = createPhrasebook()
|
|
164
|
+
await pb.frames({ lang: 'vi', region: 'Southern', topic: 'questions' }) // every frame, slots open
|
|
165
|
+
await pb.render('ask-where', { lang: 'vi', region: 'Central', slots: { place: 'bathroom' } })
|
|
166
|
+
// → { text: 'Phòng tắm ở mô?', parts: [{ text: 'Phòng tắm', kind: 'content', from: 'bathroom' }, …,
|
|
167
|
+
// { text: 'mô', kind: 'word', why: 'where (Central)' }, …], complete: true }
|
|
168
|
+
await pb.match('Do you want coffee?', { lang: 'vi' }) // → 'Bạn có muốn cà phê không?'
|
|
169
|
+
await pb.matchFrames('Bạn có đi?', { lang: 'vi' }) // → 'Bạn có đi không?' (missing "không")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Options: `lang`, `region`, `listener` (a pronoun-table row id; without it, the neutral default row), `speaker`, `register` (`'casual'` allows colloquial words like Southern *hông*, *tui*). Content slots take English (translated for you, in the region) or `{ text }` in the language itself. `parts` says why each word was chosen, for highlighting. `matchFrames` recognizes a frame in a sentence in the language itself (the journal review uses it to list the frames a sentence follows) and returns the changes the frame would suggest: a missing required word (*không*), a word from another region (*mô* in Southern text → *đâu*), missing accents in a frame position (*Toi* → *Tôi*), and, only when a listener is given, a pronoun that doesn't fit them.
|
|
173
|
+
|
|
174
|
+
The frames themselves are written once, in English, in [`languages/frames.ts`](languages/frames.ts) (34 so far: journal sentences, feelings, questions, requests, greetings). Each language says how it expresses them (`frames` in `languages/<code>.ts`), with pronoun slots (`{I}`, `{YOU}`, `{WE_INCL}`, …, filled from the pronoun table) and **frame words** for what changes by region (`frameWords`: Vietnamese `{WHERE}` *đâu* / Central *mô*, `{HOW}` Northern *thế nào* / *sao* / Central *răng*, `{SOFT}` Northern *nhé* / Central and Southern *nha*, `{Q_END}` *không* / Southern casual *hông*, `{POLITE}` *ạ* when speaking up). Each frame word comes from a dictionary definition, keeping its region and labels, or from an override with a note, like the pronoun table. A language without frames returns none.
|
|
175
|
+
|
|
176
|
+
## Reviewing journal entries
|
|
177
|
+
|
|
178
|
+
`createReviewer().review(entry, { lang, base?, region?, register?, checks?, listener?, speaker? })` reviews an entry written in the target language, the learner's own language (`base`, default `'en'`), or a mix of both, and returns the corrected entry, in the target language, sentence by sentence:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import { createReviewer } from 'which-dialect'
|
|
182
|
+
|
|
183
|
+
const review = await createReviewer().review(
|
|
184
|
+
'Hôm nay tôi đi market với má. Tôi muốn ăn thịt lợn but it was expensive. Ngày mai tui đi lại khong?',
|
|
185
|
+
{ lang: 'vi', region: 'Southern' },
|
|
186
|
+
)
|
|
187
|
+
review.corrected
|
|
188
|
+
// 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt lợn nhưng mắc quá. Ngày mai tui đi lại không?'
|
|
189
|
+
review.sentences[1].changes
|
|
190
|
+
// [{ from: 'but it was expensive', to: 'nhưng mắc quá', kind: 'frame', why: 'Sentence frame “but it was {quality}”' }]
|
|
191
|
+
|
|
192
|
+
// With the regional and consistency checks on:
|
|
193
|
+
await createReviewer().review(entry, { lang: 'vi', region: 'Southern', checks: { dialect: true, 'pronoun-consistency': true } })
|
|
194
|
+
// 'Hôm nay tôi đi chợ với má. Tôi muốn ăn thịt heo nhưng mắc quá. Ngày mai tôi đi lại không?'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
For each sentence:
|
|
198
|
+
|
|
199
|
+
1. **Each word is marked as the target or the base language** (`parts`), as in the checker's `foreign-word` check.
|
|
200
|
+
2. **A whole base-language sentence or clause that follows a sentence frame is filled in** with the frame's wording (*but it was expensive* → *nhưng mắc quá*; *Where is the bathroom?* → *Phòng tắm ở đâu?*).
|
|
201
|
+
3. **The checker runs on the whole entry**: by default the spellchecker, so every remaining English word gets its translation and every misspelled word its accents; `checks` turns on more (regions, pronouns, consistency across sentences, polite endings). Its errors and warnings are applied (`changes`); its suggestions, like ending with *ạ*, are returned as `hints`.
|
|
202
|
+
4. **The frames the corrected sentence follows are listed** (`frames`), for suggesting patterns to reuse. Frames don't change the sentence.
|
|
149
203
|
|
|
150
|
-
|
|
204
|
+
Each sentence has `original`, `parts`, `corrected`, `changes` (`{ from, to, why, kind }`; `to` is `''` when a word is left out), `hints`, `frames` (`{ id, en, text }`, the frame's English and its pattern) and `unchecked` (English words with no translation, left as written). For a journal, leave `listener` out: people written about (*má*, *thầy*) aren't treated as the listener. Corrections are word by word except for frames: word order and grammar aren't fixed (*I am very happy* → *Tôi là rất mừng*).
|
|
151
205
|
|
|
152
206
|
## Looking words up
|
|
153
207
|
|
|
@@ -157,6 +211,7 @@ One language's dictionary. `baseUrl` defaults to `https://cdn.jsdelivr.net/npm/w
|
|
|
157
211
|
|
|
158
212
|
- `dictionary.lookup(word)`: all entries for a word, with every sense's definitions, regions, labels, synonyms and variant links (`altOf`).
|
|
159
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
|
+
- `dictionary.frames()`: the language's sentence frames and frame words, unfilled (`{ frames, words }`; empty without frames).
|
|
160
215
|
- `dictionary.checker()` / `dictionary.syllables()`: the language's checker settings and syllable list (with frequencies), used by `createChecker` (`{}` for languages without them).
|
|
161
216
|
- `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).
|
|
162
217
|
- `dictionary.meta()`: the language's name, regions, region groups, source, license, build date and counts.
|
|
@@ -255,7 +310,8 @@ Generated data isn't committed (it would bloat git history); it's built before p
|
|
|
255
310
|
3. Add the language's code to `translationLangs` in `languages/en.ts` (so English keeps its translation tables for it) and rebuild English.
|
|
256
311
|
4. `npm run build:data -- <code>`, check the results, add a few real-data tests and evaluation cases, and publish.
|
|
257
312
|
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`. Without them, the language is read as space-separated words and gets the region check.
|
|
258
|
-
6. Optional
|
|
313
|
+
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.
|
|
314
|
+
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`.
|
|
259
315
|
|
|
260
316
|
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.
|
|
261
317
|
|
package/dist/check.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { type TranslatorOptions } from './translate.ts';
|
|
2
|
-
export type CheckRule = 'spelling' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'polite-ending';
|
|
2
|
+
export type CheckRule = 'spelling' | 'foreign-word' | 'dialect' | 'pronoun-relationship' | 'pronoun-pair' | 'pronoun-consistency' | 'polite-ending';
|
|
3
|
+
/**
|
|
4
|
+
* Which checks run unless `rules` says otherwise: the spellchecker (spelling, and words in the learner's
|
|
5
|
+
* own language, with their translation). The rest are opt-in, e.g. `rules: { dialect: true }`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_CHECK_RULES: Record<CheckRule, boolean>;
|
|
3
8
|
export type CheckIssue = {
|
|
4
9
|
rule: CheckRule;
|
|
5
10
|
/**
|
|
@@ -20,15 +25,28 @@ export type CheckIssue = {
|
|
|
20
25
|
export type CheckOptions = {
|
|
21
26
|
/** Language code of the text, e.g. "vi". */
|
|
22
27
|
lang: string;
|
|
23
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The learner's own language, whose words in the text are translated (`foreign-word`). Default "en";
|
|
30
|
+
* none when it's the text's language.
|
|
31
|
+
*/
|
|
32
|
+
base?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Region or region group the text should be in, e.g. "Southern": translations use its words, and the
|
|
35
|
+
* `dialect` check flags other regions' words (without it, mixed regions).
|
|
36
|
+
*/
|
|
24
37
|
region?: string;
|
|
38
|
+
/** 'casual' allows colloquial translations (Southern "tui"); otherwise plain ones come first. */
|
|
39
|
+
register?: 'casual' | 'neutral' | 'polite';
|
|
25
40
|
/** Who you're talking to, as a pronoun-table row id (see Dictionary.pronouns), e.g. "parent". */
|
|
26
41
|
listener?: string;
|
|
27
42
|
/** Who you're talking about, as a pronoun-table row id, for "he/she" and "they" pronouns. */
|
|
28
43
|
about?: string;
|
|
29
44
|
/** The speaker's gender, for pronouns that depend on it. */
|
|
30
45
|
speaker?: 'male' | 'female';
|
|
31
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Turn checks on or off. By default only the spellchecker runs (DEFAULT_CHECK_RULES: `spelling` and
|
|
48
|
+
* `foreign-word`); opt in to others, e.g. { dialect: true, 'pronoun-relationship': true }.
|
|
49
|
+
*/
|
|
32
50
|
rules?: Partial<Record<CheckRule, boolean>>;
|
|
33
51
|
};
|
|
34
52
|
export type CheckResult = {
|
|
@@ -36,6 +54,13 @@ export type CheckResult = {
|
|
|
36
54
|
text: string;
|
|
37
55
|
/** Sorted by position. */
|
|
38
56
|
issues: CheckIssue[];
|
|
57
|
+
/** The text split by language (the target and the base), in order; one part when no base is given. */
|
|
58
|
+
parts: {
|
|
59
|
+
text: string;
|
|
60
|
+
lang: string;
|
|
61
|
+
start: number;
|
|
62
|
+
end: number;
|
|
63
|
+
}[];
|
|
39
64
|
};
|
|
40
65
|
export type Checker = {
|
|
41
66
|
check(text: string, options: CheckOptions): Promise<CheckResult>;
|
package/dist/check.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
|
-
// Checking text in one language
|
|
2
|
-
//
|
|
1
|
+
// Checking text in one language. By default it's a spellchecker that gives a correction for every word it
|
|
2
|
+
// can: syllables that aren't in the language get their accented forms ("khong" → "không"), and words in the
|
|
3
|
+
// learner's own language (English by default) get the target-language word ("market" → "chợ"). Further
|
|
4
|
+
// checks are opt-in (`rules`): words from another region, pronouns and polite endings that fit who you're
|
|
5
|
+
// talking to, and consistency within the text.
|
|
3
6
|
//
|
|
4
7
|
// Every rule reads only the language's data: its dictionary, pronoun table, checker.json and
|
|
5
8
|
// syllables.json (see CheckerConfig). Adding a language means adding data and settings in
|
|
6
|
-
// languages/<lang>.ts, not code here. A rule whose data a language doesn't have is skipped
|
|
7
|
-
// without checker settings is still checked for words from another region.
|
|
9
|
+
// languages/<lang>.ts, not code here. A rule whose data a language doesn't have is skipped.
|
|
8
10
|
//
|
|
9
11
|
// The checker only speaks up when it's confident and stays silent otherwise, so no issues means none of
|
|
10
12
|
// the checks found anything, not that the text is correct.
|
|
11
13
|
import { createDictionary, resolveRegion } from "./index.js";
|
|
14
|
+
import { joined, matchCase, plain, segment, sentencesOf, units } from "./text.js";
|
|
15
|
+
import { markLanguages } from "./detect.js";
|
|
12
16
|
import { createTranslator } from "./translate.js";
|
|
13
17
|
import { PRONOUN_PERSONS, } from "./types.js";
|
|
18
|
+
/**
|
|
19
|
+
* Which checks run unless `rules` says otherwise: the spellchecker (spelling, and words in the learner's
|
|
20
|
+
* own language, with their translation). The rest are opt-in, e.g. `rules: { dialect: true }`.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_CHECK_RULES = {
|
|
23
|
+
spelling: true,
|
|
24
|
+
'foreign-word': true,
|
|
25
|
+
dialect: false,
|
|
26
|
+
'pronoun-relationship': false,
|
|
27
|
+
'pronoun-pair': false,
|
|
28
|
+
'pronoun-consistency': false,
|
|
29
|
+
'polite-ending': false,
|
|
30
|
+
};
|
|
14
31
|
// How each pronoun-table column is named in messages.
|
|
15
32
|
const PERSON_NAMES = {
|
|
16
33
|
self: 'I', addressee: 'you', third: 'he/she', selfPlural: 'we', addresseePlural: 'you (plural)', thirdPlural: 'they',
|
|
@@ -19,65 +36,24 @@ const PERSON_NAMES = {
|
|
|
19
36
|
const ABOUT_PERSONS = new Set(['third', 'thirdPlural']);
|
|
20
37
|
const SELF_PERSONS = new Set(['self', 'selfPlural']);
|
|
21
38
|
const ADDRESSEE_PERSONS = new Set(['addressee', 'addresseePlural']);
|
|
22
|
-
// A run of letters (with their accents), digits and apostrophes: a word, or a syllable in syllable languages.
|
|
23
|
-
const UNIT = /[\p{L}\p{M}\p{N}'’]+/gu;
|
|
24
|
-
const SENTENCE_END = /[.!?…]+|\n+/g;
|
|
25
39
|
const quote = (s) => `“${s}”`;
|
|
26
40
|
const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
27
41
|
const listOf = (items, conjunction = 'and') => items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
|
|
28
|
-
// Keeps a capital first letter when replacing a capitalized word ("Tôi" → "Con").
|
|
29
|
-
const matchCase = (original, replacement) => original.charAt(0) !== original.charAt(0).toLowerCase() ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
|
|
30
|
-
// Without accents, for finding the accented forms of a syllable: "không" → "khong", "đi" → "di".
|
|
31
|
-
const plain = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D').normalize('NFC');
|
|
32
42
|
const unique = (items) => [...new Set(items)];
|
|
33
|
-
function units(text) {
|
|
34
|
-
return [...text.matchAll(UNIT)].map((m) => ({ text: m[0], start: m.index, end: m.index + m[0].length }));
|
|
35
|
-
}
|
|
36
|
-
function sentencesOf(text, all) {
|
|
37
|
-
const ends = [...text.matchAll(SENTENCE_END)].map((m) => m.index);
|
|
38
|
-
const out = [[]];
|
|
39
|
-
let next = 0;
|
|
40
|
-
for (const u of all) {
|
|
41
|
-
while (next < ends.length && ends[next] < u.start) {
|
|
42
|
-
if (out[out.length - 1].length)
|
|
43
|
-
out.push([]);
|
|
44
|
-
next++;
|
|
45
|
-
}
|
|
46
|
-
out[out.length - 1].push(u);
|
|
47
|
-
}
|
|
48
|
-
return out.filter((s) => s.length);
|
|
49
|
-
}
|
|
50
|
-
// Units join into one word only when just spaces separate them ("thịt heo", not "thịt, heo").
|
|
51
|
-
const joined = (text, a, b) => /^[^\S\n]+$/.test(text.slice(a.end, b.start));
|
|
52
|
-
// Longest match against the dictionary: at each position, the longest run of units that's a headword
|
|
53
|
-
// ("kết quả" is one word, so its "quả" isn't checked on its own). Unknown single units become words with
|
|
54
|
-
// no entries.
|
|
55
|
-
async function segment(text, list, maxUnits, lookup) {
|
|
56
|
-
const words = [];
|
|
57
|
-
for (let i = 0; i < list.length;) {
|
|
58
|
-
let taken = null;
|
|
59
|
-
for (let k = Math.min(maxUnits, list.length - i); k >= 1; k--) {
|
|
60
|
-
const span = list.slice(i, i + k);
|
|
61
|
-
if (span.some((u, j) => j > 0 && !joined(text, span[j - 1], u)))
|
|
62
|
-
continue;
|
|
63
|
-
const phrase = span.map((u) => u.text).join(' ');
|
|
64
|
-
const entries = /\d/.test(phrase) ? [] : await lookup(phrase);
|
|
65
|
-
if (entries.length || k === 1) {
|
|
66
|
-
const start = span[0].start;
|
|
67
|
-
const end = span[k - 1].end;
|
|
68
|
-
taken = { text: text.slice(start, end), lower: phrase.toLowerCase(), start, end, units: span, entries };
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
words.push(taken);
|
|
73
|
-
i += taken.units.length;
|
|
74
|
-
}
|
|
75
|
-
return words;
|
|
76
|
-
}
|
|
77
43
|
// --- Rules -----------------------------------------------------------------------------------------------
|
|
78
|
-
// Spelling and accents, for languages written in syllables with a syllable list
|
|
79
|
-
// the language is flagged, with the accented forms it could be ("khong" →
|
|
80
|
-
// syllables with no accented form are left alone
|
|
44
|
+
// Spelling and accents, for languages written in syllables with a syllable list (with frequencies):
|
|
45
|
+
// - a syllable that isn't in the language is flagged, with the accented forms it could be ("khong" →
|
|
46
|
+
// "không"); plain-letter syllables with no accented form are left alone (names, foreign words: "email");
|
|
47
|
+
// - a plain-letter syllable that is a word but a rare one, next to a much more common accented form, is
|
|
48
|
+
// probably missing its accents ("toi" → "tôi", "hom" → "hôm"). In a sentence typed without accents (at
|
|
49
|
+
// least half plain letters and a missing-accents error already, or 3+ words with no accent at all; half,
|
|
50
|
+
// because filled-in sentence frames add accented words), any accented form that's more common
|
|
51
|
+
// counts ("an" → "ăn"; but not "con" → "còn", about as common). Left alone: capitalized words
|
|
52
|
+
// mid-sentence (names) and the language's listed pronouns, some written without accents ("tui").
|
|
53
|
+
// Units inside a known word, or in the learner's own language, aren't checked.
|
|
54
|
+
// Zipf points by which an accented form must be more common than a plain-letter word to be suggested.
|
|
55
|
+
const ACCENTS_GAP = 1.5;
|
|
56
|
+
const ACCENTS_GAP_UNACCENTED_TEXT = 0.2;
|
|
81
57
|
async function spelling(ctx) {
|
|
82
58
|
if (ctx.config.units !== 'syllables' || !ctx.meta.syllables)
|
|
83
59
|
return [];
|
|
@@ -88,29 +64,111 @@ async function spelling(ctx) {
|
|
|
88
64
|
for (const list of byPlain.values())
|
|
89
65
|
list.sort((a, b) => syllables[b] - syllables[a]);
|
|
90
66
|
const issues = [];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
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
|
+
const options = (u, candidates) => listOf(candidates.map((c) => quote(matchCase(u.text, c))), 'or');
|
|
69
|
+
const pronouns = new Set(Object.values(ctx.config.pronouns ?? {}).flat().map((w) => w.toLowerCase()));
|
|
70
|
+
for (const sentence of ctx.sentences) {
|
|
71
|
+
const lone = sentence.words.filter((w) => !w.entries.length || w.units.length === 1).flatMap((w) => w.units).filter((u) => !ctx.inBase(u));
|
|
72
|
+
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) {
|
|
95
77
|
const lower = u.text.toLowerCase().replace(/[’']/g, '');
|
|
96
|
-
if (/\d/.test(lower)
|
|
78
|
+
if (/\d/.test(lower))
|
|
97
79
|
continue;
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (!candidates.length && !accented)
|
|
80
|
+
const inWord = sentence.words.some((w) => w.entries.length && w.units.length > 1 && w.units.includes(u));
|
|
81
|
+
if (inWord)
|
|
101
82
|
continue;
|
|
102
|
-
|
|
103
|
-
|
|
83
|
+
if (!(lower in syllables)) {
|
|
84
|
+
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;
|
|
104
90
|
// Missing accents on plain letters is the common learner slip; a wrong accent is more surely wrong.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
91
|
+
suggest(u, candidates, accented ? 'error' : 'warning', candidates.length
|
|
92
|
+
? `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Did you mean ${options(u, candidates)}?`
|
|
93
|
+
: `${quote(u.text)} isn't a ${ctx.meta.name} syllable. Check the spelling and accents.`);
|
|
94
|
+
}
|
|
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);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
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
|
+
}
|
|
112
|
+
return issues;
|
|
113
|
+
}
|
|
114
|
+
// Words in the learner's own language (English by default), with the target-language word to use: "market"
|
|
115
|
+
// → "chợ". Runs of base units are split into the longest phrases the base dictionary knows ("ice cream"),
|
|
116
|
+
// each translated in the region and register. Words the language usually has no word for
|
|
117
|
+
// (CheckerConfig.leaveOut: Vietnamese has no articles) are suggested to be left out.
|
|
118
|
+
async function foreignWord(ctx) {
|
|
119
|
+
const base = ctx.base;
|
|
120
|
+
if (!base)
|
|
121
|
+
return [];
|
|
122
|
+
const leaveOut = new Set((ctx.config.leaveOut ?? []).map((w) => w.toLowerCase()));
|
|
123
|
+
const issues = [];
|
|
124
|
+
for (const s of ctx.sentences) {
|
|
125
|
+
const runs = [];
|
|
126
|
+
s.units.forEach((u, i) => {
|
|
127
|
+
if (!ctx.inBase(u))
|
|
128
|
+
return;
|
|
129
|
+
const last = runs[runs.length - 1];
|
|
130
|
+
if (last && last[last.length - 1] === s.units[i - 1] && joined(ctx.text, s.units[i - 1], u))
|
|
131
|
+
last.push(u);
|
|
132
|
+
else
|
|
133
|
+
runs.push([u]);
|
|
134
|
+
});
|
|
135
|
+
for (const run of runs) {
|
|
136
|
+
for (let i = 0; i < run.length;) {
|
|
137
|
+
// The longest phrase the base dictionary knows and the language has a word for; single words
|
|
138
|
+
// always give an issue, with or without a translation.
|
|
139
|
+
let found;
|
|
140
|
+
for (let k = Math.min(MAX_FOREIGN_UNITS, run.length - i); k >= 1 && !found; k--) {
|
|
141
|
+
const phrase = ctx.text.slice(run[i].start, run[i + k - 1].end);
|
|
142
|
+
if (k === 1 && leaveOut.has(phrase.toLowerCase())) {
|
|
143
|
+
found = { k, words: [], leave: true };
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (k > 1 && !(await ctx.translator.senses(phrase, { from: base }).catch(() => [])).length)
|
|
147
|
+
continue;
|
|
148
|
+
const [group] = await ctx.translator.translate(phrase, {
|
|
149
|
+
from: base, to: ctx.options.lang, toRegion: ctx.options.region, register: ctx.options.register, listener: ctx.options.listener, limit: 3,
|
|
150
|
+
}).catch(() => []);
|
|
151
|
+
const words = unique((group?.translations ?? []).map((t) => t.word));
|
|
152
|
+
if (words.length || k === 1)
|
|
153
|
+
found = { k, words, leave: false };
|
|
154
|
+
}
|
|
155
|
+
const { k, words, leave } = found;
|
|
156
|
+
const span = run.slice(i, i + k);
|
|
157
|
+
const text = ctx.text.slice(span[0].start, span[k - 1].end);
|
|
158
|
+
i += k;
|
|
159
|
+
const at = { rule: 'foreign-word', severity: 'error', start: span[0].start, end: span[k - 1].end, text };
|
|
160
|
+
if (leave) {
|
|
161
|
+
issues.push({ ...at, message: `${quote(text)} isn't ${ctx.meta.name}; ${ctx.meta.name} usually has no word for it, so leave it out.`, suggestions: [''] });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
issues.push({
|
|
165
|
+
...at,
|
|
166
|
+
message: words.length
|
|
167
|
+
? `${quote(text)} isn't ${ctx.meta.name}: say ${listOf(words.map(quote), 'or')}.`
|
|
168
|
+
: `${quote(text)} isn't ${ctx.meta.name}, and no translation was found.`,
|
|
169
|
+
suggestions: words.map((w) => (span[0].start === s.units[0].start ? matchCase(text, w) : w)),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
114
172
|
}
|
|
115
173
|
}
|
|
116
174
|
return issues;
|
|
@@ -123,6 +181,7 @@ async function dialect(ctx) {
|
|
|
123
181
|
const all = new Set(ctx.meta.regions);
|
|
124
182
|
const regional = ctx.sentences
|
|
125
183
|
.flatMap((s) => s.words)
|
|
184
|
+
.filter((word) => !word.units.some((u) => ctx.inBase(u)))
|
|
126
185
|
.map((word) => ({ word, sense: word.entries[0]?.senses[0] }))
|
|
127
186
|
.filter((x) => Boolean(x.sense?.regionTagged && x.sense.regions.length < all.size));
|
|
128
187
|
if (!regional.length)
|
|
@@ -233,6 +292,33 @@ async function pronounPair(ctx) {
|
|
|
233
292
|
}
|
|
234
293
|
return issues;
|
|
235
294
|
}
|
|
295
|
+
// Switching words for the same person within one text ("tôi" in one sentence, "tui" in the next): the first
|
|
296
|
+
// one used is kept. Useful for journals and longer messages. Ambiguous pronouns ("bạn": also "friend") are
|
|
297
|
+
// never the ones flagged as a warning.
|
|
298
|
+
async function pronounConsistency(ctx) {
|
|
299
|
+
const first = new Map();
|
|
300
|
+
const issues = [];
|
|
301
|
+
for (const m of ctx.pronounMatches()) {
|
|
302
|
+
const earlier = first.get(m.person);
|
|
303
|
+
if (!earlier) {
|
|
304
|
+
first.set(m.person, m);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (earlier.lower === m.lower)
|
|
308
|
+
continue;
|
|
309
|
+
const ambiguous = ctx.config.ambiguousPronouns?.includes(m.lower) || ctx.config.ambiguousPronouns?.includes(earlier.lower);
|
|
310
|
+
issues.push({
|
|
311
|
+
rule: 'pronoun-consistency',
|
|
312
|
+
severity: ambiguous ? 'suggestion' : 'warning',
|
|
313
|
+
start: m.start,
|
|
314
|
+
end: m.end,
|
|
315
|
+
text: m.text,
|
|
316
|
+
message: `You used ${quote(earlier.text)} for “${PERSON_NAMES[m.person]}” earlier; keep one word: ${quote(earlier.lower)}.`,
|
|
317
|
+
suggestions: [matchCase(m.text, earlier.lower)],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return issues;
|
|
321
|
+
}
|
|
236
322
|
// Polite endings when speaking up (the listener's row is marked `respect`): Vietnamese sentences to parents,
|
|
237
323
|
// elders and teachers usually end in "ạ". A suggestion, not an error: it isn't always needed.
|
|
238
324
|
async function politeEnding(ctx) {
|
|
@@ -262,11 +348,15 @@ async function politeEnding(ctx) {
|
|
|
262
348
|
}
|
|
263
349
|
return issues;
|
|
264
350
|
}
|
|
351
|
+
// The longest base-language phrase looked up as one ("ice cream", "take care of").
|
|
352
|
+
const MAX_FOREIGN_UNITS = 3;
|
|
265
353
|
const RULES = {
|
|
266
354
|
spelling,
|
|
355
|
+
'foreign-word': foreignWord,
|
|
267
356
|
dialect,
|
|
268
357
|
'pronoun-relationship': pronounRelationship,
|
|
269
358
|
'pronoun-pair': pronounPair,
|
|
359
|
+
'pronoun-consistency': pronounConsistency,
|
|
270
360
|
'polite-ending': politeEnding,
|
|
271
361
|
};
|
|
272
362
|
const SEVERITY_ORDER = { error: 0, warning: 1, suggestion: 2 };
|
|
@@ -303,10 +393,13 @@ export function createChecker(options = {}) {
|
|
|
303
393
|
}
|
|
304
394
|
return pending;
|
|
305
395
|
};
|
|
396
|
+
const base = (opts.base ?? 'en') === opts.lang ? undefined : (opts.base ?? 'en');
|
|
306
397
|
const sentences = [];
|
|
307
398
|
for (const list of sentencesOf(text, units(text))) {
|
|
308
|
-
|
|
399
|
+
const langs = base ? await markLanguages(list, { lang: opts.lang, base, target: d, baseDictionary: dict(base), translator }) : list.map(() => opts.lang);
|
|
400
|
+
sentences.push({ units: list, langs, words: await segment(text, list, config.maxWordUnits ?? 3, lookup) });
|
|
309
401
|
}
|
|
402
|
+
const baseUnits = new Set(sentences.flatMap((s) => s.units.filter((_, i) => s.langs[i] !== opts.lang).map((u) => u.start)));
|
|
310
403
|
let rows;
|
|
311
404
|
let matches;
|
|
312
405
|
const ctx = {
|
|
@@ -320,6 +413,9 @@ export function createChecker(options = {}) {
|
|
|
320
413
|
// The language's reliable pronouns in the text, longest first ("chúng tôi" before "tôi"), matched on
|
|
321
414
|
// units directly so pronouns the dictionary doesn't list as one word ("tụi mày") are found too.
|
|
322
415
|
pronounMatches: () => (matches ??= findPronouns(text, sentences, config)),
|
|
416
|
+
base,
|
|
417
|
+
inBase: (u) => baseUnits.has(u.start),
|
|
418
|
+
translator,
|
|
323
419
|
suggestRegional: async (word, region) => {
|
|
324
420
|
try {
|
|
325
421
|
const [group] = await translator.translate(word.lower, {
|
|
@@ -332,9 +428,22 @@ export function createChecker(options = {}) {
|
|
|
332
428
|
}
|
|
333
429
|
},
|
|
334
430
|
};
|
|
335
|
-
const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r]
|
|
431
|
+
const results = await Promise.all(Object.keys(RULES).filter((r) => opts.rules?.[r] ?? DEFAULT_CHECK_RULES[r]).map((r) => RULES[r](ctx)));
|
|
336
432
|
const issues = results.flat().sort((a, b) => a.start - b.start || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
337
|
-
|
|
433
|
+
// The text split by language: consecutive units in one language, with the text between them.
|
|
434
|
+
const parts = [];
|
|
435
|
+
for (const s of sentences) {
|
|
436
|
+
s.units.forEach((u, i) => {
|
|
437
|
+
const last = parts[parts.length - 1];
|
|
438
|
+
if (last && last.lang === s.langs[i])
|
|
439
|
+
last.end = u.end;
|
|
440
|
+
else
|
|
441
|
+
parts.push({ text: '', lang: s.langs[i], start: u.start, end: u.end });
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
for (const p of parts)
|
|
445
|
+
p.text = text.slice(p.start, p.end);
|
|
446
|
+
return { text, issues, parts };
|
|
338
447
|
},
|
|
339
448
|
};
|
|
340
449
|
}
|
package/dist/detect.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Dictionary } from './index.ts';
|
|
2
|
+
import { type Unit } from './text.ts';
|
|
3
|
+
import type { Translator } from './translate.ts';
|
|
4
|
+
export declare function markLanguages(list: Unit[], o: {
|
|
5
|
+
lang: string;
|
|
6
|
+
base: string;
|
|
7
|
+
target: Dictionary;
|
|
8
|
+
baseDictionary: Dictionary;
|
|
9
|
+
translator: Translator;
|
|
10
|
+
}): Promise<string[]>;
|