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
+2 -1
View File
@@ -21,10 +21,11 @@
"dependencies": {
"@pubscript/core": "workspace:*",
"@tiptap/core": "3.32.0",
"@tiptap/extension-text-style": "3.32.0",
"@tiptap/pm": "3.32.0",
"@tiptap/react": "3.32.0",
"@tiptap/starter-kit": "3.32.0",
"@tiptap/extension-text-style": "3.32.0"
"hunspell-wasm": "^0.3.0"
},
"devDependencies": {
"@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));
}
+4
View File
@@ -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";
+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[]>;
}
+2
View File
@@ -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);
}
}
}
+18
View File
@@ -44,6 +44,24 @@ export { serializeToMarkdown, parseMarkdown, markdownInputRules, markdownPasteRu
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 type { MentionEntity, MentionProvider, MentionPickerState } from "./mention.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;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./spell.js";
export * from "./hunspell-engine.js";
+91
View File
@@ -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[]>;
}
+8
View File
@@ -133,6 +133,9 @@ importers:
'@tiptap/starter-kit':
specifier: 3.32.0
version: 3.32.0
hunspell-wasm:
specifier: ^0.3.0
version: 0.3.0
devDependencies:
'@types/node':
specifier: ^22.10.2
@@ -2110,6 +2113,9 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
hunspell-wasm@0.3.0:
resolution: {integrity: sha512-D7/QLaeekJYYlE37D7KBq5v38vL4tQutTFIUBOUGBl0g9la8OUgfSd8+XbwNPV5uYMHS13umXR9MtdAxStpHJg==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -4875,6 +4881,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
hunspell-wasm@0.3.0: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2