Markdown（HTML）原稿とソースコードを分離して、自動で検証する

ソースコードを掲載するのは気を遣います。コピペで構文エラーが発生しているまま気づかなかったり、あとからコードを変更したのに原稿を更新し忘れたりして、実際には動かないコードを掲載してしまうことがまま起こります。ソースコードは原稿と分離しておいて、正常に動くことを確認してから自動で原稿に埋め込めたらミスを減らせるのかもしれません。

次の原稿に埋め込まれたPythonコードは、よく見ると`print`関数のおしりにカッコがありません。

~~~markdown:manuscript.md
# ほげ

```python
print("ふが")
```

```python
# 構文エラー
print("ぴよ"
```
~~~

ソースコードは別ファイルに切り出し、カスタムデータ属性`data-*`を使用して次のように書いておきます。HTMLを書くのは少し手間ですが、Markdownプレビューが崩れないのがうま味です。

```markdown:manuscript.md
# ほげ

<pre data-src="fuga.py" data-test="python fuga.py" class="language-python"><code class="language-python"></code></pre>

<pre data-src="piyo.py#L3-L4" data-test="python piyo.py" class="language-python"><code class="language-python"></code></pre>
```

```python:fuga.py
print("ふが")
```

```python:piyo.py
# このファイルの3-4行目が抜粋される

# 構文エラー
print("ぴよ"
```

