Replace the kitten's random generic tips with a deterministic, LLM-free prose checker that reads the actual document and surfaces one gentle, bilingual note at a time — quoting a slice of her own sentence so the advice is clearly about *this* draft. Rules (all conservative, tuned for a Mandarin-native ESL writer): run-ons, comma splices, unclear antecedents, Oxford comma, a/an, uncountable plurals, capitalize languages/days/months, subject-verb agreement, plural-after-number, double determiner, "there is" + plural, its/it's, than/then, comma-after-transition, doubled words, lowercase "i", sentence capitals, and spacing around punctuation. Each rule is paired with false-positive guards (e.g. "a university", "After dinner, I went home and read", existing Oxford lists) and pinned by a 45-case Vitest suite (npm test). Also: bubbles now linger proportional to how much there is to read (zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
578 lines
24 KiB
TypeScript
578 lines
24 KiB
TypeScript
// Rules-based prose checker — NO LLM. This is the companion's "smart" side: it
|
||
// reads the writer's actual text and surfaces one gentle, context-aware grammar
|
||
// or style note at a time, the way an attentive tutor leaning over her shoulder
|
||
// would. Every rule here is intentionally conservative: a wrong nudge erodes
|
||
// trust far faster than a missed one earns it, so we only speak up when a
|
||
// pattern is a high-confidence, genuinely-common English mistake.
|
||
//
|
||
// Each finding becomes a Mandarin-first bubble Line (see tips.ts), often quoting
|
||
// a short slice of her own sentence so the advice clearly belongs to *this*
|
||
// paragraph and not a generic tip jar.
|
||
|
||
import type { Line } from './tips'
|
||
|
||
export interface ProseHint extends Line {
|
||
// Stable, content-derived id so the same untouched sentence isn't re-flagged
|
||
// every cadence; the companion remembers recently-shown ids.
|
||
id: string
|
||
// Rule family, used to vary advice (don't show the same kind twice in a row).
|
||
rule: string
|
||
}
|
||
|
||
// ── small text helpers ──────────────────────────────────────────────────────
|
||
|
||
const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g
|
||
|
||
// Split into sentence-ish chunks (text + leading punctuation trimmed). Mirrors
|
||
// the SENTENCE_RE terminators used elsewhere, including CJK fullwidth forms.
|
||
function sentences(text: string): string[] {
|
||
const out: string[] = []
|
||
const re = /[^.!?。!?]+[.!?。!?]*/g
|
||
let m: RegExpExecArray | null
|
||
while ((m = re.exec(text))) {
|
||
const s = m[0].trim()
|
||
if (s) out.push(s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// First few words of a sentence, as an anchor we can quote back to her.
|
||
function anchor(s: string, n = 6): string {
|
||
const words = s.split(/\s+/).slice(0, n).join(' ')
|
||
return words.length < s.length ? `${words}…` : words
|
||
}
|
||
|
||
// Cheap, stable hash → short id suffix, so ids stay stable across recomputes but
|
||
// change the moment she edits the offending text.
|
||
function key(s: string): string {
|
||
return s.toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 48)
|
||
}
|
||
|
||
// ── individual rules ────────────────────────────────────────────────────────
|
||
// Each rule pushes at most a couple of findings; the caller shows one at a time.
|
||
|
||
// Run-on / overly long sentences — the single most common readability problem
|
||
// for ESL writers, who often chain clauses that a period would serve better.
|
||
function runOns(text: string, out: ProseHint[]) {
|
||
let found = 0
|
||
for (const s of sentences(text)) {
|
||
if (found >= 2) break
|
||
const words = s.match(ENGLISH_WORD_RE)?.length ?? 0
|
||
const commas = (s.match(/,/g) ?? []).length
|
||
const longRun = words >= 32
|
||
const commaHeavy = words >= 24 && commas >= 2
|
||
if (longRun || commaHeavy) {
|
||
found++
|
||
out.push({
|
||
id: `runon:${key(s)}`,
|
||
rule: 'runon',
|
||
zh: '这句话有点长啦,分成两三句会更清楚 🌸',
|
||
en: `This one runs long — try splitting it: “${anchor(s)}”`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Comma splice — two independent clauses joined by only a comma. We look for a
|
||
// comma immediately followed by a subject pronoun + a finite verb, which is a
|
||
// strong, low-false-positive signal of a spliced second clause.
|
||
// A comma, then a subject pronoun starting a fresh clause, then its verb. We no
|
||
// longer whitelist verbs (that lost too many real splices) — instead we take any
|
||
// word after the pronoun and reject the function words that signal it *isn't* a
|
||
// new clause (a conjunction/preposition list like “she and I”, “it of course”).
|
||
const SPLICE_RE = /[a-z]\w*,\s+(I|we|they|he|she|it|you)\s+([a-z]+)\b/g
|
||
const SPLICE_NONVERB = new Set([
|
||
'and', 'or', 'but', 'nor', 'yet', 'so', 'to', 'too', 'also', 'of', 'in', 'on',
|
||
'at', 'for', 'with', 'as', 'who', 'whom', 'which', 'that', 'than', 'then',
|
||
'will', // “…, I will, …” is usually an aside, not a clean splice
|
||
])
|
||
|
||
// First words that signal the lead is an *introductory* element (transition,
|
||
// subordinate clause, or adverbial phrase), where a following “, subject verb”
|
||
// is correct punctuation — not a splice. Guards against “However, we left.” and
|
||
// “After dinner, I went home.”
|
||
const INTRO_LEAD = new Set([
|
||
'after', 'before', 'when', 'while', 'although', 'though', 'because', 'since',
|
||
'if', 'as', 'until', 'unless', 'whenever', 'wherever', 'whereas', 'however',
|
||
'therefore', 'moreover', 'furthermore', 'nevertheless', 'nonetheless',
|
||
'meanwhile', 'consequently', 'otherwise', 'besides', 'instead', 'finally',
|
||
'first', 'firstly', 'second', 'secondly', 'next', 'then', 'later', 'eventually',
|
||
'afterwards', 'anyway', 'yesterday', 'today', 'tomorrow', 'honestly',
|
||
'actually', 'suddenly', 'sometimes', 'usually', 'often', 'also', 'still',
|
||
'now', 'here', 'there', 'well', 'yes', 'no', 'fortunately', 'unfortunately',
|
||
'surprisingly', 'clearly', 'obviously', 'basically', 'generally', 'initially',
|
||
'recently', 'currently', 'sadly', 'luckily', 'hopefully', 'frankly',
|
||
'personally', 'overall', 'soon', 'once', 'during', 'in', 'on', 'at', 'for',
|
||
'with', 'without', 'by', 'from', 'despite', 'throughout', 'given', 'regarding',
|
||
])
|
||
|
||
// A genuine comma splice has an *independent clause* before the comma. We
|
||
// approximate that by requiring the lead to be ≥3 words and not begin with an
|
||
// introductory word — short or transition-led leads are intro phrases, not
|
||
// spliced clauses. Precision over recall: a wrong nudge costs more than a miss.
|
||
function commaSplices(text: string, out: ProseHint[]) {
|
||
let found = 0
|
||
for (const s of sentences(text)) {
|
||
if (found >= 2) break
|
||
SPLICE_RE.lastIndex = 0
|
||
let m: RegExpExecArray | null
|
||
while ((m = SPLICE_RE.exec(s)) && found < 2) {
|
||
const commaIdx = s.indexOf(',', m.index)
|
||
const lead = s.slice(0, commaIdx).trim()
|
||
const words = lead.split(/\s+/).filter(Boolean)
|
||
const firstWord = words[0]?.toLowerCase().replace(/[^a-z]/g, '') ?? ''
|
||
if (words.length < 3 || INTRO_LEAD.has(firstWord)) continue
|
||
if (SPLICE_NONVERB.has(m[2].toLowerCase())) continue
|
||
found++
|
||
out.push({
|
||
id: `splice:${key(m[0])}`,
|
||
rule: 'splice',
|
||
zh: '这里用逗号连了两句话,可以改成句号,或加个 “and / but”。',
|
||
en: `Two sentences joined by a comma: “…${m[0].trim()}…” — use a period or add a joining word.`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Unclear antecedent — a sentence that opens with This/That/These/Those riding
|
||
// straight into a verb, with no noun naming what it points back to. Only flag
|
||
// when there's a prior sentence (so an antecedent is actually in question).
|
||
const ANTECEDENT_RE =
|
||
/^(This|That|These|Those)\s+(is|are|was|were|will|would|can|could|should|makes?|made|means?|shows?|showed|gives?|gave|causes?|caused|creates?|created|leads?|led|results?|happens?|happened|helps?|helped)\b/
|
||
|
||
function antecedents(text: string, out: ProseHint[]) {
|
||
const list = sentences(text)
|
||
let found = 0
|
||
for (let i = 1; i < list.length && found < 2; i++) {
|
||
const s = list[i]
|
||
const m = s.match(ANTECEDENT_RE)
|
||
if (m) {
|
||
found++
|
||
out.push({
|
||
id: `antecedent:${key(s)}`,
|
||
rule: 'antecedent',
|
||
zh: `“${m[1]}” 指代不太清楚,最好点明它指的是什么(比如 “${m[1]} idea / change…”)。`,
|
||
en: `“${m[1]}” here is a little vague — name what it refers to.`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Oxford comma — a 3+ item list ending “… X and Y” with no comma before the
|
||
// conjunction. Guarded by a clause-starter stoplist so we don't mistake a
|
||
// compound clause (“…, but she and I…”) for a list.
|
||
const OXFORD_RE = /(\w+),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\s+(and|or)\s+[\w'’-]+/g
|
||
const CLAUSE_STARTERS = new Set([
|
||
'but', 'so', 'because', 'which', 'who', 'that', 'when', 'while', 'if',
|
||
'although', 'though', 'since', 'as', 'and', 'or', 'nor', 'yet', 'then',
|
||
'however', 'whereas',
|
||
// Subject pronouns signal a clause, not a list item (“…, I went home and…”).
|
||
'i', 'you', 'he', 'she', 'it', 'we', 'they',
|
||
])
|
||
|
||
function oxford(text: string, out: ProseHint[]) {
|
||
let m: RegExpExecArray | null
|
||
let found = 0
|
||
OXFORD_RE.lastIndex = 0
|
||
while ((m = OXFORD_RE.exec(text)) && found < 1) {
|
||
const firstWord = m[2].split(/\s+/)[0]?.toLowerCase() ?? ''
|
||
if (CLAUSE_STARTERS.has(firstWord)) continue
|
||
found++
|
||
out.push({
|
||
id: `oxford:${key(m[0])}`,
|
||
rule: 'oxford',
|
||
zh: '列举三样以上时,在 “and / or” 前也加个逗号会更清楚(牛津逗号)。',
|
||
en: `In a list, a comma before “${m[3]}” keeps it clear: “a, b, ${m[3]} c”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// a vs. an — chosen by the *sound* of the next word. We work only on lowercase
|
||
// words (sidestepping proper nouns / acronyms) and keep sound-exception lists.
|
||
const A_BEFORE_VOWEL_RE = /\ba\s+([aeiou][a-z]+)\b/g
|
||
// Vowel-spelled but consonant-sounding → “a” is correct, don't flag.
|
||
const CONSONANT_SOUND_RE = /^(uni|use|usu|util|euro?|eul|ewe|once|one|ubiqu|unanim)/
|
||
const AN_BEFORE_CONSONANT_RE = /\ban\s+([b-df-hj-np-tv-z][a-z]+)\b/g
|
||
// Consonant-spelled but vowel-sounding (silent h) → “an” is correct, don't flag.
|
||
const VOWEL_SOUND_RE = /^(hour|honest|honou?r|heir|homage)/
|
||
|
||
function articles(text: string, out: ProseHint[]) {
|
||
let m: RegExpExecArray | null
|
||
let found = 0
|
||
A_BEFORE_VOWEL_RE.lastIndex = 0
|
||
while ((m = A_BEFORE_VOWEL_RE.exec(text)) && found < 1) {
|
||
if (CONSONANT_SOUND_RE.test(m[1])) continue
|
||
found++
|
||
out.push({
|
||
id: `article-an:${key(m[1])}`,
|
||
rule: 'article',
|
||
zh: `元音开头的词前用 “an”:“an ${m[1]}”。`,
|
||
en: `Before a vowel sound, use “an”: “an ${m[1]}”.`,
|
||
})
|
||
}
|
||
AN_BEFORE_CONSONANT_RE.lastIndex = 0
|
||
while ((m = AN_BEFORE_CONSONANT_RE.exec(text)) && found < 2) {
|
||
if (VOWEL_SOUND_RE.test(m[1])) continue
|
||
found++
|
||
out.push({
|
||
id: `article-a:${key(m[1])}`,
|
||
rule: 'article',
|
||
zh: `辅音开头的词前用 “a”:“a ${m[1]}”。`,
|
||
en: `Before a consonant sound, use “a”: “a ${m[1]}”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Doubled word — “the the”, “is is”. Excludes the handful of doublings that can
|
||
// be legitimate (“the fact that that happened”, “she had had enough”).
|
||
const DOUBLE_RE = /\b([A-Za-z]+)\s+\1\b/gi
|
||
const LEGIT_DOUBLES = new Set(['that', 'had'])
|
||
|
||
function doubles(text: string, out: ProseHint[]) {
|
||
let m: RegExpExecArray | null
|
||
let found = 0
|
||
DOUBLE_RE.lastIndex = 0
|
||
while ((m = DOUBLE_RE.exec(text)) && found < 1) {
|
||
const w = m[1].toLowerCase()
|
||
if (LEGIT_DOUBLES.has(w)) continue
|
||
found++
|
||
out.push({
|
||
id: `double:${key(m[0])}`,
|
||
rule: 'double',
|
||
zh: `“${m[1]}” 好像写了两遍,检查一下哦。`,
|
||
en: `“${m[1]} ${m[1]}” — looks like a word got doubled.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Lowercase standalone “I”. Conservative: only when bounded by spaces / line
|
||
// edge, to avoid tangling with “i.e.” and the like.
|
||
const LOWER_I_RE = /(?:^|\s)i(?=\s|$)/m
|
||
|
||
function pronounI(text: string, out: ProseHint[]) {
|
||
if (LOWER_I_RE.test(text)) {
|
||
out.push({
|
||
id: 'cap-i',
|
||
rule: 'cap-i',
|
||
zh: '英文里的 “I”(我)任何时候都要大写哦。',
|
||
en: 'In English, “I” is always written as a capital letter.',
|
||
})
|
||
}
|
||
}
|
||
|
||
// Sentence not starting with a capital. We look after a terminator, and ignore
|
||
// CJK sentences (she writes Mandarin too — those don't take Latin capitals).
|
||
const LOWER_START_RE = /[.!?]\s+([a-z])/
|
||
|
||
function sentenceCaps(text: string, out: ProseHint[]) {
|
||
if (LOWER_START_RE.test(text)) {
|
||
out.push({
|
||
id: 'cap-sentence',
|
||
rule: 'cap-sentence',
|
||
zh: '每个句子的开头,用大写字母开始吧。',
|
||
en: 'Start each new sentence with a capital letter.',
|
||
})
|
||
}
|
||
}
|
||
|
||
// A stray space *before* punctuation (a common habit carried from CJK spacing).
|
||
const SPACE_BEFORE_PUNCT_RE = /[A-Za-z] +([,;:!?]|\.(?!\.))/
|
||
|
||
function spaceBeforePunct(text: string, out: ProseHint[]) {
|
||
if (SPACE_BEFORE_PUNCT_RE.test(text)) {
|
||
out.push({
|
||
id: 'space-punct',
|
||
rule: 'space-punct',
|
||
zh: '标点前面不用空格,逗号、句号紧跟在前一个词后面就好。',
|
||
en: 'No space before punctuation — it tucks right against the word.',
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── Chinese-L1 interference rules (Tier 1) ──────────────────────────────────
|
||
// Mandarin lacks plural inflection, articles, and verb agreement, so these
|
||
// patterns are the predictable places that grammar "leaks" into her English.
|
||
// All are closed-list or strong-signal to keep false positives near zero.
|
||
|
||
// Mass nouns that Mandarin speakers very commonly pluralize. These words are
|
||
// essentially never valid with a trailing “s”, so the list is its own guard.
|
||
const UNCOUNTABLE_PLURAL_RE =
|
||
/\b(informations|advices|knowledges|equipments|furnitures|homeworks|softwares|hardwares|luggages|baggages|sceneries|machineries)\b/i
|
||
|
||
function uncountables(text: string, out: ProseHint[]) {
|
||
const m = UNCOUNTABLE_PLURAL_RE.exec(text)
|
||
UNCOUNTABLE_PLURAL_RE.lastIndex = 0
|
||
if (m) {
|
||
const singular = m[1].replace(/s$/i, '')
|
||
out.push({
|
||
id: `uncountable:${m[1].toLowerCase()}`,
|
||
rule: 'uncountable',
|
||
zh: `“${m[1]}” 是不可数名词,不用加 s,写 “${singular}” 就好。`,
|
||
en: `“${m[1]}” is uncountable — drop the “s”: just “${singular}”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Languages, nationalities, days, and months are proper nouns in English but
|
||
// not in Mandarin, so they're routinely left lowercase. We list only the
|
||
// unambiguous ones — “may/march/august/china/turkey/polish” are skipped because
|
||
// they're also ordinary lowercase words and would misfire.
|
||
const PROPER_NOUNS =
|
||
'monday|tuesday|wednesday|thursday|friday|saturday|sunday|' +
|
||
'january|february|april|june|july|september|october|november|december|' +
|
||
'english|chinese|mandarin|cantonese|japanese|korean|french|spanish|german|' +
|
||
'italian|russian|portuguese|arabic|vietnamese|thai|american|british|canadian|' +
|
||
'australian|mexican|brazilian|european'
|
||
// Case-sensitive (lowercase-only) so already-capitalized words aren't flagged.
|
||
const PROPER_NOUN_RE = new RegExp(`\\b(${PROPER_NOUNS})\\b`)
|
||
|
||
function properCaps(text: string, out: ProseHint[]) {
|
||
const m = PROPER_NOUN_RE.exec(text)
|
||
if (m) {
|
||
const fixed = m[1][0].toUpperCase() + m[1].slice(1)
|
||
out.push({
|
||
id: `propercap:${m[1]}`,
|
||
rule: 'propercap',
|
||
zh: `语言、国籍、星期和月份在英文里要大写:“${fixed}”。`,
|
||
en: `Languages, days, and months are capitalized in English: “${fixed}”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Third-person singular verb form, for the agreement rule's suggestion.
|
||
function third(verb: string): string {
|
||
const v = verb.toLowerCase()
|
||
const irregular: Record<string, string> = { have: 'has', do: 'does', go: 'goes' }
|
||
if (irregular[v]) return irregular[v]
|
||
if (/[^aeiou]y$/.test(v)) return v.slice(0, -1) + 'ies'
|
||
if (/(s|x|z|ch|sh)$/.test(v)) return v + 'es'
|
||
return v + 's'
|
||
}
|
||
|
||
// Subject–verb agreement after he/she/it: a bare (base-form) verb where the
|
||
// third-person “-s” form is required. We skip the causative/perception frames
|
||
// (“let it go”, “make it work”, “help it grow”) where the bare verb is correct.
|
||
const SVA_BASE =
|
||
'go|have|do|make|like|want|need|know|think|say|see|feel|get|take|come|give|' +
|
||
'run|play|work|live|look|seem|become|bring|buy|find|keep|leave|tell|try|call|' +
|
||
'move|turn|put|mean|show|use|love|hope|wish|believe|understand|remember|enjoy'
|
||
// The preceding word is optional so a sentence-initial subject (“She have…”)
|
||
// still matches; when present it lets us skip causative/perception frames.
|
||
const SVA_RE = new RegExp(`(?:(\\w+)\\s+)?\\b(he|she|it)\\s+(${SVA_BASE})\\b`, 'gi')
|
||
const CAUSATIVE = new Set([
|
||
'let', 'lets', 'make', 'makes', 'made', 'help', 'helps', 'helped', 'watch',
|
||
'watches', 'watched', 'see', 'sees', 'saw', 'hear', 'heard', 'have', 'has',
|
||
'had', 'let’s', "let's",
|
||
])
|
||
|
||
function subjectVerbAgreement(text: string, out: ProseHint[]) {
|
||
let m: RegExpExecArray | null
|
||
let found = 0
|
||
SVA_RE.lastIndex = 0
|
||
while ((m = SVA_RE.exec(text)) && found < 2) {
|
||
if (m[1] && CAUSATIVE.has(m[1].toLowerCase())) continue
|
||
found++
|
||
const fixed = third(m[3])
|
||
out.push({
|
||
id: `sva:${m[2].toLowerCase()}:${m[3].toLowerCase()}`,
|
||
rule: 'sva',
|
||
zh: `主语是 he/she/it 时,动词要加 -s:“${m[2]} ${fixed}”。`,
|
||
en: `After he/she/it the verb takes “-s”: “${m[2]} ${fixed}”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Singular noun after a cardinal number (“three apple”). Adjectives between the
|
||
// number and noun would misfire (“two small dogs”), so we only flag when the
|
||
// candidate is followed by a clause boundary — a verb, preposition, article, or
|
||
// punctuation — which means it's the noun itself, not a modifier.
|
||
const NUMBER_NOUN_RE =
|
||
/\b(two|three|four|five|six|seven|eight|nine|ten)\s+([a-z]{3,})\b(?=\s*(?:[.,!?;:]|$|\s(?:is|are|was|were|and|or|but|that|which|who|in|on|at|to|for|with|will|can|could|would|should|the|a|an)\b))/g
|
||
// Irregular plurals / mass nouns that are correct without an “s”.
|
||
const NON_S_PLURAL = new Set([
|
||
'people', 'children', 'men', 'women', 'fish', 'deer', 'sheep', 'feet', 'teeth',
|
||
'mice', 'geese', 'police', 'staff', 'series', 'species', 'aircraft', 'cattle',
|
||
'hundred', 'thousand', 'million', 'billion', 'dozen', 'percent',
|
||
])
|
||
|
||
function pluralAfterNumber(text: string, out: ProseHint[]) {
|
||
let m: RegExpExecArray | null
|
||
let found = 0
|
||
NUMBER_NOUN_RE.lastIndex = 0
|
||
while ((m = NUMBER_NOUN_RE.exec(text)) && found < 1) {
|
||
const noun = m[2].toLowerCase()
|
||
if (noun.endsWith('s') || NON_S_PLURAL.has(noun)) continue
|
||
found++
|
||
out.push({
|
||
id: `plural:${key(m[0])}`,
|
||
rule: 'plural',
|
||
zh: `“${m[1]}” 后面的名词要用复数:“${m[1]} ${m[2]}s”。`,
|
||
en: `After “${m[1]}”, the noun is plural: “${m[1]} ${m[2]}s”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Two stacked determiners (“the my book”, “a the”) — Mandarin uses a bare
|
||
// possessive, so the article often gets stacked on top. One of the two must go.
|
||
const DOUBLE_DET_RE =
|
||
/\b(a|an|the)\s+(a|an|the|my|your|his|her|its|our|their)\b/gi
|
||
|
||
function doubleDeterminer(text: string, out: ProseHint[]) {
|
||
const m = DOUBLE_DET_RE.exec(text)
|
||
DOUBLE_DET_RE.lastIndex = 0
|
||
if (m) {
|
||
out.push({
|
||
id: `doubledet:${key(m[0])}`,
|
||
rule: 'doubledet',
|
||
zh: `“${m[1]} ${m[2]}” 用了两个限定词,留一个就好(比如去掉 “${m[1]}”)。`,
|
||
en: `“${m[1]} ${m[2]}” stacks two determiners — keep just one.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// “there is” with a plural quantifier → should be “there are”. We trigger only
|
||
// on clearly-plural cues (skip “some/any”, which are fine with singular mass
|
||
// nouns: “there is some water”).
|
||
const THERE_IS_RE =
|
||
/\bthere(?:\s+is|'s|’s)\s+(many|several|numerous|various|few|two|three|four|five|six|seven|eight|nine|ten)\b/i
|
||
|
||
function thereIsPlural(text: string, out: ProseHint[]) {
|
||
const m = THERE_IS_RE.exec(text)
|
||
THERE_IS_RE.lastIndex = 0
|
||
if (m) {
|
||
out.push({
|
||
id: `thereis:${m[1].toLowerCase()}`,
|
||
rule: 'thereis',
|
||
zh: `后面是复数时用 “there are”:“there are ${m[1]}…”。`,
|
||
en: `With a plural, use “there are”: “there are ${m[1]}…”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── confusables (Tier 2) ─────────────────────────────────────────────────────
|
||
|
||
// its / it's — only the two unambiguous directions: “it's own” (always its own)
|
||
// and “its a/an” (the possessive can't take an article → it's a/an).
|
||
const ITS_OWN_RE = /\bit's\s+own\b/i
|
||
const ITS_ARTICLE_RE = /\bits\s+(a|an)\b/i
|
||
|
||
function itsConfusion(text: string, out: ProseHint[]) {
|
||
if (ITS_OWN_RE.test(text)) {
|
||
out.push({
|
||
id: 'its-own',
|
||
rule: 'its',
|
||
zh: '“it’s” = “it is”;表示“它的”要用 “its”,所以是 “its own”。',
|
||
en: '“it’s” means “it is” — the possessive is “its”: “its own”.',
|
||
})
|
||
return
|
||
}
|
||
const m = ITS_ARTICLE_RE.exec(text)
|
||
ITS_ARTICLE_RE.lastIndex = 0
|
||
if (m) {
|
||
out.push({
|
||
id: 'its-article',
|
||
rule: 'its',
|
||
zh: `这里应该是 “it’s ${m[1]}”(it is),“its” 是“它的”。`,
|
||
en: `Here it should be “it’s ${m[1]}” (it is); “its” means belonging to it.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Comparative + “then” where “than” is meant (“better then”, “more then”). We
|
||
// use an explicit comparative list rather than a generic “-er” pattern, which
|
||
// would snag “after then”, “however”, “remember”, etc.
|
||
const COMPARATIVES =
|
||
'better|more|less|rather|worse|greater|other|older|younger|bigger|smaller|' +
|
||
'larger|faster|slower|higher|lower|cheaper|stronger|weaker|easier|harder|' +
|
||
'earlier|later|sooner|longer|shorter|taller|richer|poorer|happier|safer'
|
||
const THAN_THEN_RE = new RegExp(`\\b(${COMPARATIVES})\\s+then\\b`, 'i')
|
||
|
||
function thanThen(text: string, out: ProseHint[]) {
|
||
const m = THAN_THEN_RE.exec(text)
|
||
if (m) {
|
||
out.push({
|
||
id: `than:${m[1].toLowerCase()}`,
|
||
rule: 'than',
|
||
zh: `比较的时候用 “than”,不是 “then”:“${m[1]} than”。`,
|
||
en: `For comparisons use “than”, not “then”: “${m[1]} than”.`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// A transition word opening a sentence with no comma after it (“However we…”).
|
||
// Limited to conjunctive adverbs that strongly want the comma — sequence words
|
||
// like “Then/First/Finally” are left out (their comma is optional).
|
||
const INTRO_RE =
|
||
/^(However|Therefore|Moreover|Furthermore|Nevertheless|Nonetheless|Meanwhile|Consequently|In addition|In conclusion|As a result|On the other hand|For example|For instance)\s+[A-Za-z]/
|
||
|
||
function introComma(text: string, out: ProseHint[]) {
|
||
let found = false
|
||
for (const s of sentences(text)) {
|
||
const m = s.match(INTRO_RE)
|
||
if (m) {
|
||
out.push({
|
||
id: `introcomma:${m[1].toLowerCase()}`,
|
||
rule: 'introcomma',
|
||
zh: `开头的过渡词后面加个逗号:“${m[1]}, …”。`,
|
||
en: `Put a comma after the opening transition: “${m[1]}, …”.`,
|
||
})
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
|
||
// Missing space *after* a comma or sentence period (“apple,banana”, “end.Next”).
|
||
// The comma case requires letters on both sides so numbers like 1,000 are safe;
|
||
// the period case requires a real word boundary so “e.g.”/“U.S.” are skipped.
|
||
const NO_SPACE_COMMA_RE = /[A-Za-z],[A-Za-z]/
|
||
const NO_SPACE_PERIOD_RE = /[a-z]{2,}\.[A-Z][a-z]/
|
||
|
||
function spaceAfterPunct(text: string, out: ProseHint[]) {
|
||
if (NO_SPACE_COMMA_RE.test(text) || NO_SPACE_PERIOD_RE.test(text)) {
|
||
out.push({
|
||
id: 'space-after',
|
||
rule: 'space-after',
|
||
zh: '逗号、句号后面要空一格,再接下一个词。',
|
||
en: 'Add a space after a comma or period before the next word.',
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── orchestration ───────────────────────────────────────────────────────────
|
||
|
||
// Rules run in priority order — the ones the writer cares most about first, so
|
||
// that when several fire at once the companion leads with the weightiest note.
|
||
const RULES: Array<(text: string, out: ProseHint[]) => void> = [
|
||
runOns,
|
||
commaSplices,
|
||
antecedents,
|
||
oxford,
|
||
articles,
|
||
uncountables,
|
||
properCaps,
|
||
subjectVerbAgreement,
|
||
pluralAfterNumber,
|
||
doubleDeterminer,
|
||
thereIsPlural,
|
||
itsConfusion,
|
||
thanThen,
|
||
introComma,
|
||
doubles,
|
||
pronounI,
|
||
sentenceCaps,
|
||
spaceBeforePunct,
|
||
spaceAfterPunct,
|
||
]
|
||
|
||
// analyzeProse returns context-aware hints, highest-priority first. It bails on
|
||
// text too short to advise on (mid-thought drafts shouldn't get picked apart).
|
||
export function analyzeProse(text: string): ProseHint[] {
|
||
const englishWords = text.match(ENGLISH_WORD_RE)?.length ?? 0
|
||
if (englishWords < 8) return []
|
||
const out: ProseHint[] = []
|
||
for (const rule of RULES) rule(text, out)
|
||
return out
|
||
}
|