diff --git a/.gitmodules b/.gitmodules index 3fba50b..de1d23b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "markdown_to_html"] - path = markdown_to_html - url = https://github.com/cpprefjp/markdown_to_html.git [submodule "crsearch"] path = crsearch url = git@github.com:cpprefjp/crsearch.git diff --git a/markdown_to_html b/markdown_to_html deleted file mode 160000 index 802af99..0000000 --- a/markdown_to_html +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 802af99c3860a4278e4c5b71645aa8ec1e326f7e diff --git a/markdown_to_html/.gitignore b/markdown_to_html/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/markdown_to_html/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/markdown_to_html/__init__.py b/markdown_to_html/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/markdown_to_html/commit.py b/markdown_to_html/commit.py new file mode 100644 index 0000000..0a9ac94 --- /dev/null +++ b/markdown_to_html/commit.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +コミット構文 +========================================= + +コミットIDをリンクに変換する +[commit REPOSITORY_NAME, commit-id0, commit-id-2...] + + >>> text = "[commit REPOSITORY_NAME, 1234567, abcdefg]" + >>> md = markdown.Markdown(['commit']) + >>> print md.convert(text) + 1234567 abcdefg +""" + +import re + +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor + + +def replace_commit_line(line: str) -> str: + new_line: str = line + for m in re.finditer(r'\[commit (.*?)\]', line.strip()): + c = m[1].split(", ") + repo = c[0] + links: list[str] = [] + for id in c[1:]: + id = id.strip() + if len(id) == 0: + continue + links.append("{1}".format(repo, id)) + commits: str = " ".join(links) + new_line = new_line.replace(m[0], commits) + return new_line + +class CommitExtension(Extension): + + def extendMarkdown(self, md): + pre = CommitPreprocessor(md) + + md.registerExtension(self) + md.preprocessors.register(pre, 'commit', 25) + + +class CommitPreprocessor(Preprocessor): + + def __init__(self, md): + Preprocessor.__init__(self, md) + + def run(self, lines): + new_lines = [] + + for line in lines: + new_line = replace_commit_line(line) + new_lines.append(new_line) + + return new_lines + + +def makeExtension(**kwargs): + return CommitExtension(**kwargs) diff --git a/markdown_to_html/defined_words.py b/markdown_to_html/defined_words.py new file mode 100644 index 0000000..78eb620 --- /dev/null +++ b/markdown_to_html/defined_words.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +"""定義語をリンクに変換 +======================= + +DEFINED_WORDS.json でリンクの指定されている定義語を本文中から検索しリンクに変換 +する。 + + +xml.etree.ElementTree にまつわる実装の留意事項 +---------------------------------------------- + +markdown.treeprocessors では Python の "標準的な XML ライブラリ"である +xml.etree.ElementTree (etree) を使っているようだ (最終的にこの実装では +markdown.postprocessors を用いることにしたので必ずしも xml.etree.ElementTree を +使わなければならないわけではないが)。etree のドキュメントはあるが仕様が色々信じ +がたいので結局直接ソースコード [1] を見るのが確実である。 + +* etree は XPath を部分的に実装していると謳っている [2] が XPath 特有の機能は全 + く実装されていない。子・子孫・属性による要素の選択を XPath に似た文法で指定で + きるというだけである。XPath ならではの機能はない。textノードも抜き出せないし、 + 何なら /xxx() のような文法はないし、集合演算にも対応していない。当初、以下を + 動かそうと試行錯誤していたが全く無駄な努力だった。 + + for text in root.findall("(.//* except .//*/(a | code | pre)/*)/child::text()"): + print(text) + +* etree では要素からその親要素を取得する方法がない。なのでそもそも XPath なり何 + なりのセレクターで列挙したとしても自身を置き換えるような DOM 修正が不可能であ + る。なので、木構造を直接自前で辿るしかない。 + +* etree の実装は不思議なことに node の概念がなく、文字列は直前の開始タグまたは + 終了タグの付属物として記録されている。element.text が開始タグ直後の文字列で + element.tail が終了タグ直後の文字列である。特に、element.text は「その要素内 + に含まれる文字列全て」ではなく最初の子要素が現れるまでの文字列であるという事 + に注意する。親要素の最初の node としての文字列でないものは全て直前の要素の終 + 了タグの付属物として記録されている。例えば、 + + text1text2text3text4text5 + + に対しては + + a.text = "text1" + b.text = "text2" + b.tail = "text3" + c.text = "text4" + c.tail = "text5" + a.tail = None + + という具合に文字列が格納されている。 + + 元ソースの実体参照 ("<" など) は解決された状態 (つまり "<" など) で tail, + text に格納されると思われる。少なくとも a.text = "<"としたらソースに変換する + 時点で "<" が出力される。 + +* 子要素を iterate する方法が分からないと思ったら親要素が Iterable であり + elem.iter() で子要素のイテレータが得られる。これは変だ。 + + for e in elem: + print(e) + +* etree では子要素を追加する elem.insert(index, childElement) という関数がある。 + 引数に index を要求しているが、そもそも子要素を index 指定で取得する機能もな + いし、子要素から index を取得する機能もないので、index の指定のしようがない。 + 呼び出し側で、親要素の構築時に何番目にどの要素が格納されているかを別に記録し + ていなければどうにもならない。或いは既存の要素に対して処理する時は elem を一 + 旦 iterate して対応表を手元に作るか、private メンバ _children に直接アクセス + する必要がある (但し _children はバージョンが変わった時に変わらないとも限らな + い)。 + +* xml.dom.minidom という多少はましなものもある [3,4] 様だが xml.etree とは互換 + 性がない。木を再構築する必要がある。これを使うのだったらそもそも + markdown.treeprocessors を使う意味がない (現在は Postprocessor に移行して自前 + で xml.etree の木を構築しているのでこの際 minidom に乗り換えても良いのかもし + れない)。 + +* Q 要素を作る時は必ず elem.SubElement などの関数経由で構築する必要はあるか? + + A. 恐らくない。直接要素を構築してから追加すれば良いと思われる。質問サイトの + [5] の質疑応答を見る限りは ([5] の質問自体は今回の疑問と直接関係ないが)、取り + 合えず要素は etree.Element で作成してから append して問題ないようだ (DOM の場 + 合には、アロケータの都合だろうか、document.createElement を使う必要があったが + その様な制約はないようである)。 + +* etree では if elem: は elem に子要素が存在するかどうかで判定される。つまり、 + None かそうでないかの判定に使おうと思っていると痛い目を見る。 + +- [1] https://github.com/python/cpython/blob/main/Lib/xml/etree/ElementTree.py +- [2] https://docs.python.org/ja/3/library/xml.etree.elementtree.html#elementtree-xpath +- [3] [XMLを扱うモジュール群 — Python 3.10.4 ドキュメント](https://docs.python.org/ja/3/library/xml.html) +- [4] https://github.com/python/cpython/blob/main/Lib/xml/dom/minidom.py +- [5] https://stackoverflow.com/questions/37572695/python-etree-insert-append-and-subelement + + +その他の留意事項 +---------------- + +* Python-Markdown のプロセッサの処理順序: md.treeprocessors.register, + md.postprocessors.register の第3引数 priority に渡す値で処理の順序が変わる。 + 小さな値の方が後段で処理が実施されるようだ。postprocessors の場合 10 より小さ + な値を指定しておけば最後に実施される。 + + treeprocessors の場合、priority=1 に設定すると: + + * リンク []() は要素 a に変換された状態で渡されるので問題なくスキップできる。 + + * 実体参照は "乱数:番号" に置換された状態で渡される。つまり、<>& などを含んだ + 文字列に対して一致させることはできない? + + * htmlStash されている要素はこの時点で "乱数:番号" に変換されているので、中に + 含まれる単語について処理することはできない。 + + 実体参照や htmlStash された文字列は Postprocessor で復元される。 + Python-Markdown のソースを見ると Treeprocessors を全て処理した後に + Postprocessor が実行されるので、実体参照や htmlStash された情報を参照する処理 + は Treeprocessor ではできない。仕方がないので Postprocessor で処理することに + した。 + +""" + +from markdown.extensions import Extension +from markdown.postprocessors import Postprocessor + +import regex as re + +import xml.etree.ElementTree as etree + +# リンク・コード・タイトルなどの内部は自動リンクの対象としない。除外タグ判定用正規表現 +_RE_EXCLUDED_TAGS = re.compile(r'^(?:a|code|pre|kbd|dfn|h1)$', re.IGNORECASE) + +# 自動リンク対象を英単語境界に一致させる必要があるかの判定用正規表現 +_RE_WBEG = re.compile(r'^[\p{Ll}\p{Lu}_0-9]') +_RE_WEND = re.compile(r'[\p{Ll}\p{Lu}_0-9]$') + +# ソース名 (.md) からHTML名 (.html) に置換する時に使う正規表現 +_RE_LINK_EXTENSION = re.compile(r'^([^?#]+?)(?:\.md)([?#]|$)') + +# リンクに "https:" 等のスキーム名が含まれているか判定するのに使う正規表現 +_RE_LINK_SCHEME = re.compile(r'^[a-zA-Z0-9]+:') + + +def _quoteWordForRegex(word): + ret = re.escape(word) + if _RE_WBEG.match(word): + ret = r'(?<=^|[^\p{Ll}\p{Lu}_0-9])' + ret + if _RE_WEND.search(word): + ret = ret + r'(?=$|[^\p{Ll}\p{Lu}_0-9])' + return ret + + +class DefinedWordTreeprocessor(Postprocessor): + """A postprocessor for Python-Markdown to create links of defined words.""" + + def _resolveWordProperty(self, word, prop): + if prop in self._dict[word]: + return self._dict[word][prop], None + visited = {} + while 'redirect' in self._dict[word]: + if word in visited: + raise Exception("defined_words: redirection loop for '%s'" % word) + visited[word] = True + word = self._dict[word]['redirect'] + if prop in self._dict[word]: + return self._dict[word][prop], word + return None, None + + def _resolveDictionary(self): + for word in self._dict.keys(): + entry = self._dict[word] + if 'link' not in entry: + value, redirect = self._resolveWordProperty(word, 'link') + if value is not None: + entry['link'] = value + if 'desc' not in entry: + value, redirect = self._resolveWordProperty(word, 'desc') + if value is not None: + entry['desc'] = "%s。%s" % (redirect, value) + + for word in self._dict.keys(): + entry = self._dict[word] + if 'link' in entry: + link = entry['link'] + if _RE_LINK_SCHEME.search(link) is None: + link = _RE_LINK_EXTENSION.sub(r'\1%s\2' % self.extension, link, count=1) + if not link.startswith('/'): + raise Exception("defined_words: link='%s': relative link is unallowed" % link) + link = self.base_url + link + entry['resolved_link'] = link + + def __init__(self, md, config): + Postprocessor.__init__(self, md) + self._markdown = md + + self.config = config + self.base_url = self.config['base_url'] + self.base_path = self.config['base_path'] + self.extension = self.config['extension'] + self._dict = self.config['dict'] + + if len(self._dict) > 0: + # Note: regex には 500 個の制限があるらしい (以下参照)。 + # https://github.com/cpprefjp/site_generator/issues/72 + # https://github.com/cpprefjp/markdown_to_html/commit/fb18c87b48c6290dd6ba00141ecb2f5dc8aba930 + if len(self._dict) > 500: + raise Exception("Too many defined words: count = %d must not be greater than 500" % len(self._dict)) + # Note: できるだけ長い一致を優先させるため逆ソートしてから正規表現にす + # る。例えば "不定|不定値" ではなく "不定値|不定" になるようにしないと、 + # 本文中の "不定値" に対して "[不定値]" とリンク付けされて欲しいが "[不 + # 定]値" とリンク付けされてしまう。 + self.re_defined_words = re.compile(r'|'.join([_quoteWordForRegex(key) for key in sorted(self._dict.keys(), reverse=True)]), re.MULTILINE) + + self._resolveDictionary() + + def _convertText(self, text): + new_text = None + ins = [] + pos = 0 + prev = None + for m in self.re_defined_words.finditer(text): + word = m.group(0) + if word not in self._dict: + continue + left = text[pos:m.start()] + if prev is not None: + prev.tail = left + else: + new_text = left + + entry = self._dict[word] + attrs = {'class': 'cpprefjp-defined-word'} + if 'resolved_link' in entry: + attrs['href'] = entry['resolved_link'] + if 'desc' in entry: + attrs['data-desc'] = entry['desc'] + a = etree.Element('a', attrs) + a.text = word + ins.append(a) + + pos = m.end() + prev = a + + left = text[pos:] + if prev is not None: + prev.tail = left + else: + new_text = left + + return new_text, ins + + def _recurseElement(self, elem): + if elem.tag is etree.Comment or elem.tag is etree.ProcessingInstruction: + return + if _RE_EXCLUDED_TAGS.match(elem.tag): + return + + insertions = [] + + if elem.text is not None: + elem.text, ins = self._convertText(elem.text) + else: + ins = [] + insertions.append(ins) + + for e in elem: + self._recurseElement(e) + if e.tail is not None: + e.tail, ins = self._convertText(e.tail) + else: + ins = [] + insertions.append(ins) + + for i, ins in reversed(list(enumerate(insertions))): + for e in reversed(ins): + elem.insert(i, e) + + def run(self, text): + """Construct ElementTree, convert and re-serialize it""" + if len(self._dict) == 0: + return + + try: + md = self._markdown + text = '<{tag}>{text}'.format(tag=md.doc_tag, text=text) + root = etree.fromstring(text) + self._recurseElement(root) + output = etree.tostring(root, encoding="unicode", method="xml") + beg = output.index('<%s>' % md.doc_tag) + len(md.doc_tag) + 2 + end = output.rindex('' % md.doc_tag) + return output[beg:end].strip() + except etree.ParseError as e: + lineno = e.position[0] + xs = text.split('\n')[lineno - 5:lineno + 5] + print('[Parse Error : {0}]'.format(self.config['full_path'])) + for x, n in zip(xs, range(lineno - 5, lineno + 5)): + print('{0:5d} {1}'.format(n + 1, x)) + raise + + +class DefinedWordExtension(Extension): + """An extension for Python-Markdown to create links of defined words.""" + + def __init__(self, **kwargs): + # define default configs + self.config = { + 'base_url': ["https://cpprefjp.github.io", + "base url of the site"], + 'base_path': ["", + "directory path that contains the current document"], + 'full_path': ["implementation-compliance.md", + "path to the source file"], + 'extension': ['.html', + "the extension of the generated HTML files"], + 'dict': [{"不適格": "/implementation-compliance.md"}, + "dictionary that maps a defined word to a link"], + } + + for key, value in kwargs.items(): + if key in self.config: + self.setConfig(key, value) + + def extendMarkdown(self, md): + """Add DefinedWordTreeprocessor to Markdown instance.""" + proc = DefinedWordTreeprocessor(md, self.getConfigs()) + md.postprocessors.register(proc, 'defined_words', 1) + md.registerExtension(self) + + +def makeExtension(**kwargs): + return DefinedWordExtension(**kwargs) diff --git a/markdown_to_html/fix_display_error.py b/markdown_to_html/fix_display_error.py new file mode 100644 index 0000000..b35e5e9 --- /dev/null +++ b/markdown_to_html/fix_display_error.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +表示崩れを事前修正 +========================================= + +Markdownライブラリの以下の制限を回避: + +- 箇条書きの前に空行が必要な制限を回避して、自動で空行を挟む +""" + +import re +import datetime + +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor + +def is_item_line(line: str) -> bool: + stripped_line = line.strip() + m = re.match(r'^([0-9]+\.\s)', stripped_line) + if m: + return True + + m = re.match(r'^([*+-]\s)', stripped_line) + if m: + return True + return False + +def is_item_end_line(line: str) -> bool: + if len(line) == 0: + return True + if re.match(r'^#+ ', line): + return True + return False + +class FixDisplayErrorExtension(Extension): + + def extendMarkdown(self, md): + pre = FixDisplayErrorPreprocessor(md) + + md.registerExtension(self) + md.preprocessors.register(pre, 'fix_display_error', 28) + + +class FixDisplayErrorPreprocessor(Preprocessor): + + def __init__(self, md): + Preprocessor.__init__(self, md) + + def run(self, lines): + new_lines = [] + + prev_line: str | None = None + in_item: bool = False + for line in lines: + if prev_line == None: + prev_line = line + new_lines.append(line) + continue + + if not is_item_line(prev_line) and not in_item and is_item_line(line): + new_lines.append("") + + if not in_item and is_item_line(line): + in_item = True + if in_item and is_item_end_line(line): + in_item = False + + prev_line = line + new_lines.append(line) + + return new_lines + + +def makeExtension(**kwargs): + return FixDisplayErrorExtension(**kwargs) diff --git a/markdown_to_html/footer.py b/markdown_to_html/footer.py new file mode 100644 index 0000000..d0b6f30 --- /dev/null +++ b/markdown_to_html/footer.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +import markdown +from markdown.util import etree + + +class FooterExtension(markdown.Extension): + """Footer Extension.""" + def __init__(self, configs): + # デフォルトの設定 + self.config = { + 'url': [None, 'URL'], + } + + # ユーザ設定で上書き + for key, value in configs: + self.setConfig(key, value) + + def extendMarkdown(self, md): + footer = FooterTreeprocessor() + footer.config = self.getConfigs() + md.registerExtension(self) + #md.treeprocessors.add('footer', footer, '_begin') + md.treeprocessors.register(footer, 'footer', 50) # top priority (begin) + + +class FooterTreeprocessor(markdown.treeprocessors.Treeprocessor): + """Build and append footnote div to end of document.""" + def _make_footer(self): + footer = etree.Element('footer') + a = etree.SubElement(footer, 'a') + a.set('href', self.config['url']) + a.text = u'編集' + return footer + + def run(self, root): + footer = self._make_footer() + root.append(footer) + + +def makeExtension(**kwargs): + return FooterExtension(**kwargs) diff --git a/markdown_to_html/html_attribute.py b/markdown_to_html/html_attribute.py new file mode 100644 index 0000000..8f372fb --- /dev/null +++ b/markdown_to_html/html_attribute.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- +""" +markdown から変換した HTML に属性を追加する +""" + +import posixpath +import re +import sys + +import markdown +from markdown import postprocessors +from markdown import serializers + +import xml.etree.ElementTree as etree + +HTML_TAGS = { + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'command', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'link', + 'map', + 'mark', + 'menu', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'param', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr', +} + + +class SafeRawHtmlPostprocessor(postprocessors.Postprocessor): + + HTML_TAG_RE = re.compile(r'^\<\/?([a-zA-Z0-9]+)[^\>]*\>$') + + def run(self, text): + for i in range(self.md.htmlStash.html_counter): + html = self.md.htmlStash.rawHtmlBlocks[i] + # if not safe: + # html = self.escape(html) + text = text.replace(self.md.htmlStash.get_placeholder(i), html) + return text + + def escape(self, html): + # html tag + m = re.match(self.HTML_TAG_RE, html) + if m: + if m.group(1) in HTML_TAGS: + return html + # html entity + m = re.match(r'^\&.*\;$', html) + if m: + return html + return self.basic_escape(html) + + def basic_escape(self, html): + html = html.replace('&', '&') + html = html.replace('<', '<') + html = html.replace('>', '>') + return html.replace('"', '"') + + +class AttributePostprocessor(postprocessors.Postprocessor): + + def __init__(self, md, config): + postprocessors.Postprocessor.__init__(self, md) + self._markdown = md + + self.config = config + self.re_url_hash = re.compile(r'#.*$') + self.url_base = self.config['base_url'].strip('/') + '/' + self.url_current = self.url_base + self._remove_md(self.config['full_path']) + self.url_current_base = self.url_base + self.config['base_path'].strip('/') + + image_repo = self.config['image_repo'] + self.re_url_github_image = re.compile(r'^https?://(?:raw.github.com/%s/master|github.com/%s/raw)/' % (image_repo, image_repo)) + self.image_base = 'https://raw.githubusercontent.com/%s/master/' % image_repo + + def _iterate(self, elements, f): + f(elements) + for child in elements: + self._iterate(child, f) + + def _add_color_code(self, element): + if element.tag == 'code': + text = element.text + element.text = '' + e = etree.SubElement(element, 'span', style='color: #000') + e.text = text + + def _add_border_table(self, element): + if element.tag == 'table': + element.attrib['border'] = '1' + element.attrib['bordercolor'] = '#888' + element.attrib['style'] = 'border-collapse:collapse' + + def _remove_md(self, url): + # サイト内絶対パスで末尾に .md があった場合、取り除く + # (github のプレビューとの互換性のため) + # その後、指定があればその拡張子を追加する + matched = re.match('([^#]*)\\.md(#.*)?$', url) + if matched: + url = matched.group(1) + if self.config['extension']: + url = url + self.config['extension'] + anchor = matched.group(2) + if anchor is not None: + url = url + anchor + return url + + def _to_absolute_url(self, element): + if element.tag == 'a' and 'href' in element.attrib: + base_url = self.config['base_url'].strip('/') + base_paths = self.config['base_path'].strip('/').split('/') + full_path = self.config['full_path'] + + check_href = None + + url = element.attrib['href'] + if url.startswith('http://') or url.startswith('https://'): + # 絶対パス + base_url_body = base_url.split('//', 2)[1] + url_body = url.split('//', 2)[1] + # 別ドメインの場合は別タブで開く + if not url_body.startswith(base_url_body): + element.attrib['target'] = '_blank' + else: + check_href = url_body[len(base_url_body):] + elif url.startswith('/'): + # サイト内絶対パス + element.attrib['href'] = base_url + url + element.attrib['href'] = self._remove_md(element.attrib['href']) + check_href = self._remove_md(url) + elif url.startswith('#'): + # ページ内リンク + element.attrib['href'] = base_url + '/' + self._remove_md(full_path) + url + check_href = '/' + self._remove_md(full_path) + elif url.startswith('mailto:'): + # メール + pass + else: + # サイト内相対パス + paths = [] + for p in base_paths + url.split('/'): + if p == '': + continue + elif p == '.': + continue + elif p == '..': + paths = paths[:-1] + else: + paths.append(p) + element.attrib['href'] = base_url + '/' + '/'.join(paths) + element.attrib['href'] = self._remove_md(element.attrib['href']) + check_href = self._remove_md('/' + '/'.join(paths)) + + if hasattr(self._markdown, '_html_attribute_hrefs') and self._markdown._html_attribute_hrefs is not None: + # パスの存在チェック + if check_href is not None: + check_href = re.sub('#.*', '', check_href) + if check_href.endswith('.nolink'): + # そのうち作られるはずだけど、まだリンク先のファイルが存在していないケース + if self._remove_md(check_href.replace('.nolink', '')) in self._markdown._html_attribute_hrefs: + # .nolink マークされていたけど、実際はもうこのファイルは作られているっぽいケース + # .nolink を外すこと + sys.stderr.write('Warning: [nolinked {full_path}] href "{url} ({check_href})" found.\n'.format(**locals())) + element.tag = 'span' + else: + # このファイルを作るように促す + check_href = check_href.replace('.nolink', '') + sys.stdout.write('Note: You can create {check_href} for {full_path}.\n'.format(**locals())) + element.tag = 'span' + else: + # .nolink でない、普通のファイル + if check_href not in self._markdown._html_attribute_hrefs: + sys.stderr.write('Warning: [{full_path}] href "{url} ({check_href})" not found.\n'.format(**locals())) + element.tag = 'span' + + def _to_relative_url(self, element): + if element.tag == 'a' and 'href' in element.attrib: + href = element.attrib['href'] + if self.re_url_hash.sub("", href) == self.url_current: + element.attrib['href'] = href[len(self.url_current):] + elif href.startswith(self.url_base): + element.attrib['href'] = posixpath.relpath(href, self.url_current_base) + + def _resolve_image_src(self, element): + if element.tag == 'img' and 'src' in element.attrib: + src = element.attrib['src'] + src = self.re_url_github_image.sub(self.image_base, src, count=1) + if self.config['use_static_image'] and src.startswith(self.image_base): + src = 'static/image/' + src[len(self.image_base):] + if self.config['use_relative_link']: + src = posixpath.relpath(self.url_base + src, self.url_current_base) + else: + src = '/' + src + element.attrib['src'] = src + + def _adjust_url(self, element): + self._to_absolute_url(element) + + # 一旦絶対パスに統一してから相対パスに変換する + if self.config['use_relative_link']: + self._to_relative_url(element) + + self._resolve_image_src(element) + + def _add_meta(self, element): + body = etree.Element('div', itemprop="articleBody") + after_h1 = False + for e in list(element): + if e.tag == 'h1': + e.attrib['itemprop'] = 'name' + after_h1 = True + elif after_h1: + body.append(e) + element.remove(e) + element.append(body) + + def _tohtml(self, element): + # Note: 以下の様に etree.tostring(method="xml") を用いると + # や が や になってしまう。また、 + # HTML 属性の順序が保持されない。 + # + # return etree.tostring(element, encoding="unicode", method="xml") + + # Note: 代わりに etree.tostring(method="html") を用いると、今度は

