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.
This commit is contained in:
@@ -2,3 +2,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";
|
||||
export { toCheckedIssues, collectBlock, textOffsetToDocPos } from "./decorations.js";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseGrammarResponse, type LTResponse } from "./grammar.js";
|
||||
|
||||
describe("parseGrammarResponse", () => {
|
||||
it("returns an empty array when issues is missing", () => {
|
||||
const result = parseGrammarResponse({});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when issues is an empty list", () => {
|
||||
const result = parseGrammarResponse({ issues: [] });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a single issue with all fields", () => {
|
||||
const response: LTResponse = {
|
||||
issues: [
|
||||
{
|
||||
offset: 12,
|
||||
length: 2,
|
||||
message: "Use 'a' before consonant sounds",
|
||||
replacements: [{ value: "a", quality: 1 }],
|
||||
rule: { id: "EN_A_VS_AN", category: { id: "GRAMMAR" } },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parseGrammarResponse(response);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.offset).toBe(12);
|
||||
expect(result[0]!.length).toBe(2);
|
||||
expect(result[0]!.message).toBe("Use 'a' before consonant sounds");
|
||||
expect(result[0]!.replacements).toEqual(["a"]);
|
||||
expect(result[0]!.ruleId).toBe("EN_A_VS_AN");
|
||||
expect(result[0]!.category).toBe("GRAMMAR");
|
||||
});
|
||||
|
||||
it("parses multiple issues in order", () => {
|
||||
const response: LTResponse = {
|
||||
issues: [
|
||||
{ offset: 0, length: 3, message: "First error", replacements: [{ value: "fix1" }] },
|
||||
{ offset: 10, length: 5, message: "Second error", replacements: [{ value: "fix2" }] },
|
||||
{ offset: 20, length: 1, message: "Third error", replacements: [] },
|
||||
],
|
||||
};
|
||||
const result = parseGrammarResponse(response);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]!.offset).toBe(0);
|
||||
expect(result[1]!.offset).toBe(10);
|
||||
expect(result[2]!.offset).toBe(20);
|
||||
expect(result[0]!.replacements).toEqual(["fix1"]);
|
||||
expect(result[1]!.replacements).toEqual(["fix2"]);
|
||||
expect(result[2]!.replacements).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits ruleId and category when not present", () => {
|
||||
const response: LTResponse = {
|
||||
issues: [{ offset: 5, length: 2, message: "No rule info", replacements: [{ value: "x" }] }],
|
||||
};
|
||||
const result = parseGrammarResponse(response);
|
||||
expect(result[0]!.ruleId).toBeUndefined();
|
||||
expect(result[0]!.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles replacements with empty value", () => {
|
||||
const response: LTResponse = {
|
||||
issues: [{ offset: 0, length: 1, message: "msg", replacements: [{ value: "" }] }],
|
||||
};
|
||||
const result = parseGrammarResponse(response);
|
||||
expect(result[0]!.replacements).toEqual([""]);
|
||||
});
|
||||
|
||||
it("preserves offset and length exactly as provided", () => {
|
||||
const response: LTResponse = {
|
||||
issues: [{ offset: 42, length: 7, message: "test", replacements: [] }],
|
||||
};
|
||||
const result = parseGrammarResponse(response);
|
||||
expect(result[0]!.offset).toBe(42);
|
||||
expect(result[0]!.length).toBe(7);
|
||||
});
|
||||
|
||||
it("handles issues with no replacements field", () => {
|
||||
const response: LTResponse = {
|
||||
issues: [{ offset: 3, length: 4, message: "no repl" }],
|
||||
};
|
||||
const result = parseGrammarResponse(response);
|
||||
expect(result[0]!.replacements).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findSpellErrors, type WordChecker } from "./spell.js";
|
||||
|
||||
describe("findSpellErrors", () => {
|
||||
const correctWords = new Set([
|
||||
"hello", "world", "the", "quick", "brown", "fox",
|
||||
"stop", "fact",
|
||||
]);
|
||||
|
||||
const checker: WordChecker = (word) => {
|
||||
if (correctWords.has(word.toLowerCase())) return null;
|
||||
return [{ word: word + "d", weight: 1 }];
|
||||
};
|
||||
|
||||
it("returns no errors when all words are correct", () => {
|
||||
const result = findSpellErrors("hello world the quick brown fox", checker);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a single misspelled word with correct offset", () => {
|
||||
const result = findSpellErrors("helo world", checker);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.word).toBe("helo");
|
||||
expect(result[0]!.offset).toBe(0);
|
||||
expect(result[0]!.length).toBe(4);
|
||||
expect(result[0]!.candidates).toEqual([{ word: "helod", weight: 1 }]);
|
||||
});
|
||||
|
||||
it("flags multiple misspelled words", () => {
|
||||
const result = findSpellErrors("helo wrld", checker);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.word).toBe("helo");
|
||||
expect(result[0]!.offset).toBe(0);
|
||||
expect(result[1]!.word).toBe("wrld");
|
||||
expect(result[1]!.offset).toBe(5);
|
||||
});
|
||||
|
||||
it("skips words shorter than minWordLength", () => {
|
||||
const result = findSpellErrors("a b c hello", checker, { minWordLength: 2 });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles words with apostrophes", () => {
|
||||
const result = findSpellErrors("don't stop", checker);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.word).toBe("don't");
|
||||
expect(result[0]!.offset).toBe(0);
|
||||
expect(result[0]!.length).toBe(5);
|
||||
});
|
||||
|
||||
it("handles words with hyphens", () => {
|
||||
const result = findSpellErrors("well-known fact", checker);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.word).toBe("well-known");
|
||||
expect(result[0]!.offset).toBe(0);
|
||||
expect(result[0]!.length).toBe(10);
|
||||
});
|
||||
|
||||
it("deduplicates repeated words", () => {
|
||||
const result = findSpellErrors("helo helo helo", checker);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.offset).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty text", () => {
|
||||
const result = findSpellErrors("", checker);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles unicode words", () => {
|
||||
const result = findSpellErrors("café résumé", checker);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.word).toBe("café");
|
||||
expect(result[1]!.word).toBe("résumé");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user