import html as html_mod
import pathlib
import re
from urllib.parse import unquote
import cmarkgfm
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound
_CSS = None
_PYGMENTS_FORMATTER = None
_PYGMENTS_CSS = None
def _load_css():
global _CSS
if _CSS is not None:
return _CSS
candidates = [
pathlib.Path(__file__).parent.parent.parent
/ "node_modules"
/ "github-markdown-css"
/ "github-markdown.css",
pathlib.Path(__file__).parent.parent
/ "node_modules"
/ "github-markdown-css"
/ "github-markdown.css",
pathlib.Path(
"/home/zjj/md-parser/node_modules/github-markdown-css/github-markdown.css"
),
]
for p in candidates:
if p.exists():
_CSS = p.read_text(encoding="utf-8")
return _CSS
_CSS = ""
return _CSS
_OPTIONS = cmarkgfm.Options.CMARK_OPT_UNSAFE
_MD_LINK_RE = re.compile(r'(href|src)="((?!https?:|//)[^"]+)\.md(#[^"]*)?"')
_CANN_OPEN_RE = re.compile(
r'<cann-filter\b[^>]*npu[_-]type\s*=\s*"([^"]+)"[^>]*>',
re.IGNORECASE,
)
_CANN_CLOSE_RE = re.compile(
r"</cann-filter\b[^>]*>",
re.IGNORECASE,
)
_OTHER_TAG_NAMES = r"term|ph|__gm__|__ubuf__"
_OTHER_TAG_LINE_RE = re.compile(
r"^[ \t]*</?(" + _OTHER_TAG_NAMES + r")\b[^>]*>[ \t]*$",
re.IGNORECASE,
)
_OTHER_TAG_WRAP_RE = re.compile(
r"<(" + _OTHER_TAG_NAMES + r")\b[^>]*>([\s\S]*?)</\1>",
re.IGNORECASE,
)
_OTHER_TAG_ANY_RE = re.compile(
r"</?(" + _OTHER_TAG_NAMES + r")\b[^>]*>",
re.IGNORECASE,
)
_FENCE_OPEN_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})", re.MULTILINE)
_INLINE_CODE_SPAN_RE = re.compile(r"`+[^\n]*?`+")
_LONE_TILDE_RE = re.compile(r"(?<!\\)(?<!~)~(?!~)")
def _escape_lone_tildes_in_line(line: str) -> str:
result = []
last_end = 0
for m in _INLINE_CODE_SPAN_RE.finditer(line):
before = line[last_end : m.start()]
result.append(_LONE_TILDE_RE.sub(r"\\~", before))
result.append(m.group())
last_end = m.end()
remaining = line[last_end:]
result.append(_LONE_TILDE_RE.sub(r"\\~", remaining))
return "".join(result)
def _escape_lone_tildes(md_text: str) -> str:
lines = md_text.split("\n")
result = []
in_fence = False
fence_char = None
for line in lines:
stripped = line.lstrip(" \t")
if in_fence:
result.append(line)
if fence_char and re.match(
rf"^{re.escape(fence_char)}{{3,}}[ \t]*$", stripped
):
in_fence = False
fence_char = None
continue
fence_match = re.match(r"^(`{3,}|~{3,})", stripped)
if fence_match:
in_fence = True
fence_char = fence_match.group(1)[0]
result.append(line)
continue
result.append(_escape_lone_tildes_in_line(line))
return "\n".join(result)
_MATH_BLOCK_RE = re.compile(r"(?<!\\)\$\$[\s\S]*?(?<!\\)\$\$")
_MATH_INLINE_RE = re.compile(r"(?<!\\)(?<!\$)\$(?!\$)[^\n$]+?(?<!\\)\$(?!\$)")
_MATH_PLACEHOLDER_FMT = "@@MATH{n}@@"
def _split_by_fences(md_text: str):
lines = md_text.split("\n")
segments = []
current = []
is_code = False
fence_char = None
for line in lines:
stripped = line.lstrip(" \t")
if is_code:
current.append(line)
if fence_char and re.match(
rf"^{re.escape(fence_char)}{{3,}}[ \t]*$", stripped
):
is_code = False
fence_char = None
segments.append((True, "\n".join(current)))
current = []
continue
fence_match = re.match(r"^(`{3,}|~{3,})", stripped)
if fence_match:
if current:
segments.append((False, "\n".join(current)))
current = []
is_code = True
fence_char = fence_match.group(1)[0]
current.append(line)
continue
current.append(line)
if current:
segments.append((is_code, "\n".join(current)))
return segments
def _make_code_stasher():
spans = []
def _stash(m):
spans.append(m.group(0))
return f"\x00CODE{len(spans) - 1}\x00"
return _stash, spans
def _make_math_stasher(blocks):
def _stash(m):
blocks.append(m.group(0))
return _MATH_PLACEHOLDER_FMT.format(n=len(blocks) - 1)
return _stash
def _extract_math_blocks(md_text: str) -> tuple:
segments = _split_by_fences(md_text)
blocks = []
result = []
for is_code, content in segments:
if is_code:
result.append(content)
continue
stash_code, code_spans = _make_code_stasher()
text = _INLINE_CODE_SPAN_RE.sub(stash_code, content)
stash_math = _make_math_stasher(blocks)
text = _MATH_BLOCK_RE.sub(stash_math, text)
text = _MATH_INLINE_RE.sub(stash_math, text)
for i, code in enumerate(code_spans):
text = text.replace(f"\x00CODE{i}\x00", code)
result.append(text)
return "\n".join(result), blocks
def _restore_math_blocks(html: str, blocks: list) -> str:
for i, block in enumerate(blocks):
placeholder = _MATH_PLACEHOLDER_FMT.format(n=i)
encoded = block.replace("&", "&").replace("<", "<").replace(">", ">")
html = html.replace(placeholder, encoded)
return html
def _convert_cann_filter(md_text: str) -> str:
"""将所有 cann-filter 标签转为 HTML 注释,保留筛选信息。
<cann-filter> 标签(无论独立行还是行内)会被 cmarkgfm 视为 raw HTML,
若跨表格单元格/列表项边界则产生非法 HTML 结构。转为注释后 cmarkgfm
透传为无害注释,不破坏结构。后续由 config.mjs 将注释还原为 div[data-filter]。
"""
md_text = _CANN_OPEN_RE.sub(r"<!--cann-filter:\1-->", md_text)
md_text = _CANN_CLOSE_RE.sub(r"<!--/cann-filter-->", md_text)
return md_text
def _strip_other_tags(md_text: str) -> str:
"""清除 term / ph / __gm__ / __ubuf__ 等不参与筛选的标签。
这些标签同样会被 cmarkgfm 视为 raw HTML block,破坏 markdown 结构。
"""
lines = md_text.split("\n")
result = []
for line in lines:
if _OTHER_TAG_LINE_RE.match(line):
continue
result.append(line)
md_text = "\n".join(result)
md_text = _OTHER_TAG_WRAP_RE.sub(r"\2", md_text)
md_text = _OTHER_TAG_ANY_RE.sub("", md_text)
return md_text
_CALLOUT_RE = re.compile(
r"<blockquote>\s*\n<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](.*?)</p>\s*\n(.*?)</blockquote>",
re.DOTALL,
)
_CALLOUT_ICONS = {
"NOTE": (
'<svg class="callout-icon" viewBox="0 0 16 16" width="16" height="16">'
'<path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z'
"M6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2"
'a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>'
"</svg>"
),
"TIP": (
'<svg class="callout-icon" viewBox="0 0 16 16" width="16" height="16">'
'<path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253'
"c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.21"
"c-.132-.932-.5-1.583-.83-2.06A10.5 10.5 0 0 0 3.5 7.8c-.574-.686-1-1.441-1-2.55"
"C2.5 3.11 4.637 0 8 0s5.5 3.11 5.5 5.25c0 1.109-.426 1.864-1 2.55-.202.241-.39.452"
"-.557.645-.328.38-.534.821-.534 1.305 0 .553-.448 1-1 1s-1-.448-1-1"
"c0-.984.424-1.625.984-2.304l.214-.253c.223-.264.47-.556.673-.848"
"C11.549 6.26 12 5.38 12 4.5 12 2.69 10.363 1.5 8 1.5Zm2 12.75"
"a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h2.5a.75.75 0 0 1 .75.75"
'ZM8 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/>'
"</svg>"
),
"IMPORTANT": (
'<svg class="callout-icon" viewBox="0 0 16 16" width="16" height="16">'
'<path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5'
"A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.574A1.458 1.458 0 0 1 3 14.543V13"
"H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5"
"c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5"
"a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5"
'a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>'
"</svg>"
),
"WARNING": (
'<svg class="callout-icon" viewBox="0 0 16 16" width="16" height="16">'
'<path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378'
"A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Z"
"M8 5a.75.75 0 0 0-.75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A.75.75 0 0 0 8 5Z"
'm1 6a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z"/>'
"</svg>"
),
"CAUTION": (
'<svg class="callout-icon" viewBox="0 0 16 16" width="16" height="16">'
'<path d="M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25'
"c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5"
"a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5"
"c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31"
"L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5"
'A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>'
"</svg>"
),
}
_CALLOUT_CSS = """
.markdown-body .callout {
padding: 0 1rem;
margin-bottom: 1rem;
border-left: 0.25em solid;
border-radius: 0;
}
.markdown-body .callout .callout-title {
display: flex;
align-items: center;
font-weight: 600;
line-height: 1.5;
margin-bottom: 0;
padding-top: 0.5rem;
}
.markdown-body .callout .callout-title .callout-icon {
margin-right: 0.5rem;
flex-shrink: 0;
}
.markdown-body .callout > p:last-child {
padding-bottom: 0.5rem;
margin-bottom: 0;
}
.markdown-body .callout > p {
margin-top: 0.25rem;
}
.markdown-body .callout-note {
border-left-color: #0969da;
}
.markdown-body .callout-note .callout-title { color: #0969da; }
.markdown-body .callout-note .callout-icon { fill: #0969da; }
.markdown-body .callout-tip {
border-left-color: #1a7f37;
}
.markdown-body .callout-tip .callout-title { color: #1a7f37; }
.markdown-body .callout-tip .callout-icon { fill: #1a7f37; }
.markdown-body .callout-important {
border-left-color: #8250df;
}
.markdown-body .callout-important .callout-title { color: #8250df; }
.markdown-body .callout-important .callout-icon { fill: #8250df; }
.markdown-body .callout-warning {
border-left-color: #9a6700;
}
.markdown-body .callout-warning .callout-title { color: #9a6700; }
.markdown-body .callout-warning .callout-icon { fill: #9a6700; }
.markdown-body .callout-caution {
border-left-color: #cf222e;
}
.markdown-body .callout-caution .callout-title { color: #cf222e; }
.markdown-body .callout-caution .callout-icon { fill: #cf222e; }
"""
_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
{style}
</style>
</head>
<body>
<article class="markdown-body">
{body}
</article>
</body>
</html>
"""
def markdown_to_html(text: str) -> str:
return cmarkgfm.markdown_to_html(text, options=_OPTIONS)
def gfm_to_html(text: str) -> str:
return cmarkgfm.github_flavored_markdown_to_html(text, options=_OPTIONS)
def _is_within(path: pathlib.Path, parent: pathlib.Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def _repository_roots(source_path):
if source_path is None:
return None, None
source = pathlib.Path(source_path).resolve()
for parent in source.parents:
if parent.name in ("zh", "en") and parent.parent.name == "docs":
return parent.parent.parent, parent.parent
return None, None
def _fix_links(html: str, source_path=None) -> str:
html = re.sub(
r'(href|src)="https://gitcode\.com/cann/asc-devkit/blob/master/docs/(api|guide)/([^"]+)\.md(#[^"]*)?"',
r'\1="/\2/\3.html\4"',
html,
flags=re.IGNORECASE,
)
repo_root, docs_root = _repository_roots(source_path)
def _replace_markdown_link(match):
link_path = match.group(2)
if match.group(1).lower() == "href" and repo_root is not None:
target = (
pathlib.Path(source_path).resolve().parent / unquote(link_path)
).resolve()
if _is_within(target, repo_root) and not _is_within(target, docs_root):
return match.group(0)
return f'{match.group(1)}="{link_path}.html{match.group(3) or ""}"'
return _MD_LINK_RE.sub(_replace_markdown_link, html)
def _fix_callouts(html: str) -> str:
def _replace(m):
callout_type = m.group(1)
callout_lower = callout_type.lower()
title = m.group(2).strip()
body_content = m.group(3).strip()
icon = _CALLOUT_ICONS.get(callout_type, "")
type_label = dict(
NOTE="Note",
TIP="Tip",
IMPORTANT="Important",
WARNING="Warning",
CAUTION="Caution",
).get(callout_type, callout_type)
if not body_content and "\n" in title:
title, body_content = title.split("\n", 1)
title = title.strip()
body_content = f"<p>{body_content.strip()}</p>"
title_text = title if title else type_label
parts = [f'<p class="callout-title">{icon}{title_text}</p>']
if body_content:
parts.append(body_content)
body_html = "\n".join(parts)
return f'<div class="callout callout-{callout_lower}">\n{body_html}\n</div>'
return _CALLOUT_RE.sub(_replace, html)
_PYGMENTS_LANG_LABELS = {
"py": "Python",
"python": "Python",
"cpp": "C++",
"c++": "C++",
"cxx": "C++",
"c": "C",
"bash": "Bash",
"sh": "Shell",
"shell": "Shell",
"js": "JavaScript",
"javascript": "JavaScript",
"ts": "TypeScript",
"typescript": "TypeScript",
"java": "Java",
"go": "Go",
"rust": "Rust",
"json": "JSON",
"yaml": "YAML",
"yml": "YAML",
"xml": "XML",
"html": "HTML",
"css": "CSS",
"sql": "SQL",
"makefile": "Makefile",
"mk": "Makefile",
"cmake": "CMake",
"dockerfile": "Dockerfile",
"docker": "Dockerfile",
"diff": "Diff",
"text": "Text",
"ini": "INI",
"toml": "TOML",
}
_PYGMENTS_FORMATTER = None
_PYGMENTS_CSS = None
def _get_pygments_formatter():
global _PYGMENTS_FORMATTER
if _PYGMENTS_FORMATTER is None:
_PYGMENTS_FORMATTER = HtmlFormatter(style="default", nowrap=True)
return _PYGMENTS_FORMATTER
def _get_pygments_css() -> str:
global _PYGMENTS_CSS
if _PYGMENTS_CSS is not None:
return _PYGMENTS_CSS
_PYGMENTS_CSS = HtmlFormatter(style="default").get_style_defs(
".markdown-body .highlight"
)
return _PYGMENTS_CSS
_HIGHLIGHT_CSS = """
.markdown-body .code-block {
margin-bottom: 1rem;
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
}
.markdown-body .code-block .code-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 1rem;
background: #f6f8fa;
border-bottom: 1px solid #d0d7de;
font-size: 12px;
color: #57606a;
}
.markdown-body .code-block .code-header .lang-label {
font-weight: 500;
text-transform: uppercase;
}
.markdown-body .code-block pre {
margin: 0;
border: 0;
border-radius: 0;
background: #f6f8fa;
}
.markdown-body .code-block pre code {
background: transparent;
border: 0;
}
.markdown-body .code-header .copy-btn {
display: flex;
align-items: center;
padding: 2px 6px;
border: 1px solid #d0d7de;
border-radius: 4px;
background: #fff;
color: #57606a;
cursor: pointer;
font-size: 12px;
}
.markdown-body .code-header .copy-btn:hover {
background: #eaeef2;
}
.markdown-body .code-header .copy-btn svg {
fill: #57606a;
}
.markdown-body .code-header .copy-btn.copied {
color: #1a7f37;
border-color: #1a7f37;
}
.markdown-body .code-header .copy-btn.copied svg {
fill: #1a7f37;
}
"""
_CODEFENCE_RE = re.compile(
r'<pre(?:\s+lang="([^"]*)")?>\s*<code>(.*?)</code>\s*</pre>',
re.DOTALL,
)
def _highlight_code(html: str) -> str:
def _replace(m):
lang = m.group(1)
code = m.group(2)
text = html_mod.unescape(code)
text = re.sub(r"<br\s*/?>", "\n", text)
if not lang:
lang = "text"
label = _PYGMENTS_LANG_LABELS.get(lang.lower(), lang.capitalize())
try:
lexer = get_lexer_by_name(lang, stripall=True)
except ClassNotFound:
return f'<div class="code-block"><pre class="highlight"><code>{code}</code></pre></div>'
formatter = _get_pygments_formatter()
highlighted = highlight(text, lexer, formatter)
return (
f'<div class="code-block">'
f'<div class="code-header"><span class="lang-label">{label}</span>'
f'<button class="copy-btn" title="复制代码">'
f'<svg width="16" height="16" viewBox="0 0 16 16">'
f'<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5'
f"a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25"
f"v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5"
f'A1.75 1.75 0 0 1 0 14.25Z"/>'
f'<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75'
f"v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Z"
f"m1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5"
f'a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>'
f"</svg>"
f"</button>"
f"</div>"
f'<pre class="highlight"><code>{highlighted}</code></pre>'
f"</div>"
)
return _CODEFENCE_RE.sub(_replace, html)
def parse_string(text: str, gfm: bool = True, source_path=None) -> str:
text = _convert_cann_filter(text)
text = _escape_lone_tildes(text)
text, math_blocks = _extract_math_blocks(text)
if gfm:
html = gfm_to_html(text)
else:
html = markdown_to_html(text)
html = _restore_math_blocks(html, math_blocks)
html = _fix_links(html, source_path=source_path)
html = _fix_callouts(html)
html = _highlight_code(html)
return html
def parse_file(filepath: str, gfm: bool = True) -> str:
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
return parse_string(content, gfm=gfm, source_path=filepath)
def render_html(body: str) -> str:
html = _TEMPLATE
html = html.replace("{style}", _load_css())
html = html.replace("{body}", body)
return html