になってしまい好ましくない。またこの時 + # も HTML 属性の順序が保持されない。 + # + # return etree.tostring(element, encoding="unicode", method="html") + + # 今は代わりに以下のようにして markdown.serializers の内部変数 + # markdown.serializers.RE_AMP を一時的に書き換えることによって期待する + # 動作を得ている。これは markdown.serializers の内部実装に依存している + # ので、markdown.serializers の上流で内部実装に変更があると動かなくなる + # 可能性があることに注意する。 + old_RE_AMP = serializers.RE_AMP + try: + serializers.RE_AMP = re.compile(r'&') + output = self._markdown.serializer(element) + finally: + serializers.RE_AMP = old_RE_AMP + return output + + def run(self, text): + text = '<{tag}>{text}'.format(tag=self._markdown.doc_tag, text=text) + try: + root = etree.fromstring(text) + except etree.ParseError as e: + lineno = e.position[0] + xs = text.split('\n')[lineno - 5:lineno + 5] + print('[Parse Error : {0}]'.format(self.config['full_path'])) + for x, n in zip(xs, range(lineno - 5, lineno + 5)): + print('{0:5d} {1}'.format(n + 1, x)) + raise + # self._iterate(root, self._add_color_code) + self._iterate(root, self._add_border_table) + self._iterate(root, self._adjust_url) + self._add_meta(root) + + output = self._tohtml(root) + if self._markdown.stripTopLevelTags: + try: + start = output.index('<%s>' % self._markdown.doc_tag) + len(self._markdown.doc_tag) + 2 + end = output.rindex('' % self._markdown.doc_tag) + output = output[start:end].strip() + except ValueError: + if output.strip().endswith('<%s />' % self._markdown.doc_tag): + # We have an empty document + output = '' + else: + # We have a serious problem + raise ValueError('Markdown failed to strip top-level tags. Document=%r' % output.strip()) + return output + + +class AttributeExtension(markdown.Extension): + + def __init__(self, **kwargs): + # デフォルトの設定 + self.config = { + 'base_url': ['', "Base URL used to link URL as absolute URL"], + 'base_path': ['', "Base Path used to link URL as relative URL"], + 'full_path': ['', "Full Path used to link URL as anchor URL"], + 'extension': ['', "URL extension"], + 'use_relative_link': [False, "Whether to use relative paths for domestic links"], + 'image_repo': ['cpprefjp/image', "Name of GitHub repository that contains the images"], + 'use_static_image': [False, "Whether to use the images in static/image instead on GitHub"] + } + + super().__init__(**kwargs) + + def extendMarkdown(self, md): + attr = AttributePostprocessor(md, self.getConfigs()) + md.postprocessors.register(attr, 'html_attribute', 0) + md.postprocessors.deregister('raw_html') + md.postprocessors.register(SafeRawHtmlPostprocessor(md), 'raw_html', 30) + + +def makeExtension(**kwargs): + return AttributeExtension(**kwargs) diff --git a/markdown_to_html/mark.py b/markdown_to_html/mark.py new file mode 100644 index 0000000..d3ff1b8 --- /dev/null +++ b/markdown_to_html/mark.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +絵文字の置き換え +========================================= + +絵文字を以下の方針に従って置き換える: +・表示できない環境を考慮する (アクセシビリティ) +・絵文字の意味をツールチップで表示する + + >>> text = "GCC: 12.0 [mark noimpl], 13.1 [mark impl], 14.1 [mark verified]" + >>> md = markdown.Markdown(['mark']) + >>> print md.convert(text) + GCC: 12.0 , 13.1 , 14.1 +""" + +import re + +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor + + +MARK_DICT = { + "[mark noimpl]": "", + "[mark impl]": "", + "[mark verified]": "", +} + +class MarkExtension(Extension): + + def extendMarkdown(self, md): + markpre = MarkPreprocessor(md) + + md.registerExtension(self) + md.preprocessors.register(markpre, 'mark', 25) + + +class MarkPreprocessor(Preprocessor): + + def __init__(self, md): + Preprocessor.__init__(self, md) + + def run(self, lines): + new_lines = [] + pattern = re.compile("|".join(map(re.escape, MARK_DICT.keys()))) + + for line in lines: + new_line = pattern.sub(lambda match: MARK_DICT[match.group(0)], line) + new_lines.append(new_line) + + return new_lines + + +def makeExtension(**kwargs): + return MarkExtension(**kwargs) diff --git a/markdown_to_html/mathjax.py b/markdown_to_html/mathjax.py new file mode 100644 index 0000000..41c2150 --- /dev/null +++ b/markdown_to_html/mathjax.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +MathJax を使えるようにする +========================= + +テキストのどこかに以下の文字列を書くことで有効になる + +* [mathjax enable] + +MathJaxを有効にした場合、$$...$$ (ブロック)か $...$ (インライン)に挟まれた文字列が数式になる +""" + +import re + +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor +from markdown.util import code_escape + + +MATHJAX_CONFIG_RE = re.compile(r'^\s*\*\s*(?P.*?)\[mathjax\s+(?P.*?)\]\s*$') +MATHJAX_BLOCK_RE = re.compile(r'\$\$.*?\$\$', re.MULTILINE | re.DOTALL) +MATHJAX_INLINE_RE = re.compile(r'\$[^\\\$]*(?:\\\$[^\\\$]*)*\$') + + +class MathJaxExtension(Extension): + + def extendMarkdown(self, md): + mathjaxpre = MathJaxPreprocessor(md) + + md.registerExtension(self) + md.preprocessors.register(mathjaxpre, 'mathjax', 25) + + +class MathJaxPreprocessor(Preprocessor): + + def __init__(self, md): + Preprocessor.__init__(self, md) + self._markdown = md + + def run(self, lines): + lines2 = [] + self._markdown._mathjax_enabled = False + for line in lines: + m = MATHJAX_CONFIG_RE.match(line) + if m: + name = m.group('name') + if name == 'enable': + self._markdown._mathjax_enabled = True + else: + lines2.append(line) + if not self._markdown._mathjax_enabled: + return lines2 + + text = "\n".join(lines2) + while True: + m = MATHJAX_BLOCK_RE.search(text) + if not m: + break + tex = m.group(0) + placeholder = self.md.htmlStash.store(code_escape(tex)) + text = text[:m.start()] + placeholder + text[m.end():] + + lines3 = [] + + for line in text.split('\n'): + while True: + m = MATHJAX_INLINE_RE.search(line) + if not m: + break + tex = m.group(0) + placeholder = self.md.htmlStash.store(code_escape(tex)) + line = line[:m.start()] + placeholder + line[m.end():] + lines3.append(line) + + return lines3 + + +def makeExtension(**kwargs): + return MathJaxExtension(**kwargs) diff --git a/markdown_to_html/meta.py b/markdown_to_html/meta.py new file mode 100644 index 0000000..060a85e --- /dev/null +++ b/markdown_to_html/meta.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +""" +メタデータ +========================================= + +メタデータを記述できるようにする + + >>> text = '''# push_back + ... * vector[meta header] + ... * function[meta id-type] + ... * std[meta namespace] + ... * vector[meta class] + ... * cpp11deprecated[meta cpp] + ... * cpp14removed[meta cpp] + ... + ... 本文 + ... ''' + >>> md = markdown.Markdown(['meta']) + >>> print md.convert(text) +

