隠蔽モードの原理:なぜ幅計算が重要なのか
学習後のゴール
- OpenCode 隠蔽モードの動作原理を理解する
- 通常のフォーマットツールが隠蔽モードでズレる理由を知る
- プラグインの幅計算アルゴリズム(3ステップ)を習得する
Bun.stringWidthの役割を理解する
現在の課題
OpenCode でコードを書いていて、AI がきれいなテーブルを生成しました:
| フィールド | タイプ | 説明 |
|--- | --- | ---|
| **name** | string | ユーザー名 |
| age | number | 年齢 |ソースビューでは整然と見えます。しかしプレビューモードに切り替えると、テーブルがズレています:
| フィールド | タイプ | 説明 |
|--- | --- | ---|
| name | string | ユーザー名 | ← なぜ短い?
| age | number | 年齢 |問題はどこにある?隠蔽モードです。
隠蔽モードとは
OpenCode はデフォルトで**隠蔽モード(Concealment Mode)**が有効になっており、レンダリング時に Markdown 構文記号を非表示にします:
| ソース | 隠蔽モードでの表示 |
|---|---|
**太字** | 太字(4 文字) |
*斜体* | 斜体(4 文字) |
~~打ち消し線~~ | 打ち消し線(6 文字) |
`コード` | コード(4 文字 + 背景色) |
隠蔽モードのメリット
コンテンツそのものに集中でき、** や * などの記号に視線を邪魔されません。
通常のフォーマットツールが問題を起こす理由
通常のテーブルフォーマットツールは幅を計算する際、**name** を 8 文字として扱います:
** n a m e ** = 8 文字しかし隠蔽モードでは、ユーザーが見るのは name で、4 文字しかありません。
結果として、フォーマットツールは 8 文字で揃えますが、ユーザーが見るのは 4 文字なので、テーブルがズレてしまいます。
コアアイデア:「文字数」ではなく「表示幅」を計算する
このプラグインのコアアイデアは:ユーザーが実際に見る幅を計算し、ソースコードの文字数を計算しないことです。
アルゴリズムは 3 ステップです:
ステップ 1:コードブロックを保護する(コードブロック内の記号は除去しない)
ステップ 2:Markdown 記号を除去する(**、*、~~ など)
ステップ 3:Bun.stringWidth で最終的な幅を計算する実践:3 ステップアルゴリズムを理解する
ステップ 1:コードブロックを保護する
なぜ必要か
インラインコード(バッククォートで囲まれたもの)内の Markdown 記号は「リテラル」です。ユーザーは **bold** という 8 文字を見て、bold という 4 文字を見ません。
そのため、Markdown 記号を除去する前に、コードブロックの内容を「隠す」必要があります。
ソースコード
// ステップ 1:インラインコードを抽出して保護する
const codeBlocks: string[] = []
let textWithPlaceholders = text.replace(/`(.+?)`/g, (match, content) => {
codeBlocks.push(content)
return `\x00CODE${codeBlocks.length - 1}\x00`
})動作原理
| 入力 | 処理後 | codeBlocks 配列 |
|---|---|---|
`**bold**` | \x00CODE0\x00 | ["**bold**"] |
`a` and `b` | \x00CODE0\x00 and \x00CODE1\x00 | ["a", "b"] |
\x00CODE0\x00 のような特殊なプレースホルダーでコードブロックを置換することで、後で Markdown 記号を除去する際に誤って削除されるのを防ぎます。
ステップ 2:Markdown 記号を除去する
なぜ必要か
隠蔽モードでは、**太字** は 太字 として表示され、*斜体* は 斜体 として表示されます。幅を計算する際にこれらの記号を削除する必要があります。
ソースコード
// ステップ 2:コード以外の部分から Markdown 記号を除去する
let visualText = textWithPlaceholders
let previousText = ""
while (visualText !== previousText) {
previousText = visualText
visualText = visualText
.replace(/\*\*\*(.+?)\*\*\*/g, "$1") // ***太字斜体*** → テキスト
.replace(/\*\*(.+?)\*\*/g, "$1") // **太字** → 太字
.replace(/\*(.+?)\*/g, "$1") // *斜体* → 斜体
.replace(/~~(.+?)~~/g, "$1") // ~~打ち消し線~~ → 打ち消し線
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "$1") //  → alt
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") // [text](url) → text (url)
}なぜ while ループを使うのか
ネストされた構文を処理するためです。例えば ***太字斜体*** の場合:
第 1 ラウンド:***太字斜体*** → **太字斜体**(最外層の *** を除去)
第 2 ラウンド:**太字斜体** → *太字斜体*(** を除去)
第 3 ラウンド:*太字斜体* → 太字斜体(* を除去)
第 4 ラウンド:太字斜体 = 太字斜体(変化なし、ループ終了)画像とリンクの処理
- 画像
:OpenCode は alt テキストのみ表示するため、altに置換 - リンク
[text](url):text (url)として表示され、URL 情報を保持
ステップ 3:コードブロックを復元 + 幅を計算する
なぜ必要か
コードブロックの内容を戻し、Bun.stringWidth で最終的な表示幅を計算する必要があります。
ソースコード
// ステップ 3:コードブロックの内容を復元する
visualText = visualText.replace(/\x00CODE(\d+)\x00/g, (match, index) => {
return codeBlocks[parseInt(index)]
})
return Bun.stringWidth(visualText)なぜ Bun.stringWidth を使うのか
Bun.stringWidth は以下を正しく計算できます:
| 文字タイプ | 例 | 文字数 | 表示幅 |
|---|---|---|---|
| ASCII | abc | 3 | 3 |
| 日本語 | 你好 | 2 | 4(各 2 マス) |
| Emoji | 😀 | 1 | 2(2 マス) |
| ゼロ幅文字 | a\u200Bb | 3 | 2(ゼロ幅文字はスペースを取らない) |
通常の text.length は文字数しかカウントできず、これらの特殊なケースを処理できません。
完全な例
セルの内容が **`code`** and *text* だとします。
ステップ 1:コードブロックを保護する
入力:**`code`** and *text*
出力:**\x00CODE0\x00** and *text*
codeBlocks = ["code"]ステップ 2:Markdown 記号を除去する
第 1 ラウンド:**\x00CODE0\x00** and *text* → \x00CODE0\x00 and text
第 2 ラウンド:変化なし、終了ステップ 3:コードブロックを復元 + 幅を計算する
復元後:code and text
幅:Bun.stringWidth("code and text") = 13最終的に、プラグインはこのセルを 13 文字の幅で揃え、ソースコードの 22 文字ではありません。
チェックポイント
このレッスンを完了した後、以下の質問に答えられるはずです:
- [ ] 隠蔽モードはどの記号を非表示にしますか?(答:
**、*、~~などの Markdown 構文記号) - [ ] なぜ先にコードブロックを保護する必要がありますか?(答:コードブロック内の記号はリテラルであり、除去すべきではないから)
- [ ] なぜ while ループで記号を除去しますか?(答:
***太字斜体***などのネストされた構文を処理するため) - [ ]
Bun.stringWidthはtext.lengthより何が優れていますか?(答:日本語、Emoji、ゼロ幅文字の表示幅を正しく計算できる)
よくある落とし穴
よくある誤解
誤解:コードブロック内の ** も除去される
事実:いいえ。プラグインは先にプレースホルダーでコードブロックの内容を保護し、他の部分の記号を除去してから復元します。
そのため、`**bold**` の幅は 8(**bold**)であり、4(bold)ではありません。
このレッスンのまとめ
| ステップ | 役割 | キーコード |
|---|---|---|
| コードブロックの保護 | コードブロック内の記号が誤って除去されるのを防ぐ | text.replace(/\(.+?)`/g, ...)` |
| Markdown の除去 | 隠蔽モードでの実際の表示内容を計算する | 複数回の正規表現置換 |
| 幅の計算 | 日本語、Emoji などの特殊文字を処理する | Bun.stringWidth() |
次のレッスンの予告
次のレッスンでは テーブル仕様 を学習します。
学習内容:
- どのようなテーブルがフォーマットできるか
- テーブル検証の 4 つのルール
- 「無効なテーブル」エラーを回避する方法
付録:ソースコード参照
クリックしてソースコードの位置を表示
更新日時:2026-01-26
| 機能 | ファイルパス | 行番号 |
|---|---|---|
| 表示幅計算のエントリーポイント | index.ts | 151-159 |
| コードブロックの保護 | index.ts | 168-173 |
| Markdown 記号の除去 | index.ts | 175-188 |
| コードブロックの復元 | index.ts | 190-193 |
| Bun.stringWidth の呼び出し | index.ts | 195 |
主要な関数:
calculateDisplayWidth():キャッシュ付きの幅計算エントリーポイントgetStringWidth():コアルゴリズム、Markdown 記号を除去して表示幅を計算
主要な定数:
\x00CODE{n}\x00:コードブロックのプレースホルダー形式