v2 / cmd / tools / vdoc / html.v
950 lines · 899 sloc · 27.21 KB · 8e35f4d9848f7ad35d857a187dddbfd2eca5e19d
Raw
1module main
2
3import os
4import net.urllib
5import encoding.html
6import strings
7import markdown
8import v.scanner
9import v.ast
10import v.token
11import document as doc
12import v.pref
13import v.util { tabs }
14
15const css_js_assets = ['doc.css', 'normalize.css', 'doc.js', 'dark-mode.js']
16const default_theme = os.resource_abs_path('theme')
17const link_svg = '<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>'
18
19const single_quote = "'"
20const double_quote = '"'
21const quote_escape_seq = [single_quote, '', double_quote, '']
22
23enum HighlightTokenTyp {
24 unone
25 boolean
26 builtin
27 char
28 comment
29 function
30 keyword
31 name
32 number
33 operator
34 punctuation
35 string
36 // For string interpolation
37 opening_string
38 string_interp
39 partial_string
40 closing_string
41 symbol
42 none
43 module_
44 prefix
45}
46
47struct SearchModuleResult {
48 description string
49 link string
50}
51
52struct SearchResult {
53 prefix string
54 badge string
55 description string
56 link string
57}
58
59fn (vd &VDoc) render_search_index(out Output) {
60 mut js_search_index := strings.new_builder(200)
61 mut js_search_data := strings.new_builder(200)
62 js_search_index.write_string('var searchModuleIndex = [\n')
63 js_search_data.write_string('var searchModuleData = [\n')
64 for i, title in vd.search_module_index {
65 data := vd.search_module_data[i]
66 js_search_index.write_string('"${title}",\n')
67 description :=
68 data.description.replace('\n', '').replace('\r', '') // fix multiline js string bug
69 js_search_data.write_string('["${description}","${data.link}"],\n')
70 }
71 js_search_index.writeln('];\n')
72 js_search_index.write_string('var searchIndex = [\n')
73 js_search_data.writeln('];\n')
74 js_search_data.write_string('var searchData = [\n')
75 for i, title in vd.search_index {
76 data := vd.search_data[i]
77 js_search_index.write_string('"${title}",\n')
78 // array instead of object to reduce file size
79 js_search_data.write_string('["${data.badge}","${data.description}","${data.link}","${data.prefix}"],\n')
80 }
81 js_search_index.writeln('];\n')
82 js_search_data.writeln('];\n')
83 final := js_search_index.str() + js_search_data.str()
84 out_file_path := os.join_path(out.path, 'search_index.js')
85 println('Generating search_index.js of ${final.len:8} bytes in `${out_file_path} ...')
86 os.write_file(out_file_path, final) or { panic(err) }
87}
88
89fn (mut vd VDoc) render_static_html(out Output) {
90 vd.assets = {
91 'doc_css': vd.get_resource(css_js_assets[0], out)
92 'normalize_css': vd.get_resource(css_js_assets[1], out)
93 'doc_js': vd.get_resource(css_js_assets[2], out)
94 'dark_mode_js': vd.get_resource(css_js_assets[3], out)
95 'light_icon': vd.get_resource('light.svg', out)
96 'dark_icon': vd.get_resource('dark.svg', out)
97 'menu_icon': vd.get_resource('menu.svg', out)
98 'arrow_icon': vd.get_resource('arrow.svg', out)
99 }
100}
101
102fn (vd &VDoc) get_resource(name string, out Output) string {
103 cfg := vd.cfg
104 path := os.join_path(cfg.theme_dir, name)
105 mut res := os.read_file(path) or { panic('vdoc: could not read ${path}') }
106 /*
107 if minify {
108 if name.ends_with('.js') {
109 res = js_compress(res)
110 } else {
111 res = res.split_into_lines().map(it.trim_space()).join('')
112 }
113 }
114 */
115 // TODO: Make SVG inline for now
116 if cfg.inline_assets || path.ends_with('.svg') {
117 return res
118 } else {
119 output_path := os.join_path(out.path, name)
120 if !os.exists(output_path) {
121 println('Copying ${res.len:8} bytes from `${path}` to `${output_path}` ...')
122 os.write_file(output_path, res) or { panic(err) }
123 }
124 return name
125 }
126}
127
128fn (mut vd VDoc) collect_search_index(out Output) {
129 cfg := vd.cfg
130 for doc in vd.docs {
131 mod := doc.head.name
132 vd.search_module_index << mod
133 comments := if cfg.include_examples {
134 doc.head.merge_comments()
135 } else {
136 doc.head.merge_comments_without_examples()
137 }
138 vd.search_module_data << SearchModuleResult{
139 description: trim_doc_node_description(mod, comments)
140 link: vd.get_file_name(mod, out)
141 }
142 for _, dn in doc.contents {
143 vd.create_search_results(mod, dn, out)
144 }
145 }
146}
147
148fn (mut vd VDoc) create_search_results(mod string, dn doc.DocNode, out Output) {
149 cfg := vd.cfg
150 if dn.kind == .const_group {
151 return
152 }
153 comments := if cfg.include_examples {
154 dn.merge_comments()
155 } else {
156 dn.merge_comments_without_examples()
157 }
158 dn_description := trim_doc_node_description(dn.name, comments)
159 vd.search_index << dn.name
160 vd.search_data << SearchResult{
161 prefix: if dn.parent_name != '' {
162 '${dn.kind} (${dn.parent_name})'
163 } else {
164 '${dn.kind} '
165 }
166 description: dn_description
167 badge: mod
168 link: vd.get_file_name(mod, out) + '#' + get_node_id(dn)
169 }
170 for child in dn.children {
171 vd.create_search_results(mod, child, out)
172 }
173}
174
175fn (vd &VDoc) get_repo_file_path_for_links(file_path string) string {
176 if file_path == '' {
177 return ''
178 }
179 cfg := vd.cfg
180 if !cfg.is_multi {
181 return os.file_name(file_path).replace('\\', '/')
182 }
183 base_dir := os.dir(os.real_path(cfg.input_path))
184 prefix := base_dir + os.path_separator
185 if file_path.starts_with(prefix) {
186 return file_path[prefix.len..].replace('\\', '/')
187 }
188 return file_path.replace('\\', '/')
189}
190
191fn (vd &VDoc) write_content(cn &doc.DocNode, d &doc.Doc, mut hw strings.Builder) {
192 cfg := vd.cfg
193 file_path_name := vd.get_repo_file_path_for_links(cn.file_path)
194 src_link := get_src_link(vd.manifest.repo_url, vd.manifest.repo_branch, file_path_name,
195
196 cn.pos.line_nr + 1)
197 md_link_base := get_src_dir_link(vd.manifest.repo_url, vd.manifest.repo_branch, file_path_name)
198 if cn.content.len != 0 || cn.name == 'Constants' {
199 hw.write_string(vd.doc_node_html(cn, src_link, md_link_base, false, cfg.include_examples,
200 d.table))
201 hw.write_string('\n')
202 }
203 for child in cn.children {
204 child_file_path_name := vd.get_repo_file_path_for_links(child.file_path)
205 child_src_link := get_src_link(vd.manifest.repo_url, vd.manifest.repo_branch,
206 child_file_path_name, child.pos.line_nr + 1)
207 child_md_link_base := get_src_dir_link(vd.manifest.repo_url, vd.manifest.repo_branch,
208 child_file_path_name)
209 hw.write_string(vd.doc_node_html(child, child_src_link, child_md_link_base, false,
210 cfg.include_examples, d.table))
211 hw.write_string('\n')
212 }
213}
214
215fn (vd &VDoc) gen_html(d doc.Doc) string {
216 cfg := vd.cfg
217 mut symbols_toc := strings.new_builder(200)
218 mut contents := strings.new_builder(200)
219 dcs_contents := d.contents.arr()
220 // generate toc first
221 head_md_link_base := if is_module_readme(d.head) {
222 readme_file_path := vd.get_repo_file_path_for_links(d.head.file_path)
223 get_src_dir_link(vd.manifest.repo_url, vd.manifest.repo_branch, readme_file_path)
224 } else {
225 ''
226 }
227 contents.writeln(vd.doc_node_html(d.head, '', head_md_link_base, true, cfg.include_examples,
228 d.table))
229 if is_module_readme(d.head) {
230 write_toc(d.head, mut symbols_toc)
231 }
232 for cn in dcs_contents {
233 vd.write_content(&cn, &d, mut contents)
234 write_toc(cn, mut symbols_toc) // write head
235 }
236 if cfg.html_only_contents {
237 // no need for theming, styling etc, useful for testing and for external documentation generators
238 return contents.str()
239 }
240
241 // write css
242 header_name := if cfg.is_multi && vd.docs.len > 1 {
243 os.file_name(os.real_path(cfg.input_path))
244 } else {
245 d.head.name
246 }
247 modules_toc_str := if cfg.is_multi || vd.docs.len > 1 {
248 vd.gen_modules_toc(d.head.name)
249 } else {
250 ''
251 }
252 symbols_toc_str := symbols_toc.str()
253 mut result := os.read_file(os.join_path(cfg.theme_dir, 'index.html')) or { panic(err) }
254 if cfg.html_no_vhash {
255 result = result.replace('{{ version }}', 'latest')
256 } else {
257 mut version := if vd.manifest.version.len != 0 { vd.manifest.version } else { '' }
258 version = [version, @VCURRENTHASH].join(' ')
259 result = result.replace('{{ version }}', version)
260 }
261 result = result.replace('{{ title }}', d.head.name)
262 result = result.replace('{{ head_name }}', header_name)
263 result = result.replace('{{ light_icon }}', vd.assets['light_icon'])
264 result = result.replace('{{ dark_icon }}', vd.assets['dark_icon'])
265 result = result.replace('{{ menu_icon }}', vd.assets['menu_icon'])
266 if cfg.html_no_assets {
267 result = result.replace('{{ head_assets }}', '')
268 } else {
269 result = result.replace('{{ head_assets }}', if cfg.inline_assets {
270 '<style>${vd.assets['doc_css']}</style>
271${tabs(2)}<style>${vd.assets['normalize_css']}</style>
272${tabs(2)}<script>${vd.assets['dark_mode_js']}</script>'
273 } else {
274 '<link rel="stylesheet" href="${vd.assets['doc_css']}" />
275${tabs(2)}<link rel="stylesheet" href="${vd.assets['normalize_css']}" />
276${tabs(2)}<script src="${vd.assets['dark_mode_js']}"></script>'
277 })
278 }
279 if cfg.html_no_toc_urls {
280 result = result.replace('{{ toc_links }}', '')
281 } else {
282 result = result.replace('{{ toc_links }}', if cfg.is_multi || vd.docs.len > 1 {
283 modules_toc_str
284 } else {
285 symbols_toc_str
286 })
287 }
288 result = result.replace('{{ contents }}', contents.str())
289 if cfg.html_no_right {
290 result = result.replace('{{ right_content }}', '')
291 } else {
292 result = result.replace('{{ right_content }}', if cfg.is_multi && d.head.name != 'README' {
293 '<div class="doc-toc"><ul>${symbols_toc_str}</ul></div>'
294 } else {
295 ''
296 })
297 }
298 if cfg.html_no_footer {
299 result = result.replace('{{ footer_content }}', '')
300 } else {
301 result = result.replace('{{ footer_content }}', gen_footer_text(d, !cfg.no_timestamp))
302 }
303 if cfg.html_no_assets {
304 result = result.replace('{{ footer_assets }}', '')
305 } else {
306 result = result.replace('{{ footer_assets }}', if cfg.inline_assets {
307 '<script>${vd.assets['doc_js']}</script>'
308 } else {
309 '<script src="${vd.assets['doc_js']}"></script>'
310 })
311 }
312 return result
313}
314
315fn (vd &VDoc) gen_modules_toc(active_doc string) string {
316 mut modules_toc := strings.new_builder(200)
317 mut used_submod_prefixes := map[string]bool{}
318 doc_names := vd.docs.map(it.head.name)
319 for dc in vd.docs {
320 mut submod_prefix := dc.head.name.all_before('.')
321 if index := dc.head.frontmatter['index'] {
322 if dc.head.name == 'index' {
323 submod_prefix = index
324 }
325 }
326 if used_submod_prefixes[submod_prefix] {
327 continue
328 }
329 used_submod_prefixes[submod_prefix] = true
330 mut href_name := ''
331 if dc.head.name in ['README', 'index'] {
332 href_name = './index.html'
333 } else if submod_prefix in doc_names {
334 href_name = './${submod_prefix}.html'
335 }
336 submodules := vd.docs.filter(it.head.name.starts_with(submod_prefix + '.'))
337 dropdown := if submodules.len > 0 { vd.assets['arrow_icon'] } else { '' }
338 active_class := if dc.head.name == active_doc { ' active' } else { '' }
339 menu_item := if href_name != '' {
340 '<a href="${href_name}">${submod_prefix}</a>'
341 } else {
342 '<a>${submod_prefix}</a>'
343 }
344 modules_toc.write_string('<li class="open${active_class}">\n<div class="menu-row">${dropdown}${menu_item}</div>\n')
345 for j, cdoc in submodules {
346 if j == 0 {
347 modules_toc.write_string('<ul>\n')
348 }
349 submod_name := cdoc.head.name.all_after(submod_prefix + '.')
350 sub_selected_classes := if cdoc.head.name == active_doc {
351 ' class="active"'
352 } else {
353 ''
354 }
355 modules_toc.write_string('<li${sub_selected_classes}><a href="./${cdoc.head.name}.html">${submod_name}</a></li>\n')
356 if j == submodules.len - 1 {
357 modules_toc.write_string('</ul>\n')
358 }
359 }
360 modules_toc.write_string('</li>\n')
361 }
362 return modules_toc.str()
363}
364
365fn get_repo_file_link(repo_url string, repo_branch string, file_name string) string {
366 mut url := urllib.parse(repo_url) or { return '' }
367 if url.path.len <= 1 || file_name == '' {
368 return ''
369 }
370 url.path = url.path.trim_right('/') + match url.host {
371 'github.com' { '/blob/${repo_branch}/${file_name}' }
372 'gitlab.com' { '/-/blob/${repo_branch}/${file_name}' }
373 'git.sir.ht' { '/tree/${repo_branch}/${file_name}' }
374 else { '' }
375 }
376
377 if url.path == '/' {
378 return ''
379 }
380 return url.str()
381}
382
383fn get_src_dir_link(repo_url string, repo_branch string, file_name string) string {
384 file_url := get_repo_file_link(repo_url, repo_branch, file_name)
385 if file_url == '' {
386 return ''
387 }
388 mut parsed_file_url := urllib.parse(file_url) or { return '' }
389 mut dir_path := parsed_file_url.path.all_before_last('/')
390 if dir_path == '' {
391 dir_path = '/'
392 }
393 parsed_file_url.path = if dir_path == '/' { '/' } else { dir_path + '/' }
394 parsed_file_url.raw_query = ''
395 parsed_file_url.fragment = ''
396 return parsed_file_url.str()
397}
398
399fn get_src_link(repo_url string, repo_branch string, file_name string, line_nr int) string {
400 file_url := get_repo_file_link(repo_url, repo_branch, file_name)
401 if file_url == '' {
402 return ''
403 }
404 mut parsed_file_url := urllib.parse(file_url) or { return '' }
405 parsed_file_url.fragment = 'L${line_nr}'
406 return parsed_file_url.str()
407}
408
409fn normalize_url_path(path string) string {
410 if path == '' {
411 return ''
412 }
413 is_absolute := path.starts_with('/')
414 mut parts := []string{}
415 for part in path.split('/') {
416 match part {
417 '', '.' {}
418 '..' {
419 if parts.len > 0 {
420 parts.delete_last()
421 }
422 }
423 else {
424 parts << part
425 }
426 }
427 }
428 mut normalized := parts.join('/')
429 if is_absolute {
430 normalized = '/' + normalized
431 }
432 return if normalized == '' && is_absolute { '/' } else { normalized }
433}
434
435fn is_relative_markdown_link(link string) bool {
436 value := link.trim_space()
437 if value == '' || value.starts_with('#') || value.starts_with('//') {
438 return false
439 }
440 if value.starts_with('/') {
441 return false
442 }
443 if url := urllib.parse(value) {
444 return url.scheme == '' && url.host == ''
445 }
446 return true
447}
448
449fn resolve_relative_markdown_link(base_url string, link string) string {
450 if base_url == '' || !is_relative_markdown_link(link) {
451 return link
452 }
453 mut parsed_base := urllib.parse(base_url) or { return link }
454 mut relative_path := link
455 mut fragment := ''
456 if hash_idx := relative_path.index('#') {
457 fragment = relative_path[hash_idx + 1..]
458 relative_path = relative_path[..hash_idx]
459 }
460 mut query := ''
461 if query_idx := relative_path.index('?') {
462 query = relative_path[query_idx + 1..]
463 relative_path = relative_path[..query_idx]
464 }
465 base_path := if parsed_base.path.ends_with('/') {
466 parsed_base.path
467 } else {
468 parsed_base.path.all_before_last('/') + '/'
469 }
470 parsed_base.path = normalize_url_path(base_path + relative_path)
471 parsed_base.raw_query = query
472 parsed_base.fragment = fragment
473 return parsed_base.str()
474}
475
476fn write_token(tok token.Token, typ HighlightTokenTyp, mut buf strings.Builder) {
477 mut token_content := ''
478 match typ {
479 .unone, .operator, .punctuation {
480 token_content = tok.kind.str()
481 }
482 .string_interp {
483 // tok.kind.str() for this returns $2 instead of $
484 token_content = '$'
485 }
486 .opening_string {
487 token_content = "'${tok.lit}"
488 }
489 .closing_string {
490 // A string as the next token of the expression
491 // inside the string interpolation indicates that
492 // this is the closing of string interpolation
493 token_content = "${tok.lit}'"
494 }
495 .string {
496 token_content = "'${tok.lit}'"
497 }
498 .char {
499 token_content = '`${tok.lit}`'
500 }
501 .comment {
502 if tok.lit != '' && tok.lit[0] == 1 {
503 token_content = '//${tok.lit[1..]}'
504 } else {
505 token_content = '//${tok.lit}'
506 }
507 }
508 else {
509 token_content = tok.lit
510 }
511 }
512
513 buf.write_string(html.escape(token_content))
514}
515
516fn html_highlight(code string, tb &ast.Table) string {
517 mut s := scanner.new_scanner(code, .parse_comments, &pref.Preferences{
518 output_mode: .silent
519 })
520 mut tok := s.scan()
521 mut prev_tok := tok
522 mut next_tok := s.scan()
523 mut buf := strings.new_builder(200)
524 mut i := 0
525 mut inside_string_interp := false
526 for i < code.len {
527 if i != tok.pos {
528 // All characters not detected by the scanner
529 // (mostly whitespaces) go here.
530 ch := code[i]
531 if ch == `<` {
532 buf.write_string('<')
533 } else if ch == `>` {
534 buf.write_string('>')
535 } else if ch == `&` {
536 buf.write_string('&')
537 } else {
538 buf.write_u8(ch)
539 }
540 i++
541 continue
542 }
543
544 mut tok_typ := HighlightTokenTyp.unone
545 match tok.kind {
546 .name {
547 if tok.lit in highlight_builtin_types || tb.known_type(tok.lit) {
548 tok_typ = .builtin
549 } else if next_tok.kind == .lcbr {
550 tok_typ = .symbol
551 } else if next_tok.kind == .lpar || (!tok.lit[0].is_capital()
552 && next_tok.kind == .lt && next_tok.pos == tok.pos + tok.lit.len) {
553 tok_typ = .function
554 } else {
555 tok_typ = .name
556 }
557 }
558 .comment {
559 tok_typ = .comment
560 }
561 .chartoken {
562 tok_typ = .char
563 }
564 .str_dollar {
565 tok_typ = .string_interp
566 inside_string_interp = true
567 }
568 .string {
569 if inside_string_interp {
570 if next_tok.kind == .str_dollar {
571 // the " hello " in "${a} hello ${b} world"
572 tok_typ = .partial_string
573 } else {
574 // the " world" in "${a} hello ${b} world"
575 tok_typ = .closing_string
576 }
577
578 // NOTE: Do not switch inside_string_interp yet!
579 // It will be handy later when we do some special
580 // handling in generating code (see code below)
581 } else if next_tok.kind == .str_dollar {
582 tok_typ = .opening_string
583 } else {
584 tok_typ = .string
585 }
586 }
587 .number {
588 tok_typ = .number
589 }
590 .key_true, .key_false {
591 tok_typ = .boolean
592 }
593 .lpar, .lcbr, .rpar, .rcbr, .lsbr, .rsbr, .semicolon, .colon, .comma, .dot, .dotdot,
594 .ellipsis {
595 tok_typ = .punctuation
596 }
597 else {
598 if token.is_key(tok.lit) || token.is_decl(tok.kind) {
599 tok_typ = .keyword
600 } else if tok.kind.is_assign() || tok.is_unary() || tok.kind.is_relational()
601 || tok.kind.is_infix() || tok.kind.is_postfix() {
602 tok_typ = .operator
603 }
604 }
605 }
606
607 if tok_typ in [.unone, .name] {
608 write_token(tok, tok_typ, mut buf)
609 } else {
610 // Special handling for "complex" string literals
611 if tok_typ in [.partial_string, .closing_string] && inside_string_interp {
612 // rcbr is not rendered when the string on the right
613 // side of the expr/string interpolation is not empty.
614 // e.g. "${a}.${b}${c}"
615 // expectation: "${a}.${b}${c}"
616 // reality: "${a.${b}${c}"
617 if tok.lit.len != 0 {
618 write_token(token.Token{ kind: .rcbr }, .unone, mut buf)
619 }
620
621 inside_string_interp = false
622 }
623
624 // Properly treat and highlight the "string"-related types
625 // as if they are "string" type.
626 final_tok_typ := match tok_typ {
627 .opening_string, .partial_string, .closing_string { HighlightTokenTyp.string }
628 else { tok_typ }
629 }
630
631 buf.write_string('<span class="token ${final_tok_typ}">')
632 if tok_typ == .string {
633 // Make sure to escape html in strings. Otherwise it will be rendered in the
634 // html documentation outputs / its style rules will affect the readme.
635 buf.write_string("'${html.escape(tok.lit.str())}'")
636 } else {
637 if final_tok_typ == .string && prev_tok.lit == 'return' {
638 buf.write_string(' ')
639 }
640 write_token(tok, tok_typ, mut buf)
641 }
642 buf.write_string('</span>')
643 }
644
645 if next_tok.kind == .eof {
646 break
647 }
648
649 i = tok.pos + tok.len
650
651 // This is to avoid issues that skips any "unused" tokens
652 // For example: Call expr with complex string literals as arg
653 if i - 1 == next_tok.pos {
654 i--
655 }
656 prev_tok = tok
657 tok = next_tok
658 next_tok = s.scan()
659 }
660 return buf.str()
661}
662
663fn (vd &VDoc) doc_node_html(dn doc.DocNode, link string, md_link_base string, head bool, include_examples bool, tb &ast.Table) string {
664 mut dnw := strings.new_builder(200)
665 head_tag := if head { 'h1' } else { 'h2' }
666 mut renderer := markdown.HtmlRenderer{
667 transformer: &MdHtmlCodeHighlighter{
668 table: tb
669 relative_link_base: md_link_base
670 }
671 }
672 only_comments_text := dn.merge_comments_without_examples()
673 md_content := markdown.render(prepare_markdown_for_html(only_comments_text), mut renderer) or {
674 ''
675 }
676 highlighted_code := html_highlight(dn.content, tb)
677 node_class := if dn.kind == .const_group { ' const' } else { '' }
678 sym_name := get_sym_name(dn)
679 mut deprecated_tags := dn.tags.filter(it.starts_with('deprecated'))
680 if doc.should_sort {
681 deprecated_tags.sort()
682 }
683 mut tags := dn.tags.filter(!it.starts_with('deprecated'))
684 if doc.should_sort {
685 tags.sort()
686 }
687 mut node_id := get_node_id(dn)
688 mut hash_link := if !head { ' <a href="#${node_id}">#</a>' } else { '' }
689 if head && is_module_readme(dn) {
690 node_id = 'readme_${node_id}'
691 hash_link = ' <a href="#${node_id}">#</a>'
692 }
693 dnw.writeln('${tabs(2)}<section id="${node_id}" class="doc-node${node_class}">')
694 if dn.name != '' {
695 if dn.kind == .const_group {
696 dnw.write_string('${tabs(3)}<div class="title"><${head_tag}>${sym_name}${hash_link}</${head_tag}>')
697 } else {
698 dnw.write_string('${tabs(3)}<div class="title"><${head_tag}>${dn.kind} ${sym_name}${hash_link}</${head_tag}>')
699 }
700 if link != '' {
701 dnw.write_string('<a class="link" rel="noreferrer" target="_blank" href="${link}">${link_svg}</a>')
702 }
703 dnw.write_string('</div>\n')
704 }
705 if deprecated_tags.len > 0 {
706 attributes :=
707 deprecated_tags.map('<div class="attribute attribute-deprecated">${it.replace_each(quote_escape_seq)}</div>\n').join('')
708 dnw.writeln('<div class="attributes">${attributes}</div>\n')
709 }
710 if tags.len > 0 {
711 attributes := tags.map('<div class="attribute">${it}</div>').join('')
712 dnw.writeln('<div class="attributes">${attributes}</div>')
713 }
714 if !head && dn.content.len > 0 {
715 dnw.writeln('<pre class="signature">\n<code>${highlighted_code}</code></pre>')
716 }
717 // do not mess with md_content further, its formatting is important, just output it 1:1 !
718 dnw.writeln('${md_content}\n')
719 // Write examples if any found
720 examples := dn.examples()
721 if include_examples && examples.len > 0 {
722 example_title := if examples.len > 1 { 'Examples' } else { 'Example' }
723 dnw.writeln('<section class="doc-node examples"><h4>${example_title}</h4>')
724 for example in examples {
725 hl_example := html_highlight(example, tb)
726 dnw.writeln('<pre>\n<code class="language-v">${hl_example}</code></pre>')
727 }
728 dnw.writeln('</section>')
729 }
730 dnw.writeln('</section>')
731 dnw_str := dnw.str()
732 return dnw_str
733}
734
735fn prepare_markdown_for_html(text string) string {
736 if !text.contains('\n') {
737 return text
738 }
739 lines := text.split_into_lines()
740 mut prepared := []string{cap: lines.len}
741 mut is_codeblock := false
742 mut prev_line := ''
743 for i, line in lines {
744 trimmed := line.trim_space()
745 if trimmed.starts_with('```') {
746 prepared << line
747 is_codeblock = !is_codeblock
748 prev_line = line
749 continue
750 }
751 if is_codeblock {
752 prepared << line
753 prev_line = line
754 continue
755 }
756 if line_continues_previous_block(prev_line, line) && prepared.len > 0 {
757 prepared[prepared.len - 1] += ' ' + trimmed
758 prev_line = line
759 continue
760 }
761 next_line := if i + 1 < lines.len { lines[i + 1] } else { '' }
762 if blockquote_line_needs_hard_break(line, next_line) {
763 prepared << line + ' '
764 } else {
765 prepared << line
766 }
767 prev_line = line
768 }
769 return prepared.join('\n')
770}
771
772fn line_continues_previous_block(prev_line string, line string) bool {
773 prev_trimmed := prev_line.trim_space()
774 trimmed := line.trim_space()
775 if prev_trimmed == '' || trimmed == '' {
776 return false
777 }
778 if prev_trimmed.starts_with('>') || trimmed.starts_with('>') {
779 return false
780 }
781 if prev_trimmed.starts_with('```') || trimmed.starts_with('```') {
782 return false
783 }
784 if markdown_line_starts_new_block(line) {
785 return false
786 }
787 if prev_trimmed.starts_with('#') || prev_trimmed.starts_with('|')
788 || markdown_line_is_horizontal_rule(prev_trimmed) {
789 return false
790 }
791 return true
792}
793
794fn markdown_line_starts_new_block(line string) bool {
795 trimmed := line.trim_space()
796 if trimmed == '' {
797 return false
798 }
799 if trimmed.starts_with('#') || trimmed.starts_with('>') || trimmed.starts_with('|')
800 || trimmed.starts_with('```') || markdown_line_is_horizontal_rule(trimmed) {
801 return true
802 }
803 if markdown_indent_width(line) >= 4 {
804 return true
805 }
806 return markdown_line_is_list_item(trimmed)
807}
808
809fn markdown_line_is_list_item(line string) bool {
810 if line.len > 1 && line[1] == ` ` && line[0] in [`-`, `*`, `+`] {
811 return true
812 }
813 return line.len > 2 && line[2] == ` ` && line[1] == `.` && line[0].is_digit()
814}
815
816fn markdown_line_is_horizontal_rule(line string) bool {
817 line_no_spaces := line.replace(' ', '')
818 if line_no_spaces.len < 3 {
819 return false
820 }
821 for ch in ['-', '=', '*', '_', '~'] {
822 if line_no_spaces.starts_with(ch.repeat(3))
823 && line_no_spaces.count(ch) == line_no_spaces.len {
824 return true
825 }
826 }
827 return false
828}
829
830fn markdown_indent_width(line string) int {
831 mut width := 0
832 for ch in line {
833 if ch == ` ` {
834 width++
835 continue
836 }
837 if ch == `\t` {
838 width += 4
839 continue
840 }
841 break
842 }
843 return width
844}
845
846fn blockquote_line_needs_hard_break(line string, next_line string) bool {
847 if line.ends_with(' ') {
848 return false
849 }
850 payload := blockquote_payload(line) or { return false }
851 if payload == '' {
852 return false
853 }
854 next_payload := blockquote_payload(next_line) or { return false }
855 if next_payload == '' {
856 return false
857 }
858 return !blockquote_payload_starts_new_block(next_payload)
859}
860
861fn blockquote_payload(line string) ?string {
862 trimmed := line.trim_space()
863 if !trimmed.starts_with('>') {
864 return none
865 }
866 return trimmed[1..].trim_space()
867}
868
869fn blockquote_payload_starts_new_block(payload string) bool {
870 if payload == '' || payload.starts_with('```') || payload.starts_with('>')
871 || payload.starts_with('|') {
872 return true
873 }
874 if payload.len > 1 && payload[1] == ` ` && payload[0] in [`-`, `*`, `+`] {
875 return true
876 }
877 if payload.len > 2 && payload[2] == ` ` && payload[1] == `.` && payload[0].is_digit() {
878 return true
879 }
880 if !payload.starts_with('#') {
881 return false
882 }
883 line_before_spaces := payload.before(' ')
884 return line_before_spaces.count('#') == line_before_spaces.len
885}
886
887fn write_toc(dn doc.DocNode, mut toc strings.Builder) {
888 mut toc_slug := if dn.name == '' || dn.content.len == 0 { '' } else { slug(dn.name) }
889 if toc_slug == '' && dn.children.len > 0 {
890 if dn.children[0].name == '' {
891 toc_slug = slug(dn.name)
892 } else {
893 toc_slug = slug(dn.name + '.' + dn.children[0].name)
894 }
895 }
896 if is_module_readme(dn) {
897 if dn.comments.len == 0 || (dn.comments.len > 0 && dn.comments[0].text.len == 0) {
898 return
899 }
900 toc.write_string('<li class="open"><a href="#readme_${toc_slug}">README</a>')
901 } else if dn.name != 'Constants' {
902 toc.write_string('<li class="open"><a href="#${toc_slug}">${dn.kind} ${dn.name}</a>')
903 toc.writeln(' <ul>')
904 for child in dn.children {
905 cname := dn.name + '.' + child.name
906 toc.writeln('<li><a href="#${slug(cname)}">${child.kind} ${child.name}</a></li>')
907 }
908 toc.writeln('</ul>')
909 } else {
910 toc.write_string('<li class="open"><a href="#${toc_slug}">${dn.name}</a>')
911 }
912 toc.writeln('</li>')
913}
914
915struct MdHtmlCodeHighlighter {
916mut:
917 language string
918 table &ast.Table
919 relative_link_base string
920}
921
922fn (f &MdHtmlCodeHighlighter) transform_attribute(p markdown.ParentType, name string, value string) string {
923 mut transformed := value
924 if p is markdown.MD_SPANTYPE && p in [.md_span_a, .md_span_img] && name in ['href', 'src'] {
925 transformed = resolve_relative_markdown_link(f.relative_link_base, value)
926 }
927 return markdown.default_html_transformer.transform_attribute(p, name, transformed)
928}
929
930fn (f &MdHtmlCodeHighlighter) transform_content(parent markdown.ParentType, text string) string {
931 if parent is markdown.MD_BLOCKTYPE && parent == .md_block_code {
932 if f.language == '' {
933 return html.escape(text)
934 }
935 output := html_highlight(text, f.table)
936 // Reset the language, so that it will not persist between blocks,
937 // and will not be accidentally re-used for the next block, that may be lacking ```language :
938 unsafe {
939 f.language = ''
940 }
941 return output
942 }
943 return text
944}
945
946fn (mut f MdHtmlCodeHighlighter) config_set(key string, val string) {
947 if key == 'code_language' {
948 f.language = val
949 }
950}
951