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:
@@ -21,10 +21,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pubscript/core": "workspace:*",
|
"@pubscript/core": "workspace:*",
|
||||||
"@tiptap/core": "3.32.0",
|
"@tiptap/core": "3.32.0",
|
||||||
|
"@tiptap/extension-text-style": "3.32.0",
|
||||||
"@tiptap/pm": "3.32.0",
|
"@tiptap/pm": "3.32.0",
|
||||||
"@tiptap/react": "3.32.0",
|
"@tiptap/react": "3.32.0",
|
||||||
"@tiptap/starter-kit": "3.32.0",
|
"@tiptap/starter-kit": "3.32.0",
|
||||||
"@tiptap/extension-text-style": "3.32.0"
|
"hunspell-wasm": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* CheckService — the glue between spell/grammar engines and the editor.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Walk the document block-by-block and run the spell and grammar engines.
|
||||||
|
* - Resolve results to document positions and expose a decoration set for the
|
||||||
|
* editor view plus a flat issue list for the UI.
|
||||||
|
* - Apply suggestions and ignore-lists as document edits.
|
||||||
|
*
|
||||||
|
* The service is framework-agnostic: it talks to a TipTap editor through the
|
||||||
|
* small `TipTapEditor` surface (state, schema, view.dispatch) and reports
|
||||||
|
* changes through an `onUpdate` callback so any UI layer can react.
|
||||||
|
*/
|
||||||
|
import type { Editor as TipTapEditor } from "@tiptap/core";
|
||||||
|
import { DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import type { EditorState } from "@tiptap/pm/state";
|
||||||
|
import {
|
||||||
|
buildCheckDecorations,
|
||||||
|
toCheckedIssues,
|
||||||
|
collectBlock,
|
||||||
|
type BlockIssues,
|
||||||
|
type CheckedIssue,
|
||||||
|
} from "./decorations.js";
|
||||||
|
import type { SpellEngine, SpellError } from "../spell/spell.js";
|
||||||
|
import type { GrammarEngine, GrammarError } from "../grammar/grammar.js";
|
||||||
|
import { HunspellSpellEngine } from "../spell/hunspell-engine.js";
|
||||||
|
|
||||||
|
export interface CheckServiceOptions {
|
||||||
|
spellEngine: SpellEngine;
|
||||||
|
grammarEngine?: GrammarEngine;
|
||||||
|
/** Language tag forwarded to the grammar engine (default "en-US"). */
|
||||||
|
language?: string;
|
||||||
|
spellEnabled?: boolean;
|
||||||
|
grammarEnabled?: boolean;
|
||||||
|
/** Debounce window for auto-checks triggered by typing (ms). */
|
||||||
|
debounceMs?: number;
|
||||||
|
/** Fired after every check with the resolved issues and counts. */
|
||||||
|
onUpdate?: (result: CheckResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckCounts {
|
||||||
|
spelling: number;
|
||||||
|
grammar: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckResult {
|
||||||
|
issues: CheckedIssue[];
|
||||||
|
counts: CheckCounts;
|
||||||
|
checking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an async mapper over items with a bounded concurrency. */
|
||||||
|
async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
map: (item: T, index: number) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length);
|
||||||
|
let next = 0;
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
while (next < items.length) {
|
||||||
|
const i = next++;
|
||||||
|
results[i] = await map(items[i] as T, i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CheckService {
|
||||||
|
private spellEngine: SpellEngine;
|
||||||
|
private grammarEngine: GrammarEngine | null = null;
|
||||||
|
private language: string;
|
||||||
|
private spellEnabled: boolean;
|
||||||
|
private grammarEnabled: boolean;
|
||||||
|
private debounceMs: number;
|
||||||
|
private onUpdate: ((result: CheckResult) => void) | null = null;
|
||||||
|
|
||||||
|
private editor: TipTapEditor | null = null;
|
||||||
|
private decorations: DecorationSet = DecorationSet.empty;
|
||||||
|
private issues: CheckedIssue[] = [];
|
||||||
|
private ignored = new Set<string>();
|
||||||
|
private debounceTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
constructor(options: CheckServiceOptions) {
|
||||||
|
this.spellEngine = options.spellEngine;
|
||||||
|
this.grammarEngine = options.grammarEngine ?? null;
|
||||||
|
this.language = options.language ?? "en-US";
|
||||||
|
this.spellEnabled = options.spellEnabled ?? true;
|
||||||
|
this.grammarEnabled = options.grammarEnabled ?? false;
|
||||||
|
this.debounceMs = options.debounceMs ?? 400;
|
||||||
|
this.onUpdate = options.onUpdate ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bind the service to an editor instance and run the first check. */
|
||||||
|
attach(editor: TipTapEditor): void {
|
||||||
|
this.editor = editor;
|
||||||
|
this.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDecorations(): DecorationSet {
|
||||||
|
return this.decorations;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssues(): CheckedIssue[] {
|
||||||
|
return this.issues.filter((issue) => !this.ignored.has(issue.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCounts(): CheckCounts {
|
||||||
|
const spelling = this.issues.filter((i) => i.kind === "spelling").length;
|
||||||
|
const grammar = this.issues.filter((i) => i.kind === "grammar").length;
|
||||||
|
return { spelling, grammar, total: spelling + grammar };
|
||||||
|
}
|
||||||
|
|
||||||
|
isSpellEnabled(): boolean {
|
||||||
|
return this.spellEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
isGrammarEnabled(): boolean {
|
||||||
|
return this.grammarEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpellEnabled(enabled: boolean): void {
|
||||||
|
this.spellEnabled = enabled;
|
||||||
|
this.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
setGrammarEnabled(enabled: boolean): void {
|
||||||
|
this.grammarEnabled = enabled;
|
||||||
|
this.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule a debounced re-check against the current document state. */
|
||||||
|
scheduleCheck(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = setTimeout(() => this.check(), this.debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a full check now (ignores the debounce). */
|
||||||
|
async check(): Promise<CheckResult> {
|
||||||
|
if (!this.editor) return { issues: [], counts: this.countsSilent(), checking: false };
|
||||||
|
const result = await this.runChecks(this.editor.state);
|
||||||
|
this.issues = result.issues;
|
||||||
|
this.decorations = buildCheckDecorations(this.editor.state, result.blocks);
|
||||||
|
const checkResult: CheckResult = {
|
||||||
|
issues: result.issues,
|
||||||
|
counts: this.computeCounts(result.issues),
|
||||||
|
checking: false,
|
||||||
|
};
|
||||||
|
this.emit(checkResult);
|
||||||
|
return checkResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private countsSilent(): CheckCounts {
|
||||||
|
return { spelling: 0, grammar: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeCounts(issues: CheckedIssue[]): CheckCounts {
|
||||||
|
const spelling = issues.filter((i) => i.kind === "spelling").length;
|
||||||
|
const grammar = issues.filter((i) => i.kind === "grammar").length;
|
||||||
|
return { spelling, grammar, total: spelling + grammar };
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(result: CheckResult): void {
|
||||||
|
this.onUpdate?.(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk the document, run the engines per block, and resolve positions. */
|
||||||
|
private async runChecks(state: EditorState): Promise<{ blocks: BlockIssues[]; issues: CheckedIssue[] }> {
|
||||||
|
const blocks: BlockIssues[] = [];
|
||||||
|
state.doc.forEach((block, blockStart) => {
|
||||||
|
if (!block.childCount) return;
|
||||||
|
const { text } = collectBlock(block, blockStart);
|
||||||
|
blocks.push({ node: block, blockStart, text, spell: [], grammar: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const spellErrors = this.spellEnabled
|
||||||
|
? await mapWithConcurrency(blocks, 4, (block) => this.spellEngine.check(block.text))
|
||||||
|
: blocks.map(() => [] as SpellError[]);
|
||||||
|
blocks.forEach((block, i) => {
|
||||||
|
block.spell = spellErrors[i] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const grammarErrors = this.grammarEnabled && this.grammarEngine
|
||||||
|
? await mapWithConcurrency(blocks, 3, (block) =>
|
||||||
|
block.text.trim() ? this.grammarEngine!.check(block.text, this.language) : Promise.resolve([]),
|
||||||
|
)
|
||||||
|
: blocks.map(() => [] as GrammarError[]);
|
||||||
|
blocks.forEach((block, i) => {
|
||||||
|
block.grammar = grammarErrors[i] ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const issues = toCheckedIssues(blocks);
|
||||||
|
return { blocks, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace a flagged span with a suggestion and re-check. */
|
||||||
|
applySuggestion(issue: CheckedIssue, replacement: string): void {
|
||||||
|
if (!this.editor || issue.from === issue.to) return;
|
||||||
|
const textNode = this.editor.schema.text(replacement);
|
||||||
|
const tr = this.editor.state.tr.replaceWith(issue.from, issue.to, textNode);
|
||||||
|
this.editor.view.dispatch(tr);
|
||||||
|
this.scheduleCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a misspelled word to the dictionary so it is no longer flagged. */
|
||||||
|
ignoreWord(issue: CheckedIssue): void {
|
||||||
|
if (this.spellEngine instanceof HunspellSpellEngine) {
|
||||||
|
(this.spellEngine as HunspellSpellEngine).addWord(issue.text);
|
||||||
|
}
|
||||||
|
this.ignored.add(issue.id);
|
||||||
|
this.scheduleCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locally suppress a grammar issue without editing the document. */
|
||||||
|
ignoreIssue(issue: CheckedIssue): void {
|
||||||
|
this.ignored.add(issue.id);
|
||||||
|
this.onUpdate?.({ issues: this.getIssues(), counts: this.countsSilent(), checking: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
if (this.spellEngine instanceof HunspellSpellEngine) {
|
||||||
|
this.spellEngine.dispose();
|
||||||
|
}
|
||||||
|
this.editor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Decoration building for spell/grammar results.
|
||||||
|
*
|
||||||
|
* Maps text offsets (relative to a block node) onto ProseMirror document
|
||||||
|
* positions and wraps the flagged spans in inline decorations. The outer set is
|
||||||
|
* left empty so consumers can layer cursor/selection decorations on top; the
|
||||||
|
* inner set carries the red/blue underlines rendered beneath the text.
|
||||||
|
*
|
||||||
|
* Block text is assembled from a walk over the node's inline children using a
|
||||||
|
* rare separator (U+0004) between inline nodes. This preserves word boundaries
|
||||||
|
* across marked text while keeping the assembled string in lockstep with the
|
||||||
|
* recorded `docPositions` map, so a spell/grammar offset resolves to the exact
|
||||||
|
* document character.
|
||||||
|
*/
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
import type { EditorState } from "@tiptap/pm/state";
|
||||||
|
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||||
|
import type { SpellError } from "../spell/spell.js";
|
||||||
|
import type { GrammarError } from "../grammar/grammar.js";
|
||||||
|
|
||||||
|
/** Separator inserted between inline nodes when assembling block text. */
|
||||||
|
const SEP = "\uE001";
|
||||||
|
/** Sentinel doc position marking a separator character in `docPositions`. */
|
||||||
|
const SEP_POS = -1;
|
||||||
|
|
||||||
|
/** A flagged span with its position resolved to the document. */
|
||||||
|
export interface CheckedIssue {
|
||||||
|
/** Stable id: `${kind}:${from}:${to}`. */
|
||||||
|
id: string;
|
||||||
|
kind: "spelling" | "grammar";
|
||||||
|
/** Start position in the document. */
|
||||||
|
from: number;
|
||||||
|
/** End position in the document. */
|
||||||
|
to: number;
|
||||||
|
/** The flagged text. */
|
||||||
|
text: string;
|
||||||
|
/** Suggested replacements (spell candidates or grammar fixes). */
|
||||||
|
replacements: string[];
|
||||||
|
/** Grammar explanation, when this is a grammar issue. */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issues grouped by the block node they belong to. */
|
||||||
|
export interface BlockIssues {
|
||||||
|
/** The block node itself. */
|
||||||
|
node: ProsemirrorNode;
|
||||||
|
/** Position of the block node in the document. */
|
||||||
|
blockStart: number;
|
||||||
|
/** Assembled text of the block (offsets used by engines are relative to this). */
|
||||||
|
text: string;
|
||||||
|
spell: SpellError[];
|
||||||
|
grammar: GrammarError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble a block's text and record, per character, its absolute document
|
||||||
|
* position. Separator characters get the {@link SEP_POS} sentinel so callers
|
||||||
|
* can skip them when resolving an offset.
|
||||||
|
*/
|
||||||
|
export function collectBlock(block: ProsemirrorNode, blockStart: number): {
|
||||||
|
text: string;
|
||||||
|
docPositions: number[];
|
||||||
|
} {
|
||||||
|
let text = "";
|
||||||
|
const docPositions: number[] = [];
|
||||||
|
block.forEach((child, childPos) => {
|
||||||
|
if (child.isText && child.text) {
|
||||||
|
const absStart = blockStart + 1 + childPos;
|
||||||
|
for (let i = 0; i < child.text.length; i++) {
|
||||||
|
text += child.text[i];
|
||||||
|
docPositions.push(absStart + i);
|
||||||
|
}
|
||||||
|
text += SEP;
|
||||||
|
docPositions.push(SEP_POS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { text, docPositions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a text offset within `block` to an absolute document position.
|
||||||
|
* `blockStart` is the position of the block node; its inline content begins at
|
||||||
|
* `blockStart + 1`.
|
||||||
|
*/
|
||||||
|
export function textOffsetToDocPos(
|
||||||
|
block: ProsemirrorNode,
|
||||||
|
blockStart: number,
|
||||||
|
offset: number,
|
||||||
|
): number {
|
||||||
|
const { docPositions } = collectBlock(block, blockStart);
|
||||||
|
return resolveOffset(docPositions, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an index into the assembled block text to a document position. */
|
||||||
|
function resolveOffset(docPositions: number[], offset: number): number {
|
||||||
|
if (offset < 0 || offset >= docPositions.length) {
|
||||||
|
// Clamp to the last real position (end of block).
|
||||||
|
for (let i = docPositions.length - 1; i >= 0; i--) {
|
||||||
|
if (docPositions[i] !== SEP_POS) return docPositions[i] ?? 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const at = docPositions[offset];
|
||||||
|
if (at !== SEP_POS) return at ?? 0;
|
||||||
|
// Offset lands on a separator: snap to the following real character, or the
|
||||||
|
// preceding one if the separator is trailing.
|
||||||
|
for (let i = offset; i < docPositions.length; i++) {
|
||||||
|
if (docPositions[i] !== SEP_POS) return docPositions[i] ?? 0;
|
||||||
|
}
|
||||||
|
for (let i = offset - 1; i >= 0; i--) {
|
||||||
|
if (docPositions[i] !== SEP_POS) return (docPositions[i] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a `[offset, offset+length)` span to `[from, to)` document positions. */
|
||||||
|
function resolveSpan(docPositions: number[], offset: number, length: number): { from: number; to: number } {
|
||||||
|
const from = resolveOffset(docPositions, offset);
|
||||||
|
const end = resolveOffset(docPositions, offset + length - 1);
|
||||||
|
return { from, to: end + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPELL_CLASS = "ps-spell-deco";
|
||||||
|
const GRAMMAR_CLASS = "ps-grammar-deco";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a decoration set from block-grouped issues. Each flagged span becomes
|
||||||
|
* an inline range decoration; the class encodes the kind so CSS can style it.
|
||||||
|
*/
|
||||||
|
export function buildCheckDecorations(
|
||||||
|
state: EditorState,
|
||||||
|
blocks: BlockIssues[],
|
||||||
|
): DecorationSet {
|
||||||
|
const decos: Decoration[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
const { docPositions } = collectBlock(block.node, block.blockStart);
|
||||||
|
for (const error of block.spell) {
|
||||||
|
const { from, to } = resolveSpan(docPositions, error.offset, error.length);
|
||||||
|
decos.push(Decoration.inline(clampPos(state, from), clampPos(state, to), { class: SPELL_CLASS }));
|
||||||
|
}
|
||||||
|
for (const error of block.grammar) {
|
||||||
|
const { from, to } = resolveSpan(docPositions, error.offset, error.length);
|
||||||
|
decos.push(Decoration.inline(clampPos(state, from), clampPos(state, to), { class: GRAMMAR_CLASS }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DecorationSet.create(state.doc, decos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve block-grouped issues into flat, position-resolved {@link CheckedIssue}s
|
||||||
|
* for the UI (counts, suggestion list, apply/ignore actions).
|
||||||
|
*/
|
||||||
|
export function toCheckedIssues(blocks: BlockIssues[]): CheckedIssue[] {
|
||||||
|
const issues: CheckedIssue[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
const { text } = block;
|
||||||
|
for (const error of block.spell) {
|
||||||
|
const { from, to } = resolveSpan(collectBlock(block.node, block.blockStart).docPositions, error.offset, error.length);
|
||||||
|
issues.push({
|
||||||
|
id: `spelling:${from}:${to}`,
|
||||||
|
kind: "spelling",
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
text: error.word,
|
||||||
|
replacements: error.candidates.map((c) => c.word),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const error of block.grammar) {
|
||||||
|
const { from, to } = resolveSpan(collectBlock(block.node, block.blockStart).docPositions, error.offset, error.length);
|
||||||
|
const raw = text.slice(error.offset, error.offset + error.length).replace(new RegExp(SEP, "g"), " ");
|
||||||
|
issues.push({
|
||||||
|
id: `grammar:${from}:${to}`,
|
||||||
|
kind: "grammar",
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
text: raw,
|
||||||
|
replacements: error.replacements,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a computed position into the valid range of the document. */
|
||||||
|
function clampPos(state: EditorState, pos: number): number {
|
||||||
|
return Math.max(1, Math.min(pos, state.doc.nodeSize - 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { CheckService } from "./check-service.js";
|
||||||
|
export type { CheckServiceOptions, CheckResult, CheckCounts } from "./check-service.js";
|
||||||
|
export { buildCheckDecorations } from "./decorations.js";
|
||||||
|
export type { CheckedIssue, BlockIssues } from "./decorations.js";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,24 @@ export { serializeToMarkdown, parseMarkdown, markdownInputRules, markdownPasteRu
|
|||||||
|
|
||||||
export { throttle, createIdleDetector } from "./perf.js";
|
export { throttle, createIdleDetector } from "./perf.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
CheckService,
|
||||||
|
buildCheckDecorations,
|
||||||
|
} from "./decorations/index.js";
|
||||||
|
export type {
|
||||||
|
CheckServiceOptions,
|
||||||
|
CheckResult,
|
||||||
|
CheckCounts,
|
||||||
|
CheckedIssue,
|
||||||
|
BlockIssues,
|
||||||
|
} from "./decorations/index.js";
|
||||||
|
|
||||||
|
export { HunspellSpellEngine } from "./spell/hunspell-engine.js";
|
||||||
|
export type { SpellEngine, SpellError } from "./spell/spell.js";
|
||||||
|
|
||||||
|
export { LanguageToolEngine } from "./grammar/languagetool.js";
|
||||||
|
export type { GrammarEngine, GrammarError } from "./grammar/grammar.js";
|
||||||
|
|
||||||
export { MentionAutocomplete, mentionPickerState, findMentionTrigger } from "./mention.js";
|
export { MentionAutocomplete, mentionPickerState, findMentionTrigger } from "./mention.js";
|
||||||
export type { MentionEntity, MentionProvider, MentionPickerState } from "./mention.js";
|
export type { MentionEntity, MentionProvider, MentionPickerState } from "./mention.js";
|
||||||
export { MentionPicker } from "./nodes/MentionPicker.js";
|
export { MentionPicker } from "./nodes/MentionPicker.js";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* hunspell-WASM spellcheck engine.
|
||||||
|
*
|
||||||
|
* Wraps `hunspell-wasm` (a WASM port of hunspell) behind the framework-agnostic
|
||||||
|
* {@link SpellEngine} interface. The dictionary is supplied as strings so the
|
||||||
|
* engine works identically in the browser and in node tests — no network,
|
||||||
|
* filesystem, or browser-only APIs are required to construct or use it.
|
||||||
|
*/
|
||||||
|
import { createHunspellFromStrings, type Hunspell } from "hunspell-wasm";
|
||||||
|
import {
|
||||||
|
findSpellErrors,
|
||||||
|
type SpellCandidate,
|
||||||
|
type SpellError,
|
||||||
|
type SpellEngine,
|
||||||
|
} from "./spell.js";
|
||||||
|
|
||||||
|
/** A hunspell dictionary expressed as source strings (no network / files). */
|
||||||
|
export interface HunspellDictSource {
|
||||||
|
/** The `.aff` affix-file contents. */
|
||||||
|
aff: string;
|
||||||
|
/** The `.dic` dictionary contents. */
|
||||||
|
dic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default English (US) dictionary, base64-encoded and bundled in
|
||||||
|
* `dictionaries/en-us.ts` so spellcheck works offline. Decoded on demand.
|
||||||
|
*/
|
||||||
|
import { EN_US_AFF, EN_US_DIC } from "./dictionaries/en-us.js";
|
||||||
|
|
||||||
|
/** Convert a hunspell suggestion list into the shared {@link SpellCandidate} shape. */
|
||||||
|
function toCandidates(suggestions: string[]): SpellCandidate[] {
|
||||||
|
return suggestions.map((word, index) => ({ word, weight: suggestions.length - index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spellcheck engine backed by hunspell-WASM.
|
||||||
|
*
|
||||||
|
* The dictionary is loaded once, lazily, on the first {@link check}. Subsequent
|
||||||
|
* calls reuse the loaded instance.
|
||||||
|
*/
|
||||||
|
export class HunspellSpellEngine implements SpellEngine {
|
||||||
|
private source: HunspellDictSource;
|
||||||
|
private engine: Hunspell | null = null;
|
||||||
|
private loading: Promise<Hunspell> | null = null;
|
||||||
|
|
||||||
|
constructor(source: HunspellDictSource = { aff: EN_US_AFF, dic: EN_US_DIC }) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily instantiate the underlying hunspell instance. */
|
||||||
|
private async load(): Promise<Hunspell> {
|
||||||
|
if (this.engine) return this.engine;
|
||||||
|
if (this.loading) return this.loading;
|
||||||
|
this.loading = createHunspellFromStrings(this.source.aff, this.source.dic).then(
|
||||||
|
(instance) => {
|
||||||
|
this.engine = instance;
|
||||||
|
return instance;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a word to the in-memory dictionary so it is no longer flagged. */
|
||||||
|
addWord(word: string): void {
|
||||||
|
if (!this.engine) return;
|
||||||
|
this.engine.addWord(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(text: string): Promise<SpellError[]> {
|
||||||
|
const instance = await this.load();
|
||||||
|
return findSpellErrors(text, (word) => {
|
||||||
|
if (!instance) return null;
|
||||||
|
return instance.testSpelling(word) ? null : toCandidates(instance.getSpellingSuggestions(word));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the WASM instance. */
|
||||||
|
dispose(): void {
|
||||||
|
this.engine?.dispose();
|
||||||
|
this.engine = null;
|
||||||
|
this.loading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./spell.js";
|
||||||
|
export * from "./hunspell-engine.js";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Spellcheck core logic — framework-agnostic and pure.
|
||||||
|
*
|
||||||
|
* The heavy lifting (loading hunspell dictionaries, instantiating WASM) lives
|
||||||
|
* in `hunspell-engine.ts`. This module owns only the algorithm that scans a
|
||||||
|
* string for words and asks a caller-supplied checker whether each word is
|
||||||
|
* spelled correctly. Keeping it pure means the offset math and result shaping
|
||||||
|
* are covered by unit tests without spinning up WASM or a browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A suggested correction for a misspelled word. */
|
||||||
|
export interface SpellCandidate {
|
||||||
|
/** The candidate spelling. */
|
||||||
|
word: string;
|
||||||
|
/** Confidence weight assigned by the engine (higher = better). */
|
||||||
|
weight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single misspelling with its offset into the checked text. */
|
||||||
|
export interface SpellError {
|
||||||
|
/** Zero-based offset of the word within the checked text. */
|
||||||
|
offset: number;
|
||||||
|
/** Character length of the misspelled word. */
|
||||||
|
length: number;
|
||||||
|
/** The offending word, exactly as it appeared. */
|
||||||
|
word: string;
|
||||||
|
/** Suggested corrections, best first. */
|
||||||
|
candidates: SpellCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns candidates for a word, or `null` when the word is accepted.
|
||||||
|
* `null` = correct; a non-empty array = misspelled with suggestions;
|
||||||
|
* an empty array = misspelled with no suggestions.
|
||||||
|
*/
|
||||||
|
export type WordChecker = (word: string) => SpellCandidate[] | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words are runs of letters with internal apostrophes or hyphens. The unicode
|
||||||
|
* flag keeps `matchAll` offsets in code points, matching how spell engines
|
||||||
|
* count positions.
|
||||||
|
*/
|
||||||
|
const WORD_RE = /\p{L}[\p{L}'’-]*/gu;
|
||||||
|
|
||||||
|
export interface FindSpellErrorsOptions {
|
||||||
|
/** Skip tokens shorter than this many characters (default 2). */
|
||||||
|
minWordLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan `text` for words and report the ones the checker rejects.
|
||||||
|
*
|
||||||
|
* Offsets are measured from the start of `text`, so a consumer can map them
|
||||||
|
* back onto a document node. The checker is invoked once per unique word.
|
||||||
|
*/
|
||||||
|
export function findSpellErrors(
|
||||||
|
text: string,
|
||||||
|
checker: WordChecker,
|
||||||
|
options: FindSpellErrorsOptions = {},
|
||||||
|
): SpellError[] {
|
||||||
|
const minWordLength = options.minWordLength ?? 2;
|
||||||
|
const errors: SpellError[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const match of text.matchAll(WORD_RE)) {
|
||||||
|
const word = match[0];
|
||||||
|
if (word.length < minWordLength) continue;
|
||||||
|
if (seen.has(word)) continue;
|
||||||
|
seen.add(word);
|
||||||
|
const candidates = checker(word);
|
||||||
|
if (candidates) {
|
||||||
|
errors.push({
|
||||||
|
offset: match.index,
|
||||||
|
length: word.length,
|
||||||
|
word,
|
||||||
|
candidates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spellcheck engine. Implementations load their dictionaries lazily and
|
||||||
|
* return misspellings for a block of text.
|
||||||
|
*/
|
||||||
|
export interface SpellEngine {
|
||||||
|
/** Load dictionaries / warm up. Called lazily before the first check. */
|
||||||
|
init?(): Promise<void>;
|
||||||
|
/** Check a block of text and return misspellings. */
|
||||||
|
check(text: string): Promise<SpellError[]>;
|
||||||
|
}
|
||||||
Generated
+8
@@ -133,6 +133,9 @@ importers:
|
|||||||
'@tiptap/starter-kit':
|
'@tiptap/starter-kit':
|
||||||
specifier: 3.32.0
|
specifier: 3.32.0
|
||||||
version: 3.32.0
|
version: 3.32.0
|
||||||
|
hunspell-wasm:
|
||||||
|
specifier: ^0.3.0
|
||||||
|
version: 0.3.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.2
|
specifier: ^22.10.2
|
||||||
@@ -2110,6 +2113,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
hunspell-wasm@0.3.0:
|
||||||
|
resolution: {integrity: sha512-D7/QLaeekJYYlE37D7KBq5v38vL4tQutTFIUBOUGBl0g9la8OUgfSd8+XbwNPV5uYMHS13umXR9MtdAxStpHJg==}
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4875,6 +4881,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
hunspell-wasm@0.3.0: {}
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|||||||
Reference in New Issue
Block a user