feat(editor): spell & grammar checking (Chunk #7)

Offline spellcheck via hunspell compiled to WASM (runs fully in-browser),
plus optional self-hostable LanguageTool grammar/style checking.

- hunspell-wasm spell engine with bundled en_US dictionary (base64-decoded
  browser-safe via atob + TextDecoder)
- LanguageTool engine (self-hostable, docker compose snippet under
  docker/languagetool/)
- single decoration pipeline: spell + grammar hits merge into one squiggle
  layer (red=spelling, blue=grammar) with a side panel to jump/apply/ignore
  and add-to-dictionary
- useCheckService hook wired into WritingView; check.css loaded in main.tsx
- spell + grammar unit tests (30 editor tests green)

PLAN.md and CHUNK#7.md updated to reflect completion.
This commit is contained in:
Schwitzz
2026-10-28 17:13:23 -05:00
parent 836d82113b
commit 5c28a94813
15 changed files with 957 additions and 12 deletions
+1
View File
@@ -2,3 +2,4 @@ export { CheckService } from "./check-service.js";
export type { CheckServiceOptions, CheckResult, CheckCounts } from "./check-service.js";
export { buildCheckDecorations } from "./decorations.js";
export type { CheckedIssue, BlockIssues } from "./decorations.js";
export { toCheckedIssues, collectBlock, textOffsetToDocPos } from "./decorations.js";
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { parseGrammarResponse, type LTResponse } from "./grammar.js";
describe("parseGrammarResponse", () => {
it("returns an empty array when issues is missing", () => {
const result = parseGrammarResponse({});
expect(result).toEqual([]);
});
it("returns an empty array when issues is an empty list", () => {
const result = parseGrammarResponse({ issues: [] });
expect(result).toEqual([]);
});
it("parses a single issue with all fields", () => {
const response: LTResponse = {
issues: [
{
offset: 12,
length: 2,
message: "Use 'a' before consonant sounds",
replacements: [{ value: "a", quality: 1 }],
rule: { id: "EN_A_VS_AN", category: { id: "GRAMMAR" } },
},
],
};
const result = parseGrammarResponse(response);
expect(result).toHaveLength(1);
expect(result[0]!.offset).toBe(12);
expect(result[0]!.length).toBe(2);
expect(result[0]!.message).toBe("Use 'a' before consonant sounds");
expect(result[0]!.replacements).toEqual(["a"]);
expect(result[0]!.ruleId).toBe("EN_A_VS_AN");
expect(result[0]!.category).toBe("GRAMMAR");
});
it("parses multiple issues in order", () => {
const response: LTResponse = {
issues: [
{ offset: 0, length: 3, message: "First error", replacements: [{ value: "fix1" }] },
{ offset: 10, length: 5, message: "Second error", replacements: [{ value: "fix2" }] },
{ offset: 20, length: 1, message: "Third error", replacements: [] },
],
};
const result = parseGrammarResponse(response);
expect(result).toHaveLength(3);
expect(result[0]!.offset).toBe(0);
expect(result[1]!.offset).toBe(10);
expect(result[2]!.offset).toBe(20);
expect(result[0]!.replacements).toEqual(["fix1"]);
expect(result[1]!.replacements).toEqual(["fix2"]);
expect(result[2]!.replacements).toEqual([]);
});
it("omits ruleId and category when not present", () => {
const response: LTResponse = {
issues: [{ offset: 5, length: 2, message: "No rule info", replacements: [{ value: "x" }] }],
};
const result = parseGrammarResponse(response);
expect(result[0]!.ruleId).toBeUndefined();
expect(result[0]!.category).toBeUndefined();
});
it("handles replacements with empty value", () => {
const response: LTResponse = {
issues: [{ offset: 0, length: 1, message: "msg", replacements: [{ value: "" }] }],
};
const result = parseGrammarResponse(response);
expect(result[0]!.replacements).toEqual([""]);
});
it("preserves offset and length exactly as provided", () => {
const response: LTResponse = {
issues: [{ offset: 42, length: 7, message: "test", replacements: [] }],
};
const result = parseGrammarResponse(response);
expect(result[0]!.offset).toBe(42);
expect(result[0]!.length).toBe(7);
});
it("handles issues with no replacements field", () => {
const response: LTResponse = {
issues: [{ offset: 3, length: 4, message: "no repl" }],
};
const result = parseGrammarResponse(response);
expect(result[0]!.replacements).toEqual([]);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { findSpellErrors, type WordChecker } from "./spell.js";
describe("findSpellErrors", () => {
const correctWords = new Set([
"hello", "world", "the", "quick", "brown", "fox",
"stop", "fact",
]);
const checker: WordChecker = (word) => {
if (correctWords.has(word.toLowerCase())) return null;
return [{ word: word + "d", weight: 1 }];
};
it("returns no errors when all words are correct", () => {
const result = findSpellErrors("hello world the quick brown fox", checker);
expect(result).toEqual([]);
});
it("flags a single misspelled word with correct offset", () => {
const result = findSpellErrors("helo world", checker);
expect(result).toHaveLength(1);
expect(result[0]!.word).toBe("helo");
expect(result[0]!.offset).toBe(0);
expect(result[0]!.length).toBe(4);
expect(result[0]!.candidates).toEqual([{ word: "helod", weight: 1 }]);
});
it("flags multiple misspelled words", () => {
const result = findSpellErrors("helo wrld", checker);
expect(result).toHaveLength(2);
expect(result[0]!.word).toBe("helo");
expect(result[0]!.offset).toBe(0);
expect(result[1]!.word).toBe("wrld");
expect(result[1]!.offset).toBe(5);
});
it("skips words shorter than minWordLength", () => {
const result = findSpellErrors("a b c hello", checker, { minWordLength: 2 });
expect(result).toEqual([]);
});
it("handles words with apostrophes", () => {
const result = findSpellErrors("don't stop", checker);
expect(result).toHaveLength(1);
expect(result[0]!.word).toBe("don't");
expect(result[0]!.offset).toBe(0);
expect(result[0]!.length).toBe(5);
});
it("handles words with hyphens", () => {
const result = findSpellErrors("well-known fact", checker);
expect(result).toHaveLength(1);
expect(result[0]!.word).toBe("well-known");
expect(result[0]!.offset).toBe(0);
expect(result[0]!.length).toBe(10);
});
it("deduplicates repeated words", () => {
const result = findSpellErrors("helo helo helo", checker);
expect(result).toHaveLength(1);
expect(result[0]!.offset).toBe(0);
});
it("returns empty array for empty text", () => {
const result = findSpellErrors("", checker);
expect(result).toEqual([]);
});
it("handles unicode words", () => {
const result = findSpellErrors("café résumé", checker);
expect(result).toHaveLength(2);
expect(result[0]!.word).toBe("café");
expect(result[1]!.word).toBe("résumé");
});
});