opengrammar-server 2.0.615350

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.
Files changed (53) hide show
  1. package/README.npm.md +95 -0
  2. package/bin/opengrammar-server.js +111 -0
  3. package/dist/server.js +48639 -0
  4. package/package.json +80 -0
  5. package/server-node.ts +159 -0
  6. package/server.ts +15 -0
  7. package/src/analyzer.ts +542 -0
  8. package/src/dictionary.ts +1973 -0
  9. package/src/index.ts +978 -0
  10. package/src/nlp/nlp-engine.ts +17 -0
  11. package/src/nlp/tone-analyzer.ts +269 -0
  12. package/src/rephraser.ts +146 -0
  13. package/src/rules/categories/academic-writing.ts +182 -0
  14. package/src/rules/categories/adjectives-adverbs.ts +152 -0
  15. package/src/rules/categories/articles.ts +160 -0
  16. package/src/rules/categories/business-writing.ts +250 -0
  17. package/src/rules/categories/capitalization.ts +79 -0
  18. package/src/rules/categories/clarity.ts +117 -0
  19. package/src/rules/categories/common-errors.ts +601 -0
  20. package/src/rules/categories/confused-words.ts +219 -0
  21. package/src/rules/categories/conjunctions.ts +176 -0
  22. package/src/rules/categories/dangling-modifiers.ts +123 -0
  23. package/src/rules/categories/formality.ts +274 -0
  24. package/src/rules/categories/formatting-idioms.ts +323 -0
  25. package/src/rules/categories/gerund-infinitive.ts +274 -0
  26. package/src/rules/categories/grammar-advanced.ts +294 -0
  27. package/src/rules/categories/grammar.ts +286 -0
  28. package/src/rules/categories/inclusive-language.ts +280 -0
  29. package/src/rules/categories/nouns-pronouns.ts +233 -0
  30. package/src/rules/categories/prepositions-extended.ts +217 -0
  31. package/src/rules/categories/prepositions.ts +159 -0
  32. package/src/rules/categories/punctuation.ts +347 -0
  33. package/src/rules/categories/quantity-agreement.ts +200 -0
  34. package/src/rules/categories/readability.ts +293 -0
  35. package/src/rules/categories/sentence-structure.ts +100 -0
  36. package/src/rules/categories/spelling-advanced.ts +164 -0
  37. package/src/rules/categories/spelling.ts +119 -0
  38. package/src/rules/categories/style-tone.ts +511 -0
  39. package/src/rules/categories/style.ts +78 -0
  40. package/src/rules/categories/subject-verb-agreement.ts +201 -0
  41. package/src/rules/categories/tone-rules.ts +206 -0
  42. package/src/rules/categories/verb-tense.ts +582 -0
  43. package/src/rules/context-filter.ts +446 -0
  44. package/src/rules/index.ts +96 -0
  45. package/src/rules/ruleset-part1-cj-pu-sp.json +657 -0
  46. package/src/rules/ruleset-part1-np-ad-aa-pr.json +831 -0
  47. package/src/rules/ruleset-part1-ss-vt.json +907 -0
  48. package/src/rules/ruleset-part2-cw-st-nf.json +318 -0
  49. package/src/rules/ruleset-part3-aw-bw-il-rd.json +161 -0
  50. package/src/rules/types.ts +79 -0
  51. package/src/shared-types.ts +152 -0
  52. package/src/spellchecker.ts +418 -0
  53. package/tsconfig.json +25 -0
