0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Markdownテーブルの書き方と生成ツールの実装 — GFM区切り行の配置指定・エスケープ・Reactの状態設計

0
Posted at

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

& を最初に置換するのが鉄則。順序を逆にすると、< から作った &lt;& が再度 &amp;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 にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?