@consilioweb/spellcheck 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1677 @@
1
+ // src/collections/SpellCheckResults.ts
2
+ function createSpellCheckResultsCollection() {
3
+ return {
4
+ slug: "spellcheck-results",
5
+ admin: {
6
+ hidden: true
7
+ },
8
+ access: {
9
+ read: ({ req }) => !!req.user,
10
+ create: ({ req }) => !!req.user,
11
+ update: ({ req }) => !!req.user,
12
+ delete: ({ req }) => !!req.user
13
+ },
14
+ timestamps: false,
15
+ fields: [
16
+ {
17
+ name: "docId",
18
+ type: "text",
19
+ required: true,
20
+ index: true,
21
+ admin: {
22
+ description: "ID of the checked document"
23
+ }
24
+ },
25
+ {
26
+ name: "collection",
27
+ type: "text",
28
+ required: true,
29
+ index: true,
30
+ admin: {
31
+ description: "Collection slug (e.g. 'pages', 'posts')"
32
+ }
33
+ },
34
+ {
35
+ name: "title",
36
+ type: "text",
37
+ admin: {
38
+ description: "Document title (for dashboard display)"
39
+ }
40
+ },
41
+ {
42
+ name: "slug",
43
+ type: "text",
44
+ admin: {
45
+ description: "Document slug"
46
+ }
47
+ },
48
+ {
49
+ name: "score",
50
+ type: "number",
51
+ required: true,
52
+ min: 0,
53
+ max: 100,
54
+ admin: {
55
+ description: "Spellcheck score (0-100, 100 = no issues)"
56
+ }
57
+ },
58
+ {
59
+ name: "issueCount",
60
+ type: "number",
61
+ required: true,
62
+ min: 0,
63
+ admin: {
64
+ description: "Number of issues found"
65
+ }
66
+ },
67
+ {
68
+ name: "wordCount",
69
+ type: "number",
70
+ min: 0,
71
+ admin: {
72
+ description: "Word count of the extracted text"
73
+ }
74
+ },
75
+ {
76
+ name: "issues",
77
+ type: "json",
78
+ admin: {
79
+ description: "JSON array of SpellCheckIssue objects"
80
+ }
81
+ },
82
+ {
83
+ name: "ignoredIssues",
84
+ type: "json",
85
+ admin: {
86
+ description: "JSON array of { ruleId, original } \u2014 issues ignored by the user, filtered on rescan"
87
+ }
88
+ },
89
+ {
90
+ name: "lastChecked",
91
+ type: "date",
92
+ required: true,
93
+ index: true,
94
+ defaultValue: () => (/* @__PURE__ */ new Date()).toISOString(),
95
+ admin: {
96
+ description: "Date of the last spellcheck"
97
+ }
98
+ }
99
+ ]
100
+ };
101
+ }
102
+
103
+ // src/collections/SpellCheckDictionary.ts
104
+ function createSpellCheckDictionaryCollection() {
105
+ return {
106
+ slug: "spellcheck-dictionary",
107
+ admin: {
108
+ hidden: true
109
+ },
110
+ access: {
111
+ read: ({ req }) => !!req.user,
112
+ create: ({ req }) => !!req.user,
113
+ update: ({ req }) => !!req.user,
114
+ delete: ({ req }) => !!req.user
115
+ },
116
+ hooks: {
117
+ beforeValidate: [
118
+ ({ data }) => {
119
+ if (data?.word && typeof data.word === "string") {
120
+ data.word = data.word.trim().toLowerCase();
121
+ }
122
+ return data;
123
+ }
124
+ ]
125
+ },
126
+ fields: [
127
+ {
128
+ name: "word",
129
+ type: "text",
130
+ required: true,
131
+ unique: true,
132
+ index: true,
133
+ admin: {
134
+ description: "Dictionary word (auto-lowercased)"
135
+ }
136
+ },
137
+ {
138
+ name: "addedBy",
139
+ type: "relationship",
140
+ relationTo: "users",
141
+ admin: {
142
+ description: "User who added this word"
143
+ }
144
+ }
145
+ ]
146
+ };
147
+ }
148
+
149
+ // src/engine/shared.ts
150
+ var SKIP_TYPES = /* @__PURE__ */ new Set(["code", "code-block", "codeBlock"]);
151
+ function isLexicalJson(value) {
152
+ if (!value || typeof value !== "object") return false;
153
+ const obj = value;
154
+ return Boolean(
155
+ obj.root && typeof obj.root === "object" || Array.isArray(obj.children) && obj.type !== void 0
156
+ );
157
+ }
158
+ var SKIP_KEYS = /* @__PURE__ */ new Set([
159
+ "id",
160
+ "_order",
161
+ "_parent_id",
162
+ "_path",
163
+ "_locale",
164
+ "_uuid",
165
+ "blockType",
166
+ "blockName",
167
+ "icon",
168
+ "color",
169
+ "link",
170
+ "link_url",
171
+ "enable_link",
172
+ "image",
173
+ "media",
174
+ "form",
175
+ "form_id",
176
+ "rating",
177
+ "size",
178
+ "position",
179
+ "relationTo",
180
+ "value",
181
+ "updatedAt",
182
+ "createdAt",
183
+ "_status",
184
+ "slug",
185
+ "meta",
186
+ "publishedAt",
187
+ "populatedAuthors"
188
+ ]);
189
+ var PLAIN_TEXT_KEYS = /* @__PURE__ */ new Set([
190
+ "title",
191
+ "description",
192
+ "heading",
193
+ "subheading",
194
+ "subtitle",
195
+ "quote",
196
+ "author",
197
+ "role",
198
+ "label",
199
+ "link_label",
200
+ "block_name",
201
+ "caption",
202
+ "alt",
203
+ "text",
204
+ "summary",
205
+ "excerpt"
206
+ ]);
207
+
208
+ // src/engine/lexicalParser.ts
209
+ function extractAllTextFromDocWithSources(doc, contentField = "content") {
210
+ const rawSegments = [];
211
+ const visited = /* @__PURE__ */ new WeakSet();
212
+ if (doc.title && typeof doc.title === "string") {
213
+ rawSegments.push({ text: doc.title, source: { type: "title" } });
214
+ }
215
+ if (doc.hero?.richText) {
216
+ rawSegments.push({
217
+ text: extractTextFromLexical(doc.hero.richText),
218
+ source: { type: "lexical", data: doc.hero.richText, topField: "hero" }
219
+ });
220
+ }
221
+ if (doc[contentField] && isLexicalJson(doc[contentField])) {
222
+ rawSegments.push({
223
+ text: extractTextFromLexical(doc[contentField]),
224
+ source: { type: "lexical", data: doc[contentField], topField: contentField }
225
+ });
226
+ }
227
+ if (Array.isArray(doc.layout)) {
228
+ for (const block of doc.layout) {
229
+ extractBlockSegments(block, rawSegments, visited, "layout");
230
+ }
231
+ }
232
+ const segments = rawSegments.filter((s) => Boolean(s.text));
233
+ const fullText = segments.map((s) => s.text).join("\n").trim();
234
+ return { fullText, segments };
235
+ }
236
+ function extractAllTextFromDoc(doc, contentField = "content") {
237
+ return extractAllTextFromDocWithSources(doc, contentField).fullText;
238
+ }
239
+ function extractBlockSegments(obj, segments, visited, topField, depth = 0) {
240
+ if (!obj || typeof obj !== "object" || depth > 10) return;
241
+ if (visited.has(obj)) return;
242
+ visited.add(obj);
243
+ if (Array.isArray(obj)) {
244
+ for (const item of obj) {
245
+ extractBlockSegments(item, segments, visited, topField, depth + 1);
246
+ }
247
+ return;
248
+ }
249
+ const record = obj;
250
+ for (const [key, value] of Object.entries(record)) {
251
+ if (SKIP_KEYS.has(key)) continue;
252
+ if (isLexicalJson(value)) {
253
+ segments.push({
254
+ text: extractTextFromLexical(value),
255
+ source: { type: "lexical", data: value, topField }
256
+ });
257
+ continue;
258
+ }
259
+ if (typeof value === "string" && value.length > 2 && value.length < 5e3) {
260
+ if (/^(https?:|\/|#|\d{4}-\d{2}|[0-9a-f-]{36}|data:|mailto:)/i.test(value)) continue;
261
+ if (/^\{.*\}$/.test(value) || /^\[.*\]$/.test(value)) continue;
262
+ if (PLAIN_TEXT_KEYS.has(key)) {
263
+ segments.push({
264
+ text: value,
265
+ source: { type: "plain", parent: record, key, topField }
266
+ });
267
+ }
268
+ }
269
+ if (typeof value === "object" && value !== null) {
270
+ extractBlockSegments(value, segments, visited, topField, depth + 1);
271
+ }
272
+ }
273
+ }
274
+ function extractTextFromLexical(node, maxDepth = 50) {
275
+ return extractRecursive(node, 0, maxDepth).trim();
276
+ }
277
+ function extractRecursive(node, depth, maxDepth) {
278
+ if (!node || depth > maxDepth) return "";
279
+ if (Array.isArray(node)) {
280
+ let text2 = "";
281
+ for (const item of node) {
282
+ text2 += extractRecursive(item, depth + 1, maxDepth);
283
+ }
284
+ return text2;
285
+ }
286
+ if (typeof node !== "object") return "";
287
+ if (node.type && SKIP_TYPES.has(node.type)) return "";
288
+ let text = "";
289
+ if (node.type === "text" && typeof node.text === "string") {
290
+ text += node.text;
291
+ }
292
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "listitem") {
293
+ for (const child of node.children || []) {
294
+ text += extractRecursive(child, depth + 1, maxDepth);
295
+ }
296
+ text += "\n";
297
+ return text;
298
+ }
299
+ if (Array.isArray(node.children)) {
300
+ for (const child of node.children) {
301
+ text += extractRecursive(child, depth + 1, maxDepth);
302
+ }
303
+ }
304
+ if (node.root) {
305
+ text += extractRecursive(node.root, depth + 1, maxDepth);
306
+ }
307
+ return text;
308
+ }
309
+ function countWords(text) {
310
+ return text.split(/\s+/).filter((w) => w.length > 0).length;
311
+ }
312
+
313
+ // src/engine/languagetool.ts
314
+ var LANGUAGETOOL_API = "https://api.languagetool.org/v2/check";
315
+ var MAX_TEXT_LENGTH = 18e3;
316
+ var REQUEST_TIMEOUT = 3e4;
317
+ async function checkWithLanguageTool(text, language, config) {
318
+ if (!text.trim()) return [];
319
+ const truncatedText = text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
320
+ const disabledRules = [
321
+ ...config.skipRules || [],
322
+ "WHITESPACE_RULE",
323
+ "COMMA_PARENTHESIS_WHITESPACE",
324
+ "UNPAIRED_BRACKETS"
325
+ ].join(",");
326
+ const params = new URLSearchParams({
327
+ text: truncatedText,
328
+ language,
329
+ disabledRules
330
+ });
331
+ const controller = new AbortController();
332
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
333
+ try {
334
+ const response = await fetch(LANGUAGETOOL_API, {
335
+ method: "POST",
336
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
337
+ body: params.toString(),
338
+ signal: controller.signal
339
+ });
340
+ clearTimeout(timeoutId);
341
+ if (!response.ok) {
342
+ throw new Error(`LanguageTool API error: ${response.status} ${response.statusText}`);
343
+ }
344
+ const data = await response.json();
345
+ return parseMatches(data.matches || []);
346
+ } catch (error) {
347
+ clearTimeout(timeoutId);
348
+ if (error.name === "AbortError") {
349
+ console.error("[spellcheck] LanguageTool request timed out");
350
+ return [];
351
+ }
352
+ console.error("[spellcheck] LanguageTool error:", error);
353
+ return [];
354
+ }
355
+ }
356
+ function parseMatches(matches) {
357
+ return matches.map((m) => ({
358
+ ruleId: m.rule.id,
359
+ category: m.rule.category.id,
360
+ message: m.message,
361
+ context: m.context.text,
362
+ contextOffset: m.context.offset,
363
+ offset: m.offset,
364
+ length: m.length,
365
+ original: m.context.text.slice(m.context.offset, m.context.offset + m.context.length),
366
+ replacements: m.replacements.slice(0, 3).map((r) => r.value),
367
+ source: "languagetool",
368
+ isPremium: m.rule.isPremium ?? false
369
+ }));
370
+ }
371
+
372
+ // src/engine/claude.ts
373
+ var ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
374
+ var REQUEST_TIMEOUT2 = 6e4;
375
+ var MAX_TEXT_LENGTH2 = 8e3;
376
+ async function checkWithClaude(text, language, apiKey) {
377
+ if (!text.trim() || !apiKey) return [];
378
+ const truncatedText = text.length > MAX_TEXT_LENGTH2 ? text.slice(0, MAX_TEXT_LENGTH2) : text;
379
+ const langLabel = language === "fr" ? "French" : "English";
380
+ const prompt = `Analyze this ${langLabel} web content for semantic issues ONLY (NOT spelling/grammar \u2014 a separate tool handles that). Check for:
381
+ 1. Inconsistent tone or register (formal vs informal mixing)
382
+ 2. Incoherent statements or contradictions
383
+ 3. Awkward phrasing that a spellchecker wouldn't catch
384
+ 4. Missing words that change meaning
385
+
386
+ Return a JSON array of issues found. Each issue: { "message": "...", "context": "10-word excerpt around issue", "original": "problematic phrase", "suggestion": "improved version", "category": "COHERENCE|TONE|PHRASING|MISSING_WORD" }
387
+
388
+ Return [] if no issues found. Be strict \u2014 only flag clear problems, not style preferences.
389
+
390
+ Text:
391
+ ${truncatedText}`;
392
+ const controller = new AbortController();
393
+ const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT2);
394
+ try {
395
+ const response = await fetch(ANTHROPIC_API, {
396
+ method: "POST",
397
+ headers: {
398
+ "Content-Type": "application/json",
399
+ "x-api-key": apiKey,
400
+ "anthropic-version": "2023-06-01"
401
+ },
402
+ body: JSON.stringify({
403
+ model: "claude-haiku-4-5-20251001",
404
+ max_tokens: 2048,
405
+ messages: [
406
+ { role: "user", content: prompt }
407
+ ]
408
+ }),
409
+ signal: controller.signal
410
+ });
411
+ clearTimeout(timeoutId);
412
+ if (!response.ok) {
413
+ console.error(`[spellcheck] Claude API error: ${response.status}`);
414
+ return [];
415
+ }
416
+ const data = await response.json();
417
+ const responseText = data.content?.[0]?.text || "[]";
418
+ const jsonMatch = responseText.match(/\[[\s\S]*\]/);
419
+ if (!jsonMatch) return [];
420
+ const issues = JSON.parse(jsonMatch[0]);
421
+ return issues.map((issue) => ({
422
+ ruleId: `CLAUDE_${issue.category}`,
423
+ category: issue.category,
424
+ message: issue.message,
425
+ context: issue.context,
426
+ contextOffset: issue.context ? issue.context.indexOf(issue.original) : 0,
427
+ offset: 0,
428
+ length: issue.original.length,
429
+ original: issue.original,
430
+ replacements: issue.suggestion ? [issue.suggestion] : [],
431
+ source: "claude"
432
+ }));
433
+ } catch (error) {
434
+ clearTimeout(timeoutId);
435
+ console.error("[spellcheck] Claude error:", error);
436
+ return [];
437
+ }
438
+ }
439
+
440
+ // src/endpoints/dictionary.ts
441
+ function createDictionaryListHandler() {
442
+ return async (req) => {
443
+ if (!req.user) {
444
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
445
+ }
446
+ try {
447
+ const result = await req.payload.find({
448
+ collection: "spellcheck-dictionary",
449
+ limit: 0,
450
+ sort: "word",
451
+ overrideAccess: true
452
+ });
453
+ return Response.json({
454
+ words: result.docs,
455
+ count: result.totalDocs
456
+ });
457
+ } catch (error) {
458
+ console.error("[spellcheck/dictionary] List error:", error);
459
+ return Response.json({ error: "Internal server error" }, { status: 500 });
460
+ }
461
+ };
462
+ }
463
+ function createDictionaryAddHandler() {
464
+ return async (req) => {
465
+ if (!req.user) {
466
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
467
+ }
468
+ try {
469
+ const body = await req.json().catch(() => ({}));
470
+ const { word, words } = body;
471
+ const wordsToAdd = [];
472
+ if (word) wordsToAdd.push(word);
473
+ if (Array.isArray(words)) wordsToAdd.push(...words);
474
+ if (wordsToAdd.length === 0) {
475
+ return Response.json({ error: "Provide { word } or { words: [] }" }, { status: 400 });
476
+ }
477
+ const added = [];
478
+ const skipped = [];
479
+ for (const w of wordsToAdd) {
480
+ const cleaned = w.trim().toLowerCase();
481
+ if (!cleaned) continue;
482
+ try {
483
+ const existing = await req.payload.find({
484
+ collection: "spellcheck-dictionary",
485
+ where: { word: { equals: cleaned } },
486
+ limit: 1,
487
+ overrideAccess: true
488
+ });
489
+ if (existing.docs.length > 0) {
490
+ skipped.push(cleaned);
491
+ continue;
492
+ }
493
+ await req.payload.create({
494
+ collection: "spellcheck-dictionary",
495
+ data: {
496
+ word: cleaned,
497
+ addedBy: typeof req.user.id !== "undefined" ? req.user.id : void 0
498
+ },
499
+ overrideAccess: true
500
+ });
501
+ added.push(cleaned);
502
+ } catch (err) {
503
+ skipped.push(cleaned);
504
+ }
505
+ }
506
+ invalidateDictionaryCache();
507
+ return Response.json({ added, skipped, count: added.length });
508
+ } catch (error) {
509
+ console.error("[spellcheck/dictionary] Add error:", error);
510
+ return Response.json({ error: "Internal server error" }, { status: 500 });
511
+ }
512
+ };
513
+ }
514
+ function createDictionaryDeleteHandler() {
515
+ return async (req) => {
516
+ if (!req.user) {
517
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
518
+ }
519
+ try {
520
+ const body = await req.json().catch(() => ({}));
521
+ const { id: bodyId, ids } = body;
522
+ const url = new URL(req.url || "", "http://localhost");
523
+ const queryId = url.searchParams.get("id");
524
+ const idsToDelete = [];
525
+ if (bodyId) idsToDelete.push(bodyId);
526
+ if (queryId) idsToDelete.push(queryId);
527
+ if (Array.isArray(ids)) idsToDelete.push(...ids);
528
+ if (idsToDelete.length === 0) {
529
+ return Response.json({ error: "Provide { id } or { ids: [] }" }, { status: 400 });
530
+ }
531
+ let deleted = 0;
532
+ for (const deleteId of idsToDelete) {
533
+ try {
534
+ await req.payload.delete({
535
+ collection: "spellcheck-dictionary",
536
+ id: deleteId,
537
+ overrideAccess: true
538
+ });
539
+ deleted++;
540
+ } catch {
541
+ }
542
+ }
543
+ invalidateDictionaryCache();
544
+ return Response.json({ deleted });
545
+ } catch (error) {
546
+ console.error("[spellcheck/dictionary] Delete error:", error);
547
+ return Response.json({ error: "Internal server error" }, { status: 500 });
548
+ }
549
+ };
550
+ }
551
+ var cachedWords = null;
552
+ var cacheTimestamp = 0;
553
+ var CACHE_TTL = 5 * 60 * 1e3;
554
+ function invalidateDictionaryCache() {
555
+ cachedWords = null;
556
+ cacheTimestamp = 0;
557
+ }
558
+ async function loadDictionaryWords(payload) {
559
+ const now = Date.now();
560
+ if (cachedWords && now - cacheTimestamp < CACHE_TTL) {
561
+ return cachedWords;
562
+ }
563
+ try {
564
+ const result = await payload.find({
565
+ collection: "spellcheck-dictionary",
566
+ limit: 0,
567
+ overrideAccess: true
568
+ });
569
+ cachedWords = result.docs.map((doc) => doc.word.toLowerCase());
570
+ cacheTimestamp = now;
571
+ return cachedWords;
572
+ } catch {
573
+ return [];
574
+ }
575
+ }
576
+
577
+ // src/engine/filters.ts
578
+ var DEFAULT_SKIP_RULES = /* @__PURE__ */ new Set([
579
+ "WHITESPACE_RULE",
580
+ "COMMA_PARENTHESIS_WHITESPACE",
581
+ "UNPAIRED_BRACKETS",
582
+ "UPPERCASE_SENTENCE_START",
583
+ // Headings/titles don't start with uppercase
584
+ "FRENCH_WHITESPACE",
585
+ // Non-breaking spaces are inconsistent in CMS
586
+ "MORFOLOGIK_RULE_FR_FR",
587
+ // Overly aggressive French spelling (flags proper nouns)
588
+ "APOS_TYP",
589
+ // Typography apostrophe (curly vs straight)
590
+ "APOS_INCORRECT",
591
+ // Backtick/apostrophe in code contexts
592
+ "POINT_VIRGULE",
593
+ // Semicolon spacing
594
+ "DASH_RULE",
595
+ // Dash types (em vs en)
596
+ "FRENCH_WORD_REPEAT_RULE",
597
+ // Repetitions from heading+body extraction
598
+ "MOT_TRAIT_MOT",
599
+ // Hyphenated English tech terms (mobile-first, utility-first)
600
+ "PAS_DE_TRAIT_UNION",
601
+ // Prefix hyphenation (multi-appareils)
602
+ "D_N",
603
+ // Determiner gender mismatch on English terms (un pull, du June)
604
+ "DOUBLES_ESPACES",
605
+ // Double spaces — auto-fixable but noisy in CMS content
606
+ "ESPACE_ENTRE_VIRGULE_ET_MOT",
607
+ // Grammalecte: space after comma (often CMS extraction artifacts)
608
+ "ESPACE_ENTRE_POINT_ET_MOT",
609
+ // Grammalecte: space after period (extraction artifacts)
610
+ "PRONOMS_PERSONNELS_MINUSCULE",
611
+ // False positive from paragraph boundary extraction (heading + body)
612
+ "DET_MAJ_SENT_START",
613
+ // Same issue: paragraph boundary makes it look like missing capital
614
+ "FR_SPLIT_WORDS_HYPHEN"
615
+ // Suggests splitting hyphenated words (éco-responsable → éco responsable)
616
+ ]);
617
+ var DEFAULT_SKIP_CATEGORIES = /* @__PURE__ */ new Set([
618
+ // English categories
619
+ "TYPOGRAPHY",
620
+ "TYPOS",
621
+ "STYLE",
622
+ // French categories (LanguageTool uses different IDs for French)
623
+ "CAT_TYPOGRAPHIE",
624
+ // French typography rules
625
+ "REPETITIONS_STYLE",
626
+ // French style/repetition suggestions
627
+ "CAT_REGLES_DE_BASEE"
628
+ // French basic rules (word repetition from CMS extraction)
629
+ ]);
630
+ var SKIP_PATTERNS = [
631
+ /^https?:\/\//i,
632
+ // URLs
633
+ /^mailto:/i,
634
+ // Email links
635
+ /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
636
+ // Emails
637
+ /^[A-Z][a-z]+[A-Z]/,
638
+ // CamelCase (JavaScript, TypeScript)
639
+ /^[A-Z]{2,}$/,
640
+ // All caps abbreviations (API, CSS, SEO)
641
+ /^\d+[\d.,]*$/,
642
+ // Numbers
643
+ /^\d+[a-zA-Z]+$/,
644
+ // Numbers with units (80px, 24h, mp3)
645
+ /^[#@]/,
646
+ // Hashtags, mentions
647
+ /^[€$£¥]/,
648
+ // Currency amounts
649
+ /^0[1-9]\d{8}$/,
650
+ // French phone numbers
651
+ /^\+?\d[\d\s.-]{8,}$/,
652
+ // International phone numbers
653
+ /^[a-z-]+\.[a-z]{2,}$/i,
654
+ // Domain names
655
+ /^`.*`$/,
656
+ // Inline code (backticks)
657
+ /^[a-z]+_[a-z]+/i,
658
+ // snake_case identifiers
659
+ /^--[a-z]/
660
+ // CSS custom properties (--color-primary)
661
+ ];
662
+ var CODE_CONTEXT_PATTERNS = [
663
+ /`[^`]*$/,
664
+ // Inside backtick code span (opening but no close before issue)
665
+ /\b(npm|pnpm|yarn|git|docker|curl|wget)\b/i
666
+ // CLI commands
667
+ ];
668
+ async function filterFalsePositives(issues, config, payload) {
669
+ const skipRules = /* @__PURE__ */ new Set([
670
+ ...DEFAULT_SKIP_RULES,
671
+ ...config.skipRules || []
672
+ ]);
673
+ const skipCategories = /* @__PURE__ */ new Set([
674
+ ...DEFAULT_SKIP_CATEGORIES,
675
+ ...config.skipCategories || []
676
+ ]);
677
+ const configWords = (config.customDictionary || []).map((w) => w.toLowerCase());
678
+ const dbWords = payload ? await loadDictionaryWords(payload) : [];
679
+ const dictionaryWords = [.../* @__PURE__ */ new Set([...configWords, ...dbWords])];
680
+ const dictionary = new Set(dictionaryWords);
681
+ return issues.filter((issue) => {
682
+ if (issue.isPremium) return false;
683
+ if (skipRules.has(issue.ruleId)) return false;
684
+ if (skipCategories.has(issue.category)) return false;
685
+ if (issue.original && dictionary.has(issue.original.toLowerCase())) return false;
686
+ if (issue.original) {
687
+ const lower = issue.original.toLowerCase();
688
+ for (const word of dictionaryWords) {
689
+ if (lower.includes(word) || word.includes(lower)) return false;
690
+ }
691
+ }
692
+ if (issue.context) {
693
+ const ctxLower = issue.context.toLowerCase();
694
+ for (const word of dictionaryWords) {
695
+ if (word.includes(" ") && ctxLower.includes(word)) {
696
+ return false;
697
+ }
698
+ }
699
+ }
700
+ if (issue.original && issue.original.length <= 1 && issue.category !== "GRAMMAR") return false;
701
+ if (issue.original) {
702
+ for (const pattern of SKIP_PATTERNS) {
703
+ if (pattern.test(issue.original)) return false;
704
+ }
705
+ }
706
+ if (issue.context) {
707
+ for (const pattern of CODE_CONTEXT_PATTERNS) {
708
+ if (pattern.test(issue.context)) return false;
709
+ }
710
+ if (issue.original && issue.context.includes("`" + issue.original + "`")) return false;
711
+ }
712
+ if (issue.replacements.length > 0 && issue.replacements[0] === issue.original) return false;
713
+ if (issue.context && issue.context.trim().length < 5) return false;
714
+ if (issue.ruleId.includes("REPET") || issue.category === "CAT_REGLES_DE_BASE") {
715
+ if (issue.original) {
716
+ const lower = issue.original.toLowerCase();
717
+ for (const word of dictionaryWords) {
718
+ if (lower.includes(word) || word.includes(lower)) return false;
719
+ }
720
+ }
721
+ if (issue.replacements.length > 0 && issue.replacements[0] === issue.original) return false;
722
+ if (issue.replacements.length > 0 && issue.replacements[0] === "") return false;
723
+ }
724
+ return true;
725
+ });
726
+ }
727
+ function calculateScore(wordCount, issueCount) {
728
+ if (wordCount === 0) return 100;
729
+ if (issueCount === 0) return 100;
730
+ const issuesPerHundredWords = issueCount / wordCount * 100;
731
+ const score = Math.max(0, Math.round(100 - issuesPerHundredWords * 10));
732
+ return Math.min(100, score);
733
+ }
734
+
735
+ // src/endpoints/validate.ts
736
+ function createValidateHandler(pluginConfig) {
737
+ return async (req) => {
738
+ try {
739
+ if (!req.user) {
740
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
741
+ }
742
+ const body = await req.json().catch(() => ({}));
743
+ const {
744
+ id,
745
+ collection,
746
+ text: rawText,
747
+ language: bodyLanguage
748
+ } = body;
749
+ const language = bodyLanguage || pluginConfig.language || "fr";
750
+ const contentField = pluginConfig.contentField || "content";
751
+ let textToCheck;
752
+ let fetchedDoc = null;
753
+ if (rawText) {
754
+ textToCheck = rawText;
755
+ } else if (id && collection) {
756
+ const findResult = await req.payload.find({
757
+ collection,
758
+ where: { id: { equals: id } },
759
+ limit: 1,
760
+ depth: 0,
761
+ draft: true,
762
+ overrideAccess: true
763
+ });
764
+ if (!findResult.docs.length) {
765
+ return Response.json({ error: "Document not found" }, { status: 404 });
766
+ }
767
+ fetchedDoc = findResult.docs[0];
768
+ textToCheck = extractAllTextFromDoc(fetchedDoc, contentField);
769
+ } else {
770
+ return Response.json(
771
+ { error: "Provide { id, collection } or { text }" },
772
+ { status: 400 }
773
+ );
774
+ }
775
+ const wordCount = countWords(textToCheck);
776
+ let issues = await checkWithLanguageTool(textToCheck, language, pluginConfig);
777
+ if (pluginConfig.enableAiFallback && pluginConfig.anthropicApiKey) {
778
+ const claudeIssues = await checkWithClaude(textToCheck, language, pluginConfig.anthropicApiKey);
779
+ issues = [...issues, ...claudeIssues];
780
+ }
781
+ issues = await filterFalsePositives(issues, pluginConfig, req.payload);
782
+ let ignoredIssues = [];
783
+ let existingDoc = null;
784
+ if (id && collection) {
785
+ try {
786
+ const existing = await req.payload.find({
787
+ collection: "spellcheck-results",
788
+ where: {
789
+ docId: { equals: String(id) },
790
+ collection: { equals: collection }
791
+ },
792
+ limit: 1,
793
+ overrideAccess: true
794
+ });
795
+ if (existing.docs.length > 0) {
796
+ existingDoc = existing.docs[0];
797
+ ignoredIssues = Array.isArray(existingDoc.ignoredIssues) ? existingDoc.ignoredIssues : [];
798
+ }
799
+ } catch {
800
+ }
801
+ }
802
+ if (ignoredIssues.length > 0) {
803
+ issues = issues.filter(
804
+ (issue) => !ignoredIssues.some((ignored) => ignored.ruleId === issue.ruleId && ignored.original === issue.original)
805
+ );
806
+ }
807
+ const score = calculateScore(wordCount, issues.length);
808
+ if (id && collection) {
809
+ const docIdStr = String(id);
810
+ try {
811
+ const title = fetchedDoc?.title || "";
812
+ const slug = fetchedDoc?.slug || "";
813
+ const resultData = {
814
+ docId: docIdStr,
815
+ collection,
816
+ title,
817
+ slug,
818
+ score,
819
+ issueCount: issues.length,
820
+ wordCount,
821
+ issues,
822
+ ignoredIssues,
823
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
824
+ };
825
+ if (existingDoc) {
826
+ await req.payload.update({
827
+ collection: "spellcheck-results",
828
+ id: existingDoc.id,
829
+ data: resultData,
830
+ overrideAccess: true
831
+ });
832
+ } else {
833
+ await req.payload.create({
834
+ collection: "spellcheck-results",
835
+ data: resultData,
836
+ overrideAccess: true
837
+ });
838
+ }
839
+ } catch (err) {
840
+ console.error("[spellcheck] Failed to store result:", err);
841
+ }
842
+ }
843
+ const result = {
844
+ docId: id ? String(id) : "",
845
+ collection: collection || "",
846
+ score,
847
+ issueCount: issues.length,
848
+ wordCount,
849
+ issues,
850
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
851
+ };
852
+ return Response.json(result);
853
+ } catch (error) {
854
+ console.error("[spellcheck/validate] Error:", error);
855
+ return Response.json({ error: "Internal server error" }, { status: 500 });
856
+ }
857
+ };
858
+ }
859
+
860
+ // src/endpoints/fix.ts
861
+ function fixInLexicalTree(node, targetOffset, targetLength, replacement, currentPos, depth = 0, maxDepth = 50) {
862
+ if (!node || depth > maxDepth) return { fixed: false, chars: 0 };
863
+ if (Array.isArray(node)) {
864
+ let chars2 = 0;
865
+ for (const item of node) {
866
+ const r = fixInLexicalTree(item, targetOffset, targetLength, replacement, currentPos + chars2, depth + 1, maxDepth);
867
+ chars2 += r.chars;
868
+ if (r.fixed) return { fixed: true, chars: chars2 };
869
+ }
870
+ return { fixed: false, chars: chars2 };
871
+ }
872
+ if (typeof node !== "object") return { fixed: false, chars: 0 };
873
+ if (node.type && SKIP_TYPES.has(node.type)) return { fixed: false, chars: 0 };
874
+ if (node.type === "text" && typeof node.text === "string") {
875
+ const nodeStart = currentPos;
876
+ const nodeEnd = currentPos + node.text.length;
877
+ if (targetOffset >= nodeStart && targetOffset < nodeEnd) {
878
+ const posInNode = targetOffset - nodeStart;
879
+ node.text = node.text.slice(0, posInNode) + replacement + node.text.slice(posInNode + targetLength);
880
+ return { fixed: true, chars: node.text.length };
881
+ }
882
+ return { fixed: false, chars: node.text.length };
883
+ }
884
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "listitem") {
885
+ let chars2 = 0;
886
+ for (const child of node.children || []) {
887
+ const r = fixInLexicalTree(child, targetOffset, targetLength, replacement, currentPos + chars2, depth + 1, maxDepth);
888
+ chars2 += r.chars;
889
+ if (r.fixed) return { fixed: true, chars: chars2 + 1 };
890
+ }
891
+ chars2 += 1;
892
+ return { fixed: false, chars: chars2 };
893
+ }
894
+ let chars = 0;
895
+ if (Array.isArray(node.children)) {
896
+ for (const child of node.children) {
897
+ const r = fixInLexicalTree(child, targetOffset, targetLength, replacement, currentPos + chars, depth + 1, maxDepth);
898
+ chars += r.chars;
899
+ if (r.fixed) return { fixed: true, chars };
900
+ }
901
+ }
902
+ if (node.root) {
903
+ const r = fixInLexicalTree(node.root, targetOffset, targetLength, replacement, currentPos + chars, depth + 1, maxDepth);
904
+ chars += r.chars;
905
+ if (r.fixed) return { fixed: true, chars };
906
+ }
907
+ return { fixed: false, chars };
908
+ }
909
+ function applyFixAtOffset(segments, fullText, targetOffset, targetLength, replacement, docClone) {
910
+ const rawJoined = segments.map((s) => s.text).join("\n");
911
+ const trimOffset = rawJoined.length - rawJoined.trimStart().length;
912
+ const rawTargetOffset = targetOffset + trimOffset;
913
+ let pos = 0;
914
+ for (const segment of segments) {
915
+ const segEnd = pos + segment.text.length;
916
+ if (rawTargetOffset >= pos && rawTargetOffset < segEnd) {
917
+ const localOffset = rawTargetOffset - pos;
918
+ return applyFixToSegment(segment, localOffset, targetLength, replacement, docClone);
919
+ }
920
+ pos = segEnd + 1;
921
+ }
922
+ return { fixed: false, modifiedField: null };
923
+ }
924
+ function applyFixToSegment(segment, localOffset, targetLength, replacement, docClone) {
925
+ const { source } = segment;
926
+ switch (source.type) {
927
+ case "title": {
928
+ const title = docClone.title;
929
+ docClone.title = title.slice(0, localOffset) + replacement + title.slice(localOffset + targetLength);
930
+ return { fixed: true, modifiedField: "title" };
931
+ }
932
+ case "lexical": {
933
+ const r = fixInLexicalTree(source.data, localOffset, targetLength, replacement, 0);
934
+ return r.fixed ? { fixed: true, modifiedField: source.topField } : { fixed: false, modifiedField: null };
935
+ }
936
+ case "plain": {
937
+ const currentVal = source.parent[source.key];
938
+ source.parent[source.key] = currentVal.slice(0, localOffset) + replacement + currentVal.slice(localOffset + targetLength);
939
+ return { fixed: true, modifiedField: source.topField };
940
+ }
941
+ }
942
+ }
943
+ function legacyFixSubstring(docClone, original, replacement, contentField) {
944
+ if (typeof docClone.title === "string" && docClone.title.includes(original)) {
945
+ docClone.title = docClone.title.replace(original, replacement);
946
+ return { fixed: true, modifiedField: "title" };
947
+ }
948
+ if (docClone.hero?.richText) {
949
+ const state = { done: false };
950
+ const fixed = legacyApplyToLexical(docClone.hero.richText, original, replacement, state);
951
+ if (fixed) return { fixed: true, modifiedField: "hero" };
952
+ }
953
+ if (docClone[contentField]) {
954
+ const state = { done: false };
955
+ const fixed = legacyApplyToLexical(docClone[contentField], original, replacement, state);
956
+ if (fixed) return { fixed: true, modifiedField: contentField };
957
+ }
958
+ if (Array.isArray(docClone.layout)) {
959
+ for (const block of docClone.layout) {
960
+ const state = { done: false };
961
+ const fixed = legacyApplyInObject(block, original, replacement, state);
962
+ if (fixed) return { fixed: true, modifiedField: "layout" };
963
+ }
964
+ }
965
+ return { fixed: false, modifiedField: null };
966
+ }
967
+ function legacyApplyToLexical(node, original, replacement, state) {
968
+ if (!node || state.done) return false;
969
+ if (Array.isArray(node)) {
970
+ for (const item of node) {
971
+ if (state.done) break;
972
+ if (legacyApplyToLexical(item, original, replacement, state)) return true;
973
+ }
974
+ return false;
975
+ }
976
+ if (typeof node !== "object") return false;
977
+ if (node.type === "text" && typeof node.text === "string") {
978
+ if (!state.done && node.text.includes(original)) {
979
+ node.text = node.text.replace(original, replacement);
980
+ state.done = true;
981
+ return true;
982
+ }
983
+ }
984
+ if (Array.isArray(node.children)) {
985
+ for (const child of node.children) {
986
+ if (state.done) break;
987
+ if (legacyApplyToLexical(child, original, replacement, state)) return true;
988
+ }
989
+ }
990
+ if (node.root && !state.done) {
991
+ if (legacyApplyToLexical(node.root, original, replacement, state)) return true;
992
+ }
993
+ return false;
994
+ }
995
+ function legacyApplyInObject(obj, original, replacement, state, depth = 0) {
996
+ if (!obj || typeof obj !== "object" || state.done || depth > 10) return false;
997
+ if (Array.isArray(obj)) {
998
+ for (const item of obj) {
999
+ if (state.done) break;
1000
+ if (legacyApplyInObject(item, original, replacement, state, depth + 1)) return true;
1001
+ }
1002
+ return false;
1003
+ }
1004
+ const record = obj;
1005
+ for (const [key, value] of Object.entries(record)) {
1006
+ if (state.done) break;
1007
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1008
+ const v = value;
1009
+ if (v.root && typeof v.root === "object") {
1010
+ if (legacyApplyToLexical(value, original, replacement, state)) return true;
1011
+ continue;
1012
+ }
1013
+ }
1014
+ if (typeof value === "string" && value.includes(original)) {
1015
+ if (PLAIN_TEXT_KEYS.has(key)) {
1016
+ record[key] = value.replace(original, replacement);
1017
+ state.done = true;
1018
+ return true;
1019
+ }
1020
+ }
1021
+ if (typeof value === "object" && value !== null) {
1022
+ if (legacyApplyInObject(value, original, replacement, state, depth + 1)) return true;
1023
+ }
1024
+ }
1025
+ return false;
1026
+ }
1027
+ function findClosestMatch(text, needle, expectedOffset) {
1028
+ if (!needle) return -1;
1029
+ const occurrences = [];
1030
+ let idx = text.indexOf(needle);
1031
+ while (idx !== -1) {
1032
+ occurrences.push(idx);
1033
+ idx = text.indexOf(needle, idx + 1);
1034
+ }
1035
+ if (occurrences.length === 0) return -1;
1036
+ if (occurrences.length === 1) return occurrences[0];
1037
+ return occurrences.reduce(
1038
+ (closest, curr) => Math.abs(curr - expectedOffset) < Math.abs(closest - expectedOffset) ? curr : closest
1039
+ );
1040
+ }
1041
+ function createFixHandler(pluginConfig) {
1042
+ return async (req) => {
1043
+ try {
1044
+ if (!req.user) {
1045
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1046
+ }
1047
+ const body = await req.json();
1048
+ const { id, collection, original, replacement, offset, length, field } = body;
1049
+ if (!id || !collection || !original || replacement === void 0) {
1050
+ return Response.json(
1051
+ { error: "Missing required fields: id, collection, original, replacement" },
1052
+ { status: 400 }
1053
+ );
1054
+ }
1055
+ const contentField = field || pluginConfig.contentField || "content";
1056
+ const findResult = await req.payload.find({
1057
+ collection,
1058
+ where: { id: { equals: id } },
1059
+ limit: 1,
1060
+ depth: 0,
1061
+ draft: true,
1062
+ overrideAccess: true
1063
+ });
1064
+ if (!findResult.docs.length) {
1065
+ return Response.json({ error: "Document not found" }, { status: 404 });
1066
+ }
1067
+ const doc = findResult.docs[0];
1068
+ const { fullText, segments } = extractAllTextFromDocWithSources(doc, contentField);
1069
+ let result;
1070
+ let method = "legacy";
1071
+ if (typeof offset === "number" && typeof length === "number") {
1072
+ const actual = fullText.slice(offset, offset + length);
1073
+ if (actual === original) {
1074
+ result = applyFixAtOffset(segments, fullText, offset, length, replacement, doc);
1075
+ method = "offset";
1076
+ } else {
1077
+ const foundOffset = findClosestMatch(fullText, original, offset);
1078
+ if (foundOffset >= 0) {
1079
+ console.log(
1080
+ `[spellcheck/fix] Offset drift corrected: "${original}" at ${foundOffset} (stored: ${offset}, drift: ${foundOffset - offset})`
1081
+ );
1082
+ result = applyFixAtOffset(segments, fullText, foundOffset, original.length, replacement, doc);
1083
+ method = "search";
1084
+ } else {
1085
+ console.warn(
1086
+ `[spellcheck/fix] "${original}" not found in extracted text (${fullText.length} chars)`
1087
+ );
1088
+ result = { fixed: false, modifiedField: null };
1089
+ }
1090
+ }
1091
+ if (!result.fixed) {
1092
+ result = legacyFixSubstring(doc, original, replacement, contentField);
1093
+ if (result.fixed) method = "legacy";
1094
+ }
1095
+ } else {
1096
+ result = legacyFixSubstring(doc, original, replacement, contentField);
1097
+ }
1098
+ if (!result.fixed || !result.modifiedField) {
1099
+ return Response.json({
1100
+ success: false,
1101
+ fixesApplied: 0,
1102
+ error: "Could not locate the text to fix",
1103
+ original,
1104
+ replacement
1105
+ });
1106
+ }
1107
+ const updateData = {};
1108
+ switch (result.modifiedField) {
1109
+ case "title":
1110
+ updateData.title = doc.title;
1111
+ break;
1112
+ case "hero":
1113
+ updateData.hero = doc.hero;
1114
+ break;
1115
+ case "layout":
1116
+ updateData.layout = doc.layout;
1117
+ break;
1118
+ default:
1119
+ updateData[result.modifiedField] = doc[result.modifiedField];
1120
+ break;
1121
+ }
1122
+ await req.payload.update({
1123
+ collection,
1124
+ id,
1125
+ data: updateData,
1126
+ overrideAccess: true
1127
+ });
1128
+ return Response.json({
1129
+ success: true,
1130
+ fixesApplied: 1,
1131
+ original,
1132
+ replacement,
1133
+ method
1134
+ });
1135
+ } catch (error) {
1136
+ console.error("[spellcheck/fix] Error:", error);
1137
+ return Response.json({ error: "Internal server error" }, { status: 500 });
1138
+ }
1139
+ };
1140
+ }
1141
+
1142
+ // src/endpoints/bulk.ts
1143
+ var RATE_LIMIT_DELAY = 3e3;
1144
+ var STALE_TIMEOUT = 10 * 60 * 1e3;
1145
+ function sleep(ms) {
1146
+ return new Promise((resolve) => setTimeout(resolve, ms));
1147
+ }
1148
+ var currentJob = null;
1149
+ function isJobStale() {
1150
+ if (!currentJob || currentJob.status !== "running") return false;
1151
+ return Date.now() - currentJob.lastActivity > STALE_TIMEOUT;
1152
+ }
1153
+ function resetIfStale() {
1154
+ if (isJobStale() && currentJob) {
1155
+ console.warn(`[spellcheck/bulk] Job stale (no progress for ${STALE_TIMEOUT / 1e3}s), auto-resetting`);
1156
+ currentJob.status = "error";
1157
+ currentJob.error = "Scan timed out (no progress)";
1158
+ currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1159
+ }
1160
+ }
1161
+ async function runBulkScan(payload, collectionsToScan, idsFilter, pluginConfig) {
1162
+ const language = pluginConfig.language || "fr";
1163
+ const contentField = pluginConfig.contentField || "content";
1164
+ const results = [];
1165
+ try {
1166
+ let totalToScan = 0;
1167
+ const docsByCollection = /* @__PURE__ */ new Map();
1168
+ for (const collectionSlug of collectionsToScan) {
1169
+ const idsForCollection = idsFilter ? idsFilter.filter((i) => i.collection === collectionSlug).map((i) => i.id) : null;
1170
+ const allDocs = await payload.find({
1171
+ collection: collectionSlug,
1172
+ limit: 0,
1173
+ depth: 0,
1174
+ // depth:0 — must match fix.ts for offset alignment
1175
+ draft: true,
1176
+ // Read latest version (including unpublished edits)
1177
+ overrideAccess: true,
1178
+ where: {
1179
+ ...idsForCollection ? { id: { in: idsForCollection } } : { _status: { equals: "published" } }
1180
+ }
1181
+ });
1182
+ docsByCollection.set(collectionSlug, allDocs.docs);
1183
+ totalToScan += allDocs.docs.length;
1184
+ }
1185
+ if (currentJob) {
1186
+ currentJob.total = totalToScan;
1187
+ currentJob.lastActivity = Date.now();
1188
+ }
1189
+ let processed = 0;
1190
+ let totalIssues = 0;
1191
+ let skipped = 0;
1192
+ for (const collectionSlug of collectionsToScan) {
1193
+ const docs = docsByCollection.get(collectionSlug) || [];
1194
+ for (const doc of docs) {
1195
+ processed++;
1196
+ const docAny = doc;
1197
+ const docTitle = docAny.title || docAny.slug || String(doc.id);
1198
+ if (currentJob) {
1199
+ currentJob.current = processed;
1200
+ currentJob.currentDoc = docTitle;
1201
+ currentJob.lastActivity = Date.now();
1202
+ }
1203
+ try {
1204
+ const text = extractAllTextFromDoc(docAny, contentField);
1205
+ if (!text.trim()) {
1206
+ skipped++;
1207
+ continue;
1208
+ }
1209
+ const wordCount = countWords(text);
1210
+ let issues = await checkWithLanguageTool(text, language, pluginConfig);
1211
+ issues = await filterFalsePositives(issues, pluginConfig, payload);
1212
+ let existingDoc = null;
1213
+ try {
1214
+ const existing = await payload.find({
1215
+ collection: "spellcheck-results",
1216
+ where: {
1217
+ docId: { equals: String(doc.id) },
1218
+ collection: { equals: collectionSlug }
1219
+ },
1220
+ limit: 1,
1221
+ overrideAccess: true
1222
+ });
1223
+ if (existing.docs.length > 0) {
1224
+ existingDoc = existing.docs[0];
1225
+ }
1226
+ } catch {
1227
+ }
1228
+ const ignoredIssues = Array.isArray(existingDoc?.ignoredIssues) ? existingDoc.ignoredIssues : [];
1229
+ if (ignoredIssues.length > 0) {
1230
+ issues = issues.filter(
1231
+ (issue) => !ignoredIssues.some((ignored) => ignored.ruleId === issue.ruleId && ignored.original === issue.original)
1232
+ );
1233
+ }
1234
+ const score = calculateScore(wordCount, issues.length);
1235
+ totalIssues += issues.length;
1236
+ const result = {
1237
+ docId: String(doc.id),
1238
+ collection: collectionSlug,
1239
+ score,
1240
+ issueCount: issues.length,
1241
+ wordCount,
1242
+ issues,
1243
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
1244
+ };
1245
+ results.push(result);
1246
+ try {
1247
+ const resultData = {
1248
+ docId: String(doc.id),
1249
+ collection: collectionSlug,
1250
+ title: docAny.title || "",
1251
+ slug: docAny.slug || "",
1252
+ score,
1253
+ issueCount: issues.length,
1254
+ wordCount,
1255
+ issues,
1256
+ ignoredIssues,
1257
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
1258
+ };
1259
+ if (existingDoc) {
1260
+ await payload.update({
1261
+ collection: "spellcheck-results",
1262
+ id: existingDoc.id,
1263
+ data: resultData,
1264
+ overrideAccess: true
1265
+ });
1266
+ } else {
1267
+ await payload.create({
1268
+ collection: "spellcheck-results",
1269
+ data: resultData,
1270
+ overrideAccess: true
1271
+ });
1272
+ }
1273
+ } catch (err) {
1274
+ console.error(`[spellcheck/bulk] Failed to store result for ${docTitle}:`, err);
1275
+ }
1276
+ } catch (docErr) {
1277
+ console.error(`[spellcheck/bulk] Error processing "${docTitle}":`, docErr);
1278
+ }
1279
+ if (currentJob) {
1280
+ currentJob.totalIssues = totalIssues;
1281
+ currentJob.totalDocuments = processed;
1282
+ currentJob.lastActivity = Date.now();
1283
+ }
1284
+ await sleep(RATE_LIMIT_DELAY);
1285
+ }
1286
+ }
1287
+ const averageScore = results.length > 0 ? Math.round(results.reduce((sum, r) => sum + r.score, 0) / results.length) : 100;
1288
+ if (currentJob) {
1289
+ currentJob.status = "completed";
1290
+ currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1291
+ currentJob.averageScore = averageScore;
1292
+ currentJob.totalDocuments = processed;
1293
+ currentJob.totalIssues = totalIssues;
1294
+ currentJob.lastActivity = Date.now();
1295
+ }
1296
+ console.log(`[spellcheck/bulk] Scan completed: ${processed} docs (${skipped} skipped), ${totalIssues} issues, avg score ${averageScore}`);
1297
+ } catch (error) {
1298
+ console.error("[spellcheck/bulk] Scan error:", error);
1299
+ if (currentJob) {
1300
+ currentJob.status = "error";
1301
+ currentJob.error = error.message;
1302
+ currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1303
+ currentJob.lastActivity = Date.now();
1304
+ }
1305
+ }
1306
+ }
1307
+ function createBulkHandler(targetCollections, pluginConfig) {
1308
+ return async (req) => {
1309
+ try {
1310
+ if (!req.user) {
1311
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1312
+ }
1313
+ resetIfStale();
1314
+ const body = await req.json().catch(() => ({}));
1315
+ const { collection: targetCollection, ids, force } = body;
1316
+ if (currentJob?.status === "running") {
1317
+ if (force) {
1318
+ console.warn("[spellcheck/bulk] Force-resetting stuck scan");
1319
+ currentJob.status = "error";
1320
+ currentJob.error = "Force reset by user";
1321
+ currentJob.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1322
+ } else {
1323
+ return Response.json({
1324
+ error: "Scan already in progress",
1325
+ ...currentJob
1326
+ }, { status: 409 });
1327
+ }
1328
+ }
1329
+ const scanSpecificIds = Array.isArray(ids) && ids.length > 0;
1330
+ const collectionsToScan = scanSpecificIds ? [...new Set(ids.map((i) => i.collection))] : targetCollection ? [targetCollection] : targetCollections;
1331
+ const idsFilter = scanSpecificIds ? ids : null;
1332
+ currentJob = {
1333
+ status: "running",
1334
+ current: 0,
1335
+ total: 0,
1336
+ currentDoc: "",
1337
+ totalIssues: 0,
1338
+ totalDocuments: 0,
1339
+ averageScore: 0,
1340
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1341
+ completedAt: null,
1342
+ error: null,
1343
+ lastActivity: Date.now()
1344
+ };
1345
+ runBulkScan(req.payload, collectionsToScan, idsFilter, pluginConfig);
1346
+ return Response.json({
1347
+ message: "Scan started",
1348
+ status: "running"
1349
+ });
1350
+ } catch (error) {
1351
+ console.error("[spellcheck/bulk] Error:", error);
1352
+ return Response.json({ error: "Internal server error" }, { status: 500 });
1353
+ }
1354
+ };
1355
+ }
1356
+ function createStatusHandler() {
1357
+ return async (req) => {
1358
+ if (!req.user) {
1359
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1360
+ }
1361
+ resetIfStale();
1362
+ if (!currentJob) {
1363
+ return Response.json({ status: "idle" });
1364
+ }
1365
+ return Response.json({ ...currentJob });
1366
+ };
1367
+ }
1368
+
1369
+ // src/hooks/afterChangeCheck.ts
1370
+ function createAfterChangeCheckHook(pluginConfig) {
1371
+ return ({ doc, collection, req }) => {
1372
+ (async () => {
1373
+ try {
1374
+ const contentField = pluginConfig.contentField || "content";
1375
+ const language = pluginConfig.language || "fr";
1376
+ const text = extractAllTextFromDoc(doc, contentField);
1377
+ if (!text) return;
1378
+ const wordCount = countWords(text);
1379
+ let issues = await checkWithLanguageTool(text, language, pluginConfig);
1380
+ issues = await filterFalsePositives(issues, pluginConfig, req.payload);
1381
+ const collectionSlug = typeof collection === "string" ? collection : collection.slug;
1382
+ const existing = await req.payload.find({
1383
+ collection: "spellcheck-results",
1384
+ where: {
1385
+ docId: { equals: String(doc.id) },
1386
+ collection: { equals: collectionSlug }
1387
+ },
1388
+ limit: 1,
1389
+ overrideAccess: true
1390
+ });
1391
+ const existingDoc = existing.docs.length > 0 ? existing.docs[0] : null;
1392
+ const ignoredIssues = Array.isArray(existingDoc?.ignoredIssues) ? existingDoc.ignoredIssues : [];
1393
+ if (ignoredIssues.length > 0) {
1394
+ issues = issues.filter(
1395
+ (issue) => !ignoredIssues.some((ignored) => ignored.ruleId === issue.ruleId && ignored.original === issue.original)
1396
+ );
1397
+ }
1398
+ const score = calculateScore(wordCount, issues.length);
1399
+ const docAny = doc;
1400
+ const resultData = {
1401
+ docId: String(doc.id),
1402
+ collection: collectionSlug,
1403
+ title: docAny.title || "",
1404
+ slug: docAny.slug || "",
1405
+ score,
1406
+ issueCount: issues.length,
1407
+ wordCount,
1408
+ issues,
1409
+ ignoredIssues,
1410
+ lastChecked: (/* @__PURE__ */ new Date()).toISOString()
1411
+ };
1412
+ if (existingDoc) {
1413
+ await req.payload.update({
1414
+ collection: "spellcheck-results",
1415
+ id: existingDoc.id,
1416
+ data: resultData,
1417
+ overrideAccess: true
1418
+ });
1419
+ } else {
1420
+ await req.payload.create({
1421
+ collection: "spellcheck-results",
1422
+ data: resultData,
1423
+ overrideAccess: true
1424
+ });
1425
+ }
1426
+ console.log(`[spellcheck] Auto-check: ${collectionSlug}/${doc.id} \u2014 score ${score}, ${issues.length} issues`);
1427
+ } catch (err) {
1428
+ console.error("[spellcheck] afterChange hook error:", err);
1429
+ }
1430
+ })();
1431
+ return doc;
1432
+ };
1433
+ }
1434
+
1435
+ // src/plugin.ts
1436
+ async function autoFixSchema(payload) {
1437
+ try {
1438
+ await payload.find({ collection: "spellcheck-dictionary", limit: 1, overrideAccess: true });
1439
+ } catch (e) {
1440
+ const msg = String(e?.message || "");
1441
+ if (!msg.includes("no such column") && !msg.includes("spellcheck_dictionary")) return;
1442
+ payload.logger.info("[spellcheck] Detected missing schema column, attempting auto-fix...");
1443
+ const alterSQL = "ALTER TABLE payload_locked_documents_rels ADD COLUMN spellcheck_dictionary_id integer";
1444
+ const db = payload.db;
1445
+ const rawClient = db.pool || db.client || db.drizzle?.session?.client;
1446
+ if (!rawClient?.exec) {
1447
+ payload.logger.warn(`[spellcheck] Auto-fix: could not access raw DB client. Run manually: ${alterSQL}`);
1448
+ return;
1449
+ }
1450
+ try {
1451
+ rawClient.exec(alterSQL);
1452
+ payload.logger.info("[spellcheck] Auto-fixed: added spellcheck_dictionary_id to payload_locked_documents_rels");
1453
+ } catch (fixErr) {
1454
+ const fixMsg = String(fixErr?.message || "");
1455
+ if (fixMsg.includes("duplicate column") || fixMsg.includes("already exists")) return;
1456
+ payload.logger.warn(`[spellcheck] Auto-fix failed: ${fixMsg}. Run manually: ${alterSQL}`);
1457
+ }
1458
+ }
1459
+ }
1460
+ var spellcheckPlugin = (pluginConfig = {}) => (incomingConfig) => {
1461
+ const config = { ...incomingConfig };
1462
+ const targetCollections = pluginConfig.collections ?? ["pages", "posts"];
1463
+ const basePath = pluginConfig.endpointBasePath ?? "/spellcheck";
1464
+ const checkOnSave = pluginConfig.checkOnSave !== false;
1465
+ const addSidebarField = pluginConfig.addSidebarField !== false;
1466
+ const addDashboardView = pluginConfig.addDashboardView !== false;
1467
+ if (config.collections) {
1468
+ config.collections = config.collections.map((collection) => {
1469
+ if (!targetCollections.includes(collection.slug)) return collection;
1470
+ const updated = { ...collection };
1471
+ if (checkOnSave) {
1472
+ const existingHooks = updated.hooks?.afterChange || [];
1473
+ updated.hooks = {
1474
+ ...updated.hooks,
1475
+ afterChange: [
1476
+ ...Array.isArray(existingHooks) ? existingHooks : [existingHooks],
1477
+ createAfterChangeCheckHook(pluginConfig)
1478
+ ]
1479
+ };
1480
+ }
1481
+ if (addSidebarField) {
1482
+ updated.fields = [
1483
+ ...updated.fields || [],
1484
+ {
1485
+ name: "_spellcheck",
1486
+ type: "ui",
1487
+ admin: {
1488
+ position: "sidebar",
1489
+ components: {
1490
+ Field: "@consilioweb/spellcheck/client#SpellCheckField"
1491
+ }
1492
+ }
1493
+ }
1494
+ ];
1495
+ }
1496
+ if (pluginConfig.addListColumn !== false) {
1497
+ updated.fields = [
1498
+ ...updated.fields || [],
1499
+ {
1500
+ name: "_spellcheckScore",
1501
+ type: "ui",
1502
+ label: "Ortho",
1503
+ admin: {
1504
+ components: {
1505
+ Cell: "@consilioweb/spellcheck/client#SpellCheckScoreCell"
1506
+ }
1507
+ }
1508
+ }
1509
+ ];
1510
+ if (updated.admin?.defaultColumns) {
1511
+ const cols = [...updated.admin.defaultColumns];
1512
+ if (!cols.includes("_spellcheckScore")) {
1513
+ cols.push("_spellcheckScore");
1514
+ }
1515
+ updated.admin = { ...updated.admin, defaultColumns: cols };
1516
+ }
1517
+ }
1518
+ return updated;
1519
+ });
1520
+ }
1521
+ config.collections = [
1522
+ ...config.collections || [],
1523
+ createSpellCheckResultsCollection(),
1524
+ createSpellCheckDictionaryCollection()
1525
+ ];
1526
+ config.endpoints = [
1527
+ ...config.endpoints || [],
1528
+ {
1529
+ path: `${basePath}/validate`,
1530
+ method: "post",
1531
+ handler: createValidateHandler(pluginConfig)
1532
+ },
1533
+ {
1534
+ path: `${basePath}/fix`,
1535
+ method: "post",
1536
+ handler: createFixHandler(pluginConfig)
1537
+ },
1538
+ {
1539
+ path: `${basePath}/bulk`,
1540
+ method: "post",
1541
+ handler: createBulkHandler(targetCollections, pluginConfig)
1542
+ },
1543
+ {
1544
+ path: `${basePath}/status`,
1545
+ method: "get",
1546
+ handler: createStatusHandler()
1547
+ },
1548
+ {
1549
+ path: `${basePath}/dictionary`,
1550
+ method: "get",
1551
+ handler: createDictionaryListHandler()
1552
+ },
1553
+ {
1554
+ path: `${basePath}/dictionary`,
1555
+ method: "post",
1556
+ handler: createDictionaryAddHandler()
1557
+ },
1558
+ {
1559
+ path: `${basePath}/dictionary`,
1560
+ method: "delete",
1561
+ handler: createDictionaryDeleteHandler()
1562
+ },
1563
+ {
1564
+ path: `${basePath}/collections`,
1565
+ method: "get",
1566
+ handler: (async (req) => {
1567
+ if (!req.user) return Response.json({ error: "Unauthorized" }, { status: 401 });
1568
+ return Response.json({ collections: targetCollections });
1569
+ })
1570
+ }
1571
+ ];
1572
+ if (addDashboardView) {
1573
+ if (!config.admin) config.admin = {};
1574
+ if (!config.admin.components) config.admin.components = {};
1575
+ if (!config.admin.components.views) config.admin.components.views = {};
1576
+ config.admin.components.views.spellcheck = {
1577
+ Component: "@consilioweb/spellcheck/views#SpellCheckView",
1578
+ path: "/spellcheck"
1579
+ };
1580
+ }
1581
+ const existingOnInit = config.onInit;
1582
+ config.onInit = async (payload) => {
1583
+ if (existingOnInit) await existingOnInit(payload);
1584
+ await autoFixSchema(payload);
1585
+ };
1586
+ return config;
1587
+ };
1588
+
1589
+ // src/i18n.ts
1590
+ var fr = {
1591
+ dashboardTitle: "Correcteur orthographique",
1592
+ dashboardDescription: "Analyse orthographique et grammaticale du contenu",
1593
+ scanAll: "Scanner tout",
1594
+ scanning: "Analyse en cours...",
1595
+ scanComplete: "Analyse termin\xE9e",
1596
+ noIssues: "Aucun probl\xE8me d\xE9tect\xE9",
1597
+ issuesFound: (count) => `${count} probl\xE8me${count > 1 ? "s" : ""} d\xE9tect\xE9${count > 1 ? "s" : ""}`,
1598
+ score: "Score",
1599
+ wordCount: "Mots",
1600
+ lastChecked: "Derni\xE8re v\xE9rification",
1601
+ collection: "Collection",
1602
+ document: "Document",
1603
+ issues: "Probl\xE8mes",
1604
+ actions: "Actions",
1605
+ fieldTitle: "Orthographe",
1606
+ fieldDescription: "V\xE9rification orthographique et grammaticale",
1607
+ checkNow: "V\xE9rifier",
1608
+ checking: "V\xE9rification...",
1609
+ fixAll: "Tout corriger",
1610
+ fix: "Corriger",
1611
+ ignore: "Ignorer",
1612
+ autoChecked: "V\xE9rifi\xE9 automatiquement",
1613
+ suggestion: "Suggestion",
1614
+ noSuggestion: "Aucune suggestion",
1615
+ context: "Contexte",
1616
+ rule: "R\xE8gle",
1617
+ category: "Cat\xE9gorie",
1618
+ applied: "Corrig\xE9",
1619
+ source: "Source",
1620
+ excellent: "Excellent",
1621
+ good: "Bon",
1622
+ needsWork: "\xC0 am\xE9liorer",
1623
+ poor: "Insuffisant",
1624
+ errorFetching: "Erreur lors de l'analyse",
1625
+ errorFixing: "Erreur lors de la correction",
1626
+ unauthorized: "Non autoris\xE9"
1627
+ };
1628
+ var en = {
1629
+ dashboardTitle: "Spellchecker",
1630
+ dashboardDescription: "Spelling and grammar analysis of content",
1631
+ scanAll: "Scan all",
1632
+ scanning: "Scanning...",
1633
+ scanComplete: "Scan complete",
1634
+ noIssues: "No issues found",
1635
+ issuesFound: (count) => `${count} issue${count > 1 ? "s" : ""} found`,
1636
+ score: "Score",
1637
+ wordCount: "Words",
1638
+ lastChecked: "Last checked",
1639
+ collection: "Collection",
1640
+ document: "Document",
1641
+ issues: "Issues",
1642
+ actions: "Actions",
1643
+ fieldTitle: "Spelling",
1644
+ fieldDescription: "Spelling and grammar check",
1645
+ checkNow: "Check",
1646
+ checking: "Checking...",
1647
+ fixAll: "Fix all",
1648
+ fix: "Fix",
1649
+ ignore: "Ignore",
1650
+ autoChecked: "Auto-checked",
1651
+ suggestion: "Suggestion",
1652
+ noSuggestion: "No suggestion",
1653
+ context: "Context",
1654
+ rule: "Rule",
1655
+ category: "Category",
1656
+ applied: "Fixed",
1657
+ source: "Source",
1658
+ excellent: "Excellent",
1659
+ good: "Good",
1660
+ needsWork: "Needs work",
1661
+ poor: "Poor",
1662
+ errorFetching: "Error during analysis",
1663
+ errorFixing: "Error applying fix",
1664
+ unauthorized: "Unauthorized"
1665
+ };
1666
+ var translations = { fr, en };
1667
+ function getTranslations(locale = "fr") {
1668
+ return translations[locale] || translations.fr;
1669
+ }
1670
+ function getScoreLabel(score, t) {
1671
+ if (score >= 95) return t.excellent;
1672
+ if (score >= 80) return t.good;
1673
+ if (score >= 50) return t.needsWork;
1674
+ return t.poor;
1675
+ }
1676
+
1677
+ export { calculateScore, checkWithClaude, checkWithLanguageTool, countWords, extractAllTextFromDoc, extractAllTextFromDocWithSources, extractTextFromLexical, filterFalsePositives, getScoreLabel, getTranslations, invalidateDictionaryCache, loadDictionaryWords, spellcheckPlugin };