UNPKG

quikdown

Version:

Small, safe, bidirectional Markdown parser and editor with streaming, undo/redo, rich fences, MCP tools, and a standalone airgapped build. Zero runtime deps. Browser and Node.js.

2,101 lines • 102 kB
/**
 * quikdown_bd - Bidirectional Markdown Parser
 * @version 1.2.22
 * @license BSD-2-Clause
 * @copyright DeftIO 2025
 */
(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
    typeof define === 'function' && define.amd ? define(factory) :
    (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.quikdown_bd = factory());
})(this, (function () { 'use strict';

    /**
     * quikdown_classify — Shared line-classification utilities
     * ═════════════════════════════════════════════════════════
     *
     * Pure functions for classifying markdown lines.  Used by both the main
     * parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
     * lives in one place.
     *
     * All functions operate on a **trimmed** line (caller must trim).
     * None use regexes with nested quantifiers — every check is either a
     * simple regex or a linear scan, so there is zero ReDoS risk.
     */

    /**
     * Full CommonMark HR check: three or more identical characters from
     * {-, *, _} with optional interspersed whitespace.
     *
     * Examples that return true:  ---, ***, ___, ----, - - -, * * *, _  _  _
     * Examples that return false: --, - text, ---text, mixed -_*, empty
     *
     * Algorithm (O(n), single pass, no backtracking):
     *   1. Strip all whitespace
     *   2. Verify length >= 3
     *   3. First char must be -, *, or _
     *   4. Every remaining char must equal the first
     *
     * @param {string} trimmed  The line, already trimmed
     * @returns {boolean}
     */
    function isHRLine(trimmed) {
        if (trimmed.length < 3) return false;

        // Strip whitespace via linear scan
        let stripped = '';
        for (let i = 0; i < trimmed.length; i++) {
            const ch = trimmed[i];
            if (ch !== ' ' && ch !== '\t') stripped += ch;
        }

        if (stripped.length < 3) return false;

        const ch = stripped[0];
        if (ch !== '-' && ch !== '*' && ch !== '_') return false;

        for (let i = 1; i < stripped.length; i++) {
            if (stripped[i] !== ch) return false;
        }
        return true;
    }

    /**
     * quikdown — A compact, scanner-based markdown parser
     * ════════════════════════════════════════════════════
     *
     * Architecture overview (v1.2.8 — lexer rewrite)
     * ───────────────────────────────────────────────
     * Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
     * type (headings, blockquotes, HR, lists, tables) and each inline format
     * (bold, italic, links, …) was handled by its own global regex applied
     * sequentially to the full document string. That worked but made the code
     * hard to extend and debug — a new construct meant adding another regex
     * pass, and ordering bugs between passes were subtle.
     *
     * Starting in v1.2.8 the parser uses a **line-scanning** approach for
     * block detection and a **per-block inline pass** for formatting:
     *
     *   ┌─────────────────────────────────────────────────────────┐
     *   │  Phase 1 — Code + Escape Extraction                     │
     *   │  1a. Fenced code blocks (``` / ~~~) → §CB§ placeholders│
     *   │  1b. Escaped backticks (\`) → §BE§ placeholders        │
     *   │  1c. Inline code spans (`…`) → §IC§ placeholders       │
     *   │      Cannot cross newlines (scoped to single line).     │
     *   │  1d. Remaining backslash escapes (\* \_ etc.) → §BE§   │
     *   │      Only ASCII punctuation is escapable; \a stays.     │
     *   ├─────────────────────────────────────────────────────────┤
     *   │  Phase 2 — HTML Escaping                                │
     *   │  Escape &, <, >, ", ' in the remaining text to prevent │
     *   │  XSS. (Skipped when allow_unsafe_html is true.)         │
     *   ├─────────────────────────────────────────────────────────┤
     *   │  Phase 3 — Block Scanning                               │
     *   │  Walk the text **line by line**.  At each line, the     │
     *   │  scanner checks (in order):                             │
     *   │    • table rows  (|)                                    │
     *   │    • headings    (#)                                    │
     *   │    • HR          (---)                                  │
     *   │    • blockquotes (&gt;)                                 │
     *   │    • list items  (-, *, +, 1.)                          │
     *   │    • code-block placeholder (§CB…§)                     │
     *   │    • paragraph text (everything else)                   │
     *   │                                                         │
     *   │  Block text is run through the **inline formatter**     │
     *   │  which handles bold, italic, strikethrough, links,      │
     *   │  images, and autolinks.                                 │
     *   │                                                         │
     *   │  Paragraphs are wrapped in <p> tags.  Lazy linefeeds   │
     *   │  (single \n → <br>) are handled here too.               │
     *   ├─────────────────────────────────────────────────────────┤
     *   │  Phase 4 — Code Restoration                             │
     *   │  Replace §CB§ / §IC§ placeholders with rendered <pre>  │
     *   │  / <code> HTML, applying the fence_plugin if present.   │
     *   └─────────────────────────────────────────────────────────┘
     *
     * Why this design?
     *  • Single pass over lines for block identification — no re-scanning.
     *  • Each block type is a clearly separated branch, easy to add new ones.
     *  • Inline formatting is confined to block text — can't accidentally
     *    match across block boundaries or inside HTML tags.
     *  • Code extraction still uses a simple regex (it's one pattern, not a
     *    chain) because the §-placeholder approach is proven and simple.
     *
     * @param {string} markdown  The markdown source text
     * @param {Object} options   Configuration (see below)
     * @returns {string}         Rendered HTML
     */


    // ────────────────────────────────────────────────────────────────────
    //  Constants
    // ────────────────────────────────────────────────────────────────────

    /** Build-time version stamp (injected by tools/updateVersion) */
    const quikdownVersion = '1.2.22';

    /** CSS class prefix used for all generated elements */
    const CLASS_PREFIX = 'quikdown-';

    /** Placeholder sigils — chosen to be extremely unlikely in real text */
    const PLACEHOLDER_CB = '§CB';   // fenced code blocks
    const PLACEHOLDER_IC = '§IC';   // inline code spans
    const PLACEHOLDER_HT = '§HT';  // safe HTML tags (limited mode)
    const PLACEHOLDER_BE = '§BE';  // backslash escapes
    const PLACEHOLDER_NL = '§NL§'; // newlines inside indented code (restored in Phase 4)

    /** Attributes whose values need URL sanitization */
    const URL_ATTRIBUTES = { href:1, src:1, action:1, formaction:1 };

    /** HTML entity escape map */
    const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};

    // ────────────────────────────────────────────────────────────────────
    //  Style definitions
    // ────────────────────────────────────────────────────────────────────

    /**
     * Inline styles for every element quikdown can emit.
     * When `inline_styles: true` these are injected as style="…" attributes.
     * When `inline_styles: false` (default) we use class="quikdown-<tag>"
     * and these same values are emitted by `quikdown.emitStyles()`.
     */
    const QUIKDOWN_STYLES = {
        h1: 'font-size:2em;margin:.67em 0;text-align:left',
        h2: 'font-size:1.5em;margin:.83em 0',
        h3: 'font-size:1.25em;margin:1em 0',
        h4: 'font-size:1em;margin:1.33em 0',
        h5: 'font-size:.875em;margin:1.67em 0',
        h6: 'font-size:.85em;margin:2em 0',
        pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
        code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
        blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
        table: 'border-collapse:collapse;width:100%;margin:1em 0',
        th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
        td: 'border:1px solid #ddd;padding:8px;text-align:left',
        hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
        img: 'max-width:100%;height:auto',
        a: 'color:#06c;text-decoration:underline',
        strong: 'font-weight:bold',
        em: 'font-style:italic',
        del: 'text-decoration:line-through',
        ul: 'margin:.5em 0;padding-left:2em',
        ol: 'margin:.5em 0;padding-left:2em',
        li: 'margin:.25em 0',
        'task-item': 'list-style:none',
        'task-checkbox': 'margin-right:.5em',
        'alert': 'padding:1em;margin:1em 0;border-left:4px solid #0969da;border-radius:4px;background:#ddf4ff',
        'alert-title': 'font-weight:600;margin:0 0 .4em',
        'alert-note': 'border-left-color:#0969da;background:#ddf4ff',
        'alert-tip': 'border-left-color:#1a7f37;background:#dafbe1',
        'alert-important': 'border-left-color:#8250df;background:#fbefff',
        'alert-warning': 'border-left-color:#9a6700;background:#fff8c5',
        'alert-caution': 'border-left-color:#cf222e;background:#ffebe9',
        sup: 'font-size:.75em;vertical-align:super;line-height:0',
        'footnotes': 'margin-top:2em;font-size:.9em',
        'footnote-backref': 'text-decoration:none;margin-left:.25em'
    };

    // ────────────────────────────────────────────────────────────────────
    //  Attribute factory
    // ────────────────────────────────────────────────────────────────────

    /**
     * Creates a `getAttr(tag, additionalStyle?)` helper that returns
     * either a class="…" or style="…" attribute string depending on mode.
     *
     * @param {boolean} inline_styles  True → emit style="…"; false → class="…"
     * @param {Object}  styles         The QUIKDOWN_STYLES map
     * @returns {Function}
     */
    function createGetAttr(inline_styles, styles) {
        return function(tag, additionalStyle = '') {
            if (inline_styles) {
                let style = styles[tag];
                if (!style && !additionalStyle) return '';

                // When adding alignment that conflicts with the tag's default,
                // strip the default text-align first.
                if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
                    style = style.replace(/text-align:[^;]+;?/, '').trim();
                    /* istanbul ignore next */
                    if (style && !style.endsWith(';')) style += ';';
                }

                /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
                const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
                return ` style="${fullStyle}"`;
            } else {
                const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
                if (additionalStyle) {
                    return `${classAttr} style="${additionalStyle}"`;
                }
                return classAttr;
            }
        };
    }

    // ────────────────────────────────────────────────────────────────────
    //  Link destination + heading slug + blockquote helpers
    // ────────────────────────────────────────────────────────────────────

    /**
     * Parse a markdown link/image destination: url with optional "title" or 'title'.
     * Supports angle-bracket form: <url>
     * @returns {{ url: string, title: string|null }}
     */
    function parseLinkDestination(raw) {
        if (raw === undefined || raw === null || raw === '') return { url: '', title: null };

        const dblQuote = raw.match(/^(.*)\s+(?:"([^"]*)"|&quot;([^&]*?)&quot;)\s*$/);
        if (dblQuote) {
            return { url: dblQuote[1].replace(/\s+$/, ''), title: dblQuote[2] ?? dblQuote[3] };
        }

        const sglQuote = raw.match(/^(.*)\s+(?:'([^']*)'|&#39;([^&]*?)&#39;)\s*$/);
        if (sglQuote) {
            return { url: sglQuote[1].replace(/\s+$/, ''), title: sglQuote[2] ?? sglQuote[3] };
        }

        if (raw.startsWith('&lt;') && raw.endsWith('&gt;')) {
            return { url: raw.slice(4, -4), title: null };
        }

        return { url: raw, title: null };
    }

    /** Build a URL-safe slug from heading text (inline markdown stripped). */
    function headingSlug(text) {
        return text
            .replace(/[*_`~]/g, '')
            .trim()
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '') || 'section';
    }

    /** Return a unique slug, suffixing -1, -2, … on duplicates. */
    function uniqueSlug(base, counts) {
        const n = counts.get(base) || 0;
        counts.set(base, n + 1);
        return n === 0 ? base : `${base}-${n}`;
    }

    /**
     * Strip trailing punctuation from an autolinked URL.
     * Handles balanced parentheses (e.g. Wikipedia URLs).
     * @param {string} url  The matched URL text
     * @returns {{ url: string, trailing: string }}
     */
    function stripTrailingPunctuation(url) {
        let trailing = '';
        const punct = /[.,;:!?)]/;
        while (url.length > 0 && punct.test(url[url.length - 1])) {
            const ch = url[url.length - 1];
            if (ch === ')') {
                const opens = (url.match(/\(/g) || []).length;
                const closes = (url.match(/\)/g) || []).length;
                if (opens >= closes) break; // balanced — ) is part of URL
            }
            trailing = ch + trailing;
            url = url.slice(0, -1);
        }
        return { url, trailing };
    }

    /**
     * Count leading blockquote depth on an HTML-escaped line (&gt; markers).
     * @returns {{ depth: number, content: string }}
     */
    function parseBlockquoteLine(line) {
        let depth = 0;
        let pos = 0;
        while (pos < line.length && line.startsWith('&gt;', pos)) {
            pos += 4;
            depth++;
            if (line[pos] === ' ') pos++;
        }
        return { depth, content: line.slice(pos) };
    }

    /**
     * Check if a line breaks lazy blockquote continuation.
     * @param {string} line  HTML-escaped line text
     * @returns {boolean}
     */
    function isLazyContinuationBreaker(line) {
        const trimmed = line.trim();
        if (trimmed === '') return true;                          // blank line
        if (/^#{1,6}\s/.test(trimmed)) return true;              // heading
        if (isHRLine(trimmed)) return true;                       // HR
        /* istanbul ignore next -- defensive: &gt; lines are caught by parseBlockquoteLine first */
        if (/^&gt;/.test(trimmed)) return true;                   // new blockquote
        if (/^[-*+]\s/.test(trimmed)) return true;               // unordered list
        if (/^\d+\.\s/.test(trimmed)) return true;               // ordered list
        if (trimmed.startsWith('|')) return true;                  // table row
        if (trimmed.startsWith(PLACEHOLDER_CB)) return true;      // code block placeholder
        return false;
    }

    /**
     * Base inline formatting patterns shared between the main pass and
     * footnotes rendering.  Defined once at module level to avoid
     * recreating regex objects on every call.
     */
    const BASE_INLINE_PATTERNS = [
        [/\*\*(.+?)\*\*/g, 'strong'],
        [/__(.+?)__/g, 'strong'],
        [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
        [/(?<![A-Za-z0-9_])_(?![_\s])(.+?)(?<![\s_])_(?![A-Za-z0-9_])/g, 'em'],
        [/~~(.+?)~~/g, 'del'],
        [/`([^`\n]+)`/g, 'code']
    ];

    /** GFM alert type labels */
    const ALERT_LABELS = {
        NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important',
        WARNING: 'Warning', CAUTION: 'Caution'
    };

    /** Render nested blockquotes from a run of parsed lines. */
    function renderNestedBlockquotes(items, getAttr, dataQd) {
        // ── GFM alert detection ──
        // Check if the first item's content matches [!TYPE]
        /* istanbul ignore next -- depth is always 1 for outermost blockquote */
        const alertMatch = items.length > 0 && items[0].depth === 1
            ? items[0].content.trim().match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$/i)
            : null;

        let html = '';
        const stack = [];
        const useInlineStyles = getAttr('blockquote').includes('style=');

        for (let idx = 0; idx < items.length; idx++) {
            const { depth, content } = items[idx];
            /* istanbul ignore next -- depth is always >= 1 from parseBlockquoteLine gate */
            if (depth <= 0) continue;

            // Skip the [!TYPE] marker line — we'll render it as a title
            if (alertMatch && idx === 0) {
                const alertType = alertMatch[1].toUpperCase();
                const typeLower = alertType.toLowerCase();
                if (useInlineStyles) {
                    const baseStyle = QUIKDOWN_STYLES['alert'];
                    const typeStyle = QUIKDOWN_STYLES['alert-' + typeLower];
                    /* istanbul ignore next -- typeStyle is always defined for valid alert types */
                    const merged = typeStyle ? `${baseStyle};${typeStyle}` : baseStyle;
                    html += `<div style="${merged}"${dataQd('>')}>`;
                } else {
                    html += `<div class="${CLASS_PREFIX}alert ${CLASS_PREFIX}alert-${typeLower}"${dataQd('>')}>`;
                }
                // Title
                const label = ALERT_LABELS[alertType];
                if (useInlineStyles) {
                    html += `<p style="${QUIKDOWN_STYLES['alert-title']}">${label}</p>`;
                } else {
                    html += `<p class="${CLASS_PREFIX}alert-title">${label}</p>`;
                }
                stack.push('alert');
                continue;
            }

            while (stack.length > depth) {
                const tag = stack.pop();
                /* istanbul ignore next -- alert closing uses </div>, blockquote uses </blockquote> */
                html += tag === 'alert' ? '</div>' : '</blockquote>';
            }
            while (stack.length < depth) {
                /* istanbul ignore next -- defensive: alert div already opened at depth 0 */
                if (stack.length === 0 && alertMatch) {
                    stack.push('alert');
                } else {
                    html += `<blockquote${getAttr('blockquote')}${dataQd('>')}>`;
                    stack.push('blockquote');
                }
            }
            html += content;
            if (idx < items.length - 1) html += '\n';
        }

        while (stack.length > 0) {
            const tag = stack.pop();
            html += tag === 'alert' ? '</div>' : '</blockquote>';
        }

        return html.trimEnd();
    }

    /** True when a line is part of a 4-space (or tab) indented code block. */
    function isIndentedCodeLine(line) {
        if (line.length === 0) return false;
        const m = line.match(/^([ \t]+)(.*)$/);
        if (!m) return false;
        const spaceEquiv = m[1].replace(/\t/g, '    ').length;
        if (spaceEquiv < 4) return false;
        const content = m[2];
        if (/^[-*+]\s/.test(content) || /^\d+\.\s/.test(content) || /^>/.test(content) || /^#{1,6}\s/.test(content)) {
            return false;
        }
        return true;
    }

    /** Strip one indent level (4 spaces or one tab) from a code line. */
    function stripCodeIndent(line) {
        if (line.startsWith('    ')) return line.slice(4);
        if (line[0] === '\t') return line.slice(1);
        const m = line.match(/^[ \t]+(.*)$/);
        /* istanbul ignore next -- isIndentedCodeLine guarantees leading whitespace */
        return m ? m[1] : line;
    }

    /** List item: optional indent, a bullet or ordinal marker, whitespace, content. */
    const LIST_ITEM_RE = /^(\s*)([*\-+]|\d+\.)\s+(.+)$/;

    /**
     * True when an indented, non-blank line is wrapped text belonging to the
     * list item above it (issue #36).  Lines that start a block construct of
     * their own (fence, blockquote, table row, heading, HR, or already-rendered
     * HTML) are excluded so they keep their existing rendering.
     */
    function isListContinuation(line) {
        const m = line.match(/^[ \t]+(\S.*)$/);
        if (!m) return false;
        const t = m[1];
        if (/^(```|~~~|>|&gt;|\||#{1,6}(\s|$)|<)/.test(t)) return false;
        if (/^([-*_])\1{2,}\s*$/.test(t)) return false;
        return true;
    }

    /**
     * processIndentedCodeBlocks — line walker for 4-space / tab indented code
     *
     * An indented line directly under a list item (or under another
     * continuation line of that item) is wrapped item text, not code, so it
     * is passed through untouched for processLists to fold.  A blank line
     * ends the item, so `- item\n\n    code` still produces a code block.
     */
    function processIndentedCodeBlocks(text, getAttr, dataQd) {
        const lines = text.split('\n');
        const result = [];
        let i = 0;
        let inListItem = false;

        while (i < lines.length) {
            const line = lines[i];
            if (LIST_ITEM_RE.test(line)) {
                inListItem = true;
                result.push(line);
                i++;
                continue;
            }
            if (inListItem) {
                if (isListContinuation(line)) {
                    result.push(line);
                    i++;
                    continue;
                }
                inListItem = false;
            }
            if (!isIndentedCodeLine(line)) {
                result.push(line);
                i++;
                continue;
            }

            const codeLines = [];
            while (i < lines.length) {
                const line = lines[i];
                if (line === '') {
                    if (i + 1 < lines.length && isIndentedCodeLine(lines[i + 1])) {
                        codeLines.push('');
                        i++;
                        continue;
                    }
                    break;
                }
                if (isIndentedCodeLine(line)) {
                    codeLines.push(stripCodeIndent(line));
                    i++;
                } else {
                    break;
                }
            }

            // Join with a placeholder so the paragraph / line-break pass cannot
            // turn blank lines into </p><p> or newlines into <br> inside <pre>.
            const codeBody = codeLines.join(PLACEHOLDER_NL);
            result.push(`<pre${getAttr('pre')}${dataQd('    ')}><code${getAttr('code')}>${codeBody}</code></pre>`);
        }

        return result.join('\n');
    }

    /** Split a table row into cell strings. */
    function parseTableCells(line) {
        return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
    }

    // ════════════════════════════════════════════════════════════════════
    //  Main parser function
    // ════════════════════════════════════════════════════════════════════

    function quikdown(markdown, options = {}) {
        // ── Guard: only process non-empty strings ──
        if (!markdown || typeof markdown !== 'string') {
            return '';
        }

        // ── Unpack options ──
        const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false, heading_ids = false, reference_links = false, footnotes = false } = options;
        const styles = QUIKDOWN_STYLES;
        const getAttr = createGetAttr(inline_styles, styles);
        const headingSlugCounts = new Map();

        // ── Helpers (closed over options) ──

        /** Escape the five HTML-special characters. */
        function escapeHtml(text) {
            return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
        }

        /**
         * Bidirectional marker helper.
         * When bidirectional mode is on, returns ` data-qd="…"`.
         * The non-bidirectional branch is a trivial no-op arrow; it is
         * exercised in the core bundle but never in quikdown_bd.
         */
        /* istanbul ignore next - trivial no-op fallback */
        const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';

        /**
         * Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
         * Returns '#' for blocked URLs.
         */
        function sanitizeUrl(url, allowUnsafe = false) {
            /* istanbul ignore next - defensive programming, regex ensures url is never empty */
            if (!url) return '';
            if (allowUnsafe) return url;

            const trimmedUrl = url.trim();
            const lowerUrl = trimmedUrl.toLowerCase();
            const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];

            for (const protocol of dangerousProtocols) {
                if (lowerUrl.startsWith(protocol)) {
                    if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
                        return trimmedUrl;
                    }
                    return '#';
                }
            }
            return trimmedUrl;
        }

        /**
         * Sanitize attributes on an HTML tag string for limited mode.
         * Strips on* event handlers (case-insensitive) and runs sanitizeUrl()
         * on href/src/action/formaction values.
         */
        function sanitizeHtmlTagAttrs(tagStr) {
            // Self-closing or void tag without attributes — pass through
            if (!/\s/.test(tagStr.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*/, '').replace(/\/?>$/, ''))) {
                return tagStr;
            }
            // Parse: <tagname ...attrs... > or <tagname ...attrs... />
            const m = tagStr.match(/^(<\/?[a-zA-Z][a-zA-Z0-9]*)([\s\S]*?)(\/?>)$/);
            /* istanbul ignore next - defensive: Phase 1.5 regex guarantees valid tag shape */
            if (!m) return tagStr;

            const [, open, attrStr, close] = m;
            // Match individual attributes: name="value", name='value', name=value, or bare name
            // eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers
            const attrRe = /([a-zA-Z_][\w\-.:]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
            const attrs = [];
            let am;
            while ((am = attrRe.exec(attrStr)) !== null) {
                const name = am[1];
                const value = am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4];
                // Strip event handlers (on*)
                if (/^on/i.test(name)) continue;
                if (value === undefined) {
                    // Boolean attribute (e.g. disabled, checked)
                    attrs.push(name);
                } else {
                    let sanitized = value;
                    if (name.toLowerCase() in URL_ATTRIBUTES) {
                        sanitized = sanitizeUrl(value);
                    }
                    attrs.push(`${name}="${sanitized}"`);
                }
            }
            return open + (attrs.length ? ' ' + attrs.join(' ') : '') + close;
        }

        // ────────────────────────────────────────────────────────────────
        //  Phase 1 — Code Extraction
        // ────────────────────────────────────────────────────────────────
        // Why extract code first?  Fenced blocks and inline code spans can
        // contain markdown-like characters (*, _, #, |, etc.) that must NOT
        // be interpreted as formatting.  By pulling them out and replacing
        // with unique placeholders, the rest of the pipeline never sees them.

        let html = markdown;
        const codeBlocks = [];    // Array of {lang, code, custom, fence, hasReverse}
        const inlineCodes = [];   // Array of escaped-HTML strings

        // ── Fenced code blocks ──
        // Matches paired fences: ``` with ``` and ~~~ with ~~~.
        // The fence must start at column 0 of a line (^ with /m flag).
        // Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
        html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
            const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
            const langTrimmed = lang ? lang.trim() : '';

            if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
                // Custom plugin — store raw code (un-escaped) so the plugin
                // receives the original source.
                codeBlocks.push({
                    lang: langTrimmed,
                    code: code.trimEnd(),
                    custom: true,
                    fence: fence,
                    hasReverse: !!fence_plugin.reverse
                });
            } else {
                // Default — pre-escape the code for safe HTML output.
                codeBlocks.push({
                    lang: langTrimmed,
                    code: escapeHtml(code.trimEnd()),
                    custom: false,
                    fence: fence
                });
            }
            return placeholder;
        });

        // ── Escaped backticks ──
        // Extract \` before inline code extraction so an escaped backtick
        // does not participate in code span pairing.
        const backslashEscapes = [];
        html = html.replace(/\\`/g, () => {
            const placeholder = `${PLACEHOLDER_BE}${backslashEscapes.length}§`;
            backslashEscapes.push('`');
            return placeholder;
        });

        // ── Inline code spans ──
        // Matches a single backtick pair: `content`.
        // Content is captured and HTML-escaped immediately.
        html = html.replace(/`([^`\n]+)`/g, (match, code) => {
            const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
            inlineCodes.push(escapeHtml(code));
            return placeholder;
        });

        // ────────────────────────────────────────────────────────────────
        //  Phase 1.25 — Backslash Escape Extraction
        // ────────────────────────────────────────────────────────────────
        // Extract remaining backslash-escaped ASCII punctuation so those
        // characters are not interpreted as markdown formatting.  Runs
        // after code extraction (so \* inside code blocks and inline code
        // is already protected) and before HTML escaping.

        html = html.replace(/\\([\\*_{}[\]()#+\-.!~|<>])/g, (match, char) => {
            const placeholder = `${PLACEHOLDER_BE}${backslashEscapes.length}§`;
            backslashEscapes.push(escapeHtml(char));
            return placeholder;
        });

        // ────────────────────────────────────────────────────────────────
        //  Phase 1.75 — Reference Link & Footnote Definition Collection
        // ────────────────────────────────────────────────────────────────
        // Scan lines BEFORE HTML escaping to collect definitions.
        // [id]: url "title"   — reference link definition
        // [^id]: text         — footnote definition (with indented continuation)
        // Characters [, ], :, ^ are NOT in the HTML escape map so they
        // survive all phases unchanged.  Definition lines are stripped.

        const refDefs = new Map();     // id (lowercase) → { url, title }
        const fnDefs = new Map();      // id → text
        const fnOrder = [];            // ordered list of footnote ids as referenced

        if (reference_links || footnotes) {
            const lines = html.split('\n');
            const kept = [];
            let i = 0;
            while (i < lines.length) {
                const line = lines[i];

                // Skip lines inside code block placeholders
                if (line.includes(PLACEHOLDER_CB)) {
                    kept.push(line);
                    i++;
                    continue;
                }

                // Footnote definition: [^id]: text
                if (footnotes) {
                    const fnMatch = line.match(/^\[\^([^\]]+)\]:\s+([\s\S]*)$/);
                    if (fnMatch) {
                        const fnId = fnMatch[1];
                        let fnText = fnMatch[2];
                        // Collect indented continuation lines
                        while (i + 1 < lines.length) {
                            const next = lines[i + 1];
                            if (/^[ \t]+\S/.test(next) && !next.includes(PLACEHOLDER_CB)) {
                                fnText += ' ' + next.trim();
                                i++;
                            } else {
                                break;
                            }
                        }
                        // First definition wins
                        const key = fnId;
                        if (!fnDefs.has(key)) {
                            fnDefs.set(key, fnText);
                        }
                        i++;
                        continue;
                    }
                }

                // Reference link definition: [id]: url "title"
                if (reference_links) {
                    // eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers on same path
                    const refMatch = line.match(/^\[([^\]]+)\]:\s+<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?\s*$/);
                    if (refMatch) {
                        const refId = refMatch[1].toLowerCase();
                        const url = refMatch[2];
                        const title = refMatch[3] !== undefined ? refMatch[3]
                            : refMatch[4] !== undefined ? refMatch[4]
                            : refMatch[5] !== undefined ? refMatch[5]
                            : null;
                        // First definition wins
                        if (!refDefs.has(refId)) {
                            refDefs.set(refId, { url, title });
                        }
                        i++;
                        continue;
                    }
                }

                kept.push(line);
                i++;
            }
            html = kept.join('\n');
        }

        // ────────────────────────────────────────────────────────────────
        //  Phase 1.5 — Safe HTML Extraction (whitelist mode)
        // ────────────────────────────────────────────────────────────────
        // When allow_unsafe_html is an object or array, extract whitelisted
        // HTML tags, sanitize their attributes, and replace with placeholders.
        // Non-whitelisted tags stay in text so Phase 2 will escape them.

        const safeTags = [];
        // Normalize: array → object for O(1) lookup; object used as-is
        const htmlAllow = Array.isArray(allow_unsafe_html)
            ? Object.fromEntries(allow_unsafe_html.map(t => [t, 1]))
            : (allow_unsafe_html && typeof allow_unsafe_html === 'object') ? allow_unsafe_html : null;

        if (htmlAllow) {
            // Pass through HTML comments — browsers render them as nothing
            html = html.replace(/<!--[\s\S]*?-->/g, (match) => {
                const idx = safeTags.length;
                safeTags.push(match);
                return `${PLACEHOLDER_HT}${idx}§`;
            });
            html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tagName) => {
                if (tagName.toLowerCase() in htmlAllow) {
                    const sanitized = sanitizeHtmlTagAttrs(match);
                    const idx = safeTags.length;
                    safeTags.push(sanitized);
                    return `${PLACEHOLDER_HT}${idx}§`;
                }
                // Not whitelisted — leave in text for Phase 2 to escape
                return match;
            });
        }

        // ────────────────────────────────────────────────────────────────
        //  Phase 2 — HTML Escaping
        // ────────────────────────────────────────────────────────────────
        // All remaining text (everything except code placeholders) is escaped
        // to prevent XSS.  The `allow_unsafe_html` option skips this for
        // trusted pipelines that intentionally embed raw HTML.
        // For whitelist mode, escaping still runs (only `true` bypasses it).

        if (allow_unsafe_html !== true) {
            html = escapeHtml(html);
        }

        // Restore safe HTML tag placeholders after escaping
        if (htmlAllow) {
            safeTags.forEach((tag, i) => {
                html = html.replace(`${PLACEHOLDER_HT}${i}§`, tag);
            });
        }

        // ────────────────────────────────────────────────────────────────
        //  Phase 3 — Block Scanning + Inline Formatting + Paragraphs
        // ────────────────────────────────────────────────────────────────
        // This is the heart of the lexer rewrite.  Instead of applying
        // 10+ global regex passes, we:
        //   0. Process indented code blocks (4-space / tab, before other blocks)
        //   1. Process tables (line walker — tables need multi-line lookahead)
        //   2. Scan remaining lines for headings, HR, blockquotes
        //   3. Process lists (line walker — lists need indent tracking)
        //   4. Apply inline formatting to all text content
        //   5. Wrap remaining text in <p> tags
        //
        // Steps 0, 1 and 3 are line-walkers that process the full text in a
        // single pass each.  Step 2 replaces global regex with a per-line
        // scanner.  Steps 4-5 are applied to the result.
        //
        // Total: 4 structured passes instead of 10+ regex passes.

        // ── Step 0: Indented code blocks ──
        html = processIndentedCodeBlocks(html, getAttr, dataQd);

        // ── Step 1: Tables ──
        // Tables need multi-line lookahead (header → separator → body rows)
        // so they're handled by a dedicated line-walker first.
        html = processTable(html, getAttr, bidirectional);

        // ── Step 2: Headings, HR, Blockquotes ──
        // These are simple line-level constructs.  We scan each line once
        // and replace matching lines with their HTML representation.
        html = scanLineBlocks(html, getAttr, dataQd, heading_ids, headingSlugCounts, escapeHtml);

        // ── Step 3: Lists ──
        // Lists need indent-level tracking across lines, so they get their
        // own line-walker.
        html = processLists(html, getAttr, inline_styles, bidirectional);

        // ── Step 4: Inline formatting ──
        // Apply bold, italic, strikethrough, images, links, and autolinks
        // to all text content.  This runs on the output of steps 1-3, so
        // it sees text inside headings, blockquotes, table cells, list
        // items, and paragraph text.

        // Images (must come before links — ![alt](src) vs [text](url))
        html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, dest) => {
            const { url, title } = parseLinkDestination(dest);
            const sanitizedSrc = sanitizeUrl(url, options.allow_unsafe_urls);
            const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
            /* istanbul ignore next - bd-only branch */
            const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
            /* istanbul ignore next - bd-only branch */
            const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(url)}"` : '';
            /* istanbul ignore next - bd-only branch */
            const titleQd = bidirectional && title ? ` data-qd-title="${escapeHtml(title)}"` : '';
            return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${titleAttr}${altAttr}${srcAttr}${titleQd}${dataQd('!')}>`;
        });

        // Links
        html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, dest) => {
            const { url, title } = parseLinkDestination(dest);
            const sanitizedHref = sanitizeUrl(url, options.allow_unsafe_urls);
            const isExternal = /^https?:\/\//i.test(sanitizedHref);
            const rel = isExternal ? ' rel="noopener noreferrer"' : '';
            const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
            /* istanbul ignore next - bd-only branch */
            const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
            /* istanbul ignore next - bd-only branch */
            const titleQd = bidirectional && title ? ` data-qd-title="${escapeHtml(title)}"` : '';
            return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${titleAttr}${textAttr}${titleQd}${dataQd('[')}>${text}</a>`;
        });

        // Autolinks — bare https?:// URLs become clickable <a> tags
        html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, rawUrl) => {
            const { url, trailing } = stripTrailingPunctuation(rawUrl);
            const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
            return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>${trailing}`;
        });

        // ── Phase 3.5: Reference Link & Footnote Resolution ──
        // Resolve [text][id], [text][], [id] patterns to <a> tags using
        // collected definitions.  Footnote markers [^id] become <sup> links.

        if (reference_links && refDefs.size > 0) {
            /** Build an <a> tag from a resolved reference definition. */
            function buildRefAnchor(def, id, displayText) {
                const sanitizedHref = sanitizeUrl(def.url, options.allow_unsafe_urls);
                const isExternal = /^https?:\/\//i.test(sanitizedHref);
                const rel = isExternal ? ' rel="noopener noreferrer"' : '';
                const titleAttr = def.title ? ` title="${escapeHtml(def.title)}"` : '';
                /* istanbul ignore next - bd-only branch */
                const refAttr = bidirectional ? ` data-qd-ref="${escapeHtml(id)}"` : '';
                return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${titleAttr}${refAttr}${dataQd('[ref')}>${displayText}</a>`;
            }

            // Full reference: [text][id]  and collapsed: [text][]
            html = html.replace(/\[([^\]]+)\]\[([^\]]*)\]/g, (match, text, id) => {
                const def = refDefs.get((id === '' ? text : id).toLowerCase());
                return def ? buildRefAnchor(def, id, text) : match;
            });

            // Shortcut reference: [id] (not followed by ( or [, not containing ^)
            html = html.replace(/(?<!\])\[([^\]^[\n]+)\](?!\(|\[)/g, (match, id) => {
                const def = refDefs.get(id.toLowerCase());
                return def ? buildRefAnchor(def, id, id) : match;
            });
        }

        if (footnotes && fnDefs.size > 0) {
            // Footnote markers: [^id]
            html = html.replace(/\[\^([^\]]+)\]/g, (match, id) => {
                if (!fnDefs.has(id)) return match; // unresolved — leave as text
                // Track ordering — each unique id gets a sequential number
                let fnNum = fnOrder.indexOf(id);
                if (fnNum === -1) {
                    fnOrder.push(id);
                    fnNum = fnOrder.length;
                } else {
                    fnNum = fnNum + 1;
                }
                /* istanbul ignore next - bd-only branch */
                const fnAttr = bidirectional ? ` data-qd-fn="${escapeHtml(id)}"` : '';
                return `<sup${getAttr('sup')}${fnAttr}${dataQd('[^')}><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">${fnNum}</a></sup>`;
            });
        }

        // Protect rendered tags so emphasis regexes don't see attribute
        // values — fixes #3 (underscores in URLs interpreted as emphasis).
        const savedTags = [];
        html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });

        // Bold, italic, strikethrough (reuses BASE_INLINE_PATTERNS; code spans
        // are already extracted in Phase 1 so only the first 5 patterns apply here)
        const emphasisMarkers = ['**', '__', '*', '_', '~~'];
        for (let pi = 0; pi < 5; pi++) {
            const [pattern, tag] = BASE_INLINE_PATTERNS[pi];
            html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(emphasisMarkers[pi])}>$1</${tag}>`);
        }

        // Restore protected tags
        html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);

        // ── Step 5: Line breaks + paragraph wrapping ──

        // Backslash at end of line → hard line break (CommonMark)
        html = html.replace(/\\\n/g, `<br${getAttr('br')}>`);

        if (lazy_linefeeds) {
            // Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
            //   • Double newlines → paragraph break
            //   • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
            //
            // Strategy: protect block-adjacent newlines with §N§, convert
            // the rest, then restore.

            const blocks = [];
            let bi = 0;

            // Protect tables and lists from <br> injection
            html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
                blocks[bi] = m;
                return `§B${bi++}§`;
            });

            html = html.replace(/\n\n+/g, '§P§')
                // After block-level closing tags
                .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
                .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
                // Before block-level opening tags
                .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
                .replace(/\n(§B\d+§)/g, '§N§$1')
                .replace(/(§B\d+§)\n/g, '$1§N§')
                // Convert surviving newlines to <br>
                .replace(/\n/g, `<br${getAttr('br')}>`)
                // Restore
                .replace(/§N§/g, '\n')
                .replace(/§P§/g, '</p><p>');

            // Restore protected blocks
            blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));

            html = '<p>' + html + '</p>';
        } else {
            // Standard mode: two trailing spaces → <br>, double newline → new paragraph
            html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);

            html = html.replace(/\n\n+/g, (match, offset) => {
                const before = html.substring(0, offset);
                if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
                    return '<p>';
                }
                return '</p><p>';
            });
            html = '<p>' + html + '</p>';
        }

        // ── Step 6: Cleanup ──
        // Remove <p> wrappers that accidentally enclose block elements.
        // This is simpler than trying to prevent them during wrapping.
        const cleanupPatterns = [
            [/<p><\/p>/g, ''],
            [/<p>(<h[1-6][^>]*>)/g, '$1'],
            [/(<\/h[1-6]>)<\/p>/g, '$1'],
            [/<p>(<blockquote[^>]*>)/g, '$1'],
            [/(<\/blockquote>)<\/p>/g, '$1'],
            [/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
            [/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
            [/<p>(<hr[^>]*>)<\/p>/g, '$1'],
            [/<p>(<table[^>]*>)/g, '$1'],
            [/(<\/table>)<\/p>/g, '$1'],
            [/<p>(<pre[^>]*>)/g, '$1'],
            [/(<\/pre>)<\/p>/g, '$1'],
            [/<p>(<div[^>]*>)/g, '$1'],
            [/(<\/div>)<\/p>/g, '$1'],
            [/<p>(<section[^>]*>)/g, '$1'],
            [/(<\/section>)<\/p>/g, '$1'],
            [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
        ];
        cleanupPatterns.forEach(([pattern, replacement]) => {
            html = html.replace(pattern, replacement);
        });

        // When a block element is followed by a newline and then text, open a <p>.
        html = html.replace(/(<\/(?:h[1-6]|blockquote|div|section|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');

        // ── Footnotes section ──
        // Only rendered if footnotes were actually referenced in the document.
        if (footnotes && fnOrder.length > 0) {
            /* istanbul ignore next - bd-only branch */
            const sectionAttr = bidirectional ? ` data-qd="[^section"` : '';
            let fnSection = `<section${getAttr('footnotes')}${sectionAttr}>`;
            fnSection += `<hr${getAttr('hr')}>`;
            fnSection += `<ol${getAttr('ol')}>`;
            for (const id of fnOrder) {
                const rawText = fnDefs.get(id) || '';
                // Escape HTML in footnote text, then apply inline formatting
                let fnHtml = allow_unsafe_html === true ? rawText : escapeHtml(rawText);
                for (const [pattern, tag] of BASE_INLINE_PATTERNS) {
                    fnHtml = fnHtml.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
                }
                /* istanbul ignore next - bd-only branch */
                const liAttr = bidirectional ? ` data-qd-fn-id="${escapeHtml(id)}"` : '';
                fnSection += `<li${getAttr('li')} id="fn-${escapeHtml(id)}"${liAttr}>${fnHtml} <a href="#fnref-${escapeHtml(id)}"${getAttr('footnote-backref')}>↩</a></li>`;
            }
            fnSection += '</ol></section>';
            html += fnSection;
        }

        // ────────────────────────────────────────────────────────────────
        //  Phase 4 — Code Restoration
        // ────────────────────────────────────────────────────────────────
        // Replace placeholders with rendered HTML.  For fenced blocks this
        // means wrapping in <pre><code>…</code></pre> (or calling the
        // fence_plugin).  For inline code it means <code>…</code>.

        codeBlocks.forEach((block, i) => {
            let replacement;

            if (block.custom && fence_plugin && fence_plugin.render) {
                // Delegate to the user-provided fence plugin.
                replacement = fence_plugin.render(block.code, block.lang);

                if (replacement === undefined) {
                    // Plugin declined — fall back to default rendering.
                    const langClass = !inline_styles && block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
                    const codeAttr = inline_styles ? getAttr('code') : langClass;
                    /* istanbul ignore next - bd-only branch */
                    const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
                    /* istanbul ignore next - bd-only branch */
                    const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
                    replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
                } else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
                    // Plugin returned HTML — inject data attributes for roundtrip.
                    replacement = replacement.replace(/^<(\w+)/,
                        `<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
                }
            } else {
                // Default rendering — wrap in <pre><code>.
                const langClass = !inline_styles && block.lang ? ` class="language-${escapeHtml(block.lang)}"` : '';
                const codeAttr = inline_styles ? getAttr('code') : langClass;
                /* istanbul ignore next - bd-only branch */
                const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
                /* istanbul ignore next - bd-only branch */
                const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
                replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
            }

            const placeholder = `${PLACEHOLDER_CB}${i}§`;
            html = html.replace(placeholder, replacement);
        });

        // Restore inline code spans
        inlineCodes.forEach((code, i) => {
            const placeholder = `${PLACEHOLDER_IC}${i}§`;
            html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
        });

        // Restore backslash escapes
        backslashEscapes.forEach((char, i) => {
            html = html.replace(`${PLACEHOLDER_BE}${i}§`, char);
        });

        // Restore newlines inside indented code blocks
        html = html.split(PLACEHOLDER_NL).join('\n');

        return html.trim();
    }

    // ════════════════════════════════════════════════════════════════════
    //  Block-level line scanner
    // ════════════════════════════════════════════════════════════════════

    /**
     * scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
     *
     * Walks the text line by line.  For each line it checks (in order):
     *   1. Heading   — starts with 1-6 '#' followed by a space
     *   2. HR        — line is entirely '---…' (3+ dashes, optional trailing space)
     *   3. Blockquote — starts with '&gt; ' (the > was already HTML-escaped)
     *
     * Lines that don't match any block pattern are passed through unchanged.
     *
     * This replaces three separate global regex passes from the pre-1.2.8
     * architecture with one structured scan.
     *
     * @param {string}   text    The document text (HTML-escaped, code extracted)
     * @param {Function} getAttr Attribute factory (class or style)
     * @param {Function} dataQd  Bidirectional marker factory
     * @param {boolean}  headingIds  Emit id attributes on headings
     * @param {Map}      slugCounts  Duplicate slug tracker
     * @param {Function} escapeHtmlFn Escape helper for id attributes
     * @returns {string}         Text with block-level elements rendered
     */
    function scanLineBlocks(text, getAttr, dataQd, headingIds, slugCounts, escapeHtmlFn) {
        const lines = text.split('\n');
        const result = [];
        let i = 0;

        while (i < lines.length) {
            const line = lines[i];

            // ── Markdown comment (reference-link hack) ──
            // [//]: # (comment)  or  [//]: # "comment"  or  [//]: #
            // These produce no output — standard markdown comment convention.
            if (/^\[\/\/\]: #/.test(line)) {
                i++;
                continue;
            }

            // ── Heading ──
            // Count leading '#' characters.  Valid heading: 1-6 hashes then a space.
            // Example: "## Hello World ##" → <h2>Hello World</h2>
            let hashCount = 0;
            while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
                hashCount++;
            }
            if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
                // Extract content after "# " and strip trailing hashes
                const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
                const tag = 'h' + hashCount;
                let idAttr = '';
                if (headingIds) {
                    const slug = uniqueSlug(headingSlug(content), slugCounts);
                    idAttr = ` id="${escapeHtmlFn(slug)}"`;
                }
                result.push(`<${tag}${getAttr(tag)}${idAttr}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
                i++;
                continue;
            }

            // ── Horizontal Rule ──
            // Three or more identical chars (-, *, _), optional interspersed spaces.
            if (isHRLine(line)) {
                result.push(`<hr${getAttr('hr')}${dataQd(line.trim())}>`);
                i++;
                continue;
            }

            // ── Blockquote ──
            // After Phase 2, the '>' character has been escaped to '&gt;'.
            // Supports nested levels: >> inner, > > inner
            // Supports lazy continuation: lines without > prefix continue
            // the blockquote at the previous depth.
            if (parseBlockquoteLine(line).depth > 0) {
                const bqLines = [];
                let lastDepth = 0;
                while (i < lines.length) {
                    const parsed = parseBlockquoteLine(lines[i]);
                    if (parsed.depth > 0) {
                        bqLines.push(parsed);
                        lastDepth = parsed.depth;
                        i++;
                    } else if (lastDepth > 0 && !isLazyContinuationBreaker(lines[i])) {
                        bqLines.push({ depth: lastDepth, content: lines[i] });
                        i++;
                    } else {
                        break;
                    }
                }
                result.push(renderNestedBlockquotes(bqLines, getAttr, dataQd));
                continue;
            }

            // ── Pass-through ──
            result.push(line);
            i++;
        }

        return result.join('\n');
    }

    // ════════════════════════════════════════════════════════════════════
    //  Table processing (line walker)
    // ════════════════════════════════════════════════════════════════════

    /**
     * Inline markdown formatter for table cells.
     * Handles bold, italic, strikethrough, and code within cell text.
     * Links / images / autolinks are handled by the global inline pass
     * (Phase 3 Step 4) which runs after table processing.
     */
    function processInlineMarkdown(text, getAttr) {
        const patterns = [
            [/\*\*(.+?)\*\*/g, 'strong'],
            [/__(.+?)__/g, 'strong'],
            [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
            [/(?<![A-Za-z0-9_])_(?![_\s])(.+?)(?<![\s_])_(?![A-Za-z0-9_])/g, 'em'],
            [/~~(.+?)~~/g, 'del'],
            [/`([^`\n]+)`/g, 'code']
        ];
        patterns.forEach(([pattern, tag]) => {
            text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
        });
        return text;
    }

    /**
     * processTable — line walker for markdown tables
     *
     * Walks through lines looking for runs of pipe-containing lines.
     * Each run is validated (must contain a separator row: |---|---|)
     * and rendered as an HTML <table>.  Invalid runs are restored as-is.
     *
     * @param {string}   text    Full document text
     * @param {Function} getAttr Attribute factory
     * @returns {string}         Text with tables rendered
     */
    function processTable(text, getAttr, bidirectional) {
        const lines = text.split('\n');
        const result = [];
        let inTable = false;
        let tableLines = [];

        for (let i = 0; i < lines.length; i++) {
            const line = lines[i].trim();

            if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
                if (!inTable) {
                    inTable = true;
                    tableLines = [];
                }
                tableLines.push(line);
            } else {
                if (inTable) {
                    const tableHtml = buildTable(tableLines, getAttr, bidirectional);
                    if (tableHtml) {
                        result.push(tableHtml);
                    } else {
                        result.push(...tableLines);
                    }
                    inTable = false;
                    tableLines = [];
                }
                result.push(lines[i]);
            }
        }

        // Handle table at end of document
        if (inTable && tableLines.length > 0) {
            const tableHtml = buildTable(tableLines, getAttr, bidirectional);
            if (tableHtml) {
                result.push(tableHtml);
            } else {
                result.push(...tableLines);
            }
        }

        return result.join('\n');
    }

    /**
     * buildTable — validate and render a table from accumulated lines
     *
     * @param {string[]} lines   Array of pipe-containing lines
     * @param {Function} getAttr Attribute factory
     * @returns {string|null}    HTML table string, or null if invalid
     */
    function buildTable(lines, getAttr, bidirectional) {
        if (lines.length < 2) return null;

        // Find the separator row (---|---|)
        let separatorIndex = -1;
        for (let i = 1; i < lines.length; i++) {
            if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
                separatorIndex = i;
                break;
            }
        }

        let headerLines;
        let bodyLines;
        let alignments;

        if (separatorIndex === -1) {
            // No separator row (common in LLM output) — first row is header, rest is body
            headerLines = [lines[0]];
            bodyLines = lines.slice(1);
            alignments = parseTableCells(lines[0]).map(() => 'left');
        } else {
            headerLines = lines.slice(0, separatorIndex);
            bodyLines = lines.slice(separatorIndex + 1);

            const separator = lines[separatorIndex];
            const separatorCells = parseTableCells(separator);
            alignments = separatorCells.map(cell => {
                const trimmed = cell.trim();
                if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
                if (trimmed.endsWith(':')) return 'right';
                return 'left';
            });
        }

        const colCount = alignments.length;

        /* istanbul ignore next - bd-only branch */
        const alignAttr = bidirectional ? ` data-qd-align="${alignments.join(',')}"` : '';
        let html = `<table${getAttr('table')}${alignAttr}>\n`;

        // Header
        html += `<thead${getAttr('thead')}>\n`;
        headerLines.forEach(line => {
            html += `<tr${getAttr('tr')}>\n`;
            const cells = parseTableCells(line);
            for (let i = 0; i < colCount; i++) {
                const cell = i < cells.length ? cells[i] : '';
                const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
                const processedCell = processInlineMarkdown(cell.trim(), getAttr);
                html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
            }
            html += '</tr>\n';
        });
        html += '</thead>\n';

        // Body
        if (bodyLines.length > 0) {
            html += `<tbody${getAttr('tbody')}>\n`;
            bodyLines.forEach(line => {
                html += `<tr${getAttr('tr')}>\n`;
                const cells = parseTableCells(line);
                for (let i = 0; i < colCount; i++) {
                    const cell = i < cells.length ? cells[i] : '';
                    const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
                    const processedCell = processInlineMarkdown(cell.trim(), getAttr);
                    html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
                }
                html += '</tr>\n';
            });
            html += '</tbody>\n';
        }

        html += '</table>';
        return html;
    }

    // ════════════════════════════════════════════════════════════════════
    //  List processing (line walker)
    // ════════════════════════════════════════════════════════════════════

    /**
     * processLists — line walker for ordered, unordered, and task lists
     *
     * Scans each line for list markers (-, *, +, 1., 2., etc.) with
     * optional leading indentation for nesting.  The indent of the item
     * that opens the outermost list is the base, so a list that starts at
     * indent 2 is a top-level list.  An indented, non-blank line directly
     * under an item is wrapped item text and is folded into that item
     * (two trailing spaces or a trailing backslash on the item keep a hard
     * break).  Any other non-list line closes all open lists and passes
     * through unchanged.
     *
     * Task lists (- [ ] / - [x]) are detected and rendered with
     * checkbox inputs.
     *
     * @param {string}   text         Full document text
     * @param {Function} getAttr      Attribute factory
     * @param {boolean}  inline_styles Whether to use inline styles
     * @param {boolean}  bidirectional Whether to add data-qd markers
     * @returns {string}              Text with lists rendered
     */
    function processLists(text, getAttr, inline_styles, bidirectional) {
        const lines = text.split('\n');
        const result = [];
        const listStack = [];   // tracks nesting: [{type:'ul', level:0}, …]
        let baseIndent = 0;     // indent of the item that opened the outermost list
        let lastWasItem = false; // result[last] is an <li> a continuation line may extend

        // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
        // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
        // callback is defensive-only and never actually fires in practice.
        /* istanbul ignore next - defensive: list markers never trigger escaping */
        const escapeHtml = (text) => text.replace(/[&<>"']/g,
            /* istanbul ignore next - defensive: list markers never contain HTML specials */
            m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
        /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
        const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';

        for (let i = 0; i < lines.length; i++) {
            const line = lines[i];
            const match = line.match(LIST_ITEM_RE);

            if (match) {
                const [, indent, marker, content] = match;
                if (listStack.length === 0) baseIndent = indent.length;
                const level = Math.max(0, Math.floor((indent.length - baseIndent) / 2));
                const isOrdered = /^\d+\./.test(marker);
                const listType = isOrdered ? 'ol' : 'ul';

                // Task list detection (only in unordered lists)
                let listItemContent = content;
                let taskListClass = '';
                const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
                if (taskMatch && !isOrdered) {
                    const [, checked, taskContent] = taskMatch;
                    const isChecked = checked.toLowerCase() === 'x';
                    const checkboxAttr = inline_styles
                        ? ' style="margin-right:.5em"'
                        : ` class="${CLASS_PREFIX}task-checkbox"`;
                    listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
                    taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
                }

                // Close deeper nesting levels
                while (listStack.length > level + 1) {
                    const list = listStack.pop();
                    result.push(`</${list.type}>`);
                }

                // Switch list type at the current level
                if (listStack.length === level + 1 && listStack[level].type !== listType) {
                    result.push(`</${listStack.pop().type}>`);
                }

                // Open lists up to the current level.  More than one opens when
                // the indent jumps (`- a\n    - deep`) so no <li> is left bare.
                while (listStack.length < level + 1) {
                    listStack.push({ type: listType, level: listStack.length });
                    result.push(`<${listType}${getAttr(listType)}>`);
                }

                const liAttr = taskListClass || getAttr('li');
                result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
                lastWasItem = true;
            } else if (lastWasItem && isListContinuation(line)) {
                // Wrapped item text — fold into the <li> above (issue #36)
                let body = result[result.length - 1].slice(0, -'</li>'.length);
                if (/( {2}|\\)$/.test(body)) {
                    body = body.replace(/( {2}|\\)$/, `<br${getAttr('br')}>`);
                } else {
                    body += ' ';
                }
                result[result.length - 1] = `${body}${line.trim()}</li>`;
            } else {
                // Not a list item — close all open lists
                while (listStack.length > 0) {
                    const list = listStack.pop();
                    result.push(`</${list.type}>`);
                }
                result.push(line);
                lastWasItem = false;
            }
        }

        // Close any remaining open lists
        while (listStack.length > 0) {
            const list = listStack.pop();
            result.push(`</${list.type}>`);
        }

        return result.join('\n');
    }

    // ════════════════════════════════════════════════════════════════════
    //  Static API
    // ════════════════════════════════════════════════════════════════════

    /**
     * Emit CSS rules for all quikdown elements.
     *
     * @param {string} prefix  Class prefix (default: 'quikdown-')
     * @param {string} theme   'light' (default) or 'dark'
     * @returns {string}       CSS text
     */
    quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
        const styles = QUIKDOWN_STYLES;

        const themeOverrides = {
            dark: {
                '#f4f4f4': '#2a2a2a',   // pre background
                '#f0f0f0': '#2a2a2a',   // code background
                '#f2f2f2': '#2a2a2a',   // th background
                '#ddd': '#3a3a3a',      // borders
                '#06c': '#6db3f2',      // links
                '#ddf4ff': '#162d50',   // alert-note bg
                '#dafbe1': '#16351d',   // alert-tip bg
                '#fbefff': '#2d1a42',   // alert-important bg
                '#fff8c5': '#342a10',   // alert-warning bg
                '#ffebe9': '#3d1418',   // alert-caution bg
                _textColor: '#e0e0e0'
            },
            light: {
                _textColor: '#333'
            }
        };

        let css = '';
        for (const [tag, style] of Object.entries(styles)) {
            let themedStyle = style;

            if (theme === 'dark' && themeOverrides.dark) {
                for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
                    if (!oldColor.startsWith('_')) {
                        themedStyle = themedStyle.replaceAll(oldColor, newColor);
                    }
                }
                const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote', 'alert', 'alert-title'];
                if (needsTextColor.includes(tag)) {
                    themedStyle += `;color:${themeOverrides.dark._textColor}`;
                }
            } else /* istanbul ignore next */ if (theme === 'light' && themeOverrides.light) {
                const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote', 'alert', 'alert-title'];
                if (needsTextColor.includes(tag)) {
                    themedStyle += `;color:${themeOverrides.light._textColor}`;
                }
            }

            css += `.${prefix}${tag} { ${themedStyle} }\n`;
        }

        return css;
    };

    /**
     * Create a pre-configured parser with baked-in options.
     *
     * @param {Object} options  Options to bake in
     * @returns {Function}      Configured quikdown(markdown) function
     */
    quikdown.configure = function(options) {
        return function(markdown) {
            return quikdown(markdown, options);
        };
    };

    /** Semantic version (injected at build time) */
    quikdown.version = quikdownVersion;


    // ════════════════════════════════════════════════════════════════════
    //  Exports
    // ════════════════════════════════════════════════════════════════════

    /* istanbul ignore next */
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = quikdown;
    }

    /* istanbul ignore next */
    if (typeof window !== 'undefined') {
        window.quikdown = quikdown;
    }

    /**
     * quikdown_bd - Bidirectional markdown/HTML converter
     * Extends core quikdown with HTML→Markdown conversion
     * 
     * Uses data-qd attributes to preserve original markdown syntax
     * Enables HTML→Markdown conversion for quikdown-generated HTML
     */


    /**
     * Create bidirectional version by extending quikdown
     * This wraps quikdown and adds the toMarkdown method
     */
    function quikdown_bd(markdown, options = {}) {
        // Use core quikdown with bidirectional flag to add data-qd attributes
        return quikdown(markdown, { ...options, bidirectional: true });
    }

    // Copy all properties and methods from quikdown (including version).
    // Skip `configure` — quikdown_bd provides its own override below, so the
    // inner quikdown.configure is dead code in this bundle.
    Object.keys(quikdown).forEach(key => {
        if (key === 'configure') return;
        quikdown_bd[key] = quikdown[key];
    });

    // Add the toMarkdown method for HTML→Markdown conversion
    quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
        // Accept either HTML string or DOM element
        let container;
        if (typeof htmlOrElement === 'string') {
            container = document.createElement('div');
            container.innerHTML = htmlOrElement;
        } else if (htmlOrElement instanceof Element) {
            /* istanbul ignore next - browser-only code path, not testable in jsdom */
            container = htmlOrElement;
        } else {
            return '';
        }
        
        // Walk the DOM tree and reconstruct markdown
        function walkNode(node, parentContext = {}) {
            if (node.nodeType === Node.TEXT_NODE) {
                // Return text content, preserving whitespace where needed
                return node.textContent;
            }
            
            if (node.nodeType !== Node.ELEMENT_NODE) {
                return '';
            }
            
            const tag = node.tagName.toLowerCase();
            const dataQd = node.getAttribute('data-qd');
            
            // Process children with context
            let childContent = '';
            for (const child of node.childNodes) {
                childContent += walkNode(child, { parentTag: tag, ...parentContext });
            }
            
            // Determine markdown based on element and attributes
            switch (tag) {
                case 'h1':
                case 'h2':
                case 'h3':
                case 'h4':
                case 'h5':
                case 'h6':
                    const level = parseInt(tag[1]);
                    const prefix = dataQd || '#'.repeat(level);
                    return `${prefix} ${childContent.trim()}\n\n`;
                    
                case 'strong':
                case 'b':
                    if (!childContent) return ''; // Don't add markers for empty content
                    const boldMarker = dataQd || '**';
                    return `${boldMarker}${childContent}${boldMarker}`;
                    
                case 'em':
                case 'i':
                    if (!childContent) return ''; // Don't add markers for empty content
                    const emMarker = dataQd || '*';
                    return `${emMarker}${childContent}${emMarker}`;
                    
                case 'del':
                case 's':
                case 'strike':
                    if (!childContent) return ''; // Don't add markers for empty content
                    const delMarker = dataQd || '~~';
                    return `${delMarker}${childContent}${delMarker}`;
                    
                case 'code':
                    // Note: code inside pre is handled directly by the pre case using querySelector
                    if (!childContent) return ''; // Don't add markers for empty content
                    const codeMarker = dataQd || '`';
                    return `${codeMarker}${childContent}${codeMarker}`;
                    
                case 'pre':
                    const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
                    const lang = node.getAttribute('data-qd-lang') || '';
                    
                    // Check if this was created by a fence plugin with reverse handler
                    if (options.fence_plugin && options.fence_plugin.reverse && lang) {
                        try {
                            const result = options.fence_plugin.reverse(node);
                            if (result && result.content) {
                                const fenceMarker = result.fence || fence;
                                const langStr = result.lang || lang;
                                return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
                            }
                        } catch (err) {
                            console.warn('Fence reverse handler error:', err);
                            // Fall through to default handling
                        }
                    }
                    
                    // Fallback: use data-qd-source if available
                    const source = node.getAttribute('data-qd-source');
                    if (source) {
                        return `${fence}${lang}\n${source}\n${fence}\n\n`;
                    }
                    
                    // Final fallback: extract text content
                    const codeEl = node.querySelector('code');
                    const codeContent = codeEl ? codeEl.textContent : childContent;
                    return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
                    
                case 'blockquote':
                    const quoteMarker = dataQd || '>';
                    const lines = childContent.trim().split('\n');
                    return lines.map(line => `${quoteMarker} ${line}`).join('\n') + '\n\n';
                    
                case 'hr':
                    const hrMarker = dataQd || '---';
                    return `${hrMarker}\n\n`;
                    
                case 'br':
                    const brMarker = dataQd || '  ';
                    return `${brMarker}\n`;
                    
                case 'a': {
                    const href = node.getAttribute('href') || '';
                    // Skip footnote back-links (↩)
                    if (href.startsWith('#fnref-')) {
                        return '';
                    }
                    // Reference link
                    const refId = node.getAttribute('data-qd-ref');
                    if (refId !== null) {
                        const linkText = node.getAttribute('data-qd-text') || childContent.trim();
                        if (refId === '') {
                            // Collapsed reference: [text][]
                            return `[${linkText}][]`;
                        }
                        if (refId.toLowerCase() === linkText.toLowerCase()) {
                            // Shortcut reference: [id]
                            return `[${refId}]`;
                        }
                        // Full reference: [text][id]
                        return `[${linkText}][${refId}]`;
                    }
                    const linkText = node.getAttribute('data-qd-text') || childContent.trim();
                    // Check for autolinks
                    if (linkText === href && !dataQd) {
                        return `<${href}>`;
                    }
                    return `[${linkText}](${href})`;
                }

                case 'sup': {
                    // Footnote marker
                    const fnId = node.getAttribute('data-qd-fn');
                    if (fnId) {
                        return `[^${fnId}]`;
                    }
                    return childContent;
                }

                case 'section': {
                    // Footnotes section
                    if (dataQd === '[^section') {
                        // Walk <ol> children to extract footnote definitions
                        let fnDefs = '';
                        const ol = node.querySelector('ol');
                        if (ol) {
                            for (const li of ol.children) {
                                if (li.tagName !== 'LI') continue;
                                const fnId = li.getAttribute('data-qd-fn-id');
                                if (!fnId) continue;
                                // Extract text content, skipping the back-reference <a>
                                let text = '';
                                for (const child of li.childNodes) {
                                    if (child.nodeType === Node.TEXT_NODE) {
                                        text += child.textContent;
                                    } else if (child.nodeType === Node.ELEMENT_NODE) {
                                        if (child.tagName === 'A' && (child.getAttribute('href') || '').startsWith('#fnref-')) {
                                            continue; // skip backref
                                        }
                                        text += child.textContent;
                                    }
                                }
                                fnDefs += `[^${fnId}]: ${text.trim()}\n`;
                            }
                        }
                        return fnDefs ? '\n' + fnDefs : '';
                    }
                    return childContent;
                }

                case 'img':
                    const alt = node.getAttribute('data-qd-alt') || node.getAttribute('alt') || '';
                    const src = node.getAttribute('data-qd-src') || node.getAttribute('src') || '';
                    const imgMarker = dataQd || '!';
                    return `${imgMarker}[${alt}](${src})`;
                    
                case 'ul':
                case 'ol':
                    return walkList(node, tag === 'ol') + '\n';
                    
                case 'li':
                    // Handled by list processor
                    return childContent;
                    
                case 'table':
                    return walkTable(node) + '\n\n';
                    
                case 'p':
                    // Check if it's actually a paragraph or just a wrapper
                    if (childContent.trim()) {
                        // Check if paragraph ends with a line that's just whitespace
                        // This indicates an intentional blank line before the next element
                        const lines = childContent.split('\n');
                        let content = childContent.trim();
                        
                        // If the last line(s) are just whitespace, preserve one blank line
                        if (lines.length > 1) {
                            let trailingBlankLines = 0;
                            for (let i = lines.length - 1; i >= 0; i--) {
                                if (lines[i].trim() === '') {
                                    trailingBlankLines++;
                                } else {
                                    break;
                                }
                            }
                            if (trailingBlankLines > 0) {
                                // Add a line with just a space, followed by single newline
                                // The \n\n will be added below for paragraph separation
                                content = content + '\n ';
                                // Only add one newline since we're preserving the space line
                                return content + '\n';
                            }
                        }
                        
                        return content + '\n\n';
                    }
                    return '';
                    
                case 'div':
                    // Check if this was created by a fence plugin with reverse handler
                    const divLang = node.getAttribute('data-qd-lang');
                    const divFence = node.getAttribute('data-qd-fence');
                    
                    if (divLang && options.fence_plugin && options.fence_plugin.reverse) {
                        try {
                            const result = options.fence_plugin.reverse(node);
                            if (result && result.content) {
                                const fenceMarker = result.fence || divFence || '```';
                                const langStr = result.lang || divLang;
                                return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
                            }
                        } catch (err) {
                            console.warn('Fence reverse handler error:', err);
                            // Fall through to default handling
                        }
                    }
                    
                    // Fallback: use data-qd-source if available
                    const divSource = node.getAttribute('data-qd-source');
                    if (divSource && divFence) {
                        return `${divFence}${divLang || ''}\n${divSource}\n${divFence}\n\n`;
                    }
                    
                    // Check if it's a mermaid container
                    if (node.classList && node.classList.contains('mermaid-container')) {
                        const fence = node.getAttribute('data-qd-fence') || '```';
                        const lang = node.getAttribute('data-qd-lang') || 'mermaid';
                        
                        // First check for data-qd-source attribute on the container
                        const source = node.getAttribute('data-qd-source');
                        if (source) {
                            // Decode HTML entities from the attribute (mainly &quot;)
                            const temp = document.createElement('textarea');
                            temp.innerHTML = source;
                            const code = temp.value;
                            return `${fence}${lang}\n${code}\n${fence}\n\n`;
                        }
                        
                        // Check for source on the pre.mermaid element
                        const mermaidPre = node.querySelector('pre.mermaid');
                        if (mermaidPre) {
                            const preSource = mermaidPre.getAttribute('data-qd-source');
                            if (preSource) {
                                const temp = document.createElement('textarea');
                                temp.innerHTML = preSource;
                                const code = temp.value;
                                return `${fence}${lang}\n${code}\n${fence}\n\n`;
                            }
                        }
                        
                        // Fallback: Look for the legacy .mermaid-source element
                        const sourceElement = node.querySelector('.mermaid-source');
                        if (sourceElement) {
                            // Decode HTML entities
                            const temp = document.createElement('div');
                            temp.innerHTML = sourceElement.innerHTML;
                            const code = temp.textContent;
                            return `${fence}${lang}\n${code}\n${fence}\n\n`;
                        }
                        
                        // Final fallback: try to extract from the mermaid element (unreliable after rendering)
                        const mermaidElement = node.querySelector('.mermaid');
                        if (mermaidElement && mermaidElement.textContent.includes('graph')) {
                            return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
                        }
                    }
                    // Check if it's a standalone mermaid diagram (legacy)
                    if (node.classList && node.classList.contains('mermaid')) {
                        const fence = node.getAttribute('data-qd-fence') || '```';
                        const lang = node.getAttribute('data-qd-lang') || 'mermaid';
                        const code = node.textContent.trim();
                        return `${fence}${lang}\n${code}\n${fence}\n\n`;
                    }
                    // Pass through other divs
                    return childContent;
                
                case 'span':
                    // Pass through container elements
                    return childContent;
                    
                default:
                    return childContent;
            }
        }
        
        // Walk list elements
        function walkList(listNode, isOrdered, depth = 0) {
            let result = '';
            let index = 1;
            const indent = '  '.repeat(depth);
            
            for (const child of listNode.children) {
                // The parser emits a nested list as a sibling of the <li> it
                // belongs to (not inside it), so walk it one level deeper.
                if (child.tagName === 'UL' || child.tagName === 'OL') {
                    result += walkList(child, child.tagName === 'OL', depth + 1);
                    continue;
                }
                if (child.tagName !== 'LI') continue;
                
                const dataQd = child.getAttribute('data-qd');
                let marker = dataQd || (isOrdered ? `${index}.` : '-');
                
                // Check for task list checkbox
                const checkbox = child.querySelector('input[type="checkbox"]');
                if (checkbox) {
                    const checked = checkbox.checked ? 'x' : ' ';
                    marker = '-';
                    // Continuation lines (after <br>) indent to the item's text column
                    const cont = '\n' + indent + ' '.repeat(marker.length + 5);
                    // Get text without the checkbox
                    let text = '';
                    for (const node of child.childNodes) {
                        if (node.nodeType === Node.TEXT_NODE) {
                            text += node.textContent;
                        } else if (node.tagName && node.tagName !== 'INPUT') {
                            text += walkNode(node);
                        }
                    }
                    result += `${indent}${marker} [${checked}] ${text.trim().replace(/\n/g, cont)}\n`;
                } else {
                    const cont = '\n' + indent + ' '.repeat(marker.length + 1);
                    let text = '';
                    let nested = '';
                    
                    for (const node of child.childNodes) {
                        if (node.tagName === 'UL' || node.tagName === 'OL') {
                            // Nested list placed inside the <li> (hand-written or edited HTML)
                            nested += walkList(node, node.tagName === 'OL', depth + 1);
                        } else {
                            text += walkNode(node);
                        }
                    }
                    
                    result += `${indent}${marker} ${text.trim().replace(/\n/g, cont)}\n${nested}`;
                }
                
                index++;
            }
            
            return result;
        }
        
        // Walk table elements
        function walkTable(table) {
            let result = '';
            const alignData = table.getAttribute('data-qd-align');
            const alignments = alignData ? alignData.split(',') : [];
            
            // Process header
            const thead = table.querySelector('thead');
            if (thead) {
                const headerRow = thead.querySelector('tr');
                if (headerRow) {
                    const headers = [];
                    for (const th of headerRow.querySelectorAll('th')) {
                        headers.push(th.textContent.trim());
                    }
                    result += '| ' + headers.join(' | ') + ' |\n';
                    
                    // Add separator with alignment
                    const separators = headers.map((_, i) => {
                        const align = alignments[i] || 'left';
                        if (align === 'center') return ':---:';
                        if (align === 'right') return '---:';
                        return '---';
                    });
                    result += '| ' + separators.join(' | ') + ' |\n';
                }
            }
            
            // Process body
            const tbody = table.querySelector('tbody');
            if (tbody) {
                for (const row of tbody.querySelectorAll('tr')) {
                    const cells = [];
                    for (const td of row.querySelectorAll('td')) {
                        cells.push(td.textContent.trim());
                    }
                    if (cells.length > 0) {
                        result += '| ' + cells.join(' | ') + ' |\n';
                    }
                }
            }
            
            return result.trim();
        }
        
        // Process the DOM tree
        let markdown = walkNode(container);
        
        // Clean up
        markdown = markdown.replace(/\n{3,}/g, '\n\n'); // Remove excessive newlines
        markdown = markdown.trim();
        
        return markdown;
    };

    // Override the configure method to return a bidirectional version.
    // We delegate to the inner quikdown.configure so the shared closure
    // machinery is exercised in both bundles (no dead code).
    quikdown_bd.configure = function(options) {
        const innerParser = quikdown.configure({ ...options, bidirectional: true });
        return function(markdown) {
            return innerParser(markdown);
        };
    };

    // Set version
    // Version is already copied from quikdown via Object.keys loop

    // Export for both module and browser
    /* istanbul ignore next */
    if (typeof module !== 'undefined' && module.exports) {
        module.exports = quikdown_bd;
    }

    /* istanbul ignore next */
    if (typeof window !== 'undefined') {
        window.quikdown_bd = quikdown_bd;
    }

    return quikdown_bd;

}));