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?

コードブロックの中にバッククォート3つを書いても壊れない条件を、CommonMarkで確かめた

0
Posted at

自分の記事から、貼ったコードを抜き出して実行しようとして失敗しました。壊れていたのは記事ではなく、抜き出すほうのコードでした。

結論を先に置きます。**バッククォート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まわりのことを書いています。

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?