Files
petal/web/src/components/Companion/prose.ts
prosolis 69ecb79da1 Companion: rules-based, context-aware writing advice
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
2026-06-26 07:16:23 -07:00

578 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'
}
// Subjectverb 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', 'lets', "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: '“its” = “it is”表示“它的”要用 “its”所以是 “its own”。',
en: '“its” 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: `这里应该是 “its ${m[1]}it is“its” 是“它的”。`,
en: `Here it should be “its ${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
}