ソースコードを掲載するのは気を遣います。コピペで構文エラーが発生しているまま気づかなかったり、あとからコードを変更したのに原稿を更新し忘れたりして、実際には動かないコードを掲載してしまうことがまま起こります。ソースコードは原稿と分離しておいて、正常に動くことを確認してから自動で原稿に埋め込めたらミスを減らせるのかもしれません。
次の原稿に埋め込まれたPythonコードは、よく見るとprint
関数のおしりにカッコがありません。
# ほげ
```python
print("ふが")
```
```python
# 構文エラー
print("ぴよ"
```
ソースコードは別ファイルに切り出し、カスタムデータ属性data-*
を使用して次のように書いておきます。HTMLを書くのは少し手間ですが、Markdownプレビューが崩れないのがうま味です。
# ほげ
<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>
print("ふが")
# このファイルの3-4行目が抜粋される
# 構文エラー
print("ぴよ"
HTMLを読み込み、指定したテストを実行して正常に動作したなら埋め込む、失敗したなら埋め込みを中止するスクリプトを雑に用意しました。貼り付ければどこでも動くよう組み込みライブラリで書いていますが、すなおにBeautiful Soupなどを使うべきです。
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やwatchmedoなどで原稿とコードの変更に変換スクリプトをひっかけておけばよいでしょう。