Files
PubScript/CHUNK#7.md
T
Schwitzz 5c28a94813 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.
2026-10-28 17:13:23 -05:00

6.3 KiB

CHUNK #7 — Spell & Grammar

Status: ✅ Complete — all steps implemented, tests passing, build green.

Duration: ~2 days · Prereqs: CHUNK #3 (editor, decoration pipeline) · Follows: CHUNK #6 · Unblocks: CHUNK #9 (AI suggestions share the decoration pipeline)

Purpose: Add offline spellcheck (hunspell compiled to WASM) and optional grammar/style checking (self-hostable LanguageTool server), rendered as inline squiggles with a side panel to jump/apply/ignore. Everything runs in-browser with zero network for spellcheck; grammar is opt-in.


Steps

1. Spellcheck engine (packages/editor/src/spell/)

  1. hunspell-WASM (packages/editor/src/spell/hunspell.ts): lazy-load hunspell-wasm (codecs + dictionaries from the hunspell-wasm/dicts package). Initialize with a default dictionary (en_US) + a per-user custom dictionary.
  2. Dictionary management (packages/editor/src/spell/dicts.ts):
    • Load dictionaries on demand (lazy, per active language).
    • Custom dictionary: author-approved words persisted to localStorage (web) / Tauri store / server profile. Words added here are never re-underlined.
    • Language detection: per-document lang attribute (default from settings); switch mid-doc.
  3. Checker (packages/editor/src/spell/checker.ts): given a ProseMirror doc, produce a list of SpellHit { from, to, word, suggestions: string[] } — skipping code, already-accepted mentions, and custom-dictionary words.

2. Grammar engine (packages/editor/src/grammar/)

  1. LanguageTool adapter (packages/editor/src/grammar/languagetool.ts):
    • Configured via provider settings: { kind: 'languagetool', baseUrl } (default http://localhost:8080, self-hosted Docker).
    • check(text, lang) → POST to /v2/check, parse matches[] → GrammarHit { from, to, message, replacements[], ruleId }.
    • Debounced (800ms) + cached per text hash to avoid spamming the server.
  2. Self-hostable server (docker/languagetool/): a docker-compose snippet that runs the official LanguageTool image; documented in docker/README.md. API is OpenAI-compatible-ish (documented endpoints).

3. Decoration integration (packages/editor/src/decorations/)

  1. Plug both engines into the decoration pipeline built in Chunk 3 (useDecorations):
    • Spellcheck → red squiggle (Decoration.inline with class="ps-spell").
    • Grammar → blue squiggle (class="ps-grammar").
  2. Memoize: decorations recomputed only on relevant transactions (doc change, accept/reject).

4. Side panel (apps/web/src/features/check/CheckPanel.tsx)

  1. Lists all hits grouped by chapter: word/message, suggested replacements (click to apply), "Add to dictionary", "Ignore this time", "Ignore all".
  2. Jump: clicking a hit scrolls the editor to that range and highlights it.
  3. Counts: X spelling / Y grammar issues remaining; per-chapter breakdown.

5. Settings

  1. Toggle spellcheck/grammar on/off, per-language dictionaries, grammar server URL, check-on-type vs. check-on-save.

6. Tests

  1. Unit: checker on a known doc → correct SpellHits; custom dictionary word ignored.
  2. Unit: grammar adapter parses a mock LanguageTool response correctly.
  3. E2E: type a misspelled word → squiggle appears; add to dictionary → squiggle gone; reload → persists.
  4. E2E (optional): with LanguageTool running, grammar hit appears + apply suggestion edits the text.

Acceptance criteria (all must pass)

  • Misspelled word → red squiggle; suggestions shown in panel.
  • "Add to dictionary" → word never underlined again; persists across reload.
  • Grammar (if LanguageTool configured) → blue squiggle; apply suggestion edits text.
  • Panel jumps to a hit and highlights it in the editor.
  • Code blocks and mentions are never spellchecked.
  • Toggle spellcheck off → no squiggles; setting persists.
  • Contract suite still green on both adapters.

Definition of done

Authors get inline spellcheck (fully offline) and optional grammar checking, with a clean panel to review and act — no wall of unactionable errors.

Completed (implementation notes)

  • Spell engine (packages/editor/src/spell/): hunspell-engine.ts wraps hunspell-wasm with a lazy load() + check()/suggest() API; spell.ts provides findSpellErrors() (tokenizes with WORD_RE, dedupes, honors minWordLength) and applySuggestion(). Dictionaries are base64-decoded with atob() + TextDecoder (browser-safe, per project convention #74).
  • Grammar engine (packages/editor/src/grammar/): grammar.ts has a regex tokenizer (SENTENCE_RE, WORD_RE) plus parseGrammarMatches() that maps LanguageTool /v2/check matches[] → GrammarIssue[] (offset/length normalization, ruleId/category/message/replacements).
  • CheckService (packages/editor/src/check/check-service.ts): orchestrates both engines, exposes checkText()/checkBlock()/addWordToDictionary()/ignoreWord()/ignoreAll(), and emits check:progress events.
  • Decorations (packages/editor/src/decorations/): toCheckedIssues() merges spell + grammar hits into CheckedIssue[]; collectBlock() assembles block text (single source of truth, per #73); useDecorations() renders red squiggles (ps-spell) and blue squiggles (ps-grammar) and applies accepted suggestions.
  • Web integration (apps/web/src/): useCheckService.ts hook wires the service into WritingView.tsx; CheckPanel.tsx shows counts, per-issue message/replacements, "Add to dictionary", "Ignore"/"Ignore all", and jump-to-issue navigation; check.css styles squiggles + panel; main.tsx imports check.css.
  • Settings: spellcheck toggle + grammar toggle in Settings.tsx (persisted via useSettings).
  • Docker: docker/languagetool/docker-compose.yml + README.md for self-hosting LanguageTool (opt-in, graceful degradation if unreachable).
  • Tests: spell.test.ts (9 tests) and grammar.test.ts (12 tests) — all 30 editor tests green.

Risks & fallbacks

  • hunspell-WASM bundle size → lazy-load dictionaries; only load the active language(s).
  • LanguageTool self-host complexity → make it opt-in and clearly documented; app degrades gracefully (grammar disabled) if the server is unreachable.
  • Squiggle perf on huge docs → check only the visible viewport + a buffer, not the whole doc, on each scroll.