コードをここに書く

+

本文

+ >>> md._meta_result + {'header': ['vector'], 'id-type': ['function'], 'namespace': ['std'], 'class': ['vector'], 'cpp': ['cpp11deprecated', 'cpp14removed']} +""" + +import re + +from markdown.extensions import Extension +from markdown import postprocessors +from markdown.preprocessors import Preprocessor + + +META_RE = re.compile(r'^\s*\*\s*(?P.*?)\[meta\s+(?P.*?)\]\s*$') + + +class MetaExtension(Extension): + + def extendMarkdown(self, md): + metapre = MetaPreprocessor(md) + metapost = MetaPostprocessor(md) + + md.registerExtension(self) + md.preprocessors.register(metapre, 'meta', 25) + md.postprocessors.register(metapost, 'meta', 0) # bottom priority (end) + + +class MetaPreprocessor(Preprocessor): + + def __init__(self, md): + Preprocessor.__init__(self, md) + self._markdown = md + + def run(self, lines): + new_lines = [] + self._markdown._meta_result = {} + for line in lines: + m = META_RE.match(line) + if m: + target = m.group('target') + name = m.group('name') + if name not in self._markdown._meta_result: + self._markdown._meta_result[name] = [] + self._markdown._meta_result[name].append(target) + else: + new_lines.append(line) + return new_lines + + +class MetaPostprocessor(postprocessors.Postprocessor): + + def __init__(self, md): + postprocessors.Postprocessor.__init__(self, md) + self._markdown = md + + CPP_DIC = { + 'future': { + 'class_name': 'future', + 'title': '将来のC++として検討中', + 'text': '(将来のC++機能)', + }, + 'archive': { + 'class_name': 'archive', + 'title': '廃案になったC++機能', + 'text': '(廃案のC++機能)', + }, + 'cpp11': { + 'class_name': 'cpp11', + 'title': 'C++11で追加', + 'text': '(C++11)', + }, + 'cpp14': { + 'class_name': 'cpp14', + 'title': 'C++14で追加', + 'text': '(C++14)', + }, + 'cpp17': { + 'class_name': 'cpp17', + 'title': 'C++17で追加', + 'text': '(C++17)', + }, + 'cpp20': { + 'class_name': 'cpp20', + 'title': 'C++20で追加', + 'text': '(C++20)', + }, + 'cpp23': { + 'class_name': 'cpp23', + 'title': 'C++23で追加', + 'text': '(C++23)', + }, + 'cpp26': { + 'class_name': 'cpp26', + 'title': 'C++26で追加', + 'text': '(C++26)', + }, + 'cpp11deprecated': { + 'class_name': 'cpp11deprecated text-warning', + 'title': 'C++11で非推奨', + 'text': '(C++11で非推奨)', + }, + 'cpp11removed': { + 'class_name': 'cpp11removed text-danger', + 'title': 'C++11で削除', + 'text': '(C++11で削除)', + }, + 'cpp14deprecated': { + 'class_name': 'cpp14deprecated text-warning', + 'title': 'C++14で非推奨', + 'text': '(C++14で非推奨)', + }, + 'cpp14removed': { + 'class_name': 'cpp14removed text-danger', + 'title': 'C++14で削除', + 'text': '(C++14で削除)', + }, + 'cpp17deprecated': { + 'class_name': 'cpp17deprecated text-warning', + 'title': 'C++17で非推奨', + 'text': '(C++17で非推奨)', + }, + 'cpp17removed': { + 'class_name': 'cpp17removed text-danger', + 'title': 'C++17で削除', + 'text': '(C++17で削除)', + }, + 'cpp20deprecated': { + 'class_name': 'cpp20deprecated text-warning', + 'title': 'C++20で非推奨', + 'text': '(C++20で非推奨)', + }, + 'cpp20removed': { + 'class_name': 'cpp20removed text-danger', + 'title': 'C++20で削除', + 'text': '(C++20で削除)', + }, + 'cpp23deprecated': { + 'class_name': 'cpp23deprecated text-warning', + 'title': 'C++23で非推奨', + 'text': '(C++23で非推奨)', + }, + 'cpp23removed': { + 'class_name': 'cpp23removed text-danger', + 'title': 'C++23で削除', + 'text': '(C++23で削除)', + }, + 'cpp26deprecated': { + 'class_name': 'cpp26deprecated text-warning', + 'title': 'C++26で非推奨', + 'text': '(C++26で非推奨)', + }, + 'cpp26removed': { + 'class_name': 'cpp26removed text-danger', + 'title': 'C++26で削除', + 'text': '(C++26で削除)', + }, + } + + def run(self, text): + if not hasattr(self._markdown, '_meta_result'): + return text + + meta = self._markdown._meta_result + + text = text.replace('

', '

').replace('

', '
') + + if 'cpp' in meta: + for name in meta['cpp']: + text = text.replace('', '{text}'.format(**self.CPP_DIC[name])) + if 'class' in meta: + text = text.replace('

', '

{cls}::'.format(cls=meta['class'][0])) + if 'namespace' in meta: + text = text.replace('

', '

{ns}::'.format(ns=meta['namespace'][0])) + if 'header' in meta: + text = '
<{}>
'.format(meta['header'][0]) + text + if 'id-type' in meta: + id_type = meta['id-type'][0] + if id_type == 'cpo': + text = '
{}
'.format('customization point object') + text + else: + text = '
{}
'.format(id_type) + text + if 'exposition-only' in meta: + # 見出しを斜体にするためのクラスを追加 + text = text.replace('

', '

') + # 説明専用バッジを追加 + text = text.replace('

', '') + return text + + +def makeExtension(**kwargs): + return MetaExtension(**kwargs) diff --git a/markdown_to_html/qualified_fenced_code.py b/markdown_to_html/qualified_fenced_code.py new file mode 100644 index 0000000..bf0472a --- /dev/null +++ b/markdown_to_html/qualified_fenced_code.py @@ -0,0 +1,330 @@ +# -*- coding: utf-8 -*- +""" +Fenced Code Extension の改造版 +========================================= + +github でのコードブロック記法が使える。 + + >>> text = ''' + ... ````` + ... # コードをここに書く + ... x = 10 + ... `````''' + >>> print markdown.markdown(text, extensions=['qualified_fenced_code']) +
# コードをここに書く
+    x = 10
+    
+ +かつ、これらのコードに修飾ができる。 + + >>> text = ''' + ... ``` + ... x = [3, 2, 1] + ... y = sorted(x) + ... x.sort() + ... ``` + ... sorted[color ff0000] + ... sort[link http://example.com/] + ... ''' + >>> print markdown.markdown(text, extensions=['qualified_fenced_code']) +""" + +import hashlib + +import regex as re + +from markdown.extensions.codehilite import CodeHilite +from markdown.extensions.codehilite import CodeHiliteExtension +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor + +CODE_WRAP = '
%s
' +LANG_TAG = ' class="%s"' + +# qualifier の各行は以下の形式を持つことを要求する。"*" による箇条書きの項目で +# あり、[meta ...], [mathjax enable ...], [link ...], [color ...], [italic] の +# 何れかの修飾子が含まれていること。インデントレベルは少なくとも閉じ ``` と同じ +# であること。 +QUALIFIER_LINE_RE_STRING = r'(?P=indent)\s*\*\s[^\n]*\[(?:meta|mathjax enable|link|color|italic)\b[^\n]*\][^\n]*\n' + +# 以下の正規表現は qualifier 行の連続を規定する。最初の qualifier が、閉じ ``` +# と同じレベルの "*" による箇条書きの項目でなければそこで中断する。 +QUALIFIERS_RE_STRING = r'(?:(?!(?P=indent)\*\s)|(?P(?:%s)*))' % QUALIFIER_LINE_RE_STRING + +QUALIFIED_FENCED_BLOCK_RE = re.compile(r'(?P`{3,})[ ]*(?P[a-zA-Z0-9_+-]*)(?P.*?)\n(?P.*?)(?<=\n)(?P[ \t]*)(?P=fence)[ ]*\n' + QUALIFIERS_RE_STRING, re.MULTILINE | re.DOTALL) +QUALIFY_COMMAND_RE = re.compile(r'\[(.*?)\]') +INDENT_RE = re.compile(r'^[ \t]+', re.MULTILINE) + + +class QualifiedFencedCodeExtension(Extension): + + def __init__(self, global_qualify_list): + self.global_qualify_list = global_qualify_list + + def extendMarkdown(self, md): + fenced_block = QualifiedFencedBlockPreprocessor(md, self.global_qualify_list) + md.registerExtension(self) + + md.preprocessors.register(fenced_block, 'qualified_fenced_code', 29) + + +def _make_random_string(): + """アルファベットから成るランダムな文字列を作る""" + from random import randrange + import string + alphabets = string.ascii_letters + return ''.join(alphabets[randrange(len(alphabets))] for i in range(32)) + + +def _escape(txt): + """basic html escaping""" + txt = txt.replace('&', '&') + txt = txt.replace('<', '<') + txt = txt.replace('>', '>') + txt = txt.replace('"', '"') + return txt + + +class QualifyDictionary(object): + + def __init__(self): + # 各コマンドに対する実際の処理 + def _qualify_italic(*xs): + return '{0}'.format(*xs) + + def _qualify_color(*xs): + return '{0}'.format(*xs) + + def _qualify_link(*xs): + return '{0}'.format(*xs) + + self.qualify_dic = { + 'italic': _qualify_italic, + 'color': _qualify_color, + 'link': _qualify_link, + } + + +class Qualifier(object): + + """修飾1個分のデータを保持するクラス""" + + def __init__(self, line, qdic): + command_res = [r'(\[{cmd}(\]|.*?\]))'.format(cmd=cmd) for cmd in qdic.qualify_dic] + + qualify_re_str = r'^[ \t]*\*[ \t]+(?P.*?)(?P({commands})+)$'.format( + commands='|'.join(command_res)) + qualify_re = re.compile(qualify_re_str) + + # parsing + m = qualify_re.search(line) + if not m: + raise ValueError('Failed parse') + self.target = m.group('target') + self.commands = [] + + def f(match): + self.commands.append(match.group(1)) + + try: + QUALIFY_COMMAND_RE.sub(f, m.group('commands')) + except TypeError: + # workaround for regex library + # TypeError: expected string instance, NoneType found + pass + + self._target_re = None + self._target_re_text = None + + # 置換対象になる単語を正規表現で表す + def get_target_re_text(self): + if self._target_re_text is None: + target_re_text = '((?<=[^a-zA-Z_])|(?:^)){target}((?=[^a-zA-Z_])|(?:$))'.format(target=re.escape(self.target)) + self._target_re_text = '(?:{})'.format(target_re_text) + return self._target_re_text + + def _get_target_re(self): + if self._target_re is None: + target_re = re.compile(self.get_target_re_text()) + self._target_re = target_re + return self._target_re + + def find_match(self, code): + return self._get_target_re().search(code) is not None + + +class QualifierList(object): + + def __init__(self, lines): + self._qdic = QualifyDictionary() + + # Qualifier を作るが、エラーになったデータは取り除く + def unique(xs): + seen = set() + results = [] + for x in xs: + if x not in seen: + seen.add(x) + try: + results.append(Qualifier(x, self._qdic)) + except Exception: + pass + return results + + self._qs = unique(lines) + + def mark(self, code): + """置換対象になる単語にマーキングを施す + + 対象文字列が 'sort' だとすれば、文字列中にある全ての 'sort' を + '{ランダムな文字列}' + という文字列に置換する。 + """ + if len(self._qs) == 0: + self._code_re = re.compile("") + return code + + pre_target_re_text_list = [q.get_target_re_text() for q in self._qs if q.find_match(code)] + if len(pre_target_re_text_list) == 0: + self._code_re = re.compile("") + return code + + target_re_text = '|'.join(pre_target_re_text_list) + + # 対象となる単語を置換し、その置換された文字列を後で辿るための正規表現(text_re_list)と、 + # 置換された文字列に対してどのような修飾を行えばいいかという辞書(match_qualifier)を作る。 + text_re_list = [] + match_qualifier = {} + + def mark_command(match): + # 各置換毎に一意な文字列を用意する + match_name = _make_random_string() + # 対象となる単語がどの修飾のデータなのかを調べる + text = match.group(0) + q = next(q for q in self._qs if q.target == text) + match_qualifier[match_name] = q + + # text をこの文字列に置換する + text = '{match_name}'.format( + match_name=match_name, + ) + # 置換された text だけを確実に検索するための正規表現 + text_re = '(?P<{match_name}>{match_name})'.format( + match_name=match_name + ) + text_re_list.append(text_re) + return text + # 対象になる単語を一括置換 + code = re.sub(target_re_text, mark_command, code) + # マークされた文字列を見つけるための正規表現を作る + self._code_re = re.compile('|'.join(r for r in text_re_list)) + self._match_qualifier = match_qualifier + return code + + def qualify(self, html): + # 修飾の指定がなかった + if len(self._qs) == 0: + return html + # 修飾の指定はあったが、検索してみると修飾する文字列が見つからなかった + if len(self._code_re.pattern) == 0: + return html + + # マークされた文字列を探しだして、そのマークに対応した修飾を行う + def convert(match): + q = next(q for m, q in self._match_qualifier.items() if match.group(m)) + text = _escape(q.target) + for command in q.commands: + xs = command.split(' ') + c = xs[0] + remain = xs[1:] + # 修飾 + text = self._qdic.qualify_dic[c](text, *remain) + return text + return self._code_re.sub(convert, html) + + +def _removeIndent(code, indent): + if len(indent) == 0: + return code + n = len(indent.expandtabs(4)) + return INDENT_RE.sub(lambda m: m.group().expandtabs(4)[n:], code) + + +class QualifiedFencedBlockPreprocessor(Preprocessor): + + def __init__(self, md, global_qualify_list): + Preprocessor.__init__(self, md) + + md._example_codes = [] + self.checked_for_codehilite = False + self.codehilite_conf = {} + self.global_qualify_list = global_qualify_list + + def run(self, lines): + # Check for code hilite extension + if not self.checked_for_codehilite: + for ext in self.md.registeredExtensions: + if isinstance(ext, CodeHiliteExtension): + self.codehilite_conf = ext.config + break + + self.checked_for_codehilite = True + + text = "\n".join(lines) + + example_counter = 0 + + while 1: + m = QUALIFIED_FENCED_BLOCK_RE.search(text) + if m: + # ```cpp example みたいに書かれていたらサンプルコードとして扱う + is_example = m.group('lang_meta') and ('example' in m.group('lang_meta').strip().split()) + + qualifies = m.group('qualifies') or '' + qualifies = qualifies + self.global_qualify_list + qualifies = [f for f in qualifies.split('\n') if f] + code = _removeIndent(*m.group('code', 'indent')) + + # サンプルコードだったら、self.markdown の中にコードの情報と ID を入れておく + if is_example: + example_id = hashlib.sha1((str(example_counter) + code).encode('utf-8')).hexdigest() + self.md._example_codes.append({"id": example_id, "code": code}) + example_counter += 1 + + qualifier_list = QualifierList(qualifies) + code = qualifier_list.mark(code) + + # If config is not empty, then the codehighlite extension + # is enabled, so we call it to highlite the code + if self.codehilite_conf and m.group('lang'): + highliter = CodeHilite( + code, + linenums=self.codehilite_conf['linenums'][0], + guess_lang=self.codehilite_conf['guess_lang'][0], + css_class=self.codehilite_conf['css_class'][0], + style=self.codehilite_conf['pygments_style'][0], + lang=(m.group('lang') or None), + noclasses=self.codehilite_conf['noclasses'][0]) + + code = highliter.hilite() + # サンプルコードだったら
で囲む + if is_example: + code = '
%s
' % (example_id, code) + else: + lang = '' + if m.group('lang'): + lang = LANG_TAG % m.group('lang') + + code = CODE_WRAP % (lang, _escape(code)) + + code = qualifier_list.qualify(code) + + placeholder = self.md.htmlStash.store(code) + text = '%s\n%s\n%s' % (text[:m.start()], placeholder, text[m.end():]) + else: + break + return text.split("\n") + + +def makeExtension(**kwargs): + return QualifiedFencedCodeExtension(**kwargs) diff --git a/markdown_to_html/sponsor.py b/markdown_to_html/sponsor.py new file mode 100644 index 0000000..e4bfb53 --- /dev/null +++ b/markdown_to_html/sponsor.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +スポンサー表示 +========================================= + +スポンサーを掲載期限付きで表示する +- name : スポンサー名を指定する。ロゴ画像があればaltとして、なければ名前表示 (required) +- img : ロゴ画像へのURLを指定する (optional) +- link : リンク先URL (optional) +- size : ロゴ画像のピクセル幅サイズ (optional) +- period : スポンサーの掲載期限 (required) +- fee : 金額。生成HTMLには影響なし。スポンサーの並び替え用 (required) +- amount : 数量。月額の場合は12、1回限りは1。スポンサーの並び替え用 (optional) +- note : メモ (optional) + + >>> text = "[sponsor name:NAME, img:IMAGE_URL, link:LINK_URL, size:PIXEL_SIZE, period:YYYY/MM/DD, amount:MONEY]" + >>> md = markdown.Markdown(['mark']) + >>> print md.convert(text) +
NAME
+ + >>> text = "[sponsor name:NAME, link:LINK_URL]" + >>> md = markdown.Markdown(['mark']) + >>> print md.convert(text) + +""" + +import re +import datetime + +from markdown.extensions import Extension +from markdown.preprocessors import Preprocessor + + +def replace_sponsor_line(line: str, now: datetime.datetime) -> str: + m = re.search(r'\[sponsor (.*?)\]', line) + if not m: + return line + + dict = {} + for x in m[1].split(", "): + y = x.split(":") + dict[y[0]] = ":".join(y[1:]) + + # check expired (one-time or canceled) + if dict.get("period"): + period = datetime.datetime.fromisoformat(dict["period"] + " 23:59:59.000000+09:00") + if now > period: + return line.replace(m[0], "") + + if dict.get("img") is None: + new_sponsor = "" + if dict.get("link") is None: + new_sponsor = "
  • {}
