510 lines
19 KiB
Plaintext
510 lines
19 KiB
Plaintext
# SPDX-FileCopyrightText: 2026 William Bell
|
|
#
|
|
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
#
|
|
# A basic Markdown -> HTML converter. Handles block-level structure (ATX
|
|
# headings, setext headings, paragraphs, blockquotes, fenced code blocks,
|
|
# ordered/unordered lists, horizontal rules) and inline formatting (bold,
|
|
# italic, strikethrough, inline code, links w/ titles, images, autolinks,
|
|
# backslash escapes, hard line breaks). Not full CommonMark: no tables,
|
|
# no reference-style links, no footnotes.
|
|
#
|
|
# Regex API used (per the project's actual regex.ar stdlib module):
|
|
# re.match(subject) -> bool, whether the pattern matches anywhere
|
|
# re.group(subject, index) -> capture group text for the first match
|
|
# (index 0 = whole match), independent of
|
|
# any prior .match() call
|
|
# re.find_all(subject) -> list of matched substrings (no positions)
|
|
# re.replace_all(subj, repl) -> whole-pattern substitution
|
|
#
|
|
# Since there's no way to get a match's start/end offset, inline scanning
|
|
# is done manually (character-by-character) rather than by racing multiple
|
|
# patterns against each other; regex is used only for line-level block
|
|
# classification and for splitting out capture groups (heading level/text,
|
|
# link title, etc.) once we already know a construct starts at a given spot.
|
|
|
|
import "maths" as maths
|
|
import "regex" as regex
|
|
|
|
let escape_html(s) = do
|
|
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
|
|
|
let sanitize_url(url) = do
|
|
let u = url.strip(" ")
|
|
|
|
# Reject dangerous schemes.
|
|
let lower = u.lower()
|
|
|
|
if (lower.index_of("javascript:") == 0) return ""
|
|
if (lower.index_of("vbscript:") == 0) return ""
|
|
if (lower.index_of("data:") == 0) return ""
|
|
|
|
return escape_html(u)
|
|
|
|
let __is_digit__(c) = c >= "0" && c <= "9"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Compiled patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
let __HEADING_RE__ = regex.Regex.intern("^(#{1,6})(?:\\s+(.*))?$")
|
|
let __HR_RE__ = regex.Regex.intern("^(?:\\*[ \\t]*){3,}$|^(?:-[ \\t]*){3,}$|^(?:_[ \\t]*){3,}$")
|
|
let __FENCE_RE__ = regex.Regex.intern("^(```|~~~)(.*)$")
|
|
let __BLOCKQUOTE_RE__ = regex.Regex.intern("^>[ \\t]?(.*)$")
|
|
let __UL_RE__ = regex.Regex.intern("^( *)[-*+] (.*)$")
|
|
let __OL_RE__ = regex.Regex.intern("^( *)(\\d+)[.)] (.*)$")
|
|
let __SETEXT_EQ_RE__ = regex.Regex.intern("^=+[ \\t]*$")
|
|
let __SETEXT_DASH_RE__ = regex.Regex.intern("^-+[ \\t]*$")
|
|
let __TABLE_DELIM_RE__ = regex.Regex.intern("^\\|?\\s*:?-+:?\\s*(\\|\\s*:?-+:?\\s*)*\\|?$")
|
|
|
|
# Used only after we've already located a "url title" chunk between the
|
|
# parens of a link/image, to split off an optional quoted title.
|
|
let __DEST_TITLE_RE__ = regex.Regex.intern("^(.*?)\\s+[\"']([^\"']*)[\"']$")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inline scanning helpers (manual, character-by-character)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Finds the index of the next occurrence of `needle` in `s` starting at
|
|
# `from_idx`, or -1 if absent.
|
|
let __find_from__(s, needle, from_idx) = do
|
|
if (from_idx >= s.length) return -1
|
|
let rest = s[from_idx:s.length]
|
|
let pos = rest.index_of(needle)
|
|
if (pos < 0) return -1
|
|
return pos + from_idx
|
|
|
|
# Splits a markdown link/image destination like `url "title"` or
|
|
# `url 'title'` into its url and title parts, using the regex group API
|
|
# now that we have a concrete substring to test rather than needing
|
|
# position info.
|
|
let __split_dest_title__(dest) = do
|
|
let trimmed = dest.strip(" ")
|
|
if (__DEST_TITLE_RE__.match(trimmed)) do
|
|
let url = __DEST_TITLE_RE__.group(trimmed, 1)
|
|
let title = __DEST_TITLE_RE__.group(trimmed, 2)
|
|
return { url: url.strip(" "), title: title }
|
|
return { url: trimmed, title: "" }
|
|
|
|
# Renders inline markdown within a single logical line (already joined from
|
|
# a paragraph or heading text). Processes left-to-right, emitting HTML as
|
|
# it goes.
|
|
let render_inline(text) = do
|
|
let out = ""
|
|
let i = 0
|
|
let n = text.length
|
|
|
|
while (i < n) do
|
|
let c = text[i]
|
|
|
|
# Backslash escape: \X emits X literally (HTML-escaped).
|
|
if (c == "\\" && i + 1 < n) do
|
|
let escaped = text[i + 1]
|
|
out = out + escape_html(escaped)
|
|
i = i + 2
|
|
|
|
# Strikethrough: ~~...~~
|
|
else if (c == "~" && i + 1 < n && text[i + 1] == "~") do
|
|
let close = __find_from__(text, "~~", i + 2)
|
|
if (close < 0) do
|
|
out = out + escape_html(c)
|
|
i = i + 1
|
|
else do
|
|
let inner = text[i + 2:close]
|
|
out = out + "<del>" + render_inline(inner) + "</del>"
|
|
i = close + 2
|
|
|
|
# Inline code span: `...`
|
|
else if (c == "`") do
|
|
let close = __find_from__(text, "`", i + 1)
|
|
if (close < 0) do
|
|
out = out + escape_html(c)
|
|
i = i + 1
|
|
else do
|
|
let code_content = text[i + 1:close]
|
|
out = out + "<code>" + escape_html(code_content) + "</code>"
|
|
i = close + 1
|
|
|
|
# Image: 
|
|
else if (c == "!" && i + 1 < n && text[i + 1] == "[") do
|
|
let bracket_close = __find_from__(text, "]", i + 2)
|
|
if (bracket_close < 0 || bracket_close + 1 >= n || text[bracket_close + 1] != "(") do
|
|
out = out + c
|
|
i = i + 1
|
|
else do
|
|
let paren_close = __find_from__(text, ")", bracket_close + 2)
|
|
if (paren_close < 0) do
|
|
out = out + c
|
|
i = i + 1
|
|
else do
|
|
let alt = text[i + 2:bracket_close]
|
|
let dest = __split_dest_title__(text[bracket_close + 2:paren_close])
|
|
let title_attr = do
|
|
if (dest.title != "") return " title=\"" + escape_html(dest.title) + "\""
|
|
return ""
|
|
out = out + "<img src=\"" + sanitize_url(dest.url) + "\" alt=\"" + escape_html(alt) + "\"" + title_attr + ">"
|
|
i = paren_close + 1
|
|
|
|
# Link: [text](href "title")
|
|
else if (c == "[") do
|
|
let bracket_close = __find_from__(text, "]", i + 1)
|
|
if (bracket_close < 0 || bracket_close + 1 >= n || text[bracket_close + 1] != "(") do
|
|
out = out + escape_html(c)
|
|
i = i + 1
|
|
else do
|
|
let paren_close = __find_from__(text, ")", bracket_close + 2)
|
|
if (paren_close < 0) do
|
|
out = out + escape_html(c)
|
|
i = i + 1
|
|
else do
|
|
let label = text[i + 1:bracket_close]
|
|
let dest = __split_dest_title__(text[bracket_close + 2:paren_close])
|
|
let title_attr = do
|
|
if (dest.title != "") return " title=\"" + escape_html(dest.title) + "\""
|
|
return ""
|
|
out = out + "<a href=\"" + sanitize_url(dest.url) + "\"" + title_attr + ">" + render_inline(label) + "</a>"
|
|
i = paren_close + 1
|
|
|
|
# Autolink: <https://...>
|
|
else if (c == "<") do
|
|
let close = __find_from__(text, ">", i + 1)
|
|
let looks_like_url = false
|
|
if (close > i) do
|
|
let inner = text[i + 1:close]
|
|
if (inner.index_of("http://") == 0 || inner.index_of("https://") == 0) looks_like_url = true
|
|
if (looks_like_url) do
|
|
let href = text[i + 1:close]
|
|
out = out + "<a href=\"" + sanitize_url(href) + "\">" + escape_html(href) + "</a>"
|
|
i = close + 1
|
|
else do
|
|
out = out + "<"
|
|
i = i + 1
|
|
|
|
# Bold: **...** or __...__
|
|
else if ((c == "*" || c == "_") && i + 1 < n && text[i + 1] == c) do
|
|
let marker = c + c
|
|
let close = __find_from__(text, marker, i + 2)
|
|
if (close < 0) do
|
|
out = out + escape_html(c)
|
|
i = i + 1
|
|
else do
|
|
let inner = text[i + 2:close]
|
|
out = out + "<strong>" + render_inline(inner) + "</strong>"
|
|
i = close + 2
|
|
|
|
# Italic: *...* or _..._
|
|
else if (c == "*" || c == "_") do
|
|
let close = __find_from__(text, c, i + 1)
|
|
if (close < 0 || close == i + 1) do
|
|
out = out + escape_html(c)
|
|
i = i + 1
|
|
else do
|
|
let inner = text[i + 1:close]
|
|
out = out + "<em>" + render_inline(inner) + "</em>"
|
|
i = close + 1
|
|
|
|
else if (c == "&") do
|
|
out = out + "&"
|
|
i = i + 1
|
|
else if (c == "<") do
|
|
out = out + "<"
|
|
i = i + 1
|
|
else if (c == ">") do
|
|
out = out + ">"
|
|
i = i + 1
|
|
|
|
else do
|
|
out = out + c
|
|
i = i + 1
|
|
|
|
return out
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Block-level line classifiers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
let __heading_level__(line) = do
|
|
if (!__HEADING_RE__.match(line)) return 0
|
|
return __HEADING_RE__.group(line, 1).length
|
|
|
|
let __heading_text__(line) = do
|
|
if (!__HEADING_RE__.match(line)) return ""
|
|
let text = __HEADING_RE__.group(line, 2)
|
|
if (text == null) return ""
|
|
return text.strip(" ")
|
|
|
|
let __is_hr__(line) = __HR_RE__.match(line)
|
|
|
|
let __fence_info__(line) = do
|
|
if (!__FENCE_RE__.match(line)) return { is_fence: false, marker: "", lang: "" }
|
|
let marker = __FENCE_RE__.group(line, 1)
|
|
let lang = __FENCE_RE__.group(line, 2)
|
|
return { is_fence: true, marker: marker, lang: lang.strip(" ") }
|
|
|
|
let __blockquote_content__(line) = do
|
|
if (!__BLOCKQUOTE_RE__.match(line)) return null
|
|
return __BLOCKQUOTE_RE__.group(line, 1)
|
|
|
|
let __ul_item__(line) = do
|
|
if (!__UL_RE__.match(line)) return null
|
|
let indent = __UL_RE__.group(line, 1)
|
|
let content = __UL_RE__.group(line, 2)
|
|
return { indent: indent.length, content: content }
|
|
|
|
let __ol_item__(line) = do
|
|
if (!__OL_RE__.match(line)) return null
|
|
let indent = __OL_RE__.group(line, 1)
|
|
let content = __OL_RE__.group(line, 3)
|
|
return { indent: indent.length, content: content }
|
|
|
|
let __setext_level__(line) = do
|
|
if (__SETEXT_EQ_RE__.match(line)) return 1
|
|
if (__SETEXT_DASH_RE__.match(line)) return 2
|
|
return 0
|
|
|
|
let __is_table_delim__(line) = __TABLE_DELIM_RE__.match(line.strip(" "))
|
|
|
|
# Splits a pipe-delimited table row into trimmed cell strings, dropping a
|
|
# leading/trailing empty cell caused by optional outer pipes.
|
|
let __split_table_row__(line) = do
|
|
let trimmed = line.strip(" ")
|
|
let cells = trimmed.split("|")
|
|
let out = []
|
|
let n = cells.length
|
|
let i = 0
|
|
while (i < n) do
|
|
let is_first = (i == 0)
|
|
let is_last = (i == n - 1)
|
|
let cell = cells[i]
|
|
let is_edge_empty = ((is_first || is_last) && cell.strip(" ") == "" && n > 1)
|
|
if (!is_edge_empty) out.append(cell.strip(" "))
|
|
i = i + 1
|
|
return out
|
|
|
|
# Parses a delimiter row into a list of alignments: "left", "center",
|
|
# "right", or null (no explicit alignment) per column.
|
|
let __parse_table_alignments__(delim_line) = do
|
|
let cells = __split_table_row__(delim_line)
|
|
let out = []
|
|
for (cell in cells) do
|
|
let c = cell.strip(" ")
|
|
let starts_colon = (c.length > 0 && c[0] == ":")
|
|
let ends_colon = (c.length > 0 && c[c.length - 1] == ":")
|
|
if (starts_colon && ends_colon) out.append("center")
|
|
else if (ends_colon) out.append("right")
|
|
else if (starts_colon) out.append("left")
|
|
else out.append(null)
|
|
return out
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
let markdown_to_html(source) = do
|
|
let normalized = source.replace("\r\n", "\n").replace("\r", "\n")
|
|
let lines = normalized.split("\n")
|
|
let html_parts = []
|
|
|
|
let in_code_block = false
|
|
let code_fence_marker = ""
|
|
let code_lang = ""
|
|
let code_lines = []
|
|
|
|
let paragraph_lines = []
|
|
let flush_paragraph = () = do
|
|
if (paragraph_lines.length > 0) do
|
|
let joined = paragraph_lines.join(" ")
|
|
let rendered = render_inline(joined)
|
|
let with_breaks = rendered.replace("\u0001BR\u0001", "<br>")
|
|
html_parts.append("<p>" + with_breaks + "</p>")
|
|
paragraph_lines = []
|
|
|
|
let list_stack = [] # each entry: {type: "ul"|"ol", indent: <raw column>}
|
|
let close_lists_deeper_than = (indent) = do
|
|
while (list_stack.length > 0 && list_stack[list_stack.length - 1].indent > indent) do
|
|
let top = list_stack[list_stack.length - 1]
|
|
if (top.type == "ul") html_parts.append("</ul>")
|
|
else html_parts.append("</ol>")
|
|
let trimmed = []
|
|
let i = 0
|
|
while (i < list_stack.length - 1) do
|
|
trimmed.append(list_stack[i])
|
|
i = i + 1
|
|
list_stack = trimmed
|
|
let close_all_lists = () = do
|
|
while (list_stack.length > 0) do
|
|
let top = list_stack[list_stack.length - 1]
|
|
if (top.type == "ul") html_parts.append("</ul>")
|
|
else html_parts.append("</ol>")
|
|
let trimmed = []
|
|
let i = 0
|
|
while (i < list_stack.length - 1) do
|
|
trimmed.append(list_stack[i])
|
|
i = i + 1
|
|
list_stack = trimmed
|
|
|
|
let blockquote_lines = []
|
|
let flush_blockquote = () = do
|
|
if (blockquote_lines.length > 0) do
|
|
let inner_html = markdown_to_html(blockquote_lines.join("\n"))
|
|
html_parts.append("<blockquote>" + inner_html + "</blockquote>")
|
|
blockquote_lines = []
|
|
|
|
let line_idx = 0
|
|
let total_lines = lines.length
|
|
while (line_idx < total_lines) do
|
|
let line = lines[line_idx]
|
|
|
|
if (in_code_block) do
|
|
let stripped = line.replace("\t", "").strip(" ")
|
|
if (stripped == code_fence_marker) do
|
|
let lang_attr = do
|
|
if (code_lang != "") return " class=\"language-" + code_lang + "\""
|
|
return ""
|
|
html_parts.append("<pre><code" + lang_attr + ">" + escape_html(code_lines.join("\n")) + "</code></pre>")
|
|
code_lines = []
|
|
in_code_block = false
|
|
else code_lines.append(line)
|
|
|
|
else do
|
|
let fence = __fence_info__(line)
|
|
|
|
let next_line = do
|
|
if (line_idx + 1 < total_lines) return lines[line_idx + 1]
|
|
return null
|
|
let setext = do
|
|
if (next_line == null) return 0
|
|
if (paragraph_lines.length > 0) return 0
|
|
if (line.strip(" ") == "") return 0
|
|
if (__heading_level__(line) > 0) return 0
|
|
if (fence.is_fence) return 0
|
|
if (__ul_item__(line) != null || __ol_item__(line) != null) return 0
|
|
if (__blockquote_content__(line) != null) return 0
|
|
return __setext_level__(next_line)
|
|
|
|
if (setext > 0) do
|
|
flush_blockquote()
|
|
close_all_lists()
|
|
html_parts.append("<h" + string(setext) + ">" + render_inline(line.strip(" ")) + "</h" + string(setext) + ">")
|
|
line_idx = line_idx + 1 # also consume the underline line
|
|
|
|
else if (fence.is_fence) do
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
flush_blockquote()
|
|
in_code_block = true
|
|
code_fence_marker = fence.marker
|
|
code_lang = fence.lang
|
|
code_lines = []
|
|
|
|
else if (line.strip(" ") == "") do
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
flush_blockquote()
|
|
|
|
else if (__heading_level__(line) > 0) do
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
flush_blockquote()
|
|
let level = __heading_level__(line)
|
|
let text = __heading_text__(line)
|
|
html_parts.append("<h" + string(level) + ">" + render_inline(text) + "</h" + string(level) + ">")
|
|
|
|
else if (__is_hr__(line)) do
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
flush_blockquote()
|
|
html_parts.append("<hr>")
|
|
|
|
else if (__blockquote_content__(line) != null) do
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
blockquote_lines.append(__blockquote_content__(line))
|
|
|
|
else if (next_line != null && paragraph_lines.length == 0 && line.index_of("|") >= 0 && __is_table_delim__(next_line) && line.strip(" ") != "") do
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
flush_blockquote()
|
|
|
|
let header_cells = __split_table_row__(line)
|
|
let alignments = __parse_table_alignments__(next_line)
|
|
|
|
let align_attr = (idx) = do
|
|
if (idx >= alignments.length || alignments[idx] == null) return ""
|
|
return " style=\"text-align:" + alignments[idx] + "\""
|
|
|
|
let table_html = "<table>\n<thead>\n<tr>"
|
|
let ci = 0
|
|
for (cell in header_cells) do
|
|
table_html = table_html + "<th" + align_attr(ci) + ">" + render_inline(cell) + "</th>"
|
|
ci = ci + 1
|
|
table_html = table_html + "</tr>\n</thead>\n<tbody>\n"
|
|
|
|
line_idx = line_idx + 2 # consume header row + delimiter row
|
|
|
|
let still_in_table = true
|
|
while (still_in_table && line_idx < total_lines) do
|
|
let row_line = lines[line_idx]
|
|
if (row_line.strip(" ") == "" || row_line.index_of("|") < 0) do
|
|
still_in_table = false
|
|
else do
|
|
let row_cells = __split_table_row__(row_line)
|
|
table_html = table_html + "<tr>"
|
|
let rci = 0
|
|
for (cell in row_cells) do
|
|
table_html = table_html + "<td" + align_attr(rci) + ">" + render_inline(cell) + "</td>"
|
|
rci = rci + 1
|
|
table_html = table_html + "</tr>\n"
|
|
line_idx = line_idx + 1
|
|
|
|
table_html = table_html + "</tbody>\n</table>"
|
|
html_parts.append(table_html)
|
|
line_idx = line_idx - 1 # compensate for the trailing line_idx += 1 below
|
|
|
|
else if (__ul_item__(line) != null) do
|
|
flush_paragraph()
|
|
flush_blockquote()
|
|
let item = __ul_item__(line)
|
|
let indent = item.indent
|
|
close_lists_deeper_than(indent)
|
|
let top_matches = (list_stack.length > 0 && list_stack[list_stack.length - 1].indent == indent && list_stack[list_stack.length - 1].type == "ul")
|
|
if (!top_matches) do
|
|
if (list_stack.length > 0 && list_stack[list_stack.length - 1].indent == indent) close_lists_deeper_than(indent - 1)
|
|
html_parts.append("<ul>")
|
|
list_stack.append({ type: "ul", indent: indent })
|
|
html_parts.append("<li>" + render_inline(item.content) + "</li>")
|
|
|
|
else if (__ol_item__(line) != null) do
|
|
flush_paragraph()
|
|
flush_blockquote()
|
|
let item = __ol_item__(line)
|
|
let indent = item.indent
|
|
close_lists_deeper_than(indent)
|
|
let top_matches = (list_stack.length > 0 && list_stack[list_stack.length - 1].indent == indent && list_stack[list_stack.length - 1].type == "ol")
|
|
if (!top_matches) do
|
|
if (list_stack.length > 0 && list_stack[list_stack.length - 1].indent == indent) close_lists_deeper_than(indent - 1)
|
|
html_parts.append("<ol>")
|
|
list_stack.append({ type: "ol", indent: indent })
|
|
html_parts.append("<li>" + render_inline(item.content) + "</li>")
|
|
|
|
else do
|
|
flush_blockquote()
|
|
close_all_lists()
|
|
let trailing_spaces = 0
|
|
let k = line.length - 1
|
|
while (k >= 0 && line[k] == " ") do
|
|
trailing_spaces = trailing_spaces + 1
|
|
k = k - 1
|
|
if (trailing_spaces >= 2) paragraph_lines.append(line.strip(" ") + "\u0001BR\u0001")
|
|
else paragraph_lines.append(line.strip(" "))
|
|
|
|
line_idx = line_idx + 1
|
|
|
|
flush_paragraph()
|
|
close_all_lists()
|
|
flush_blockquote()
|
|
if (in_code_block && code_lines.length > 0) do
|
|
html_parts.append("<pre><code>" + escape_html(code_lines.join("\n")) + "</code></pre>")
|
|
|
|
return html_parts.join("\n")
|