Files
markdown/src/init.ar
T
2026-08-03 22:42:47 +01:00

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
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: ![alt](src "title")
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 + "&lt;"
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 + "&amp;"
i = i + 1
else if (c == "<") do
out = out + "&lt;"
i = i + 1
else if (c == ">") do
out = out + "&gt;"
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")