@@ -0,0 +1,274 @@
1
+ import { createRegexRule, type Rule } from '../types.js';
2
+
3
+ /**
4
+ * ════════════════════════════════════════════════════════
5
+ * Gerund vs Infinitive Rules — GI Module
6
+ * Modal Verb Errors — MV Module
7
+ * ════════════════════════════════════════════════════════
8
+ */
9
+ export const gerundInfinitiveRules: Rule[] = [
10
+
11
+ // ═══════════════════════════════════════════════
12
+ // MODAL VERBS: Cannot be followed by "to" + base
13
+ // ═══════════════════════════════════════════════
14
+ createRegexRule({
15
+ id: 'MV_must_to',
16
+ category: 'grammar',
17
+ pattern: /\bmust\s+to\s+(\w+)/i,
18
+ suggestion: (m) => `must ${m[1]}`,
19
+ reason: 'Modal verbs (must, can, will, may, should) are followed by the base form without "to".',
20
+ }),
21
+ createRegexRule({
22
+ id: 'MV_can_to',
23
+ category: 'grammar',
24
+ pattern: /\bcan\s+to\s+(\w+)/i,
25
+ suggestion: (m) => `can ${m[1]}`,
26
+ reason: '"Can" is a modal verb and is followed by the base form without "to".',
27
+ }),
28
+ createRegexRule({
29
+ id: 'MV_will_to',
30
+ category: 'grammar',
31
+ pattern: /\bwill\s+to\s+(\w+)/i,
32
+ suggestion: (m) => `will ${m[1]}`,
33
+ reason: '"Will" is a modal verb and is followed by the base form without "to".',
34
+ }),
35
+ createRegexRule({
36
+ id: 'MV_might_to',
37
+ category: 'grammar',
38
+ pattern: /\bmight\s+to\s+(\w+)/i,
39
+ suggestion: (m) => `might ${m[1]}`,
40
+ reason: '"Might" is a modal verb and is followed by the base form without "to".',
41
+ }),
42
+ createRegexRule({
43
+ id: 'MV_may_to',
44
+ category: 'grammar',
45
+ pattern: /\bmay\s+to\s+(\w+)/i,
46
+ suggestion: (m) => `may ${m[1]}`,
47
+ reason: '"May" is a modal verb and is followed by the base form without "to".',
48
+ }),
49
+ createRegexRule({
50
+ id: 'MV_would_to',
51
+ category: 'grammar',
52
+ pattern: /\bwould\s+to\s+(\w+)/i,
53
+ suggestion: (m) => `would ${m[1]}`,
54
+ reason: '"Would" is followed by the base form without "to".',
55
+ }),
56
+ createRegexRule({
57
+ id: 'MV_could_to',
58
+ category: 'grammar',
59
+ pattern: /\bcould\s+to\s+(\w+)/i,
60
+ suggestion: (m) => `could ${m[1]}`,
61
+ reason: '"Could" is a modal verb and is followed by the base form without "to".',
62
+ }),
63
+
64
+ // ═══════════════════════════════════════════════
65
+ // BE + ADJECTIVE (not "be + verb")
66
+ // ═══════════════════════════════════════════════
67
+ createRegexRule({
68
+ id: 'GI_be_agree',
69
+ category: 'grammar',
70
+ pattern: /\b(am|is|are|was|were|be|been)\s+agree\b/i,
71
+ suggestion: (m) => `${m[1]} in agreement`,
72
+ reason: '"Agree" is a verb, not an adjective. Say "I agree" or "I am in agreement".',
73
+ }),
74
+ createRegexRule({
75
+ id: 'GI_be_depend',
76
+ category: 'grammar',
77
+ pattern: /\b(am|is|are|was|were|be|been)\s+depend\b/i,
78
+ suggestion: (m) => `${m[1]} dependent`,
79
+ reason: 'Use "dependent on" not "depend on" after the verb "be".',
80
+ }),
81
+
82
+ // ═══════════════════════════════════════════════
83
+ // VERBS THAT REQUIRE GERUND (not TO + infinitive)
84
+ // ═══════════════════════════════════════════════
85
+
86
+ // avoid
87
+ createRegexRule({
88
+ id: 'GI_avoid_to',
89
+ category: 'grammar',
90
+ pattern: /\bavoid\s+to\s+(\w+)/i,
91
+ suggestion: (m) => `avoid ${m[1]}ing`,
92
+ reason: '"Avoid" is followed by a gerund (-ing form), not an infinitive.',
93
+ }),
94
+
95
+ // enjoy
96
+ createRegexRule({
97
+ id: 'GI_enjoy_to',
98
+ category: 'grammar',
99
+ pattern: /\benjoy\s+to\s+(\w+)/i,
100
+ suggestion: (m) => `enjoy ${m[1]}ing`,
101
+ reason: '"Enjoy" is followed by a gerund (-ing form), not an infinitive.',
102
+ }),
103
+
104
+ // suggest
105
+ createRegexRule({
106
+ id: 'GI_suggest_to',
107
+ category: 'grammar',
108
+ pattern: /\bsuggested?\s+to\s+(?!him\b|her\b|them\b|me\b|us\b|you\b|him|her|them|me|us|you)(\w+)/i,
109
+ suggestion: (m) => `suggest${m[0].match(/suggested/) ? 'ed' : ''} ${m[1]}ing`,
110
+ reason: '"Suggest" is followed by a gerund (-ing form), not an infinitive.',
111
+ }),
112
+
113
+ // consider
114
+ createRegexRule({
115
+ id: 'GI_consider_to',
116
+ category: 'grammar',
117
+ pattern: /\bconsidered?\s+to\s+(?!be\b|have\b)(\w+)/i,
118
+ suggestion: (m) => `consider${m[0].match(/considered/) ? 'ed' : ''} ${m[1]}ing`,
119
+ reason: '"Consider" is followed by a gerund (-ing form), not an infinitive.',
120
+ }),
121
+
122
+ // keep
123
+ createRegexRule({
124
+ id: 'GI_keep_to',
125
+ category: 'grammar',
126
+ pattern: /\bkept\s+to\s+(\w+(?:ing)?)\b(?!\s+(the|a|an|his|her|their|our|my|your)\b)/i,
127
+ suggestion: (m) => `kept ${m[1].endsWith('ing') ? m[1] : m[1] + 'ing'}`,
128
+ reason: '"Keep" is followed by a gerund (-ing form), not an infinitive.',
129
+ }),
130
+
131
+ // admit
132
+ createRegexRule({
133
+ id: 'GI_admit_to_verb',
134
+ category: 'grammar',
135
+ pattern: /\badmitted?\s+to\s+(\w+(?:e)?)\b(?!\s+(the|a|an|it|him|her|them|us|me|you)\b)/i,
136
+ suggestion: (m) => {
137
+ const verb = m[1];
138
+ const gerund = verb.endsWith('e') ? verb.slice(0, -1) + 'ing' : verb + 'ing';
139
+ return `admit${m[0].match(/admitted/) ? 'ted' : ''} to ${gerund}`;
140
+ },
141
+ reason: '"Admit to" is followed by a gerund (-ing form).',
142
+ }),
143
+
144
+ // practice
145
+ createRegexRule({
146
+ id: 'GI_practice_to',
147
+ category: 'grammar',
148
+ pattern: /\bpractice\s+to\s+(\w+)/i,
149
+ suggestion: (m) => `practice ${m[1]}ing`,
150
+ reason: '"Practice" is followed by a gerund (-ing form), not an infinitive.',
151
+ }),
152
+
153
+ // deny
154
+ createRegexRule({
155
+ id: 'GI_deny_to',
156
+ category: 'grammar',
157
+ pattern: /\bdenied?\s+to\s+(?!him\b|her\b|them\b|me\b|us\b|you\b)(\w+)/i,
158
+ suggestion: (m) => `deni${m[0].match(/denied/) ? 'ed' : 'es'} ${m[1]}ing`,
159
+ reason: '"Deny" is followed by a gerund (-ing form), not an infinitive.',
160
+ }),
161
+
162
+ // resist
163
+ createRegexRule({
164
+ id: 'GI_resist_to',
165
+ category: 'grammar',
166
+ pattern: /\bresisted?\s+to\s+(\w+)/i,
167
+ suggestion: (m) => `resisted ${m[1]}ing`,
168
+ reason: '"Resist" is followed by a gerund (-ing form), not an infinitive.',
169
+ }),
170
+
171
+ // finish
172
+ createRegexRule({
173
+ id: 'GI_finish_to',
174
+ category: 'grammar',
175
+ pattern: /\bfinished?\s+to\s+(\w+)/i,
176
+ suggestion: (m) => `finish${m[0].match(/finished/) ? 'ed' : ''} ${m[1]}ing`,
177
+ reason: '"Finish" is followed by a gerund (-ing form), not an infinitive.',
178
+ }),
179
+
180
+ // mind
181
+ createRegexRule({
182
+ id: 'GI_mind_to',
183
+ category: 'grammar',
184
+ pattern: /\bminds?\s+to\s+(\w+)/i,
185
+ suggestion: (m) => `mind ${m[1]}ing`,
186
+ reason: '"Mind" is followed by a gerund (-ing form), not an infinitive.',
187
+ }),
188
+
189
+ // miss
190
+ createRegexRule({
191
+ id: 'GI_miss_to',
192
+ category: 'grammar',
193
+ pattern: /\bmissed?\s+to\s+(\w+)/i,
194
+ suggestion: (m) => `miss${m[0].match(/missed/) ? 'ed' : ''} ${m[1]}ing`,
195
+ reason: '"Miss" is followed by a gerund (-ing form), not an infinitive.',
196
+ }),
197
+
198
+ // quit
199
+ createRegexRule({
200
+ id: 'GI_quit_to',
201
+ category: 'grammar',
202
+ pattern: /\bquit\s+to\s+(\w+)/i,
203
+ suggestion: (m) => `quit ${m[1]}ing`,
204
+ reason: '"Quit" is followed by a gerund (-ing form), not an infinitive.',
205
+ }),
206
+
207
+ // postpone / delay
208
+ createRegexRule({
209
+ id: 'GI_postpone_to',
210
+ category: 'grammar',
211
+ pattern: /\bpostponed?\s+to\s+(\w+)/i,
212
+ suggestion: (m) => `postponed ${m[1]}ing`,
213
+ reason: '"Postpone" is followed by a gerund (-ing form), not an infinitive.',
214
+ }),
215
+ createRegexRule({
216
+ id: 'GI_delay_to',
217
+ category: 'grammar',
218
+ pattern: /\bdelayed?\s+to\s+(\w+)/i,
219
+ suggestion: (m) => `delayed ${m[1]}ing`,
220
+ reason: '"Delay" is followed by a gerund (-ing form), not an infinitive.',
221
+ }),
222
+
223
+ // imagine
224
+ createRegexRule({
225
+ id: 'GI_imagine_to',
226
+ category: 'grammar',
227
+ pattern: /\bimagined?\s+to\s+(\w+)/i,
228
+ suggestion: (m) => `imagined ${m[1]}ing`,
229
+ reason: '"Imagine" is followed by a gerund (-ing form), not an infinitive.',
230
+ }),
231
+
232
+ // risk
233
+ createRegexRule({
234
+ id: 'GI_risk_to',
235
+ category: 'grammar',
236
+ pattern: /\brisked?\s+to\s+(\w+)/i,
237
+ suggestion: (m) => `risked ${m[1]}ing`,
238
+ reason: '"Risk" is followed by a gerund (-ing form), not an infinitive.',
239
+ }),
240
+
241
+ // cannot help
242
+ createRegexRule({
243
+ id: 'GI_cant_help_to',
244
+ category: 'grammar',
245
+ pattern: /\bcan'?t\s+help\s+to\s+(\w+)/i,
246
+ suggestion: (m) => `can't help ${m[1]}ing`,
247
+ reason: '"Cannot help" is followed by a gerund, not an infinitive.',
248
+ }),
249
+
250
+ // ═══════════════════════════════════════════════
251
+ // SUBJUNCTIVE MOOD
252
+ // ═══════════════════════════════════════════════
253
+ createRegexRule({
254
+ id: 'GI_if_i_was',
255
+ category: 'grammar',
256
+ pattern: /\bif\s+I\s+was\b(?!\s+able|\s+going|\s+supposed|\s+trying|\s+planning|\s+hoping|\s+thinking|\s+looking)/i,
257
+ suggestion: 'if I were',
258
+ reason: 'Use the subjunctive "were" (not "was") in hypothetical "if" clauses.',
259
+ }),
260
+ createRegexRule({
261
+ id: 'GI_wish_i_was',
262
+ category: 'grammar',
263
+ pattern: /\bwish\s+(I|he|she|it)\s+was\b/i,
264
+ suggestion: (m) => `wish ${m[1]} were`,
265
+ reason: 'After "wish," use the subjunctive "were" instead of "was".',
266
+ }),
267
+ createRegexRule({
268
+ id: 'GI_as_if_was',
269
+ category: 'grammar',
270
+ pattern: /\bas\s+if\s+(I|he|she|it|he|they)\s+was\b/i,
271
+ suggestion: (m) => `as if ${m[1]} were`,
272
+ reason: 'After "as if," use the subjunctive "were" instead of "was".',
273
+ }),
274
+ ];
@@ -0,0 +1,294 @@
1
+ import type { Issue } from '../../shared-types.js';
2
+ import { createRegexRule, type Rule } from '../types.js';
3
+
4
+ export const advancedGrammarRules: Rule[] = [
5
+ // Spacing Errors
6
+ createRegexRule({
7
+ id: 'double-space',
8
+ category: 'grammar',
9
+ pattern: /[^.](\s{2,})/g,
10
+ suggestion: ' ',
11
+ reason: 'Multiple spaces detected. Use single space.',
12
+ }),
13
+ createRegexRule({
14
+ id: 'space-before-punct',
15
+ category: 'grammar',
16
+ pattern: /\s+([.,!?;:])/g,
17
+ suggestion: (match) => match[1] || '',
18
+ reason: 'Remove space before punctuation.',
19
+ }),
20
+ createRegexRule({
21
+ id: 'no-space-after-punct',
22
+ category: 'grammar',
23
+ pattern: /([.,!?;:])([A-Z][a-z])/g,
24
+ suggestion: (match) => `${match[1] || ''} ${match[2] || ''}`,
25
+ reason: 'Add space after punctuation.',
26
+ }),
27
+
28
+ // Apostrophe Errors
29
+ createRegexRule({
30
+ id: 'its-possessive',
31
+ category: 'grammar',
32
+ pattern:
33
+ /\bits\s+(name|own|color|size|shape|kind|type|way|purpose|function|role|effect|impact|result|content|source|code|data|file|path|url|id|user|item|object|property|value|element|node|parent|child|sibling)\b/i,
34
+ suggestion: (match) => `it's ${match[1]}`,
35
+ reason: "Use 'it's' (contraction of 'it is') here.",
36
+ }),
37
+ createRegexRule({
38
+ id: 'youre-verb',
39
+ category: 'grammar',
40
+ pattern:
41
+ /\byour\s+(welcome|going|right|wrong|reading|writing|working|looking|sounding|feeling|thinking|doing|making|taking|getting|having|being|becoming|seeming|appearing)\b/i,
42
+ suggestion: (match) => `you're ${match[1]}`,
43
+ reason: "Use 'you're' (contraction of 'you are') here.",
44
+ }),
45
+ createRegexRule({
46
+ id: 'theyre-verb',
47
+ category: 'grammar',
48
+ pattern:
49
+ /\btheir\s+(going|coming|working|doing|making|taking|getting|having|being|becoming|seeming|looking|sounding|feeling|thinking)\b/i,
50
+ suggestion: (match) => `they're ${match[1]}`,
51
+ reason: "Use 'they're' (contraction of 'they are') here.",
52
+ }),
53
+
54
+ // That vs Which
55
+ createRegexRule({
56
+ id: 'which-no-comma',
57
+ category: 'grammar',
58
+ pattern: /([^,]\s+)which\s+(is|are|was|were|has|have|had|does|do|did)\b/i,
59
+ suggestion: (match) => `${match[1].trim()}, which ${match[2]}`,
60
+ reason: "Non-restrictive clauses need a comma before 'which'.",
61
+ }),
62
+
63
+ // Less vs Fewer
64
+ createRegexRule({
65
+ id: 'less-fewer',
66
+ category: 'grammar',
67
+ pattern:
68
+ /\bless\s+(items|things|people|words|sentences|paragraphs|pages|books|cars|houses|dogs|cats|students|teachers|errors|problems|questions|answers|ideas|concepts|rules|examples|cases|instances|occasions|times|days|weeks|months|years)\b/i,
69
+ suggestion: (match) => `fewer ${match[1]}`,
70
+ reason: "Use 'fewer' with countable nouns.",
71
+ }),
72
+
73
+ // Comma Splices (Requires more manual custom check logic)
74
+ {
75
+ id: 'comma-splice',
76
+ type: 'regex',
77
+ category: 'grammar',
78
+ pattern: /\b([A-Z][^.]*?)\s*,\s+([A-Z][^.]*?[.!?])/g,
79
+ reason: 'Comma splice detected. Use a period, semicolon, or conjunction.',
80
+ suggestion: '',
81
+ check: (text: string): Issue[] => {
82
+ const issues: Issue[] = [];
83
+ const commaSpliceRegex = /\b([A-Z][^.]*?)\s*,\s+([A-Z][^.]*?[.!?])/g;
84
+ let match: RegExpExecArray | null;
85
+ while ((match = commaSpliceRegex.exec(text)) !== null) {
86
+ const clause1 = match[1]?.trim() || '';
87
+ const clause2 = match[2]?.trim() || '';
88
+ if (clause1.split(' ').length > 3 && clause2.split(' ').length > 3) {
89
+ issues.push({
90
+ id: `comma-splice-${match.index}`,
91
+ type: 'grammar',
92
+ original: `${clause1}, ${clause2}`,
93
+ suggestion: `${clause1}. ${clause2}`,
94
+ reason: 'Comma splice detected. Use a period, semicolon, or conjunction.',
95
+ offset: match.index,
96
+ length: match[0].length,
97
+ });
98
+ }
99
+ }
100
+ return issues;
101
+ },
102
+ },
103
+
104
+ // Double Negatives
105
+ {
106
+ id: 'double-negative',
107
+ type: 'regex',
108
+ category: 'grammar',
109
+ pattern: /[^.!?]+[.!?]+/g,
110
+ reason: 'Double negative detected.',
111
+ suggestion: 'Remove one negative',
112
+ check: (text: string): Issue[] => {
113
+ const issues: Issue[] = [];
114
+ const negativeWords = [
115
+ "don't",
116
+ "doesn't",
117
+ "didn't",
118
+ "won't",
119
+ "wouldn't",
120
+ "couldn't",
121
+ "shouldn't",
122
+ "can't",
123
+ 'cannot',
124
+ 'no',
125
+ 'not',
126
+ 'never',
127
+ 'nothing',
128
+ 'nobody',
129
+ 'nowhere',
130
+ 'neither',
131
+ 'nor',
132
+ ];
133
+ const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
134
+ let currentIndex = 0;
135
+ sentences.forEach((sentence) => {
136
+ const lowerSentence = sentence.toLowerCase();
137
+ const foundNegatives = negativeWords.filter((word) => lowerSentence.includes(word));
138
+ if (foundNegatives.length >= 2) {
139
+ issues.push({
140
+ id: `double-neg-${currentIndex}`,
141
+ type: 'grammar',
142
+ original: sentence.trim(),
143
+ suggestion: 'Remove one negative',
144
+ reason: `Double negative detected: ${foundNegatives.join(', ')}. This may be unintentional.`,
145
+ offset: currentIndex,
146
+ length: sentence.length,
147
+ });
148
+ }
149
+ currentIndex += sentence.length;
150
+ });
151
+ return issues;
152
+ },
153
+ },
154
+
155
+ // Article Errors
156
+ {
157
+ id: 'article-a-an',
158
+ type: 'regex',
159
+ category: 'grammar',
160
+ pattern: /\b(?:a|an)\s+\w+/gi,
161
+ reason: 'Article mismatch',
162
+ suggestion: '',
163
+ check: (text: string): Issue[] => {
164
+ const issues: Issue[] = [];
165
+ const vowelExceptions = new Set(['uni', 'use', 'usu', 'eur', 'one', 'once']);
166
+ const aBeforeVowelRegex = /\ba\s+([aeiou]\w*)\b/gi;
167
+ let match: RegExpExecArray | null;
168
+ while ((match = aBeforeVowelRegex.exec(text)) !== null) {
169
+ const nextWord = (match[1] || '').toLowerCase();
170
+ const isException = Array.from(vowelExceptions).some((ex) => nextWord.startsWith(ex));
171
+ if (!isException) {
172
+ issues.push({
173
+ id: `article-a-${match.index}`,
174
+ type: 'grammar',
175
+ original: match[0],
176
+ suggestion: `an ${match[1]}`,
177
+ reason: 'Use "an" before words that begin with a vowel sound.',
178
+ offset: match.index,
179
+ length: match[0].length,
180
+ });
181
+ }
182
+ }
183
+
184
+ const consonantExceptions = new Set(['hour', 'honest', 'honor', 'honour', 'heir', 'herb']);
185
+ const anBeforeConsonantRegex = /\ban\s+([bcdfghjklmnpqrstvwxyz]\w*)\b/gi;
186
+ while ((match = anBeforeConsonantRegex.exec(text)) !== null) {
187
+ const nextWord = (match[1] || '').toLowerCase();
188
+ const isException = Array.from(consonantExceptions).some((ex) => nextWord.startsWith(ex));
189
+ if (!isException) {
190
+ issues.push({
191
+ id: `article-an-${match.index}`,
192
+ type: 'grammar',
193
+ original: match[0],
194
+ suggestion: `a ${match[1]}`,
195
+ reason: 'Use "a" before words that begin with a consonant sound.',
196
+ offset: match.index,
197
+ length: match[0].length,
198
+ });
199
+ }
200
+ }
201
+ return issues;
202
+ },
203
+ },
204
+
205
+ // Missing Commas
206
+ {
207
+ id: 'intro-commas',
208
+ type: 'regex',
209
+ category: 'grammar',
210
+ pattern: /(?:^|[.!?]\s+)(however|therefore|furthermore)\s+(?!,)([A-Za-z])/gi,
211
+ reason: 'Missing comma',
212
+ suggestion: '',
213
+ check: (text: string): Issue[] => {
214
+ const issues: Issue[] = [];
215
+ const introWords = [
216
+ 'however',
217
+ 'therefore',
218
+ 'furthermore',
219
+ 'moreover',
220
+ 'nevertheless',
221
+ 'meanwhile',
222
+ 'consequently',
223
+ 'additionally',
224
+ 'similarly',
225
+ 'accordingly',
226
+ 'unfortunately',
227
+ 'fortunately',
228
+ 'finally',
229
+ 'obviously',
230
+ 'clearly',
231
+ ];
232
+
233
+ for (const word of introWords) {
234
+ const regex = new RegExp(`(?:^|[.!?]\\s+)${word}\\s+(?!,)([A-Za-z])`, 'gi');
235
+ let match: RegExpExecArray | null;
236
+ while ((match = regex.exec(text)) !== null) {
237
+ const wordStart = text.indexOf(word, match.index);
238
+ if (wordStart >= 0) {
239
+ issues.push({
240
+ id: `intro-comma-${wordStart}`,
241
+ type: 'grammar',
242
+ original: `${word} ${match[1]}`,
243
+ suggestion: `${word}, ${match[1]}`,
244
+ reason: `Add a comma after the introductory word "${word}".`,
245
+ offset: wordStart,
246
+ length: word.length + 2,
247
+ });
248
+ }
249
+ }
250
+ }
251
+ return issues;
252
+ },
253
+ },
254
+
255
+ // Sentence Fragments
256
+ {
257
+ id: 'sentence-fragments',
258
+ type: 'regex',
259
+ category: 'grammar',
260
+ pattern: /^\s*(because|although|though|even though)/i,
261
+ reason: 'Sentence Fragment',
262
+ suggestion: 'This may be a sentence fragment. Add a main clause.',
263
+ check: (text: string): Issue[] => {
264
+ const issues: Issue[] = [];
265
+ const sentences = text.match(/[^.!?]+[.!?]+/g) || [];
266
+ let currentIndex = 0;
267
+ const subordinators =
268
+ /^\s*(because|although|though|even though|while|whereas|since|unless|until|if|when|whenever|wherever|after|before|as soon as|in order to|so that)\b/i;
269
+
270
+ for (const sentence of sentences) {
271
+ const trimmed = sentence.trim();
272
+ const match = subordinators.exec(trimmed);
273
+
274
+ if (match) {
275
+ const wordCount = trimmed.split(/\s+/).length;
276
+ const hasComma = trimmed.includes(',');
277
+ if (wordCount < 10 && !hasComma) {
278
+ issues.push({
279
+ id: `fragment-${currentIndex}`,
280
+ type: 'grammar',
281
+ original: trimmed,
282
+ suggestion: 'This may be a sentence fragment. Add a main clause.',
283
+ reason: `Sentences starting with "${match[1]}" need a main clause to be complete.`,
284
+ offset: currentIndex,
285
+ length: sentence.length,
286
+ });
287
+ }
288
+ }
289
+ currentIndex += sentence.length;
290
+ }
291
+ return issues;
292
+ },
293
+ },
294
+ ];