StrictDoc Documentation
strictdoc/backend/markdown/markdown_to_html_fragment_writer.py
Source file coverage
Path:
strictdoc/backend/markdown/markdown_to_html_fragment_writer.py
Lines:
159
Non-empty lines:
129
Non-empty lines covered with requirements:
129 / 129 (100.0%)
Functions:
11
Functions covered by requirements:
11 / 11 (100.0%)
1
"""
2
@relation(SDOC-SRS-24, scope=file)
3
"""
4
 
5
import re
6
from html import escape
7
from typing import Callable, MutableMapping, Optional, Sequence, Tuple, cast
8
 
9
from markdown_it import MarkdownIt
10
from markdown_it.common.utils import escapeHtml, unescapeAll
11
from markdown_it.renderer import RendererHTML
12
from markdown_it.rules_inline import StateInline
13
from markdown_it.token import Token
14
from markdown_it.utils import EnvType, OptionsDict
15
from markupsafe import Markup
16
 
17
_MARKDOWN_PARSER = MarkdownIt("default", {"html": True})
18
_MARKDOWN_RENDERER = cast(RendererHTML, _MARKDOWN_PARSER.renderer)
19
_FenceRenderer = Callable[[Sequence[Token], int, OptionsDict, EnvType], str]
20
_MARKDOWN_RENDERER_RULES = cast(
21
    MutableMapping[str, _FenceRenderer], _MARKDOWN_RENDERER.rules
22
)
23
_DEFAULT_FENCE_RENDERER: _FenceRenderer = _MARKDOWN_RENDERER_RULES["fence"]
24
 
25
 
26
def _render_fence(
27
    tokens: Sequence[Token],
28
    idx: int,
29
    options: OptionsDict,
30
    env: EnvType,
31
) -> str:
32
    token = tokens[idx]
33
    info = unescapeAll(token.info).strip() if token.info else ""
34
    lang_name = info.split(maxsplit=1)[0] if info else ""
35
 
36
    if lang_name.lower() != "mermaid":
37
        return _DEFAULT_FENCE_RENDERER(tokens, idx, options, env)
38
 
39
    return f'<pre class="mermaid">{escapeHtml(token.content)}</pre>\n'
40
 
41
 
42
def _math_inline_rule(state: StateInline, silent: bool) -> bool:
43
    pos = state.pos
44
    max_ = state.posMax
45
    src = state.src
46
 
47
    if pos >= max_ or src[pos] != "$":
48
        return False
49
 
50
    is_display = pos + 1 <= max_ and src[pos + 1] == "$"
51
    marker_len = 2 if is_display else 1
52
    start = pos + marker_len
53
 
54
    if start >= max_:
55
        return False
56
 
57
    end = -1
58
    i = start
59
    while i <= max_:
60
        dollar_idx = src.find("$", i)
61
        if dollar_idx == -1:
62
            break
63
        if is_display:
64
            if dollar_idx + 1 <= max_ and src[dollar_idx + 1] == "$":
65
                end = dollar_idx
66
                break
67
            i = dollar_idx + 1
68
        else:
69
            if dollar_idx + 1 <= max_ and src[dollar_idx + 1] == "$":
70
                i = dollar_idx + 2
71
            else:
72
                end = dollar_idx
73
                break
74
 
75
    if end == -1:
76
        return False
77
 
78
    content = src[start:end]
79
    if not content.strip():
80
        return False
81
 
82
    if not silent:
83
        token_type = "math_inline_double" if is_display else "math_inline"
84
        token = state.push(token_type, "math", 0)
85
        token.markup = "$$" if is_display else "$"
86
        token.content = content
87
 
88
    state.pos = end + marker_len
89
    return True
90
 
91
 
92
def _render_math_inline(
93
    tokens: Sequence[Token],
94
    idx: int,
95
    _options: OptionsDict,
96
    _env: EnvType,
97
) -> str:
98
    return f'<span class="math notranslate nohighlight">\\( {tokens[idx].content} \\)</span>'
99
 
100
 
101
def _render_math_inline_double(
102
    tokens: Sequence[Token],
103
    idx: int,
104
    _options: OptionsDict,
105
    _env: EnvType,
106
) -> str:
107
    return f'<div class="math notranslate nohighlight">\\[ {tokens[idx].content} \\]</div>'
108
 
109
 
110
def _strip_dotdot_from_img_src(html: str) -> str:
111
    def _rebase(m: "re.Match[str]") -> str:
112
        src = m.group(1)
113
        while src.startswith("../"):
114
            src = src[3:]
115
        return f'src="{src}"'
116
 
117
    return re.sub(r'src="(\.\./[^"]*)"', _rebase, html)
118
 
119
 
120
class MarkdownToHtmlFragmentWriter:
121
    # Use the default preset to support common Markdown extensions such as
122
    # pipe tables when rendering HTML fragments.
123
    markdown_parser = _MARKDOWN_PARSER
124
    _MARKDOWN_RENDERER_RULES["fence"] = _render_fence
125
    _MARKDOWN_PARSER.inline.ruler.before(
126
        "backticks", "math_inline", _math_inline_rule
127
    )
128
    _MARKDOWN_RENDERER_RULES["math_inline"] = _render_math_inline
129
    _MARKDOWN_RENDERER_RULES["math_inline_double"] = _render_math_inline_double
130
 
131
    def __init__(self, flat_assets: bool = False) -> None:
132
        self.flat_assets: bool = flat_assets
133
 
134
    def write(self, markdown_fragment: str) -> Markup:
135
        assert isinstance(markdown_fragment, str), markdown_fragment
136
        html = MarkdownToHtmlFragmentWriter.markdown_parser.render(
137
            markdown_fragment
138
        )
139
        if self.flat_assets:
140
            html = _strip_dotdot_from_img_src(html)
141
        return Markup(html)
142
 
143
    @staticmethod
144
    def write_with_validation(
145
        markdown_fragment: str,
146
    ) -> Tuple[Optional[str], Optional[str]]:
147
        assert isinstance(markdown_fragment, str), markdown_fragment
148
        return (
149
            MarkdownToHtmlFragmentWriter.markdown_parser.render(
150
                markdown_fragment
151
            ),
152
            None,
153
        )
154
 
155
    @staticmethod
156
    def write_anchor_link(title: str, href: str) -> str:
157
        return (
158
            f'<a href="{escape(href, quote=True)}">🔗&nbsp;{escape(title)}</a>'
159
        )