feat(editor): add spell & grammar check engines with decorations

Wire real spell-checking into the editor via hunspell-wasm (bundled en_US
dictionary) and an optional LanguageTool grammar client.

- spell/: HunspellSpellEngine wrapper, pure spell logic, base64 en_US dict
- grammar/: LanguageToolEngine grammar client and pure grammar logic
- decorations/: collectBlock text assembly, offset-mapped CheckService,
  buildCheckDecorations (flat DecorationSet, inline spell/grammar marks)
- export engines and types from editor index; add hunspell-wasm dep

Verified: typecheck (6 pkgs), tests (all pass), lint (6 pkgs), and web
build all green.
This commit is contained in:
Schwitzz
2026-10-27 12:52:29 -05:00
parent b92129b62c
commit d310635b65
13 changed files with 775 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
/**
* Grammar-check core logic — framework-agnostic and pure.
*
* The network client lives in `languagetool.ts`. This module owns the shape of
* a grammar error and the parser that turns a LanguageTool API response into a
* plain list of errors with offsets relative to the checked text. Keeping the
* parser pure makes the response-shaping logic fully unit-testable.
*/
/** A single grammar/punctuation error with its offset into the checked text. */
export interface GrammarError {
/** Zero-based offset of the flagged span within the checked text. */
offset: number;
/** Character length of the flagged span. */
length: number;
/** Human-readable explanation of the problem. */
message: string;
/** Suggested replacements, best first. */
replacements: string[];
/** LanguageTool rule id (e.g. `EN_A_VS_AN`), when available. */
ruleId?: string;
/** LanguageTool category id (e.g. `TYPOSIS`), when available. */
category?: string;
}
/** Minimal shape of a LanguageTool `/v2/check` response that we consume. */
export interface LTResponse {
issues?: Array<{
offset: number;
length: number;
message: string;
replacements?: Array<{ value: string; length?: number; quality?: number }>;
rule?: { id?: string; category?: { id?: string } };
}>;
}
/** Parse a LanguageTool response into a flat list of {@link GrammarError}. */
export function parseGrammarResponse(response: LTResponse): GrammarError[] {
const issues = response.issues ?? [];
const errors: GrammarError[] = [];
for (const issue of issues) {
const replacements = (issue.replacements ?? []).map((r) => r.value);
const error: GrammarError = {
offset: issue.offset,
length: issue.length,
message: issue.message,
replacements,
};
if (issue.rule?.id) error.ruleId = issue.rule.id;
if (issue.rule?.category?.id) error.category = issue.rule.category.id;
errors.push(error);
}
return errors;
}
/**
* A grammar-check engine. Implementations accept a block of text and a language
* tag and return grammar/punctuation errors.
*/
export interface GrammarEngine {
/** Check a block of text and return grammar errors. */
check(text: string, language?: string): Promise<GrammarError[]>;
}