module

Hwaro::Services::Importers::HtmlToMarkdown

Lightweight HTML-to-Markdown converter. Handles common HTML elements produced by WordPress and other CMS exports.

Constants

AMP_ENTITY = /&(?:amp|#0*38|#x0*26);/i

Every spelling of an encoded &: WordPress writes & often.

AMP_PLACEHOLDER = "\u0000hwaro-amp\u0000"
ANCHOR_CLOSE_RE = /<\/a>/i

Cheap short-circuit for the anchor pass: a document with no closing anchor tag has no link to convert.

ANCHOR_RE = Regex.new("<a[^>]*\\bhref=[\"']([^\"']+)[\"'][^>]*>(.{0,#{MAX_INLINE_BODY_CHARS}}?)</a>", Regex::Options::IGNORE_CASE | Regex::Options::MULTILINE)
CODE_PLACEHOLDER_RE = /\x{0}hwaro-code-(\d+)\x{0}/
EDGE_SPACE = "(?:\\s|\\x{A0}|&nbsp;|&#0*160;|&#x0*a0;)"
EDGE_SPACE_LEAD_RE = Regex.new("\\A#{EDGE_SPACE}+", Regex::Options::IGNORE_CASE)
EDGE_SPACE_TRAIL_RE = Regex.new("#{EDGE_SPACE}+\\z", Regex::Options::IGNORE_CASE)
INNERMOST_LIST_RE = /<(ul|ol)([^>]*)>((?:[^<]++|<(?![uo]l[^>]*>))*?)<\/\1>/im

Innermost-list matcher (see the list pass below). The body is consumed in CHUNKS — possessive runs of non-< plus single < guarded against opening a nested list — rather than the previous per-character (?:(?!<[uo]l[^>]*>).)*?: PCRE2's JIT pushes a backtrack frame per lazy iteration onto a fixed-size JIT stack, and per-character iteration crashed the whole conversion with "JIT stack limit reached" on list bodies only a few thousand characters long. Chunking makes the frame count scale with the number of tags instead of characters; the language matched is identical (only < could ever start a nested-list open, and a match can only ever end before the < of the closing tag, which is always a chunk boundary). A {0,n} bound is no alternative — PCRE2 unrolls bounded GROUP repeats and the pattern blows the compile-size limit. A tag-spam body can still overflow the JIT stack, which raises Regex::Error — convert catches that and falls back to the linear tag strip.

MAX_CODE_BODY_CHARS = 65535

Longest

 body the fenced-code pass will match — the
bounded-lazy-body treatment for the one other multi-literal-tail
pass (see MAX_INLINE_BODY_CHARS above), sized up because real code
samples run far longer than inline spans. 65535 is PCRE2's {n,m}
quantifier ceiling. An over-long body keeps its text: the plain
<pre> pass right after has a single-literal tail and no bound.

MAX_INLINE_BODY_CHARS = 4096

Longest anchor TEXT the link conversion will match. Bounding the lazy body is what actually makes an unclosed-anchor document linear — see the anchor pass below. Far above any real inline link.

The bound is needed exactly where a pass's tail carries MORE THAN ONE closing literal (the anchor pass: ["'][^>]*> then </a>; the pre+code pass: </code> then </pre>). A failed attempt then rescans between the literals from every opening tag — measured O(n^2), minutes of CPU inside the 4 MB cap. Passes whose tail is a SINGLE closing literal ((.*?)</p> and friends) are deliberately left open: PCRE2 satisfies those in linear time (the required- literal scan is memoized across bump-along attempts), and bounding them defeats that optimization — measured ~200x SLOWER on unclosed-tag spam with a {0,n}? body than with (.*?).

MAX_LIST_PASSES = 64

Maximum innermost-list conversion passes (one per nesting level). Real content nests a handful of levels; a crafted thousand-deep nest costs a full-document gsub per level, so cap it — deeper levels fall through to strip_tags, which keeps their text.

MAX_REGEX_HTML_BYTES = (4 * 1024) * 1024

Above this size the regex pipeline's lazy quantifiers (e.g. the anchor body (.*?)</a>) degrade to O(n^2) on adversarial markup (many unclosed tags). Imported exports are untrusted, so fall back to a cheap linear tag-strip rather than spend minutes of CPU on a single crafted item. The threshold is far above any real blog post.

MORE_PLACEHOLDER = "\u0000hwaro-more\u0000"
MORE_TAG_RE = /<!--more(?:\s.*?)?-->|<!--\s*more\s*-->/im

Class methods

convert(html : String) : String
Source