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
@@ -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([]);
});
});