Files
edentext/src/lib/spell/dictionary.ts
T
Steffen Becker f6c906f9fa perf: the word lists travel as .txt, which the host compresses
GitHub Pages serves a .dic as text/x-c and leaves it uncompressed, while
text/plain goes out gzipped: the English list cost 552 KB over the wire
where 191 KB would do. Only the fetch path changes; the mount name the
checker sees stays <code>.dic. Measured first load 1.83 → 1.70 MB.
2026-09-06 19:07:11 +02:00

66 lines
2.6 KiB
TypeScript

import type { HunspellFactory } from 'hunspell-asm';
import { NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
// Thin engine-agnostic view over a loaded dictionary, so the controller and
// extension never touch hunspell-asm directly.
export interface Checker {
correct(word: string): boolean;
suggest(word: string): string[];
add(word: string): void;
}
// One in-flight/resolved load per code, so switching back to a language is
// instant and concurrent callers share a single fetch.
const cache = new Map<string, Promise<Checker | null>>();
// The wasm module is language-independent; load it once and share it. The
// dynamic import keeps hunspell-asm (~780kB, base64-inlined wasm) out of the
// initial bundle — Vite splits it into a chunk fetched on first spell-check.
let modulePromise: Promise<HunspellFactory> | null = null;
function loadFactory(): Promise<HunspellFactory> {
if (!modulePromise) modulePromise = import('hunspell-asm').then((m) => m.loadModule());
return modulePromise;
}
async function fetchBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return new Uint8Array(await res.arrayBuffer());
}
async function build(code: string): Promise<Checker> {
// Vendored assets: public/dictionaries/<code>/<code>.{aff,dic.txt}. The word list is
// .txt so the host gzips it (GitHub Pages leaves a .dic's text/x-c uncompressed);
// BASE_URL keeps the path correct under a non-root deploy base.
const base = `${import.meta.env.BASE_URL}dictionaries/${code}/${code}`;
const [factory, aff, dic] = await Promise.all([
loadFactory(),
fetchBytes(`${base}.aff`),
fetchBytes(`${base}.dic.txt`),
]);
const affPath = factory.mountBuffer(aff, `${code}.aff`);
const dicPath = factory.mountBuffer(dic, `${code}.dic`);
const hunspell = factory.create(affPath, dicPath);
return {
correct: (word) => hunspell.spell(word),
suggest: (word) => hunspell.suggest(word),
add: (word) => hunspell.addWord(word),
};
}
// Lazily load (and cache) the Hunspell checker for a language. Resolves to null
// for NO_LANGUAGE or when the dictionary can't be fetched.
export function loadChecker(code: DocumentLanguage): Promise<Checker | null> {
if (code === NO_LANGUAGE) return Promise.resolve(null);
let pending = cache.get(code);
if (!pending) {
pending = build(code).catch((err) => {
cache.delete(code); // allow a retry after a transient failure
console.error(`[spell] failed to load dictionary "${code}":`, err);
return null;
});
cache.set(code, pending);
}
return pending;
}