KakuYaku: building a Japanese pop-up dictionary extension from scratch
When reading Japanese materials, I always need quick lookup. Existing tools were either too heavy (manual selection + dictionary lookup) or too light (just kanji readings, no grammar analysis), so I built one myself.
Tech choices
Tokenization: Sudachi
Japanese tokenization is not like Chinese; it needs dedicated tools. Sudachi is an open-source tokenizer from WorksApplications, with Python/Java/Rust bindings, and I used the Node.js native module version.
Sudachi output is rich:
{
"surface": "食べて",
"dictionaryForm": "食べる",
"reading": "タベテ",
"pos": "動詞",
"posDetail": ["一般", "*", "*", "*", "*"]
}
Dictionary: JMDict + KANJIDIC2 + PostgreSQL/PGroonga
Dictionary data uses EDRDG open data:
- JMDict — 200k+ Japanese–English entries
- KANJIDIC2 — 13,000+ kanji
- Tatoeba — 240k+ example sentences
Data is stored in PostgreSQL with the PGroonga extension for full-text search. PGroonga is optimized for Japanese/Chinese and is friendlier for Japanese text than pg_trgm.
JMDict part-of-speech annotations are stored as real text[] arrays, and queries use = ANY().
Frontend: Chrome Extension Manifest V3 + Vue 3
I used the vite-vue3-browser-extension-v3 template. Manifest V3’s service worker model is a bit annoying—the background page is no longer persistent, so reconnections are frequent, and message channel lifetime needs attention.
LLM: DeepSeek
Grammar parsing and translation are powered by an LLM. I started with Gemini Flash 2.0 (free quota: 1500 req/day); after the quota was used up in a day during dev testing, I switched to DeepSeek (deepseek-chat), which is compatible with OpenAI API and costs around $0.14/Mtok.
Core implementation
Tokenization + highlighting
The content script walks page text nodes, sends them to the API for tokenization, then replaces each raw text node with highlighted spans:
const response = await sendToBackground({ action: 'analyze', text: node.textContent })
const tokens = response.tokens
// Replace text node with span sequence
const fragment = document.createDocumentFragment()
for (const token of mergedTokens) {
const span = document.createElement('span')
span.className = `kaku-yaku-highlight kaku-yaku-${token.pos}`
span.dataset.surface = token.surface
fragment.appendChild(span)
}
node.parentNode.replaceChild(fragment, node)
Conjugation merge for verbs
Sudachi splits 「食べている」 into three tokens: 食べ, て, いる. For learners, that is one grammatical unit.
Merge rules:
- Verb + auxiliary verb* → merge into one unit (食べている, 落成した)
- サ変 noun + する + auxiliary verb* → merge (制圧する, 参加している)
- Adjective + auxiliary verb* → merge (美しかった)
// posDetail[1] === 'サ変可能' means サ変名詞
const isSahenNoun = (t: Token) =>
t.pos === '名詞' && t.posDetail?.[1] === 'サ変可能'
Popup dictionary card
Click a highlighted token and show a floating card with:
- surface + reading (if different)
- POS badge + JLPT level badge
- definitions
- example sentences
Positioning uses position: absolute, with coordinates from getBoundingClientRect() + scrollY, so the popup tracks scroll correctly.
AI grammar analysis
Call DeepSeek through OpenAI-compatible API and force JSON with response_format: { type: 'json_object' } (so no regex needed to strip Markdown code fences):
const res = await client.chat.completions.create({
model: 'deepseek-chat',
messages: [
{ role: 'system', content: `JSON schema: { role, function, rule, example, exampleTrans }` },
{ role: 'user', content: `Sentence: "${sentence}"\nExplain: "${targetWord}"` },
],
response_format: { type: 'json_object' },
max_tokens: 300,
})
Results are cached in Map<paragraphText, { grammar?, translation? }> so words within the same paragraph share cache. Parsed paragraphs show a cyan border on the left.
Pitfalls
Manifest V3 service worker lifecycle
Service workers can be killed at any time. When a content script sends a message to background while the worker is asleep, sendMessage throws message channel closed before response.
Fix: explicitly return a Promise in each case branch; don’t rely on async/await to keep the message channel alive implicitly.
JMDict field names
In the API payload, gloss fields are gloss: string[], not meanings[0].glosses. The docs were unclear; this came up in debugging.
sshfs-win remote development
I developed on Windows while code executed on a Mac Mini (100.73.231.27 via Tailscale). CRXJS HMR dev server on localhost:3303 was unreachable remotely. I switched to production watch build and mounted dist/chrome via sshfs-win, then loaded it as an unpacked extension in Chrome.
Gemini API baseURL
The correct base URL is https://generativelanguage.googleapis.com/v1beta/openai/, not /openai/v1/. It is not obvious in the docs.
Current features
- Tokenization + highlighting (POS color coding)
- Verb/adjective conjugation merging
- Floating dictionary card (reading, POS, JLPT, definitions, examples)
- Furigana overlay (hover / always mode)
- AI grammar parsing (DeepSeek, multilingual output)
- AI translation (sentence-level, not full paragraph translation)
- Vocabulary list (storage.local with status tracking)
- LLM result caching (paragraph-level)
- Settings page (language, furigana toggle, cross-device sync via storage.sync)
The main reason I built this was practical convenience. The next planned feature is one-click Anki export—importing vocab data into Anki for review.