".format(dict["name"]) + else: + new_sponsor = "".format(dict["link"], dict["name"]) + return line.replace(m[0], new_sponsor) + + img = "" + center = "" + center_close = "" + center = "
" + center_close = "
" + img = "\"{1}\"".format( + dict["img"], + dict["name"], + " width=\"{}\"".format(dict["size"]) if dict.get("size") is not None else "" + ) + link = "" + link_close = "" + if dict.get("link") is not None: + link = "".format(dict["link"]) + link_close = "" + + new_sponsor = center + link + img + link_close + center_close + return line.replace(m[0], new_sponsor) + +class SponsorExtension(Extension): + + def extendMarkdown(self, md): + pre = SponsorPreprocessor(md) + + md.registerExtension(self) + md.preprocessors.register(pre, 'sponsor', 25) + + +class SponsorPreprocessor(Preprocessor): + + def __init__(self, md): + Preprocessor.__init__(self, md) + + def run(self, lines): + new_lines = [] + + jst = datetime.timezone(datetime.timedelta(hours=+9), 'JST') + now = datetime.datetime.now(jst) + + for line in lines: + new_line = replace_sponsor_line(line, now) + new_lines.append(new_line) + + return new_lines + + +def makeExtension(**kwargs): + return SponsorExtension(**kwargs)