自分の記事から、貼ったコードを抜き出して実行しようとして失敗しました。壊れていたのは記事ではなく、抜き出すほうのコードでした。
結論を先に置きます。**バッククォート3つは、行の途中にあるぶんには何も起きません。**壊れるのは行頭に来たときだけで、しかも半角スペース3つまでの字下げなら行頭とみなされます。
以下、CommonMark 準拠のパーサ(markdown-it-py 4.0.0)で1つずつ確かめた結果です。
何が起きたか
記事に、こういうコードを貼っていました。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行に満たない断片です。
ここで一瞬、記事のほうが壊れているのではないかと思いました。表示は正常に見えていましたが、見えているものと解釈されているものが違う可能性があります。
確かめました。壊れていませんでした。
行頭かどうかで、すべてが決まる
CommonMark の仕様では、閉じフェンスとして扱われるのは行の先頭にあるバッククォートの並びだけです。行の途中にあるものは、ただの文字です。
6通り試しました。
| 中に置いた3つの位置 | コードブロック数 | 結果 |
|---|---|---|
| 行の途中 | 1 | 壊れない |
| 行頭 | 2 | 壊れる |
| 半角スペース3つ字下げ | 2 | 壊れる |
| 半角スペース4つ字下げ | 1 | 壊れない |
| 外側を4本にする | 1 | 壊れない |
外側をチルダ ~~~ にする |
1 | 壊れない |
3つ字下げでも壊れるのは、CommonMark が閉じフェンスの字下げを3つまで許しているためです。4つ以上は行頭とみなされません。ただし、その4つのスペースはコードの中身として残ります。インデントを1段ずらして回避するのは、結果を変えてしまうので勧めません。
壊れる場合、2個目のブロックのあとに続く行が、コードブロックの外に出ます。
コード外に出た行: ['y = 2']
コードだった行が、地の文として表示されます。見た目で気づける壊れ方ではあります。
フェンスの長さには規則がある
外側を4本にする方法を選ぶなら、規則を1つ覚えておく必要があります。
| 開き | 中に出てくる並び | 結果 |
|---|---|---|
| 3本 | 4本 | 壊れる |
| 4本 | 4本 | 壊れる |
| 4本 | 3本 | 壊れない |
| 5本 | 4本 | 壊れない |
閉じフェンスは、開きフェンスと同じ長さか、それより長ければ成立します。逆に言えば、開きより短い並びは閉じになりません。
なので、中に3本が出てくるなら外側は4本、中に4本が出てくるなら外側は5本にします。1本多ければ足ります。
チルダを使う手もあります。~~~ で開けば、中のバッククォートは何本並んでいても閉じになりません。記法が混ざるのを嫌う人もいると思うので、ここは好みだと思います。
抜き出す側の直し方
行頭のフェンスだけを見るようにします。
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本にするかどうか迷いました。結局、**そのままにしています。**行の途中にしか出てこないので、壊れないことを確かめたからです。
念のため、公開後に自分でもう一度抜き出して実行してみるつもりです。仕様上は壊れないはずですが、レンダリングは仕様だけで決まるものでもないので。
まとめ
- 行の途中のバッククォート3つは、ただの文字。何も起きない
- 壊れるのは行頭に来たときだけ。半角スペース3つまでの字下げも行頭扱い
- 回避するなら外側を1本増やすか、
~~~にする - 抜き出す側は、非貪欲の正規表現ではなく、行頭のフェンスだけを見る
わたしは今回、抜き出す側だけを直しました。記事のほうは壊れていなかったので。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。