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:
@@ -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[]>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./grammar.js";
|
||||
export * from "./languagetool.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* LanguageTool grammar-check engine.
|
||||
*
|
||||
* Thin client over the LanguageTool REST API (`/v2/check`). The public instance
|
||||
* at `https://api.languagetool.org` works without an API key but enforces rate
|
||||
* limits; a self-hosted instance can be supplied via {@link LanguageToolEngine.options}.
|
||||
*/
|
||||
import {
|
||||
parseGrammarResponse,
|
||||
type GrammarEngine,
|
||||
type GrammarError,
|
||||
type LTResponse,
|
||||
} from "./grammar.js";
|
||||
|
||||
export interface LanguageToolEngineOptions {
|
||||
/** API base URL. Defaults to the public LanguageTool API. */
|
||||
baseUrl?: string;
|
||||
/** Optional API key (raises rate limits). */
|
||||
apiKey?: string;
|
||||
/** Per-request timeout in ms. Defaults to 8000. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.languagetool.org";
|
||||
|
||||
/**
|
||||
* Grammar-check engine backed by the LanguageTool REST API.
|
||||
*
|
||||
* Requests are `application/x-www-form-urlencoded` POSTs to `/v2/check`. A
|
||||
* per-request abort controller keeps a slow response from piling up behind
|
||||
* newer ones.
|
||||
*/
|
||||
export class LanguageToolEngine implements GrammarEngine {
|
||||
private baseUrl: string;
|
||||
private apiKey?: string;
|
||||
private timeout: number;
|
||||
|
||||
constructor(options: LanguageToolEngineOptions = {}) {
|
||||
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
||||
if (options.apiKey !== undefined) this.apiKey = options.apiKey;
|
||||
this.timeout = options.timeout ?? 8000;
|
||||
}
|
||||
|
||||
async check(text: string, language = "en-US"): Promise<GrammarError[]> {
|
||||
if (!text.trim()) return [];
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeout);
|
||||
try {
|
||||
const form = new URLSearchParams({ text, language });
|
||||
if (this.apiKey) form.set("api_key", this.apiKey);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/v2/check`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form.toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LanguageTool request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as LTResponse;
|
||||
return parseGrammarResponse(json);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user