1from collections.abc import Mapping, Sequence
2from typing import cast, Optional, NamedTuple
3
4from html import escape
5from markdown_it.token import Token
6
7from .manual_structure import XrefTarget
8from .md import Renderer
9
10class UnresolvedXrefError(Exception):
11 pass
12
13class Heading(NamedTuple):
14 container_tag: str
15 level: int
16 html_tag: str
17 # special handling for part content: whether partinfo div was already closed from
18 # elsewhere or still needs closing.
19 partintro_closed: bool
20 # tocs are generated when the heading opens, but have to be emitted into the file
21 # after the heading titlepage (and maybe partinfo) has been closed.
22 toc_fragment: str
23
24_bullet_list_styles = [ 'disc', 'circle', 'square' ]
25_ordered_list_styles = [ '1', 'a', 'i', 'A', 'I' ]
26
27class HTMLRenderer(Renderer):
28 _xref_targets: Mapping[str, XrefTarget]
29
30 _headings: list[Heading]
31 _attrspans: list[str]
32 _hlevel_offset: int = 0
33 _bullet_list_nesting: int = 0
34 _ordered_list_nesting: int = 0
35
36 def __init__(self, manpage_urls: Mapping[str, str], xref_targets: Mapping[str, XrefTarget]):
37 super().__init__(manpage_urls)
38 self._headings = []
39 self._attrspans = []
40 self._xref_targets = xref_targets
41
42 def render(self, tokens: Sequence[Token]) -> str:
43 result = super().render(tokens)
44 result += self._close_headings(None)
45 return result
46
47 def _pull_image(self, path: str) -> str:
48 raise NotImplementedError()
49
50 def text(self, token: Token, tokens: Sequence[Token], i: int) -> str:
51 return escape(token.content)
52 def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
53 return "<p>"
54 def paragraph_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
55 return "</p>"
56 def hardbreak(self, token: Token, tokens: Sequence[Token], i: int) -> str:
57 return "<br />"
58 def softbreak(self, token: Token, tokens: Sequence[Token], i: int) -> str:
59 return "\n"
60 def code_inline(self, token: Token, tokens: Sequence[Token], i: int) -> str:
61 return f'<code class="literal">{escape(token.content)}</code>'
62 def code_block(self, token: Token, tokens: Sequence[Token], i: int) -> str:
63 return self.fence(token, tokens, i)
64 def link_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
65 href = escape(cast(str, token.attrs['href']), True)
66 tag, title, target, text = "link", "", 'target="_top"', ""
67 if href.startswith('#'):
68 if not (xref := self._xref_targets.get(href[1:])):
69 raise UnresolvedXrefError(f"bad local reference, id {href} not known")
70 if tokens[i + 1].type == 'link_close':
71 tag, text = "xref", xref.title_html
72 if xref.title:
73 # titles are not attribute-safe on their own, so we need to replace quotes.
74 title = 'title="{}"'.format(xref.title.replace('"', '"'))
75 target, href = "", xref.href()
76 return f'<a class="{tag}" href="{href}" {title} {target}>{text}'
77 def link_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
78 return "</a>"
79 def list_item_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
80 return '<li class="listitem">'
81 def list_item_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
82 return "</li>"
83 def bullet_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
84 extra = 'compact' if token.meta.get('compact', False) else ''
85 style = _bullet_list_styles[self._bullet_list_nesting % len(_bullet_list_styles)]
86 self._bullet_list_nesting += 1
87 return f'<div class="itemizedlist"><ul class="itemizedlist {extra}" style="list-style-type: {style};">'
88 def bullet_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
89 self._bullet_list_nesting -= 1
90 return "</ul></div>"
91 def em_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
92 return '<span class="emphasis"><em>'
93 def em_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
94 return "</em></span>"
95 def strong_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
96 return '<span class="strong"><strong>'
97 def strong_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
98 return "</strong></span>"
99 def fence(self, token: Token, tokens: Sequence[Token], i: int) -> str:
100 # TODO use token.info. docbook doesn't so we can't yet.
101 return f'<pre class="programlisting">\n{escape(token.content)}</pre>'
102 def blockquote_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
103 return '<div class="blockquote"><blockquote class="blockquote">'
104 def blockquote_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
105 return "</blockquote></div>"
106 def note_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
107 return '<div class="note"><h3 class="title">Note</h3>'
108 def note_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
109 return "</div>"
110 def caution_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
111 return '<div class="caution"><h3 class="title">Caution</h3>'
112 def caution_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
113 return "</div>"
114 def important_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
115 return '<div class="important"><h3 class="title">Important</h3>'
116 def important_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
117 return "</div>"
118 def tip_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
119 return '<div class="tip"><h3 class="title">Tip</h3>'
120 def tip_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
121 return "</div>"
122 def warning_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
123 return '<div class="warning"><h3 class="title">Warning</h3>'
124 def warning_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
125 return "</div>"
126 def dl_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
127 return '<div class="variablelist"><dl class="variablelist">'
128 def dl_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
129 return "</dl></div>"
130 def dt_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
131 return '<dt><span class="term">'
132 def dt_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
133 return "</span></dt>"
134 def dd_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
135 return "<dd>"
136 def dd_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
137 return "</dd>"
138 def myst_role(self, token: Token, tokens: Sequence[Token], i: int) -> str:
139 if token.meta['name'] == 'command':
140 return f'<span class="command"><strong>{escape(token.content)}</strong></span>'
141 if token.meta['name'] == 'file':
142 return f'<code class="filename">{escape(token.content)}</code>'
143 if token.meta['name'] == 'var':
144 return f'<code class="varname">{escape(token.content)}</code>'
145 if token.meta['name'] == 'env':
146 return f'<code class="envar">{escape(token.content)}</code>'
147 if token.meta['name'] == 'option':
148 return f'<code class="option">{escape(token.content)}</code>'
149 if token.meta['name'] == 'manpage':
150 [page, section] = [ s.strip() for s in token.content.rsplit('(', 1) ]
151 section = section[:-1]
152 man = f"{page}({section})"
153 title = f'<span class="refentrytitle">{escape(page)}</span>'
154 vol = f"({escape(section)})"
155 ref = f'<span class="citerefentry">{title}{vol}</span>'
156 if man in self._manpage_urls:
157 return f'<a class="link" href="{escape(self._manpage_urls[man], True)}" target="_top">{ref}</a>'
158 else:
159 return ref
160 return super().myst_role(token, tokens, i)
161 def attr_span_begin(self, token: Token, tokens: Sequence[Token], i: int) -> str:
162 # we currently support *only* inline anchors and the special .keycap class to produce
163 # keycap-styled spans.
164 (id_part, class_part) = ("", "")
165 if s := token.attrs.get('id'):
166 id_part = f'<a id="{escape(cast(str, s), True)}" />'
167 if s := token.attrs.get('class'):
168 if s == 'keycap':
169 class_part = '<span class="keycap"><strong>'
170 self._attrspans.append("</strong></span>")
171 else:
172 return super().attr_span_begin(token, tokens, i)
173 else:
174 self._attrspans.append("")
175 return id_part + class_part
176 def attr_span_end(self, token: Token, tokens: Sequence[Token], i: int) -> str:
177 return self._attrspans.pop()
178 def heading_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
179 hlevel = int(token.tag[1:])
180 htag, hstyle = self._make_hN(hlevel)
181 if hstyle:
182 hstyle = f'style="{escape(hstyle, True)}"'
183 if anchor := cast(str, token.attrs.get('id', '')):
184 anchor = f'<a id="{escape(anchor, True)}"></a>'
185 result = self._close_headings(hlevel)
186 tag = self._heading_tag(token, tokens, i)
187 toc_fragment = self._build_toc(tokens, i)
188 self._headings.append(Heading(tag, hlevel, htag, tag != 'part', toc_fragment))
189 return (
190 f'{result}'
191 f'<div class="{tag}">'
192 f' <div class="titlepage">'
193 f' <div>'
194 f' <div>'
195 f' <{htag} class="title" {hstyle}>'
196 f' {anchor}'
197 )
198 def heading_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
199 heading = self._headings[-1]
200 result = (
201 f' </{heading.html_tag}>'
202 f' </div>'
203 f' </div>'
204 f'</div>'
205 )
206 if heading.container_tag == 'part':
207 result += '<div class="partintro">'
208 else:
209 result += heading.toc_fragment
210 return result
211 def ordered_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
212 extra = 'compact' if token.meta.get('compact', False) else ''
213 start = f'start="{token.attrs["start"]}"' if 'start' in token.attrs else ""
214 style = _ordered_list_styles[self._ordered_list_nesting % len(_ordered_list_styles)]
215 self._ordered_list_nesting += 1
216 return f'<div class="orderedlist"><ol class="orderedlist {extra}" {start} type="{style}">'
217 def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
218 self._ordered_list_nesting -= 1
219 return "</ol></div>"
220 def example_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
221 if id := cast(str, token.attrs.get('id', '')):
222 id = f'id="{escape(id, True)}"' if id else ''
223 return f'<div class="example"><a {id} />'
224 def example_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
225 return '</div></div><br class="example-break" />'
226 def example_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
227 return '<p class="title"><strong>'
228 def example_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
229 return '</strong></p><div class="example-contents">'
230 def image(self, token: Token, tokens: Sequence[Token], i: int) -> str:
231 src = self._pull_image(cast(str, token.attrs['src']))
232 alt = f'alt="{escape(token.content, True)}"' if token.content else ""
233 if title := cast(str, token.attrs.get('title', '')):
234 title = f'title="{escape(title, True)}"'
235 return (
236 '<div class="mediaobject">'
237 f'<img src="{escape(src, True)}" {alt} {title} />'
238 '</div>'
239 )
240 def figure_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
241 if anchor := cast(str, token.attrs.get('id', '')):
242 anchor = f'<a id="{escape(anchor, True)}"></a>'
243 return f'<div class="figure">{anchor}'
244 def figure_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
245 return (
246 ' </div>'
247 '</div><br class="figure-break" />'
248 )
249 def figure_title_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
250 return (
251 '<p class="title">'
252 ' <strong>'
253 )
254 def figure_title_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
255 return (
256 ' </strong>'
257 '</p>'
258 '<div class="figure-contents">'
259 )
260 def table_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
261 return (
262 '<div class="informaltable">'
263 '<table class="informaltable" border="1">'
264 )
265 def table_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
266 return (
267 '</table>'
268 '</div>'
269 )
270 def thead_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
271 cols = []
272 for j in range(i + 1, len(tokens)):
273 if tokens[j].type == 'thead_close':
274 break
275 elif tokens[j].type == 'th_open':
276 cols.append(cast(str, tokens[j].attrs.get('style', 'left')).removeprefix('text-align:'))
277 return "".join([
278 "<colgroup>",
279 "".join([ f'<col align="{col}" />' for col in cols ]),
280 "</colgroup>",
281 "<thead>",
282 ])
283 def thead_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
284 return "</thead>"
285 def tr_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
286 return "<tr>"
287 def tr_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
288 return "</tr>"
289 def th_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
290 return f'<th align="{cast(str, token.attrs.get("style", "left")).removeprefix("text-align:")}">'
291 def th_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
292 return "</th>"
293 def tbody_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
294 return "<tbody>"
295 def tbody_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
296 return "</tbody>"
297 def td_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
298 return f'<td align="{cast(str, token.attrs.get("style", "left")).removeprefix("text-align:")}">'
299 def td_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
300 return "</td>"
301 def footnote_ref(self, token: Token, tokens: Sequence[Token], i: int) -> str:
302 href = self._xref_targets[token.meta['target']].href()
303 id = escape(cast(str, token.attrs["id"]), True)
304 return (
305 f'<a href="{href}" class="footnote" id="{id}">'
306 f'<sup class="footnote">[{token.meta["id"] + 1}]</sup>'
307 '</a>'
308 )
309 def footnote_block_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
310 return (
311 '<div class="footnotes">'
312 '<br />'
313 '<hr style="width:100; text-align:left;margin-left: 0" />'
314 )
315 def footnote_block_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
316 return "</div>"
317 def footnote_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
318 # meta id,label
319 id = escape(self._xref_targets[token.meta["label"]].id, True)
320 return f'<div id="{id}" class="footnote">'
321 def footnote_close(self, token: Token, tokens: Sequence[Token], i: int) -> str:
322 return "</div>"
323 def footnote_anchor(self, token: Token, tokens: Sequence[Token], i: int) -> str:
324 href = self._xref_targets[token.meta['target']].href()
325 return (
326 f'<a href="{href}" class="para">'
327 f'<sup class="para">[{token.meta["id"] + 1}]</sup>'
328 '</a>'
329 )
330
331 def _make_hN(self, level: int) -> tuple[str, str]:
332 return f"h{min(6, max(1, level + self._hlevel_offset))}", ""
333
334 def _maybe_close_partintro(self) -> str:
335 if self._headings:
336 heading = self._headings[-1]
337 if heading.container_tag == 'part' and not heading.partintro_closed:
338 self._headings[-1] = heading._replace(partintro_closed=True)
339 return heading.toc_fragment + "</div>"
340 return ""
341
342 def _close_headings(self, level: Optional[int]) -> str:
343 result = []
344 while len(self._headings) and (level is None or self._headings[-1].level >= level):
345 result.append(self._maybe_close_partintro())
346 result.append("</div>")
347 self._headings.pop()
348 return "\n".join(result)
349
350 def _heading_tag(self, token: Token, tokens: Sequence[Token], i: int) -> str:
351 return "section"
352 def _build_toc(self, tokens: Sequence[Token], i: int) -> str:
353 return ""