はじめに
「bon と入力すると 好 が候補に出る」——そんな独自の入力システム「漢字化エスペラント」をブラウザでも使えるようにした開発記です。
VS Code拡張機能とMonaco Editorを使って、2,864件のスニペット辞書を搭載した入力環境を構築し、その過程で遭遇した技術的な問題とその解決策を詳しく解説します。
この記事で得られる知識
- VS Code Web拡張の作り方(Node.js APIなしでブラウザ動作)
- Monaco Editorでのカスタム補完プロバイダ実装
-
Monaco Editorの
provideCompletionItemsの落とし穴とその回避方法 - Open VSXへの拡張公開手順
- 大規模辞書の分割と遅延読み込みの実装
TL;DR(3行で)
- エスペラント語根を漢字に変換する独自入力システム(辞書2,864件)
- VS Code拡張に加え、Monaco版のWebデモ(PWA対応)を公開
- Monacoは仕様上「候補を一度閉じて再表示」する実装で正確さを担保
目次
- 漢字化エスペラントとは
- 実現したかったこと
- VS Code拡張機能の実装
- Monaco Editorによるオンライン版
- 最大の問題:候補が正しく絞り込まれない
- 解決策:Monaco Editorの仕様を理解する
- ローカル環境との徹底比較
- 成果物とデモ
- 使い始めガイド
- PWA(オフライン対応)
- 辞書の作り方(.ke.txt → all.json → data/)
- GitHub Pages で公開
- トラブルシューティング
- 今後の課題
漢字化エスペラントとは
漢字化エスペラントは、エスペラント語の語根を漢字1文字で表記する独自の表記法です。
変換例
| エスペラント語根 | 漢字 | 意味 |
|---|---|---|
bon |
好 |
良い |
pli |
更 |
より |
am |
愛 |
愛する |
sun |
日 |
太陽 |
入力イメージ
ユーザーが bon と入力
↓
候補ウィジェットに「bon → 好」と表示
↓
Enterで確定すると「好」が挿入される
これをVS Codeとブラウザで快適に入力できる環境を構築しました。
実現したかったこと
ゴール
既にローカル環境(.ke.txtファイル)で動いている快適な入力環境を、以下の環境すべてで使えるようにする:
- VS Code Desktop(Windows/macOS/Linux)
- ブラウザ版VS Code(vscode.dev / github.dev)
- Monaco Editorベースの独自Webアプリ
技術要件
- ✅ Web拡張対応:Node.js APIを使わず、ブラウザでも動作
- ✅ 決定性:同じ入力に対して常に同じ候補
- ✅ 即座のサジェスト:Backspace後も候補を自動表示
- ✅ 大規模辞書:2,864件のスニペットを高速に処理
VS Code拡張機能の実装
基本構成
kanji-esperanto/
├─ package.json # 拡張のメタデータ
├─ language-configuration.json
├─ snippets/
│ └─ kanji-esperanto.json # 2,864件の辞書
├─ src/web/extension.ts # Web拡張のコード
└─ scripts/
├─ build.mjs # esbuildでバンドル
└─ sync-snippets.mjs # スニペット同期
言語定義
{
"wordPattern": "([a-zA-Z]+)|([\\u3400-\\u9fff々〻]+)"
}
ポイント:
- ASCII語根(
bon)と漢字(好)を単語として認識 - これにより
更bonのように漢字直後に語根を入力しても正しく認識される
エディタ設定
{
"configurationDefaults": {
"[kanji-esperanto]": {
"editor.wordBasedSuggestions": "off",
"editor.suggest.showWords": false,
"editor.snippetSuggestions": "top",
"editor.quickSuggestionsDelay": 10
}
}
}
重要な設定:
-
wordBasedSuggestions: "off":文書内の単語候補を無効化 -
snippetSuggestions: "top":スニペットを最優先表示 -
quickSuggestionsDelay: 10:10msで即座に候補表示(快適性の要)
Backspace後の自動サジェスト
import * as vscode from 'vscode';
export function activate(context: vscode.ExtensionContext) {
const deleteLeft = vscode.commands.registerCommand(
'kanji-esperanto.deleteLeftAndSuggest',
async () => {
await vscode.commands.executeCommand('deleteLeft');
await vscode.commands.executeCommand('editor.action.triggerSuggest');
}
);
context.subscriptions.push(deleteLeft);
}
キーバインド:
{
"key": "backspace",
"command": "kanji-esperanto.deleteLeftAndSuggest",
"when": "editorTextFocus && !suggestWidgetVisible && editorLangId == 'kanji-esperanto'"
}
なぜこれが必要か
VS Codeのデフォルトでは、Backspaceで文字を削除しても候補ウィジェットは自動表示されません。この実装により、削除後も即座に候補が表示され、快適な編集体験が実現します。
ビルドとパッケージング
import esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/web/extension.ts'],
bundle: true,
format: 'cjs',
platform: 'browser', // ← ブラウザ環境向け
outfile: 'dist/web/extension.js',
external: ['vscode'],
target: ['es2020']
});
npm run build
npx vsce package --target web
Open VSXへの公開
npx ovsx publish -p esperantokanjika -t <token> --target web
公開結果:
- 拡張ID:
esperantokanjika.kanji-esperanto - URL:https://open-vsx.org/extension/esperantokanjika/kanji-esperanto
Monaco Editorによるオンライン版
構成
ke-monaco-site/
├─ index.html # UIとMonacoローダ
├─ app.js # 補完ロジック
└─ data/
├─ ke-a.json # aで始まる語根
├─ ke-b.json
└─ ... (全26文字分)
URL 例:
- Monaco: https://takatakatake.github.io/ke-monaco-site/
(初回から決定的にするには `?strict=1` を付与)
strict=1 とは?
- 初回アクセス時に A〜Z 26個の辞書バケツを先読み(preload)してから補完プロバイダを登録します。
- 利点: 初回の数打鍵でも候補が確実に出る(ネットワーク遅延やキャッシュ未ヒットの揺らぎを排除)。
- トレードオフ: 初回の読み込みがやや重くなります(その後はキャッシュで軽快)。
辞書の分割
2,864件の辞書を26ファイルに分割して遅延読み込み:
import fs from 'node:fs/promises';
const src = JSON.parse(await fs.readFile('all.json', 'utf8'));
const buckets = {};
for (const item of src.items) {
const key = (item.prefix?.[0] || '#').toLowerCase();
(buckets[key] ||= []).push(item);
}
for (const [key, arr] of Object.entries(buckets)) {
arr.sort((a, b) => a.prefix.localeCompare(b.prefix));
await fs.writeFile(
`data/ke-${key}.json`,
JSON.stringify({ items: arr }, null, 2)
);
}
Monaco Editorのセットアップ
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>Kanji Esperanto Editor</title>
<script>
// Self-contained worker (CORS回避)
window.MonacoEnvironment = {
getWorkerUrl: function () {
return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
self.MonacoEnvironment = {
baseUrl: 'https://unpkg.com/monaco-editor@0.52.0/min/'
};
importScripts('https://unpkg.com/monaco-editor@0.52.0/min/vs/base/worker/workerMain.js');
`)}`;
}
};
</script>
<script src="https://unpkg.com/monaco-editor@0.52.0/min/vs/loader.js"></script>
</head>
<body>
<div id="editor"></div>
<script src="./app.js"></script>
</body>
</html>
初期実装(問題あり版)
monaco.languages.registerCompletionItemProvider('kanji-esperanto', {
triggerCharacters: 'abcdefghijklmnopqrstuvwxyz'.split(''),
provideCompletionItems: async (model, position) => {
const line = model.getLineContent(position.lineNumber);
const col = position.column - 1;
const textBeforeCursor = line.slice(0, col);
const match = textBeforeCursor.match(/[A-Za-z]+$/);
if (!match) return { suggestions: [] };
const prefix = match[0];
const bucket = await loadBucket(prefix[0]);
const items = bucket
.filter(s => s.prefix && s.prefix.startsWith(prefix))
.map(s => ({
label: s.prefix + ' → ' + s.body,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: s.body,
sortText: '0' + s.prefix
}));
return { suggestions: items };
}
});
// Backspace後にサジェスト再表示
editor.onKeyDown((e) => {
if (e.keyCode === monaco.KeyCode.Backspace) {
setTimeout(() => {
editor.trigger('ke', 'editor.action.triggerSuggest', {});
}, 0);
}
});
最大の問題:候補が正しく絞り込まれない
発生した症状
ユーザーからのフィードバック:
「pliと打っているのに、pilに反応するのはなぜか?既にスペースキーを打っているのに候補がで続けるのはおかしくないですか?」
具体的な問題:
-
pliと入力しているのに、候補にpil → 球が表示される -
papili → 蝶やpiedestal → 座台など、pliで始まらない候補も表示される - スペースを打った後も候補が残り続ける
- 次の単語(例:
bon)の候補がうまく出ない
デバッグの過程
スクリーンショットを見ると、pli と入力しているのに以下の候補が表示されていた:
pil → 球 ← なぜこれが先頭?
papili → 蝶
piedestal → 座台
これらはすべて p で始まるが、pli で始まってはいない。
原因の特定
フィルタリングロジック自体は正しい:
.filter(s => s.prefix && s.prefix.startsWith(prefix))
しかし、prefix が "pli" ではなく "p" のままになっていることが判明。
つまり:
-
pを入力 →provideCompletionItems(prefix="p")が呼ばれる -
lを入力 →triggerSuggest()を呼ぶが...? -
iを入力 →triggerSuggest()を呼ぶが...?
候補が "p" のまま更新されない!
解決策:Monaco Editorの仕様を理解する
Monaco Editorの内部仕様
Monaco Editorの SuggestController には、パフォーマンス最適化のための仕様がある:
重要な仕様
補完ウィジェットが既に表示されている状態では、triggerSuggest() を呼んでも provideCompletionItems が再実行されない
これにより:
✅ メリット:不要な再計算を防ぎ、パフォーマンスが向上
❌ デメリット:文字入力で候補を絞り込むには手動実装が必要
VS Code標準snippet機能との違い
| 実装方式 | 候補の更新 | 手動実装の必要性 |
|---|---|---|
| VS Code標準snippet | 文字入力で自動的に絞り込まれる | 不要 |
| Monaco CompletionProvider | 手動で閉じる→開くが必要 | 必須 |
VS Code拡張では標準snippet機能を使っているため、この問題は発生しなかった。
最終的な修正
// hideSuggest関数
function hideSuggest() {
try {
editor.trigger('ke', 'hideSuggestWidget', {});
} catch {}
try {
const c = editor.getContribution('editor.contrib.suggestController');
if (c && typeof c.cancel === 'function') c.cancel();
} catch {}
}
// 文字入力ごとに候補を閉じてから再度開く
editor.onDidType((text) => {
// スペース入力時は即座に閉じて終了
if (/^\s$/.test(text)) {
hideSuggest();
return;
}
// a-z以外は閉じる
if (!/^[a-z]$/i.test(text)) {
hideSuggest();
return;
}
// a-z入力時:候補を閉じてから再表示
try {
const model = editor.getModel();
const pos = editor.getPosition();
const col0 = pos.column - 1;
const line = model.getLineContent(pos.lineNumber);
const prefix = extractAsciiPrefix(line, col0);
if (!prefix) {
hideSuggest();
return;
}
const maybe = loadBucket(prefix[0]);
Promise.resolve(maybe).then(() => {
hideSuggest(); // ← 重要:一度閉じる
setTimeout(() => {
editor.trigger('ke', 'editor.action.triggerSuggest', {});
}, 10); // ← 10ms後に再度開く
});
} catch {
hideSuggest();
setTimeout(() => {
editor.trigger('ke', 'editor.action.triggerSuggest', {});
}, 10);
}
});
修正のポイント:
-
文字入力ごとに
hideSuggest()を呼ぶ
→ Monaco Editorが「新しい補完リクエスト」として認識 -
10msの遅延を設ける
→ 閉じる処理が完了してから再度開く -
スペース入力時は閉じて終了
→ 次の単語入力に備える
動作フローの比較
修正前
1. 'p' 入力 → provideCompletionItems(prefix="p") → 候補表示
2. 'l' 入力 → triggerSuggest() → ❌ 無視される
3. 'i' 入力 → triggerSuggest() → ❌ 無視される
→ 結果:pil, papili, piedestal がそのまま表示
修正後
1. 'p' 入力 → provideCompletionItems(prefix="p") → 候補表示
2. 'l' 入力 → hideSuggest() → 10ms後 → triggerSuggest()
→ ✅ provideCompletionItems(prefix="pl") 再実行
3. 'i' 入力 → hideSuggest() → 10ms後 → triggerSuggest()
→ ✅ provideCompletionItems(prefix="pli") 再実行
→ 結果:pli のみ表示
ユーザーの反応
「なんか、うまく行った気がします。一体どうして???」
ローカル環境との徹底比較
実装方式の違い
| 項目 | ローカル(VS Code) | Monaco Editor |
|---|---|---|
| 実装方式 | 標準snippet機能 | CompletionProvider |
| 候補の更新 | 自動絞り込み | 手動で閉じる→開く |
| onDidType処理 | 不要 | 必須 |
| スペース混じり語根の正規化 | VS Codeの単語認識に依存 | 現状: 未実装(ASCII連続のみ)。必要なら正規化を実装 |
| UX | スムーズ | やや雑に感じる可能性 |
UXの違い
| 側面 | ローカル | Monaco版 |
|---|---|---|
| 候補の表示 | スムーズに絞り込まれる | 一瞬消えて再表示(ちらつき) |
| レスポンス | 即座 | 10ms遅延(体感では問題なし) |
| 正確性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐(修正後) |
| 視覚的快適さ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
ちらつきの原因:
- Monaco Editorの制約により、候補を閉じる→開く処理が必要
- この処理中、ユーザーには候補が一瞬消えて見える
- しかし、正確な候補を表示するためには避けられない
成果物とデモ
VS Code拡張
- 公開先:Open VSX
-
拡張ID:
esperantokanjika.kanji-esperanto - URL:https://open-vsx.org/extension/esperantokanjika/kanji-esperanto
対応環境:
- ✅ VS Code Desktop
- ✅ vscode.dev
- ✅ github.dev
- ✅ VSCodium
Monaco Editor版
- 公開URL:https://takatakatake.github.io/ke-monaco-site/
- リポジトリ:https://github.com/Takatakatake/ke-monaco-site
特徴:
- URL一発で使える(
?strict=1で初回から決定的) - 初期テキストは2行の見本文(localStorage 保存があると初期文は表示されない)
- オフライン動作(Service Worker による事前キャッシュ)
- 自動保存(localStorage)/簡易履歴(最大50)
使い始めガイド
- アクセス
- https://takatakatake.github.io/ke-monaco-site/
- 初回から決定的:
?strict=1を付与 - strict=1 は「初回に辞書を全先読みしてから補完を有効化」するモードです(決定的ですが初回が少し重くなります)
- 初期テキスト(2行)について
- 既に localStorage に保存がある場合は初期文は表示されません
- クリア操作:
Ctrl+Alt+Backspace
- 候補の操作
- 通常入力で自動表示、
Ctrl+Spaceで強制表示 - Backspace/Delete 後は自動で再表示
- 通常入力で自動表示、
- 履歴の操作(最大50件)
- 直前のスナップショットを復元:
Ctrl+Alt+R
- 直前のスナップショットを復元:
PWA(オフライン対応)
- ルートに
manifest.webmanifestとsw.jsを配置。初回アクセス時に主要アセットを事前キャッシュします - 事前キャッシュの例
-
index.html,app.js,all.json,data/ke-*.json, Monaco の最小依存
-
- フォーク/派生で公開パスが変わる場合(例:
/ke-monaco-site_me/)はmanifest.webmanifestのstart_urlとscopeを必ず変更 - 配信更新時のコツ
- Service Worker のキャッシュ名(例:
ke-site-v3)を上げる - クライアント側は DevTools → Application → Service Workers で Unregister、Clear storage → ハードリロード
- Service Worker のキャッシュ名(例:
辞書の作り方(.ke.txt → all.json → data/)
-
.ke.txtからall.jsonを生成
node tools/ke-txt-to-all.mjs /path/to/dictionary.ke.txt ./all.json
-
all.jsonを26バケツに分割(先頭文字ごと)
node tools/split-dictionary.mjs ./all.json ./data
件数の目安: 本記事の辞書は合計 2,864 件です。
GitHub Pages で公開
- リポに
.github/workflows/pages.ymlを同梱(actions/upload-pages-artifact + deploy-pages) - Settings → Actions → General
- Actions permissions: Allow all actions
- Workflow permissions: Read and write permissions(必須)
- Settings → Pages → Source: GitHub Actions を選択
- 失敗時の再実行
- Actions タブで Re-run all jobs、または空コミットで再トリガー
- 代替: 静的サイトなら「Deploy from a branch(main /root)」でも公開可能
トラブルシューティング
- 「初期文が“更bon …”のまま」
- localStorage をクリア(
Ctrl+Alt+Backspace) - それでも変わらない場合は SW を Unregister → Clear storage → ハードリロード
- localStorage をクリア(
- 「候補が出ない/古い」
-
?strict=1を付与、辞書の先頭文字バケツの配信可否を確認 - ネットワーク失敗時は
app.jsが再試行・フォールバックする実装
-
- 「fork で PWA が動かない」
-
manifest.webmanifestのstart_url/scopeを公開パスに合わせる - SW のキャッシュ名を更新後、クライアント側で SW を再登録
-
今後の課題
技術的な改善
-
ちらつきの軽減
- 遅延時間の調整(10ms → 5ms)
- CSSアニメーションでの滑らか表示
-
パフォーマンス最適化
- 2文字×26×26の二段バケツ化
- Trieデータ構造の導入
機能拡張
-
逆引き機能
- 漢字 → 語根の候補表示
- 例:
好と入力 →bonが候補に
-
PWA化(実装済)- 本文「PWA(オフライン対応)」節を参照
-
Microsoft Marketplace公開
- VS Codeの拡張検索に表示
- 自動更新の有効化
まとめ
学んだこと
-
Monaco Editorの補完システムの仕様
- 既に候補が表示されている状態では
triggerSuggest()が無視される - 候補を更新するには「閉じる→開く」が必要
- 既に候補が表示されている状態では
-
VS Code標準snippet機能との違い
- 標準機能は自動で候補を絞り込む
- CompletionProviderは手動実装が必要
-
Web拡張の作り方
- Node.js APIを避け、ブラウザ互換のコードに
-
platform: 'browser'でビルド
技術スタック
- VS Code Extension API (Web拡張対応)
- Monaco Editor 0.52.0
- esbuild (バンドル)
- GitHub Pages (ホスティング)
- PWA (Service Worker + Web Manifest)
- Open VSX (拡張配布)
公開リポジトリ
- VS Code拡張:(リポジトリURLを追加予定)
- Monaco Editor版:https://github.com/Takatakatake/ke-monaco-site
参考資料
対象読者:
- VS Code拡張を作りたい人
- Monaco Editorで独自補完を実装したい人
- 大規模辞書を扱うWebアプリを作りたい人
- ブラウザで動くエディタ拡張に興味がある人