先に断っておくと、この記事に書いた条件は、すべて CommonMark の仕様に明記されています。Fenced code blocks の項です。
A fenced code block begins with a code fence, preceded by up to three spaces of indentation.
The closing code fence may be preceded by up to three spaces of indentation.
読めば分かります。読んでいませんでした。
書くのは、読まずに正規表現を書いて壊した記録と、仕様の条件をパーサで10通り測った結果です。壊れ方の実物と、直した抜き出し関数は、自分の手元にしかありません。
測定は markdown-it-py 4.0.0(CommonMark 準拠)で行いました。
何が起きたか
記事に、こういうコードを貼っていました。Markdownの記法を取り除く関数です。
CODE_BLOCK = re.compile(r'```.*?```', re.S)
INLINE = re.compile(r'`[^`]*`')
書いたあと、この記事のコードをそのまま実行して出力を確かめたくなりました。記事から抜き出す短いスクリプトを書きます。
blocks = re.findall(r'```(.*?)```', text, re.S)
抜き出した結果を実行したら、NameError: name 're' is not defined で落ちました。抜けていたのは import re です。
コードの途中から切り出されていました。
抜き出しが壊れた理由
正規表現が、コードの中に書いてあるバッククォート3つに反応しています。
非貪欲の (.*?) は、最初に見つかった閉じ記号で止まります。1行目の re.compile(r' のうしろにある3つが、そのまま終端として扱われました。
非貪欲 ```(.*?)``` の結果: 2 個 / "python\nCODE = re.compile(r'"
2個に割れています。1個目は1行に満たない断片です。
ここで一瞬、記事のほうが壊れているのではないかと思いました。表示は正常に見えていましたが、見えているものと解釈されているものが違う可能性があります。
確かめました。壊れていませんでした。
行頭かどうかで、すべてが決まる
仕様のとおり、閉じフェンスとして扱われるのは行の先頭にあるバッククォートの並びだけです。行の途中にあるものは、ただの文字です。
パーサに6通り食わせました。
| 中に置いた3つの位置 | コードブロック数 | 結果 |
|---|---|---|
| 行の途中 | 1 | 壊れない |
| 行頭 | 2 | 壊れる |
| 半角スペース3つ字下げ | 2 | 壊れる |
| 半角スペース4つ字下げ | 1 | 壊れない |
| 外側を4本にする | 1 | 壊れない |
外側をチルダ ~~~ にする |
1 | 壊れない |
3つ字下げでも壊れるのは、閉じフェンスの字下げが3つまで許されているからです。4つ以上は行頭とみなされません。ただし、その4つのスペースはコードの中身として残ります。インデントを1段ずらして回避するのは、結果を変えてしまうので勧めません。
壊れる場合、2個目のブロックのあとに続く行が、コードブロックの外に出ます。
コード外に出た行: ['y = 2']
コードだった行が、地の文として表示されます。見た目で気づける壊れ方ではあります。
フェンスの長さには規則がある
外側を4本にする方法を選ぶなら、規則を1つ覚えておく必要があります。こちらも4通り測りました。
| 開き | 中に出てくる並び | 結果 |
|---|---|---|
| 3本 | 4本 | 壊れる |
| 4本 | 4本 | 壊れる |
| 4本 | 3本 | 壊れない |
| 5本 | 4本 | 壊れない |
閉じフェンスは、開きフェンスと同じ長さか、それより長ければ成立します。逆に言えば、開きより短い並びは閉じになりません。
なので、中に3本が出てくるなら外側は4本、中に4本が出てくるなら外側は5本にします。1本多ければ足ります。
チルダを使う手もあります。~~~ で開けば、中のバッククォートは何本並んでいても閉じになりません。記法が混ざるのを嫌う人もいると思うので、ここは好みだと思います。
閉じ忘れると、最後まで飲み込みます
外側を4本にしたとき、閉じも4本にする必要があります。3本で閉じても閉じフェンスになりません。
そして仕様では、閉じフェンスが見つからないまま文書の終わりに達した場合、開きフェンス以降の全部がコードブロックになります。
外側を増やして閉じ忘れると、**記事の残り全部がコードとして表示されます。**これは壊れ方としては派手なので、プレビューを見ればすぐ分かります。
もう1つ、仕様に書かれていて引っかかりやすいものがあります。バッククォートのフェンスでは、情報文字列にバッククォートを含められません。
```python `code`
これは開きフェンスとして成立しません。言語名のあとにインラインコードを書きたくなる場面はないと思いますが、自動生成で情報文字列を組み立てているなら、ここに当たる余地があります。
抜き出す側の直し方
行頭のフェンスだけを見るようにします。
import re
def extract_code_blocks(text):
out, cur, opener = [], None, None
for line in text.split('\n'):
stripped = line.lstrip()
indent = len(line) - len(stripped)
if cur is None:
m = re.match(r'(`{3,}|~{3,})(.*)$', stripped)
if m and indent < 4:
opener = m.group(1)
cur = []
continue
m = re.match(r'(`{3,}|~{3,})\s*$', stripped)
if m and indent < 4 and m.group(1)[0] == opener[0] \
and len(m.group(1)) >= len(opener):
out.append('\n'.join(cur))
cur, opener = None, None
continue
cur.append(line)
return out
見ている条件は4つです。字下げが4つ未満であること。開きと同じ記号であること。開き以上の長さであること。閉じフェンスの行に情報文字列が書かれていないこと。
**どれも仕様に書いてある条件を、そのまま条件式にしただけです。**先に読んでいれば、最初からこう書けていました。
同じ入力で比べると、こうなります。
非貪欲の正規表現 : 2個 / "python\nCODE = re.compile(r'"
行頭フェンス方式 : 1個 / "CODE = re.compile(r'```.*?```')\nx = 1"
コードが1つの塊として取れています。
余談
この記事にも、バッククォート3つを含むコードブロックが入っています。
書きながら、外側を4本にするかどうか迷いました。結局、そのままにしています。行の途中にしか出てこないので、壊れないことを確かめたからです。
念のため、公開後に自分でもう一度抜き出して実行してみるつもりです。仕様上は壊れないはずですが、レンダリングは仕様だけで決まるものでもないので。Qiita が CommonMark にどこまで準拠しているかは、こちらでは分かりません。
まとめ
仕様に書いてあることと、測って確かめたことを分けて並べます。
仕様に書いてあること
- フェンスは行頭から3スペースまでの字下げを許す。4つ以上は行頭ではない
- 閉じフェンスは、開き以上の長さが必要
- 閉じフェンスが無いまま文書が終わると、以降が全部コードになる
- バッククォートのフェンスでは、情報文字列にバッククォートを含められない
測って確かめたこと
- 行の途中のバッククォート3つは、ただの文字。ブロックは1つのまま
- 3つ字下げでは壊れ、4つ字下げでは壊れない(ただし空白が中身に残る)
- 壊れたとき、続く行が地の文として外に出る
わたしは今回、抜き出す側だけを直しました。記事のほうは壊れていなかったので。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。