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

76 lines
6.3 KiB
Markdown

# 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 `SpellHit`s; 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)
- [x] Misspelled word → red squiggle; suggestions shown in panel.
- [x] "Add to dictionary" → word never underlined again; persists across reload.
- [x] Grammar (if LanguageTool configured) → blue squiggle; apply suggestion edits text.
- [x] Panel jumps to a hit and highlights it in the editor.
- [x] Code blocks and mentions are never spellchecked.
- [x] Toggle spellcheck off → no squiggles; setting persists.
- [x] 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.