HTMLを読み込み、指定したテストを実行して正常に動作したなら埋め込む、失敗したなら埋め込みを中止するスクリプトを雑に用意しました。貼り付ければどこでも動くよう組み込みライブラリで書いていますが、すなおに[Beautiful Soup](https://www.crummy.com/software/BeautifulSoup/)などを使うべきです。

```python:embed-codes.py
import argparse
import enum
import html.parser
import pathlib
import re
import subprocess
import typing


class CodeFile:
    @staticmethod
    def __read_file_span(base_dir: pathlib.Path, path_span: str):
        """
        - `path/to/file`
        - `path/to/file#Lx`
        - `path/to/file#Lx-Ly`
        """
        path, sep, span = path_span.rpartition("#")
        if path == "" and sep == "":
            # > If the separator is not found, returns a 3-tuple containing
            # > two empty strings and the original string.
            path = span
            span = ""

        with open((base_dir / path).resolve(), "r", encoding="utf-8") as file:
            content = file.read()
        lines = content.split("\n")

        if span == "":
            return content

        elif re.match(r"L\d+$", span):
            index = int(span[1:]) - 1
            if index < 1:
                raise ValueError("Line index out of bounds.")
            return lines[index]

        elif re.match(r"L\d+-L\d+$", span):
            start, end = map(int, span[1:].split("-L"))
            if start < 1 or end <= start:
                raise ValueError("Invalid line range specified.")
            return "\n".join(lines[start - 1 : end])

        assert False
        return ""

    @staticmethod
    def __run_test(path_span: str, test_cmd: str) -> bool:
        print(f'* {path_span}: "{test_cmd}" ... ', end="")

        ret = subprocess.run(test_cmd, shell=True, text=True, capture_output=True)
        if ret.returncode != 0:
            err = "\033[31mFailure"
            lines = ret.stderr.split("\n")
            max_width = max(len(line) for line in lines)
            separator = "-" * max_width
            print(err)
            print(separator)
            print("\n".join(lines).strip())
            print(separator + "\033[0m")
            return False

        else:
            print("\033[32mSuccess\033[0m")
            return True

    def __init__(self, base_dir: pathlib.Path, path_span: str, test_cmd: str) -> None:
        self.__path_span = path_span
        self.__content = CodeFile.__read_file_span(base_dir, path_span)
        self.__test_cmd = test_cmd

    def is_valid(self):
        return self.__run_test(self.__path_span, self.__test_cmd)

    @property
    def content(self):
        return self.__content


class CodeEmbedder(html.parser.HTMLParser):
    class __Position(enum.Enum):
        Others = enum.auto()
        Pre = enum.auto()
        Code = enum.auto()

    def __init__(
        self,
        base_dir: pathlib.Path,
        ignore_errors: bool,
        *,
        convert_charrefs: bool = True,
    ) -> None:
        super().__init__(convert_charrefs=convert_charrefs)
        self.__base_dir = base_dir
        self.__ignore_errors = ignore_errors
        self.__pos = CodeEmbedder.__Position.Others
        self.__ret: list[str] = []
        self.__code: CodeFile | None = None

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attrs_str = " ".join(
            f'{name}="{value}"' if value is not None else name for name, value in attrs
        )
        self.__ret.append(f"<{tag} {attrs_str}>" if attrs_str != "" else f"<{tag}>")

        attrs_dict = dict(
            typing.cast(tuple[str, str], attr if attr[1] is not None else (attr[0], ""))
            for attr in attrs
        )

        if (
            self.__pos == CodeEmbedder.__Position.Others
            and tag == "pre"
            and "data-src" in attrs_dict.keys()
            and "data-test" in attrs_dict.keys()
        ):
            self.__pos = CodeEmbedder.__Position.Pre
            self.__code = CodeFile(
                self.__base_dir, attrs_dict["data-src"], attrs_dict["data-test"]
            )

        elif self.__pos == CodeEmbedder.__Position.Pre and tag == "code":
            self.__pos = CodeEmbedder.__Position.Code
            assert self.__code is not None
            if not self.__ignore_errors:
                if self.__ignore_errors or self.__code.is_valid():
                    self.__ret.append(self.__code.content)
                else:
                    raise Exception()

    def handle_endtag(self, tag: str) -> None:
        self.__ret.append(f"</{tag}>")

        if self.__pos == CodeEmbedder.__Position.Code and tag == "code":
            self.__pos = CodeEmbedder.__Position.Pre

        elif self.__pos == CodeEmbedder.__Position.Pre and tag == "pre":
            self.__pos = CodeEmbedder.__Position.Others
            self.__code = None

    def handle_data(self, data: str) -> None:
        if self.__pos != CodeEmbedder.__Position.Code:
            self.__ret.append(data)

    def handle_comment(self, data: str) -> None:
        self.__ret.append(f"<!--{data}-->")

    def handle_decl(self, decl: str) -> None:
        self.__ret.append(f"<!{decl}>")

    def get_result(self) -> str:
        return "".join(self.__ret)


def embed_codes(html: str, base_dir: pathlib.Path, ignore_errors: bool):
    embedder = CodeEmbedder(base_dir, ignore_errors)
    embedder.feed(html)
    return embedder.get_result()


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-i", "--input", required=True)
    parser.add_argument("-o", "--output", required=True)
    parser.add_argument("--ignore-errors", action="store_true")

    args = parser.parse_args()

    with open(args.input, "r", encoding="utf-8") as f:
        html = f.read()
    base_dir = pathlib.Path(args.input).resolve().parent

    try:
        new_content = embed_codes(html, base_dir, args.ignore_errors)
    except:
        exit(1)

    with open(args.output, "w", encoding="utf-8") as f:
        f.write(new_content)


if __name__ == "__main__":
    main()
```

お好みのソフトでMarkdownをHTMLに変換してから用意したスクリプトにかけると、ちゃんと成功・失敗しているのが見て取れます。

```
> vfm manuscript.md > tmp.html                                

> python .\embed-codes.py --input tmp.html --output output.html                
* fuga.py: "python fuga.py" ... Success
* piyo.py#L3-L4: "python piyo.py" ... Failure
---------------------------------------------------
File "C:\Users\mukai\embed-codes\piyo.py", line 4
    print("ぴよ"
         ^
SyntaxError: '(' was never closed
---------------------------------------------------
```

もちろんすべて成功すればコードが適切に埋め込まれます。

```
> python .\embed-codes.py --input tmp.html --output output.html
* fuga.py: "python fuga.py" ... Success
* piyo.py#L3-L4: "python piyo.py" ... Success

> cat .\output.html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>ほげ</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <section class="level1" aria-labelledby="ほげ">
      <h1 id="ほげ">ほげ</h1>
      <pre data-src="fuga.py" data-test="python fuga.py" class="language-python"><code class="language-python">print("ふが")</code></pre>
      <pre data-src="piyo.py#L3-L4" data-test="python piyo.py" class="language-python"><code class="language-python"># 構文エラー
print("ぴよ")</code></pre>
    </section>
  </body>
</html>
```

プレビューに反映させるなら、プレビューアーには最後に出力するHTMLを監視させておき、[fswatch](https://emcrisostomo.github.io/fswatch/)や[watchmedo](https://github.com/gorakhargosh/watchdog)などで原稿とコードの変更に変換スクリプトをひっかけておけばよいでしょう。