Skip to content

Commit 4002b5c

Browse files
committed
Add 'markdown_to_html/' from commit 'c5017c366bbfabe7dddf6d72a861dd2b524ba7cf'
git-subtree-dir: markdown_to_html git-subtree-mainline: bfb4188 git-subtree-split: c5017c3
2 parents bfb4188 + c5017c3 commit 4002b5c

12 files changed

Lines changed: 1683 additions & 0 deletions

markdown_to_html/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.pyc

markdown_to_html/__init__.py

Whitespace-only changes.

markdown_to_html/commit.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
コミット構文
4+
=========================================
5+
6+
コミットIDをリンクに変換する
7+
[commit REPOSITORY_NAME, commit-id0, commit-id-2...]
8+
9+
>>> text = "[commit REPOSITORY_NAME, 1234567, abcdefg]"
10+
>>> md = markdown.Markdown(['commit'])
11+
>>> print md.convert(text)
12+
<a href="https://github.com/REPOSITORY_NAME/commit/1234567">1234567</a> <a href="https://github.com/REPOSITORY_NAME/commit/abcdefg">abcdefg</a>
13+
"""
14+
15+
import re
16+
17+
from markdown.extensions import Extension
18+
from markdown.preprocessors import Preprocessor
19+
20+
21+
def replace_commit_line(line: str) -> str:
22+
new_line: str = line
23+
for m in re.finditer(r'\[commit (.*?)\]', line.strip()):
24+
c = m[1].split(", ")
25+
repo = c[0]
26+
links: list[str] = []
27+
for id in c[1:]:
28+
id = id.strip()
29+
if len(id) == 0:
30+
continue
31+
links.append("<a href=\"https://github.com/{0}/commit/{1}\">{1}</a>".format(repo, id))
32+
commits: str = " ".join(links)
33+
new_line = new_line.replace(m[0], commits)
34+
return new_line
35+
36+
class CommitExtension(Extension):
37+
38+
def extendMarkdown(self, md):
39+
pre = CommitPreprocessor(md)
40+
41+
md.registerExtension(self)
42+
md.preprocessors.register(pre, 'commit', 25)
43+
44+
45+
class CommitPreprocessor(Preprocessor):
46+
47+
def __init__(self, md):
48+
Preprocessor.__init__(self, md)
49+
50+
def run(self, lines):
51+
new_lines = []
52+
53+
for line in lines:
54+
new_line = replace_commit_line(line)
55+
new_lines.append(new_line)
56+
57+
return new_lines
58+
59+
60+
def makeExtension(**kwargs):
61+
return CommitExtension(**kwargs)

markdown_to_html/defined_words.py

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# -*- coding: utf-8 -*-
2+
"""定義語をリンクに変換
3+
=======================
4+
5+
DEFINED_WORDS.json でリンクの指定されている定義語を本文中から検索しリンクに変換
6+
する。
7+
8+
9+
xml.etree.ElementTree にまつわる実装の留意事項
10+
----------------------------------------------
11+
12+
markdown.treeprocessors では Python の "標準的な XML ライブラリ"である
13+
xml.etree.ElementTree (etree) を使っているようだ (最終的にこの実装では
14+
markdown.postprocessors を用いることにしたので必ずしも xml.etree.ElementTree を
15+
使わなければならないわけではないが)。etree のドキュメントはあるが仕様が色々信じ
16+
がたいので結局直接ソースコード [1] を見るのが確実である。
17+
18+
* etree は XPath を部分的に実装していると謳っている [2] が XPath 特有の機能は全
19+
く実装されていない。子・子孫・属性による要素の選択を XPath に似た文法で指定で
20+
きるというだけである。XPath ならではの機能はない。textノードも抜き出せないし、
21+
何なら /xxx() のような文法はないし、集合演算にも対応していない。当初、以下を
22+
動かそうと試行錯誤していたが全く無駄な努力だった。
23+
24+
for text in root.findall("(.//* except .//*/(a | code | pre)/*)/child::text()"):
25+
print(text)
26+
27+
* etree では要素からその親要素を取得する方法がない。なのでそもそも XPath なり何
28+
なりのセレクターで列挙したとしても自身を置き換えるような DOM 修正が不可能であ
29+
る。なので、木構造を直接自前で辿るしかない。
30+
31+
* etree の実装は不思議なことに node の概念がなく、文字列は直前の開始タグまたは
32+
終了タグの付属物として記録されている。element.text が開始タグ直後の文字列で
33+
element.tail が終了タグ直後の文字列である。特に、element.text は「その要素内
34+
に含まれる文字列全て」ではなく最初の子要素が現れるまでの文字列であるという事
35+
に注意する。親要素の最初の node としての文字列でないものは全て直前の要素の終
36+
了タグの付属物として記録されている。例えば、
37+
38+
<a>text1<b>text2</b>text3<c>text4</c>text5</a>
39+
40+
に対しては
41+
42+
a.text = "text1"
43+
b.text = "text2"
44+
b.tail = "text3"
45+
c.text = "text4"
46+
c.tail = "text5"
47+
a.tail = None
48+
49+
という具合に文字列が格納されている。
50+
51+
元ソースの実体参照 ("&lt;" など) は解決された状態 (つまり "<" など) で tail,
52+
text に格納されると思われる。少なくとも a.text = "<"としたらソースに変換する
53+
時点で "&lt;" が出力される。
54+
55+
* 子要素を iterate する方法が分からないと思ったら親要素が Iterable であり
56+
elem.iter() で子要素のイテレータが得られる。これは変だ。
57+
58+
for e in elem:
59+
print(e)
60+
61+
* etree では子要素を追加する elem.insert(index, childElement) という関数がある。
62+
引数に index を要求しているが、そもそも子要素を index 指定で取得する機能もな
63+
いし、子要素から index を取得する機能もないので、index の指定のしようがない。
64+
呼び出し側で、親要素の構築時に何番目にどの要素が格納されているかを別に記録し
65+
ていなければどうにもならない。或いは既存の要素に対して処理する時は elem を一
66+
旦 iterate して対応表を手元に作るか、private メンバ _children に直接アクセス
67+
する必要がある (但し _children はバージョンが変わった時に変わらないとも限らな
68+
い)。
69+
70+
* xml.dom.minidom という多少はましなものもある [3,4] 様だが xml.etree とは互換
71+
性がない。木を再構築する必要がある。これを使うのだったらそもそも
72+
markdown.treeprocessors を使う意味がない (現在は Postprocessor に移行して自前
73+
で xml.etree の木を構築しているのでこの際 minidom に乗り換えても良いのかもし
74+
れない)。
75+
76+
* Q 要素を作る時は必ず elem.SubElement などの関数経由で構築する必要はあるか?
77+
78+
A. 恐らくない。直接要素を構築してから追加すれば良いと思われる。質問サイトの
79+
[5] の質疑応答を見る限りは ([5] の質問自体は今回の疑問と直接関係ないが)、取り
80+
合えず要素は etree.Element で作成してから append して問題ないようだ (DOM の場
81+
合には、アロケータの都合だろうか、document.createElement を使う必要があったが
82+
その様な制約はないようである)。
83+
84+
* etree では if elem: は elem に子要素が存在するかどうかで判定される。つまり、
85+
None かそうでないかの判定に使おうと思っていると痛い目を見る。
86+
87+
- [1] https://github.com/python/cpython/blob/main/Lib/xml/etree/ElementTree.py
88+
- [2] https://docs.python.org/ja/3/library/xml.etree.elementtree.html#elementtree-xpath
89+
- [3] [XMLを扱うモジュール群 — Python 3.10.4 ドキュメント](https://docs.python.org/ja/3/library/xml.html)
90+
- [4] https://github.com/python/cpython/blob/main/Lib/xml/dom/minidom.py
91+
- [5] https://stackoverflow.com/questions/37572695/python-etree-insert-append-and-subelement
92+
93+
94+
その他の留意事項
95+
----------------
96+
97+
* Python-Markdown のプロセッサの処理順序: md.treeprocessors.register,
98+
md.postprocessors.register の第3引数 priority に渡す値で処理の順序が変わる。
99+
小さな値の方が後段で処理が実施されるようだ。postprocessors の場合 10 より小さ
100+
な値を指定しておけば最後に実施される。
101+
102+
treeprocessors の場合、priority=1 に設定すると:
103+
104+
* リンク []() は要素 a に変換された状態で渡されるので問題なくスキップできる。
105+
106+
* 実体参照は "乱数:番号" に置換された状態で渡される。つまり、<>& などを含んだ
107+
文字列に対して一致させることはできない?
108+
109+
* htmlStash されている要素はこの時点で "乱数:番号" に変換されているので、中に
110+
含まれる単語について処理することはできない。
111+
112+
実体参照や htmlStash された文字列は Postprocessor で復元される。
113+
Python-Markdown のソースを見ると Treeprocessors を全て処理した後に
114+
Postprocessor が実行されるので、実体参照や htmlStash された情報を参照する処理
115+
は Treeprocessor ではできない。仕方がないので Postprocessor で処理することに
116+
した。
117+
118+
"""
119+
120+
from markdown.extensions import Extension
121+
from markdown.postprocessors import Postprocessor
122+
123+
import regex as re
124+
125+
import xml.etree.ElementTree as etree
126+
127+
# リンク・コード・タイトルなどの内部は自動リンクの対象としない。除外タグ判定用正規表現
128+
_RE_EXCLUDED_TAGS = re.compile(r'^(?:a|code|pre|kbd|dfn|h1)$', re.IGNORECASE)
129+
130+
# 自動リンク対象を英単語境界に一致させる必要があるかの判定用正規表現
131+
_RE_WBEG = re.compile(r'^[\p{Ll}\p{Lu}_0-9]')
132+
_RE_WEND = re.compile(r'[\p{Ll}\p{Lu}_0-9]$')
133+
134+
# ソース名 (.md) からHTML名 (.html) に置換する時に使う正規表現
135+
_RE_LINK_EXTENSION = re.compile(r'^([^?#]+?)(?:\.md)([?#]|$)')
136+
137+
# リンクに "https:" 等のスキーム名が含まれているか判定するのに使う正規表現
138+
_RE_LINK_SCHEME = re.compile(r'^[a-zA-Z0-9]+:')
139+
140+
141+
def _quoteWordForRegex(word):
142+
ret = re.escape(word)
143+
if _RE_WBEG.match(word):
144+
ret = r'(?<=^|[^\p{Ll}\p{Lu}_0-9])' + ret
145+
if _RE_WEND.search(word):
146+
ret = ret + r'(?=$|[^\p{Ll}\p{Lu}_0-9])'
147+
return ret
148+
149+
150+
class DefinedWordTreeprocessor(Postprocessor):
151+
"""A postprocessor for Python-Markdown to create links of defined words."""
152+
153+
def _resolveWordProperty(self, word, prop):
154+
if prop in self._dict[word]:
155+
return self._dict[word][prop], None
156+
visited = {}
157+
while 'redirect' in self._dict[word]:
158+
if word in visited:
159+
raise Exception("defined_words: redirection loop for '%s'" % word)
160+
visited[word] = True
161+
word = self._dict[word]['redirect']
162+
if prop in self._dict[word]:
163+
return self._dict[word][prop], word
164+
return None, None
165+
166+
def _resolveDictionary(self):
167+
for word in self._dict.keys():
168+
entry = self._dict[word]
169+
if 'link' not in entry:
170+
value, redirect = self._resolveWordProperty(word, 'link')
171+
if value is not None:
172+
entry['link'] = value
173+
if 'desc' not in entry:
174+
value, redirect = self._resolveWordProperty(word, 'desc')
175+
if value is not None:
176+
entry['desc'] = "%s。%s" % (redirect, value)
177+
178+
for word in self._dict.keys():
179+
entry = self._dict[word]
180+
if 'link' in entry:
181+
link = entry['link']
182+
if _RE_LINK_SCHEME.search(link) is None:
183+
link = _RE_LINK_EXTENSION.sub(r'\1%s\2' % self.extension, link, count=1)
184+
if not link.startswith('/'):
185+
raise Exception("defined_words: link='%s': relative link is unallowed" % link)
186+
link = self.base_url + link
187+
entry['resolved_link'] = link
188+
189+
def __init__(self, md, config):
190+
Postprocessor.__init__(self, md)
191+
self._markdown = md
192+
193+
self.config = config
194+
self.base_url = self.config['base_url']
195+
self.base_path = self.config['base_path']
196+
self.extension = self.config['extension']
197+
self._dict = self.config['dict']
198+
199+
if len(self._dict) > 0:
200+
# Note: regex には 500 個の制限があるらしい (以下参照)。
201+
# https://github.com/cpprefjp/site_generator/issues/72
202+
# https://github.com/cpprefjp/markdown_to_html/commit/fb18c87b48c6290dd6ba00141ecb2f5dc8aba930
203+
if len(self._dict) > 500:
204+
raise Exception("Too many defined words: count = %d must not be greater than 500" % len(self._dict))
205+
# Note: できるだけ長い一致を優先させるため逆ソートしてから正規表現にす
206+
# る。例えば "不定|不定値" ではなく "不定値|不定" になるようにしないと、
207+
# 本文中の "不定値" に対して "[不定値]" とリンク付けされて欲しいが "[不
208+
# 定]値" とリンク付けされてしまう。
209+
self.re_defined_words = re.compile(r'|'.join([_quoteWordForRegex(key) for key in sorted(self._dict.keys(), reverse=True)]), re.MULTILINE)
210+
211+
self._resolveDictionary()
212+
213+
def _convertText(self, text):
214+
new_text = None
215+
ins = []
216+
pos = 0
217+
prev = None
218+
for m in self.re_defined_words.finditer(text):
219+
word = m.group(0)
220+
if word not in self._dict:
221+
continue
222+
left = text[pos:m.start()]
223+
if prev is not None:
224+
prev.tail = left
225+
else:
226+
new_text = left
227+
228+
entry = self._dict[word]
229+
attrs = {'class': 'cpprefjp-defined-word'}
230+
if 'resolved_link' in entry:
231+
attrs['href'] = entry['resolved_link']
232+
if 'desc' in entry:
233+
attrs['data-desc'] = entry['desc']
234+
a = etree.Element('a', attrs)
235+
a.text = word
236+
ins.append(a)
237+
238+
pos = m.end()
239+
prev = a
240+
241+
left = text[pos:]
242+
if prev is not None:
243+
prev.tail = left
244+
else:
245+
new_text = left
246+
247+
return new_text, ins
248+
249+
def _recurseElement(self, elem):
250+
if elem.tag is etree.Comment or elem.tag is etree.ProcessingInstruction:
251+
return
252+
if _RE_EXCLUDED_TAGS.match(elem.tag):
253+
return
254+
255+
insertions = []
256+
257+
if elem.text is not None:
258+
elem.text, ins = self._convertText(elem.text)
259+
else:
260+
ins = []
261+
insertions.append(ins)
262+
263+
for e in elem:
264+
self._recurseElement(e)
265+
if e.tail is not None:
266+
e.tail, ins = self._convertText(e.tail)
267+
else:
268+
ins = []
269+
insertions.append(ins)
270+
271+
for i, ins in reversed(list(enumerate(insertions))):
272+
for e in reversed(ins):
273+
elem.insert(i, e)
274+
275+
def run(self, text):
276+
"""Construct ElementTree, convert and re-serialize it"""
277+
if len(self._dict) == 0:
278+
return
279+
280+
try:
281+
md = self._markdown
282+
text = '<{tag}>{text}</{tag}>'.format(tag=md.doc_tag, text=text)
283+
root = etree.fromstring(text)
284+
self._recurseElement(root)
285+
output = etree.tostring(root, encoding="unicode", method="xml")
286+
beg = output.index('<%s>' % md.doc_tag) + len(md.doc_tag) + 2
287+
end = output.rindex('</%s>' % md.doc_tag)
288+
return output[beg:end].strip()
289+
except etree.ParseError as e:
290+
lineno = e.position[0]
291+
xs = text.split('\n')[lineno - 5:lineno + 5]
292+
print('[Parse Error : {0}]'.format(self.config['full_path']))
293+
for x, n in zip(xs, range(lineno - 5, lineno + 5)):
294+
print('{0:5d} {1}'.format(n + 1, x))
295+
raise
296+
297+
298+
class DefinedWordExtension(Extension):
299+
"""An extension for Python-Markdown to create links of defined words."""
300+
301+
def __init__(self, **kwargs):
302+
# define default configs
303+
self.config = {
304+
'base_url': ["https://cpprefjp.github.io",
305+
"base url of the site"],
306+
'base_path': ["",
307+
"directory path that contains the current document"],
308+
'full_path': ["implementation-compliance.md",
309+
"path to the source file"],
310+
'extension': ['.html',
311+
"the extension of the generated HTML files"],
312+
'dict': [{"不適格": "/implementation-compliance.md"},
313+
"dictionary that maps a defined word to a link"],
314+
}
315+
316+
for key, value in kwargs.items():
317+
if key in self.config:
318+
self.setConfig(key, value)
319+
320+
def extendMarkdown(self, md):
321+
"""Add DefinedWordTreeprocessor to Markdown instance."""
322+
proc = DefinedWordTreeprocessor(md, self.getConfigs())
323+
md.postprocessors.register(proc, 'defined_words', 1)
324+
md.registerExtension(self)
325+
326+
327+
def makeExtension(**kwargs):
328+
return DefinedWordExtension(**kwargs)

0 commit comments

Comments
 (0)