module

Hwaro::Content::Processors::MarkdownExtensions

Constants

ADMONITION_BLOCKQUOTE_RE = /<blockquote>\s*<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?<fold>[+-]?)([^\n]*?)(?:\n|(?=<\/p>))\s*(.*?)<\/blockquote>/im

Group 1: type token (uppercased). Group 2: whatever else sat on the marker's own line — the custom title (Obsidian / Hugo syntax: > [!NOTE] Custom Title); empty for a bare marker. Group 3: the rest of the blockquote body, possibly starting with </p> (when the marker line was a paragraph of its own) or with the inline body content (when body text followed the marker via a soft break).

Group 2 stops at the line break, so a same-line title can never be confused with a soft-broken body. It used to be \s*(.*?), which swallowed the title into the first body paragraph — Custom Title rendered as body text under the default "Note" heading. Obsidian's optional fold marker ([!NOTE]+ / [!NOTE]-, group 4) is dropped, unless foldable ([markdown] wikilinks) renders it as <details>.

ADMONITION_TYPES = {"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}

--- GitHub-style Admonitions --- Recognised types match GitHub's alert syntax.

ANY_ID_ATTR_PRESENT_RE = /(?<![\w-])id\s*=/i
AUTOLINK_RE = /<[A-Za-z][A-Za-z0-9+.\-]{1,31}:[^\s<>]*>/

CommonMark <scheme:…> autolink: its text is a URL, not prose.

BARE_MATH_LINE_RE = /\A\x00MATH\d+\x00\z/

A line whose sole content is one math placeholder — the standalone display-math case, where the emitted

starts at line start and is a real CommonMark HTML block. Leading blockquote markers are stripped before the check: > $$x$$ is block position too (the div becomes an HTML block inside the blockquote).

BLOCKQUOTE_MARKERS_RE = /\A(?:>[ \t]?)+/
CODE_SPAN_TOKEN_RE = /\x00CS(\d{1,9})\x00/

Placeholder emitted by transform_outside_code_spans for a stashed code span; the capture is the span's index.

CONTAINER_CLOSE_RE = /\A {0,3}:{3,}\z/
CONTAINER_OPEN_RE = /\A {0,3}:{3,}[ \t]*([A-Za-z][\w-]*)[ \t]*(.*)\z/

--- Custom containers (opt-in) --- :::type Optional Title … ::: blocks (markdown-it/remark style), emitted with the admonition markup so site CSS is shared:

Title

<blank line — ends the type-6 HTML block, so the body is parsed as ordinary markdown, fences and task lists included> …body…

A bare :{3,} run closes the innermost open container, which gives natural nesting (::::outer / :::inner / ::: / ::::) with a plain counter. Unclosed containers auto-close at EOF (markdown-it behavior). Fence-aware: ::: lines inside code fences stay verbatim. The type token is class-safe by construction; the title is inline markdown (escaped).

DISPLAY_MATH_RE = InlineMarkdown::DISPLAY_MATH_RE

--- Math ---

ENGINE_MARKER_PREFIX = "<!--HWARO-"

Engine-generated marker comments (footnote data blocks, shortcode placeholders) start with this prefix and must pass through the transforming passes verbatim: a footnote body containing ~~x~~ or $x$ lives inside a <!--HWARO-FN:…--> line until postprocess, and rewriting it there corrupts the data. Author-typed lookalikes are neutralized to <!-- HWARO- (with a space) by preprocess_footnotes before this prefix check can match them.

EXISTING_ID_RE = /(?<![\w-])id\s*=\s*"[^"]*"/i
EXISTING_REL_RE = /(?<![\w-])rel\s*=\s*(["'])(.*?)\1/i
EXISTING_TARGET_RE = /(?<![\w-])target\s*=/i

CommonMark "type 6" HTML-block start condition (common block tags, including the

/
/
markup hwaro itself generates). A line opening one of these starts a raw-HTML block that runs to the next blank line — Markd performs NO inline parsing there, so backslash escapes ship verbatim instead of collapsing.

HTML_CODE_SPAN_RE = /<code(?:\s#{Utils::TextUtils::HTML_TAG_ATTRS})?>[^<]*<\/code>/

Inline <code>…</code> HTML spans — generated by InlineMarkdown for table cells / definition bodies (where the original backticks are already consumed), or author-written raw HTML. Their content is code: the strikethrough/footnote/math passes must treat it as opaque, exactly like backtick spans. [^<]* keeps the match to a flat element (generated spans never contain tags); the attribute scan is quote-aware so a > inside a quoted value doesn't end the opening tag early.

HTML_TAG_OR_AUTOLINK_RE = Regex.union(HTML_TAG_RE, AUTOLINK_RE)

Tags plus <scheme:…> autolinks, whose URL is data too: a ~~ or ~ in <https://x/~a/~b> must not turn into markup.

HTML_TAG_RE = /<[A-Za-z][A-Za-z0-9-]*+(?:\s++[a-zA-Z_:][a-zA-Z0-9:._-]*+(?:\s*+=\s*+(?:[^"'=<>`\x00-\x20]++|'[^']*+'|"[^"]*+"))?+)*+\s*+\/?>|<\/[A-Za-z][A-Za-z0-9-]*+\s*+>/

Raw inline HTML tags as Markd recognizes them (Markd::Rule::OPEN_TAG and CLOSE_TAG: tag name, attributes with optional unquoted/quoted values, /> or >), so text that merely starts with < — <b ~~x~~ y> — stays Markdown. Possessive quantifiers give the same matches without backtracking frames, so a tag with thousands of attributes cannot exhaust the PCRE JIT stack.

HTML_TAG_TOKEN_RE = /\x00HT(\d{1,9})\x00/
IMAGE_ATTR_RE = /(!\[[^\]]*\]\([^)]*\))\{([^{}]+)\}/

![alt](url){.class key=val} — an attribute block immediately following an inline image's closing ). Matched inside transform_outside_code_spans so a literal example in a code span isn't rewritten.

IMAGE_LABEL_RE = /(?<=!\[)(?:[^\[\]\\\n]|\\.|\[[^\[\]\n]*+\])++(?=\][(\[])/

The label of ![label](…) / ![label][ref], brackets excluded: it becomes the alt attribute (plain text), so no extension may inject markup into it. One level of nested brackets is allowed (![Figure[^1]](f.png)); possessive, so a long label cannot backtrack.

IMG_HATTR_RE = /(<img\b(?:[^>"'\s\/]++|\s++(?!\/?>)|\/(?!>)|"[^"]*+"|'[^']*+')*+)(\s*\/?>)((?:(?!<img\b|<!--HATTR:|<\/(?:p|li|t[dh]|d[dt]|h[1-6])\b).)*?)<!--HATTR:([0-9a-f]+)-->/m

<img ...> opening portion (quote-aware, so a > inside an attribute value like alt="Home > Docs" isn't mistaken for the tag end), its closer (> or />, with any whitespace before it), an optional wrapper the image trails inside (group 3), and the marker comment this preprocess pass appended. The wrapper is what a render-image.html hook emits around the <img> (e.g. a <figure>…</figure>): the marker lands after the whole hook output, not glued to the <img>, so a naive "marker immediately follows the tag" match would drop the attributes silently. The tempered gap stops at the next image or marker, so back-to-back attributed images each bind their own block, and the no-hook case (marker glued to the tag) keeps an empty group 3. It also stops at a closing block tag Markd wraps the image in (</p>, </li>, table cells, </hN>, …): an image's own marker is always in the same block, so the gap never legitimately crosses one — this prevents a plain (marker-less) image from reaching forward and absorbing a later element's marker (e.g. a heading whose non-conformant hook emitted non-<hN> markup, leaving its HATTR marker unconsumed by the heading pass). Hook wrappers (</figure>, </span>, </a>, </picture>, …) are deliberately NOT excluded, so they still bind normally. The tag scan is IMG_LAZY_REGEX's possessive form of (?:…)*?(\s*\/?>).

INLINE_MATH_RE = InlineMarkdown::INLINE_MATH_RE
INLINE_TAG_RE = /<(\/?)([a-zA-Z][\w-]*)[^>]*>/

Captures a blockquote whose first paragraph starts with [!TYPE]. Non-void tags that markd's inline renderer can open on a title line. Balanced when every opener on the line is closed on the line: a <strong>/<a>/<code>… opened here and closed on a later line of the same paragraph means the author wrapped one inline element across the soft break, not that they wrote a title.

LINK_DEFINITION_RE = /\A((?: {0,3}>[ \t]?)*+ {0,3}\[[^\]]++\]:[ \t]*+)(?:<[^<>\n\\]*+(?:\\.[^<>\n\\]*+)*+>|[^<\s]\S*+)(?:[ \t]++(?:"[^"\\]*+(?:\\.[^"\\]*+)*+"|'[^'\\]*+(?:\\.[^'\\]*+)*+'|\([^()\\]*+(?:\\.[^()\\]*+)*+\)))?[ \t]*\r?\n?\z/

A whole-line reference definition: [label]:, a destination (<…> or a non-space run), an optional title after whitespace, and nothing else. Group 1 ends where the destination starts. A line with trailing text is a paragraph to Markd, not a definition.

LINK_DEST_TOKEN_RE = /\x00LD(\d{1,9})\x00/
MATH_BODY_MARKD_ACTIVE_RE = /[\\`*_\[\]\-."']/

Markd-active characters inside a math body rendered in normal inline context (see the branch comments in expand_math). Beyond emphasis/code/link chars, -, ., and both quotes are included so markd's opt-in smart punctuation can't rewrite --/.../ quotes inside formulas; when smart is off the backslash escapes collapse to the same characters, so output is byte-identical.

MATH_PLACEHOLDER_RE = /\x00MATH(\d{1,9})\x00/
RAW_TEXT_CLOSE_RES = {"script" => /<\/script[\s\/>]/i, "style" => /<\/style[\s\/>]/i, "textarea" => /<\/textarea[\s\/>]/i}

The end tag that closes a raw-text body, in any case (</SCRIPT >).

RAW_TEXT_OPEN_RE = /<(script|style|textarea)(?=[\s\/>])/i

<script>/<style>/<textarea> bodies are text, not markup: the HTML passes must not rewrite an "<img …>" or "<a href=…>" JS string (their double-quoted attributes would end it).

RAW_TEXT_SCAN_RE = /<(?:!--|(script|style|textarea)(?=[\s\/>])|[a-zA-Z][^\s\/<>]*+(?:[^<>=]++|=\s*+"[^"<]*+"|=\s*+'[^'<]*+'|=(?!\s*+["']))*+=\s*+["'])/i

$1 = a raw-text opener's name. <!-- alternates so the scan steps over comments (a commented-out <!-- <script src="old.js"> --> opens nothing), and so does a tag one of whose quoted values holds a < (title="<textarea>"): the last branch stops at that value's = and the scan resumes past the whole tag. Every other tag fails that branch at its > without a match being built.

RAW_TEXT_TAG_RE = /<[a-zA-Z][^\s\/<>]*+#{Utils::TextUtils::HTML_TAG_ATTRS_QUOTED_VALUES}>/

A whole opening tag; a quote opens a value only right after =.

SINGLE_LINE_CODE_SPAN_RE = /`[^`\n]+`/

Code-span pattern confined to one line, for stashing inside multi-line chunks: a stray lone backtick in one paragraph must not absorb text from another.

STRIKETHROUGH_CODE_RE = /`[^`]+`/
STRIKETHROUGH_RE = InlineMarkdown::INLINE_STRIKETHROUGH_RE

--- Strikethrough (GFM) --- ~~text~~ → <del>text</del>. Markd doesn't ship a GFM strikethrough parser, so we apply this pre-Markd. The walk is fence-aware so examples inside fenced code blocks (``` / ~~~) render verbatim, and inline `code` runs on the same line are skipped via a placeholder pass so e.g. `~~not strike~~` stays as code.

When math is also enabled, preprocess stashes $…$/$$…$$ spans into opaque placeholders before this pass runs, so $~~x~~$ reaches KaTeX verbatim instead of being rewritten here.

TASK_LIST_ITEM_RE = /<li>(\n?(?:<p>)?)<input type="checkbox"( checked)? disabled>/

--- Task list classes (GFM markup, opt-in) --- preprocess_task_lists runs before Markd, when the

  • doesn't exist yet — so the GFM classes are added here, on the rendered HTML. Matches both list shapes markd emits: tight (<li><input …) and loose (<li>\n<p><input …). Code blocks are immune (their <input is entity-escaped).

  • TASK_LIST_RE = /^((?:[ \t]{0,3}>[ \t]?)*+[ \t]*+(?:[-*+]|\d{1,9}[.)])[ \t]{1,4})\[([ xX])\](?=[ \t]|\r?$)/m

    --- Task Lists --- Converts - [ ] and - [x] to checkbox HTML in list items. The marker must be followed by whitespace or end the line (GFM), so a list item opening with a link labelled x (- [x](url)) keeps its link. One to four spaces may follow the list marker (- [x]); five make the item's content an indented code block.

    TRUST_RE = /<!--HWARO-|<[A-Za-z][A-Za-z0-9-]*+/
    TRUSTED_MARK = "hwaro#{Random::Secure.hex(12)}"

    Safe mode keeps hwaro's own markup through markd's raw-HTML stripping by marking it: every open tag and comment a pass generates carries this per-process secret (<del MARK>, <!--HWARO-MARK…), which author content cannot forge — it never reaches any output. HighlightingRenderer keeps a marked construct (and a close tag balancing one) and omits all other raw HTML; untrust then drops the marks from the rendered HTML. Engine comments keep their ENGINE_MARKER_PREFIX.

    UNTRUST_RE = Regex.new(" ?#{TRUSTED_MARK}")
    VOID_INLINE_TAGS = Set {"br", "img", "wbr", "hr", "input"}

    Instance methods

    footnote_id_token(key : String) : String

    Derive an id-safe token from a footnote key. The key can contain ASCII whitespace ([^my note]), which is invalid in an id/fragment, so collapse runs of whitespace to a single - before HTML-escaping the rest. Must be applied identically on the reference side and the li/backref side so forward and backward anchors still match.

    Source
    image_attributes?(config : Models::MarkdownConfig) : Bool

    {…} attribute blocks on images: the attributes switch, or wikilinks, whose sized ![[img.png|300]] embeds are emitted as ![img.png](img.png){width=300}. Heading blocks stay attributes-only.

    Source
    inline_flags(config : Models::MarkdownConfig) : InlineMarkdown::Flags

    Builds the shared InlineMarkdown::Flags for a markdown config — math plus the F10 opt-in inline markup — so table cells, definition lists, and footnote bodies all see the same set of enabled transforms as the main per-line pass above.

    Source
    inline_html_balanced?(fragment : String) : Bool
    Source
    outside_raw_text(html : String, scripts : Bool = false, & : String -> String) : String

    Yields every stretch of html outside raw-text bodies and joins the results with those bodies kept verbatim. scripts: true yields each <script> body too, as a stretch of its own: for a pass whose rewrite is right for the HTML a script inserts (el.innerHTML = '<a href="/x/">'), which still never matches across a body's edge.

    Source
    postprocess(html : String, config : Models::MarkdownConfig) : String

    Post-process HTML after Markd rendering

    Source
    postprocess_admonitions(html : String, foldable : Bool = false) : String

    Post-processing: rewrite GitHub > [!TYPE] blockquotes as admonition divs. Note: the lazy match against </blockquote> means a nested blockquote inside the admonition will close the match early. Acceptable for v1 — GitHub admonitions don't support nested blockquotes either.

    Source
    postprocess_attributes(html : String) : String
    Source
    postprocess_footnotes(html : String, *, flags : InlineMarkdown::Flags = InlineMarkdown::Flags.new) : String

    Post-processing: convert footnote comments to HTML section. flags.math renders $…$ spans in footnote bodies as math, their internals untouched by emphasis/strikethrough; flags also threads the F10 opt-in inline markup (ins/mark/sub/sup).

    Source
    postprocess_heading_ids(html : String) : String
    Source
    postprocess_mermaid(html : String) : String

    --- Mermaid --- Post-processing: convert mermaid code blocks to div elements

    Source
    postprocess_task_list_classes(html : String) : String
    Source
    preprocess(content : String, config : Models::MarkdownConfig) : String

    Pre-process markdown content before Markd parsing

    Source
    preprocess_containers(content : String, *, flags : InlineMarkdown::Flags = InlineMarkdown::Flags.new, trusted : Bool = false) : String
    Source
    preprocess_definition_lists(content : String, *, flags : InlineMarkdown::Flags = InlineMarkdown::Flags.new, trusted : Bool = false) : String

    --- Definition Lists --- Converts Term\n: Definition syntax to

    HTML. Fence-aware: Term / : def lines shown inside a ```/~~~ example stay verbatim instead of becoming
    markup inside the code block. flags threads math (keeps $…$ spans in
    /
    bodies untransformed for the later math pass) and the F10 opt-in inline markup (ins/mark/sub/sup) into term/definition bodies.

    Source
    preprocess_footnotes(content : String, *, trusted : Bool = false) : String
    Source
    preprocess_task_lists(content : String, *, trusted : Bool = false) : String
    Source
    protect_math(content : String, & : String, Array(String) -> String) : String

    content through the block with its math spans (as the math pass sees them) stashed out, then put back as written — for passes that run before preprocess (wikilinks) and must not touch formulas. The block also gets the spans' sources, for restore_math on any piece it lifts out (a placeholder must never reach Markd).

    Source
    raw_text_spans(html : String, comments : Bool = false) : Array(Range(Int32, Int32))

    Byte ranges of raw-text element bodies, from the end of the opening tag up to the close tag, so the opener's own attributes stay markup. An unterminated element ends the scan and an unterminated comment is no comment; neither runs to the end. comments: true also lists each comment (<!-- through -->): only the ones outside the bodies, as a browser parses them ("<!--" in a script opens none), including those after an unterminated element.

    Source
    restore_math(text : String, sources : Array(String)) : String

    text with each math placeholder put back as written.

    Source
    trust(html : String, trusted : Bool) : String

    Marks every open tag and engine comment of html, which must be entirely hwaro-generated (any author text in it already escaped).

    Source
    untrust(html : String) : String
    Source

    Nested types

    CrystalDocs.org - Crystal Shard Documentation Hosting

    The Bushido Collective builds and maintains this site.
    Licensed under the Apache License 2.0