Skip to content

隠蔽モードの原理:なぜ幅計算が重要なのか

学習後のゴール

  • OpenCode 隠蔽モードの動作原理を理解する
  • 通常のフォーマットツールが隠蔽モードでズレる理由を知る
  • プラグインの幅計算アルゴリズム(3ステップ)を習得する
  • Bun.stringWidth の役割を理解する

現在の課題

OpenCode でコードを書いていて、AI がきれいなテーブルを生成しました:

markdown
| フィールド | タイプ | 説明 |
|--- | --- | ---|
| **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 記号を除去する前に、コードブロックの内容を「隠す」必要があります。

ソースコード

typescript
// ステップ 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 記号を除去する

なぜ必要か

隠蔽モードでは、**太字**太字 として表示され、*斜体*斜体 として表示されます。幅を計算する際にこれらの記号を削除する必要があります。

ソースコード

typescript
// ステップ 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](url) → alt
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)") // [text](url) → text (url)
}

なぜ while ループを使うのか

ネストされた構文を処理するためです。例えば ***太字斜体*** の場合:

第 1 ラウンド:***太字斜体*** → **太字斜体**(最外層の *** を除去)
第 2 ラウンド:**太字斜体** → *太字斜体*(** を除去)
第 3 ラウンド:*太字斜体* → 太字斜体(* を除去)
第 4 ラウンド:太字斜体 = 太字斜体(変化なし、ループ終了)
画像とリンクの処理
  • 画像 ![alt](url):OpenCode は alt テキストのみ表示するため、alt に置換
  • リンク [text](url)text (url) として表示され、URL 情報を保持

ステップ 3:コードブロックを復元 + 幅を計算する

なぜ必要か

コードブロックの内容を戻し、Bun.stringWidth で最終的な表示幅を計算する必要があります。

ソースコード

typescript
// ステップ 3:コードブロックの内容を復元する
visualText = visualText.replace(/\x00CODE(\d+)\x00/g, (match, index) => {
  return codeBlocks[parseInt(index)]
})

return Bun.stringWidth(visualText)

なぜ Bun.stringWidth を使うのか

Bun.stringWidth は以下を正しく計算できます:

文字タイプ文字数表示幅
ASCIIabc33
日本語你好24(各 2 マス)
Emoji😀12(2 マス)
ゼロ幅文字a\u200Bb32(ゼロ幅文字はスペースを取らない)

通常の 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.stringWidthtext.length より何が優れていますか?(答:日本語、Emoji、ゼロ幅文字の表示幅を正しく計算できる)

よくある落とし穴

よくある誤解

誤解:コードブロック内の ** も除去される

事実:いいえ。プラグインは先にプレースホルダーでコードブロックの内容を保護し、他の部分の記号を除去してから復元します。

そのため、`**bold**` の幅は 8(**bold**)であり、4(bold)ではありません。

このレッスンのまとめ

ステップ役割キーコード
コードブロックの保護コードブロック内の記号が誤って除去されるのを防ぐtext.replace(/\(.+?)`/g, ...)`
Markdown の除去隠蔽モードでの実際の表示内容を計算する複数回の正規表現置換
幅の計算日本語、Emoji などの特殊文字を処理するBun.stringWidth()

次のレッスンの予告

次のレッスンでは テーブル仕様 を学習します。

学習内容:

  • どのようなテーブルがフォーマットできるか
  • テーブル検証の 4 つのルール
  • 「無効なテーブル」エラーを回避する方法

付録:ソースコード参照

クリックしてソースコードの位置を表示

更新日時:2026-01-26

機能ファイルパス行番号
表示幅計算のエントリーポイントindex.ts151-159
コードブロックの保護index.ts168-173
Markdown 記号の除去index.ts175-188
コードブロックの復元index.ts190-193
Bun.stringWidth の呼び出しindex.ts195

主要な関数

  • calculateDisplayWidth():キャッシュ付きの幅計算エントリーポイント
  • getStringWidth():コアルゴリズム、Markdown 記号を除去して表示幅を計算

主要な定数

  • \x00CODE{n}\x00:コードブロックのプレースホルダー形式