3行まとめ
- 行列数を指定してセルを埋めるだけで、Markdown・HTML・CSV の3形式でテーブルを出力するブラウザ完結ツールを作った
- 生成ロジックは3つの純粋関数だけ。形式ごとに「エスケープすべき危険文字」が違う(Markdown は
|、HTML は& < > "、CSV は,・"・改行)のが設計の核 - React 側は「セルの2次元配列」と「列ごとの配置」だけを state に持ち、出力文字列はレンダリングのたびに導出する。useEffect での同期は書かない
Markdown でちょっとした比較表を書くとき、パイプと区切り行を手打ちするのは地味に面倒だ。列を1本増やすだけで全行を直すことになるし、中央寄せのコロンが :---: だったか :--: だったか、右寄せはコロンがどっち側だったか、毎回あやふやになる。
ぱんだツールズの Markdown テーブル生成は、行数・列数を指定してセルに入力するだけで、Markdown・HTML・CSV の3形式を切り替えて出力できるツール。入力のたびに出力がリアルタイム更新され、列ごとの配置(左・中央・右)もワンクリックで設定できる。処理はすべてブラウザ内で完結し、入力データはサーバーに送られない。
実装は React コンポーネント1ファイルで、生成ロジックは3つの純粋関数に分かれている。この記事では GFM テーブル記法の要点と、生成ロジック・状態設計を解説する。
GFMテーブル記法 — 覚えるのは区切り行だけ
Markdown のテーブルは GFM(GitHub Flavored Markdown)として標準化されていて、GitHub・GitLab・Zenn・Qiita・Notion・VS Code プレビューなど、主要な環境で同じ書き方が通る。
| 項目 | 価格 | 在庫 |
| :--- | ---: | :---: |
| りんご | 120 | あり |
| バナナ | 98 | なし |
構造は「ヘッダー行 + 区切り行 + データ行」の3層で、覚えるべきは2行目の区切り行だけ。コロンの位置が列の配置を決める。
-
:---— 左寄せ -
:---:— 中央寄せ -
---:— 右寄せ
ツールの生成コードでは、この区切り行を配置設定の配列から組み立てている。
const sepCells = Array.from({ length: colCount }, (_, i) => {
const align = aligns[i] ?? 'left'
if (align === 'center') return ':---:'
if (align === 'right') return '---:'
return ':---'
})
const sepRow = '| ' + sepCells.join(' | ') + ' |'
細かい点だが、左寄せをコロンなしの --- ではなく :--- と明示して出力している。GFM では --- も左寄せ扱いになるものの、:--- と書けば「デフォルトに任せた」のか「左寄せを選んだ」のかが出力から読み取れる。生成物を後から人間が編集することを考えると、意図が残る書き方に寄せておきたい。
Markdown生成 — パイプのエスケープと空セルの穴埋め
Markdown 出力の全体はこう。
function buildMarkdown(cells: string[][], aligns: Align[]): string {
if (cells.length === 0) return ''
const colCount = cells[0].length
const escape = (s: string) => s.replace(/\|/g, '\\|')
const headerRow = '| ' + cells[0].map((c) => escape(c) || ' ').join(' | ') + ' |'
// ...(区切り行は前述)
const bodyRows = cells.slice(1).map(
(row) => '| ' + row.map((c) => escape(c) || ' ').join(' | ') + ' |'
)
return [headerRow, sepRow, ...bodyRows].join('\n')
}
ポイントは2つ。
セル内の | は \| にエスケープする。 パイプは列の区切り記号そのものなので、セルの中身に生で入ると列がずれる。正規表現の OR(a|b)やシェルのパイプをテーブルに書くときに踏みがちな罠で、生成側で機械的に潰しておく。
空セルは半角スペースで埋める。 escape(c) || ' ' の || ' ' がそれ。区切りにスペースを挟まず '|' + row.join('|') + '|' と組む実装だと、空セルで || が隣接して列の解釈が崩れるレンダラーがある。このツールは ' | ' のスペース込みで join しているので || 自体は発生しないが、空セルにもスペース1文字を置いてセルの存在を明示し、どの環境でも安全側に倒している。
なお GFM テーブルのセルには生の改行を入れられない(改行したければ <br> を書く)。このツールの入力欄は <input type="text"> なのでそもそも改行が混入しない——UI の制約と記法の制約が一致している。
HTML生成とCSV生成 — 形式ごとに危険文字が違う
HTML 出力は <table> / <thead> / <tbody> の標準構造で、エスケープ対象が Markdown とはまったく別になる。
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
& を最初に置換するのが鉄則。順序を逆にすると、< から作った < の & が再度 &lt; に化ける二重エスケープが起きる。
配置は style="text-align:left" のインライン属性で出力し、class や外部スタイルシートには依存しない。出力に含まれるスタイル指定はこの text-align だけ。貼り付け先のページには必ず既存のスタイルがあるので、生成 HTML 側は構造と配置だけを持ち、見た目は受け入れ側に委ねる方が扱いやすい。
Markdown を文書ごと HTML に変換する話はmarked + DOMPurify の記事で書いたが、テーブル単体ならこのとおり文字列組み立てで済む。
CSV 出力は RFC 4180 に従う。
function buildCsv(cells: string[][]): string {
return cells
.map((row) =>
row
.map((c) => {
if (c.includes(',') || c.includes('"') || c.includes('\n')) {
return '"' + c.replace(/"/g, '""') + '"'
}
return c
})
.join(',')
)
.join('\n')
}
カンマ・ダブルクォート・改行を含むセルだけダブルクォートで囲み、" は "" に重ねてエスケープ。全セルを無条件で囲む実装もよく見るが、必要なセルだけ囲む方が出力が読みやすい。
並べてみると、3形式のエスケープ対象は見事に全部違う。
| 形式 | 危険文字 | エスケープ方法 |
|---|---|---|
| Markdown | | |
\| に置換 |
| HTML | & < > " |
文字実体参照 |
| CSV |
, " 改行 |
" で囲み "" に重ねる |
テーブル生成ツールの本体は「同じ2次元配列を、3つの直列化規則で書き出す」処理だと言える。入力表現を1つに固定してあるから、出力形式の追加も関数を1つ足すだけで済む。
Reactの状態設計 — 2次元配列だけを真実にする
state に持つのは3つだけ。セルの中身 cells: string[][]、列ごとの配置 aligns: Align[]、選択中の出力形式 outputFormat。出力文字列は state にしない。
const output =
outputFormat === 'markdown'
? buildMarkdown(cells, aligns)
: outputFormat === 'html'
? buildHtml(cells, aligns)
: buildCsv(cells)
「入力が変わったら useEffect で出力を再計算して setState する」構成にしたくなるところだが、最大でも20行×10列 = 200セルの文字列連結であり、毎レンダリング実行しても計算コストは無視できる。出力を導出値にしておけば、cells と output がずれる同期バグが構造的に起きない。
リサイズで既存データを消さない
行数・列数を変えたときの処理はこの1関数に集約されている。
function resizeCells(prev: string[][], newRows: number, newCols: number): string[][] {
return Array.from({ length: newRows }, (_, r) =>
Array.from({ length: newCols }, (_, c) => prev[r]?.[c] ?? '')
)
}
新しいサイズの2次元配列を作り、各座標を prev[r]?.[c] ?? '' で埋める。旧配列に存在する座標はそのままコピーされ、拡大した分は空文字で補完、縮小した分は参照されずに切り捨てられる。オプショナルチェーンと Null 合体で「境界チェック + デフォルト値」が1行に収まる。
配置の配列も列数変更時に同じ発想で追従させる。行数・列数は 1〜20行(ヘッダー含む)・1〜10列に Math.max / Math.min でクランプしていて、number input に何を打たれても壊れない。
コピーは navigator.clipboard.writeText(output) を try/catch で包むだけ。クリップボード API が使えない環境では黙って何もしない設計にして、逃げ道として出力テキストエリアからの手動コピーを残している。
まとめ
- GFM テーブルは区切り行のコロンで配置を指定する。
:---/:---:/---:の3つを押さえれば書ける。左寄せも:---と明示すると生成物に意図が残る - 3形式の直列化はエスケープ対象がそれぞれ違う: Markdown は
|、HTML は& < > "(&を最初に)、CSV は,・"・改行(RFC 4180) - 出力文字列は state にせず、レンダリング時に導出する。真実の情報源をセルの2次元配列1つに絞ると同期バグが消える
- リサイズは
prev[r]?.[c] ?? ''で新配列を埋め直すだけで「データを保持したまま拡縮」になる
表を1つ作るだけの小さなツールでも、記法の仕様・エスケープ規則・状態設計と、フロントエンドの基本が一通り詰まっている。README の比較表や Zenn 記事の一覧表をさっと作りたいときにどうぞ。
ぱんだツールズ では他にも Markdown→HTML 変換・CSV 文字コード変換・テキスト整形・文字数カウントなど、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。