Private/Hyde.Markdown.ps1

<#
.SYNOPSIS
Converts inline markdown snippets into HTML.
 
.DESCRIPTION
Applies a minimal inline markdown pass used by Hyde's lightweight renderer.
 
The converter intentionally supports a focused subset suitable for generated
paragraph, heading, and list content:
- autolinks enclosed in angle brackets
- inline code
- markdown links
- bold and emphasis markers
 
It first HTML-encodes input and then applies markdown transformations.
 
.PARAMETER Text
Inline markdown text to convert.
 
.OUTPUTS
System.String
 
.NOTES
This is not a full CommonMark implementation.
#>

# Convert plain text URLs into anchor tags while skipping existing HTML tags.
function convertHydeBareUrlAutolinks {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text
    )

    $segments = [System.Text.RegularExpressions.Regex]::Split($Text, '(<[^>]+>)')
    $result = New-Object System.Text.StringBuilder
    foreach ($segment in $segments) {
        if ([string]::IsNullOrEmpty($segment)) {
            continue
        }

        if ($segment.StartsWith('<')) {
            [void]$result.Append($segment)
            continue
        }

        $converted = [System.Text.RegularExpressions.Regex]::Replace(
            $segment,
            '(^|[\s\(\[])((?:https?://)[^\s<]+)',
            {
                param($match)

                $prefix = $match.Groups[1].Value
                $url = $match.Groups[2].Value
                $trimmedUrl = $url.TrimEnd('.', ',', ';', ':', '!', '?', ')')
                $suffix = $url.Substring($trimmedUrl.Length)

                if ([string]::IsNullOrWhiteSpace($trimmedUrl)) {
                    return $match.Value
                }

                return "$prefix<a href=`"$trimmedUrl`">$trimmedUrl</a>$suffix"
            }
        )

        [void]$result.Append($converted)
    }

    return $result.ToString()
}

# Create deterministic, unique heading IDs using a slug policy.
function newHydeHeadingSlug {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$HeadingHtml,

        [Parameter(Mandatory = $true)]
        [hashtable]$SlugState
    )

    $plainText = [System.Text.RegularExpressions.Regex]::Replace($HeadingHtml, '<[^>]+>', '')
    $plainText = [System.Net.WebUtility]::HtmlDecode($plainText)
    $slug = $plainText.ToLowerInvariant()
    $slug = [System.Text.RegularExpressions.Regex]::Replace($slug, '[^a-z0-9\s-]', '')
    $slug = [System.Text.RegularExpressions.Regex]::Replace($slug, '[\s-]+', '-')
    $slug = $slug.Trim('-')

    if ([string]::IsNullOrWhiteSpace($slug)) {
        $slug = 'section'
    }

    if (-not $SlugState.ContainsKey($slug)) {
        $SlugState[$slug] = 1
        return $slug
    }

    $SlugState[$slug] = [int]$SlugState[$slug] + 1
    return ($slug + '-' + $SlugState[$slug])
}

# Split a markdown table row into trimmed cell values.
function splitHydeMarkdownTableRow {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Row
    )

    $normalized = $Row.Trim()
    if ($normalized.StartsWith('|')) {
        $normalized = $normalized.Substring(1)
    }

    if ($normalized.EndsWith('|')) {
        $normalized = $normalized.Substring(0, $normalized.Length - 1)
    }

    return [string[]]@($normalized.Split('|') | ForEach-Object { $_.Trim() })
}

# Resolve markdown table alignment markers into HTML alignment values.
function getHydeMarkdownTableAlignments {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$DividerLine
    )

    $alignmentCells = splitHydeMarkdownTableRow -Row $DividerLine
    $alignments = New-Object System.Collections.ArrayList
    foreach ($cell in $alignmentCells) {
        $trimmedCell = $cell.Trim()
        $isValid = $trimmedCell -match '^:?-{3,}:?$'
        if (-not $isValid) {
            return [string[]]@()
        }

        if ($trimmedCell.StartsWith(':') -and $trimmedCell.EndsWith(':')) {
            [void]$alignments.Add('center')
            continue
        }

        if ($trimmedCell.EndsWith(':')) {
            [void]$alignments.Add('right')
            continue
        }

        if ($trimmedCell.StartsWith(':')) {
            [void]$alignments.Add('left')
            continue
        }

        [void]$alignments.Add('')
    }

    return [string[]]@($alignments.ToArray())
}

# Process inline markdown elements inside a single text span.
function convertHydeInlineMarkdown {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Text,

        [hashtable]$FootnoteState
    )

    # Encode HTML-sensitive characters before applying markdown substitutions.
    $encoded = [System.Net.WebUtility]::HtmlEncode($Text)

    # Preserve escaped markdown punctuation as literal characters using HTML entities.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '\\([\\`*_{}\[\]()#+\-.!|])',
        {
            param($match)

            return ('&#{0};' -f [int][char]$match.Groups[1].Value)
        }
    )

    # Convert angle-bracket autolinks such as <https://example.com>.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '&lt;(https?://[^&]+)&gt;',
        '<a href="$1">$1</a>'
    )

    # Convert angle-bracket email autolinks such as <person@example.com>.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '&lt;([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})&gt;',
        '<a href="mailto:$1">$1</a>'
    )

    # Convert inline code spans wrapped in backticks.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '`([^`]+)`',
        '<code>$1</code>'
    )

    # Convert markdown images with optional titles.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '!\[([^\]]*)\]\(([^\s\)]+)(?:\s+&quot;([^&]+)&quot;)?\)',
        {
            param($match)

            $alt = $match.Groups[1].Value
            $src = $match.Groups[2].Value
            $title = if ($match.Groups[3].Success) { $match.Groups[3].Value } else { '' }

            if ([string]::IsNullOrWhiteSpace($title)) {
                return "<img src=`"$src`" alt=`"$alt`" />"
            }

            return "<img src=`"$src`" alt=`"$alt`" title=`"$title`" />"
        }
    )

    # Convert markdown links with optional titles.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '\[([^\]]+)\]\(([^\s\)]+)(?:\s+&quot;([^&]+)&quot;)?\)',
        {
            param($match)

            $text = $match.Groups[1].Value
            $href = $match.Groups[2].Value
            $title = if ($match.Groups[3].Success) { $match.Groups[3].Value } else { '' }

            if ([string]::IsNullOrWhiteSpace($title)) {
                return "<a href=`"$href`">$text</a>"
            }

            return "<a href=`"$href`" title=`"$title`">$text</a>"
        }
    )

    # Convert strong+emphasis spans wrapped with three markers.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '\*\*\*([^\*]+)\*\*\*',
        '<strong><em>$1</em></strong>'
    )

    # Convert strong emphasis using double asterisks.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '\*\*([^\*]+)\*\*',
        '<strong>$1</strong>'
    )

    # Convert strong emphasis using double underscores.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '__([^_]+)__',
        '<strong>$1</strong>'
    )

    # Convert emphasis using single asterisks.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '(?<!\*)\*([^\*]+)\*(?!\*)',
        '<em>$1</em>'
    )

    # Convert emphasis using single underscores.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '(?<!_)_([^_]+)_(?!_)',
        '<em>$1</em>'
    )

    # Convert strikethrough using double tildes.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '(?<!~)~~([^~]+)~~(?!~)',
        '<del>$1</del>'
    )

    # Convert subscript spans wrapped in single tildes (not preceded by another tilde).
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '(?<!~)~([^~\r\n]+)~(?!~)',
        '<sub>$1</sub>'
    )

    # Convert superscript spans wrapped in single carets.
    $encoded = [System.Text.RegularExpressions.Regex]::Replace(
        $encoded,
        '\^([^\^\r\n]+)\^',
        '<sup>$1</sup>'
    )

    # Convert plain URLs into anchors after other inline replacements.
    $encoded = convertHydeBareUrlAutolinks -Text $encoded

    # Convert footnote references such as [^note] into linked superscripts.
    if ($FootnoteState) {
        $encoded = [System.Text.RegularExpressions.Regex]::Replace(
            $encoded,
            '\[\^([^\]\s]+)\]',
            {
                param($match)

                $footnoteId = $match.Groups[1].Value
                if (-not $FootnoteState.IndexById.ContainsKey($footnoteId)) {
                    [void]$FootnoteState.Order.Add($footnoteId)
                    $FootnoteState.IndexById[$footnoteId] = $FootnoteState.Order.Count
                }

                $index = [int]$FootnoteState.IndexById[$footnoteId]
                return "<sup id=`"fnref:$footnoteId`"><a href=`"#fn:$footnoteId`">$index</a></sup>"
            }
        )
    }

    return [string]$encoded
}

# Split abbreviation definitions (*[ABBR]: expansion) out of the markdown body lines.
function splitHydeMarkdownAbbreviations {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [string[]]$Lines
    )

    $bodyLines = New-Object System.Collections.ArrayList
    $abbreviations = @{}

    foreach ($line in $Lines) {
        if ($line -match '^\*\[([^\]]+)\]:\s*(.+)$') {
            $abbreviations[$Matches[1]] = $Matches[2]
            continue
        }

        [void]$bodyLines.Add($line)
    }

    return @{
        BodyLines     = @($bodyLines.ToArray())
        Abbreviations = $abbreviations
    }
}

# Replace plain-text occurrences of known abbreviations in rendered HTML with <abbr> elements.
# Only matches occurrences that are not already inside an HTML tag or attribute.
function applyHydeMarkdownAbbreviations {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$Html,

        [Parameter(Mandatory = $true)]
        [hashtable]$Abbreviations
    )

    if ($Abbreviations.Count -eq 0) {
        return $Html
    }

    # Sort longest abbreviations first to avoid partial replacements.
    $sortedKeys = @($Abbreviations.Keys | Sort-Object { $_.Length } -Descending)

    foreach ($abbr in $sortedKeys) {
        $title = [System.Net.WebUtility]::HtmlEncode($Abbreviations[$abbr])
        $escapedAbbr = [System.Text.RegularExpressions.Regex]::Escape($abbr)
        $segments = [System.Text.RegularExpressions.Regex]::Split($Html, '(<[^>]+>)')
        $result = New-Object System.Text.StringBuilder
        foreach ($segment in $segments) {
            if ([string]::IsNullOrEmpty($segment)) {
                continue
            }

            if ($segment.StartsWith('<')) {
                [void]$result.Append($segment)
                continue
            }

            $replaced = [System.Text.RegularExpressions.Regex]::Replace(
                $segment,
                "(?<![A-Za-z0-9])$escapedAbbr(?![A-Za-z0-9])",
                "<abbr title=`"$title`">$abbr</abbr>"
            )
            [void]$result.Append($replaced)
        }

        $Html = $result.ToString()
    }

    return $Html
}

# Split markdown into body lines plus extracted footnote definitions.
function splitHydeMarkdownFootnotes {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [string[]]$Lines
    )

    $bodyLines = New-Object System.Collections.ArrayList
    $definitions = @{}
    $index = 0

    while ($index -lt $Lines.Count) {
        $line = [string]$Lines[$index]

        if ($line -match '^\[\^([^\]\s]+)\]:\s*(.*)$') {
            $footnoteId = [string]$Matches[1]
            $definitionLines = New-Object System.Collections.ArrayList
            [void]$definitionLines.Add([string]$Matches[2])
            $index++

            while ($index -lt $Lines.Count) {
                $nextLine = [string]$Lines[$index]
                if ($nextLine -match '^(?: {4}|\t)(.*)$') {
                    [void]$definitionLines.Add([string]$Matches[1])
                    $index++
                    continue
                }

                if ([string]::IsNullOrWhiteSpace($nextLine)) {
                    [void]$definitionLines.Add('')
                    $index++
                    continue
                }

                break
            }

            $definitions[$footnoteId] = [string]::Join("`n", @($definitionLines.ToArray()))
            continue
        }

        [void]$bodyLines.Add($line)
        $index++
    }

    return @{
        BodyLines   = @($bodyLines.ToArray())
        Definitions = $definitions
    }
}

# Render collected footnote definitions as an ordered HTML list.
function convertHydeMarkdownFootnotesToHtml {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$FootnoteState,

        [Parameter(Mandatory = $true)]
        [hashtable]$Definitions
    )

    if ($FootnoteState.Order.Count -eq 0) {
        return ''
    }

    $items = New-Object System.Collections.ArrayList
    foreach ($footnoteId in @($FootnoteState.Order)) {
        $rawDefinition = if ($Definitions.ContainsKey($footnoteId)) { [string]$Definitions[$footnoteId] } else { '' }
        $paragraphs = @(@($rawDefinition -split "(?:`r?`n){2,}") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

        if ($paragraphs.Count -eq 0) {
            $content = ''
        } elseif ($paragraphs.Count -eq 1) {
            $content = "<p>$(convertHydeInlineMarkdown -Text ($paragraphs[0].Trim()))</p>"
        } else {
            $renderedParagraphs = @($paragraphs | ForEach-Object { "<p>$(convertHydeInlineMarkdown -Text ($_.Trim()))</p>" })
            $content = ($renderedParagraphs -join [Environment]::NewLine)
        }

        $content += " <a href=`"#fnref:$footnoteId`" class=`"footnote-backref`">&#8617;</a>"
        [void]$items.Add("<li id=`"fn:$footnoteId`">$content</li>")
    }

    return @"
<section class="footnotes">
<ol>
$($items -join [Environment]::NewLine)
</ol>
</section>
"@

}

<#
.SYNOPSIS
Converts markdown content to HTML.
 
.DESCRIPTION
Renders a practical markdown subset used by Hyde's built-in renderer.
 
Block support:
- ATX headings (# through ######)
- unordered lists (-, *, +)
- fenced code blocks using ```
- paragraph blocks
- raw HTML line passthrough
 
Inline rendering is delegated to convertHydeInlineMarkdown.
 
.PARAMETER Markdown
Markdown content to convert.
 
.OUTPUTS
System.String
 
.NOTES
The implementation is intentionally lightweight and deterministic.
For advanced markdown behavior, use a dedicated markdown processor plugin.
#>

# Convert markdown blocks into HTML output for Hyde documents.
function convertHydeMarkdown {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Markdown,

        [switch]$SkipFootnoteParsing,

        # Internal parameter: pre-parsed abbreviation table passed from the top-level call into recursive blockquote calls.
        [hashtable]$AbbreviationDefinitions
    )

    # Normalize line endings first so the simple parser behaves the same on all platforms.
    $normalizedContent = ($Markdown -replace "`r`n", "`n") -replace "`r", "`n"
    $lines = @($normalizedContent -split "`n")

    # Extract abbreviation definitions (*[ABBR]: expansion) at the top level only.
    $abbreviations = if ($AbbreviationDefinitions) { $AbbreviationDefinitions } else { @{} }
    if (-not $AbbreviationDefinitions) {
        $abbrevSplit = splitHydeMarkdownAbbreviations -Lines @($lines)
        $lines = @($abbrevSplit.BodyLines)
        $abbreviations = $abbrevSplit.Abbreviations
    }

    $footnoteDefinitions = @{}
    if (-not $SkipFootnoteParsing) {
        $footnoteSplit = splitHydeMarkdownFootnotes -Lines @($lines)
        $lines = @($footnoteSplit.BodyLines)
        $footnoteDefinitions = $footnoteSplit.Definitions
    }

    $footnoteState = @{
        Order     = New-Object System.Collections.ArrayList
        IndexById = @{}
    }
    $headingSlugState = @{}

    $blocks = New-Object System.Collections.ArrayList
    $paragraphLines = New-Object System.Collections.ArrayList
    $listItems = New-Object System.Collections.ArrayList
    $orderedListItems = New-Object System.Collections.ArrayList
    $codeLines = New-Object System.Collections.ArrayList
    $inCodeFence = $false
    $pendingTableCaption = $null

    # Buffer-based helpers let the parser convert markdown one block at a time.
    # Flush accumulated paragraph lines to a rendered paragraph block.
    function completeHydeParagraphBuffer {
        if ($paragraphLines.Count -eq 0) {
            return
        }

        $text = ($paragraphLines.ToArray() -join "`n").Trim()
        $text = [System.Text.RegularExpressions.Regex]::Replace($text, '( {2,}|\\)\n', '__HYDE_BR__')
        $text = [System.Text.RegularExpressions.Regex]::Replace($text, '\n+', ' ')
        $rendered = convertHydeInlineMarkdown -Text $text -FootnoteState $footnoteState
        $rendered = $rendered.Replace('__HYDE_BR__', '<br />')
        [void]$blocks.Add("<p>$rendered</p>")
        $paragraphLines.Clear()
    }

    # Flush accumulated unordered list lines to a rendered list block.
    function completeHydeListBuffer {
        if ($listItems.Count -eq 0) {
            return
        }

        $hasTaskItems = $false
        $items = New-Object System.Collections.ArrayList
        foreach ($listItem in $listItems.ToArray()) {
            if ($listItem -match '^\[(?<marker>[ xX])\]') {
                $hasTaskItems = $true
                $isChecked = $Matches['marker'] -match '[xX]'
                $taskLabel = if ($listItem.Length -gt 3) { $listItem.Substring(3).TrimStart() } else { '' }
                $taskText = convertHydeInlineMarkdown -Text $taskLabel -FootnoteState $footnoteState
                if ($isChecked) {
                    [void]$items.Add("<li class=`"task-list-item`"><input type=`"checkbox`" checked disabled /> $taskText</li>")
                    continue
                }

                [void]$items.Add("<li class=`"task-list-item`"><input type=`"checkbox`" disabled /> $taskText</li>")
                continue
            }

            [void]$items.Add("<li>$(convertHydeInlineMarkdown -Text $listItem -FootnoteState $footnoteState)</li>")
        }

        if ($hasTaskItems) {
            [void]$blocks.Add("<ul class=`"task-list`">$($items -join '')</ul>")
        } else {
            [void]$blocks.Add("<ul>$($items -join '')</ul>")
        }

        $listItems.Clear()
    }

    # Flush accumulated ordered list lines to a rendered list block.
    function completeHydeOrderedListBuffer {
        if ($orderedListItems.Count -eq 0) {
            return
        }

        $hasTaskItems = $false
        $items = New-Object System.Collections.ArrayList
        foreach ($orderedListItem in $orderedListItems.ToArray()) {
            if ($orderedListItem -match '^\[(?<marker>[ xX])\]') {
                $hasTaskItems = $true
                $isChecked = $Matches['marker'] -match '[xX]'
                $taskLabel = if ($orderedListItem.Length -gt 3) { $orderedListItem.Substring(3).TrimStart() } else { '' }
                $taskText = convertHydeInlineMarkdown -Text $taskLabel -FootnoteState $footnoteState
                if ($isChecked) {
                    [void]$items.Add("<li class=`"task-list-item`"><input type=`"checkbox`" checked disabled /> $taskText</li>")
                    continue
                }

                [void]$items.Add("<li class=`"task-list-item`"><input type=`"checkbox`" disabled /> $taskText</li>")
                continue
            }

            [void]$items.Add("<li>$(convertHydeInlineMarkdown -Text $orderedListItem -FootnoteState $footnoteState)</li>")
        }

        if ($hasTaskItems) {
            [void]$blocks.Add("<ol class=`"task-list`">$($items -join '')</ol>")
        } else {
            [void]$blocks.Add("<ol>$($items -join '')</ol>")
        }

        $orderedListItems.Clear()
    }

    # Flush fenced code content to a rendered code block.
    function completeHydeCodeFenceBuffer {
        if ($codeLines.Count -eq 0) {
            [void]$blocks.Add('<pre><code></code></pre>')
            return
        }

        $code = [System.Net.WebUtility]::HtmlEncode(($codeLines.ToArray() -join "`n"))
        [void]$blocks.Add("<pre><code>$code</code></pre>")
        $codeLines.Clear()
    }

    $index = 0
    while ($index -lt $lines.Count) {
        $line = [string]$lines[$index]

        # Handle fenced code block delimiters.
        if ($line -match '^\s*```') {
            if ($inCodeFence) {
                completeHydeCodeFenceBuffer
                $inCodeFence = $false
            } else {
                completeHydeParagraphBuffer
                completeHydeListBuffer
                completeHydeOrderedListBuffer
                $codeLines.Clear()
                $inCodeFence = $true
            }

            $index++
            continue
        }

        # Keep fenced-code body content untouched until the closing fence.
        if ($inCodeFence) {
            [void]$codeLines.Add($line)
            $index++
            continue
        }

        # Handle indented code blocks that use four leading spaces or one tab.
        if ($line -match '^(?: {4}|\t)(.*)$') {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer

            $indentedCodeLines = New-Object System.Collections.ArrayList
            while ($index -lt $lines.Count) {
                $indentedLine = [string]$lines[$index]
                if ($indentedLine -match '^(?: {4}|\t)(.*)$') {
                    [void]$indentedCodeLines.Add([string]$Matches[1])
                    $index++
                    continue
                }

                if ([string]::IsNullOrWhiteSpace($indentedLine)) {
                    [void]$indentedCodeLines.Add('')
                    $index++
                    continue
                }

                break
            }

            $code = [System.Net.WebUtility]::HtmlEncode(($indentedCodeLines.ToArray() -join "`n"))
            [void]$blocks.Add("<pre><code>$code</code></pre>")
            continue
        }

        # Handle blockquotes that start with one or more ">" prefixes.
        if ($line -match '^\s*>\s?(.*)$') {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer

            $quoteLines = New-Object System.Collections.ArrayList
            while ($index -lt $lines.Count) {
                $quoteCandidate = [string]$lines[$index]
                if ($quoteCandidate -match '^\s*>\s?(.*)$') {
                    [void]$quoteLines.Add([string]$Matches[1])
                    $index++
                    continue
                }

                if ([string]::IsNullOrWhiteSpace($quoteCandidate)) {
                    [void]$quoteLines.Add('')
                    $index++
                    continue
                }

                break
            }

            $quoteMarkdown = [string]::Join("`n", @($quoteLines.ToArray()))
            $quoteHtml = convertHydeMarkdown -Markdown $quoteMarkdown -SkipFootnoteParsing -AbbreviationDefinitions $abbreviations
            [void]$blocks.Add("<blockquote>$quoteHtml</blockquote>")
            continue
        }

        # Treat blank lines as block separators.
        if ([string]::IsNullOrWhiteSpace($line)) {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer
            $index++
            continue
        }

        # Handle Setext-style headings that underline the previous line with === or ---.
        if ($index + 1 -lt $lines.Count) {
            $nextLine = [string]$lines[$index + 1]
            if ($nextLine -match '^\s*=+\s*$') {
                completeHydeParagraphBuffer
                completeHydeListBuffer
                completeHydeOrderedListBuffer
                $headingHtml = convertHydeInlineMarkdown -Text $line.Trim() -FootnoteState $footnoteState
                $headingId = newHydeHeadingSlug -HeadingHtml $headingHtml -SlugState $headingSlugState
                [void]$blocks.Add("<h1 id=`"$headingId`">$headingHtml</h1>")
                $index += 2
                continue
            }

            if ($nextLine -match '^\s*-{2,}\s*$') {
                completeHydeParagraphBuffer
                completeHydeListBuffer
                completeHydeOrderedListBuffer
                $headingHtml = convertHydeInlineMarkdown -Text $line.Trim() -FootnoteState $footnoteState
                $headingId = newHydeHeadingSlug -HeadingHtml $headingHtml -SlugState $headingSlugState
                [void]$blocks.Add("<h2 id=`"$headingId`">$headingHtml</h2>")
                $index += 2
                continue
            }
        }

        # Handle a [Caption text] line immediately before a table header.
        if ($line -match '^\[(?!\^)([^\]]+)\]\s*$' -and ($index + 2 -lt $lines.Count) -and ([string]$lines[$index + 1]).Contains('|')) {
            $lookaheadAlignments = @(getHydeMarkdownTableAlignments -DividerLine ([string]$lines[$index + 2]))
            if ($lookaheadAlignments.Count -gt 0) {
                completeHydeParagraphBuffer
                completeHydeListBuffer
                completeHydeOrderedListBuffer
                $pendingTableCaption = convertHydeInlineMarkdown -Text $Matches[1] -FootnoteState $footnoteState
                $index++
                continue
            }
        }

        # Handle markdown tables with a header line plus divider line.
        if (($index + 1 -lt $lines.Count) -and $line.Contains('|')) {
            $dividerLine = [string]$lines[$index + 1]
            # Force array context so Count is reliable even when helper returns one value.
            $alignments = @(getHydeMarkdownTableAlignments -DividerLine $dividerLine)
            if ($alignments.Count -gt 0) {
                completeHydeParagraphBuffer
                completeHydeListBuffer
                completeHydeOrderedListBuffer

                # Keep header and row parsing in array form to avoid scalar string edge-cases.
                $headerCells = @(splitHydeMarkdownTableRow -Row $line)
                $maxCellCount = [Math]::Min($headerCells.Count, $alignments.Count)
                $headerHtml = New-Object System.Collections.ArrayList
                for ($cellIndex = 0; $cellIndex -lt $maxCellCount; $cellIndex++) {
                    $alignmentAttribute = if ([string]::IsNullOrWhiteSpace($alignments[$cellIndex])) { '' } else { " style=`"text-align: $($alignments[$cellIndex]);`"" }
                    $cellHtml = convertHydeInlineMarkdown -Text $headerCells[$cellIndex] -FootnoteState $footnoteState
                    [void]$headerHtml.Add("<th$alignmentAttribute>$cellHtml</th>")
                }

                $bodyRows = New-Object System.Collections.ArrayList
                $index += 2
                while ($index -lt $lines.Count) {
                    $rowLine = [string]$lines[$index]
                    if ([string]::IsNullOrWhiteSpace($rowLine) -or -not $rowLine.Contains('|')) {
                        break
                    }

                    $rowCells = @(splitHydeMarkdownTableRow -Row $rowLine)
                    $rowHtml = New-Object System.Collections.ArrayList
                    for ($cellIndex = 0; $cellIndex -lt $maxCellCount; $cellIndex++) {
                        $cellValue = if ($cellIndex -lt $rowCells.Count) { $rowCells[$cellIndex] } else { '' }
                        $alignmentAttribute = if ([string]::IsNullOrWhiteSpace($alignments[$cellIndex])) { '' } else { " style=`"text-align: $($alignments[$cellIndex]);`"" }
                        $cellHtml = convertHydeInlineMarkdown -Text $cellValue -FootnoteState $footnoteState
                        [void]$rowHtml.Add("<td$alignmentAttribute>$cellHtml</td>")
                    }

                    [void]$bodyRows.Add("<tr>$($rowHtml -join '')</tr>")
                    $index++
                }

                $tableBody = if ($bodyRows.Count -gt 0) { "<tbody>$($bodyRows -join '')</tbody>" } else { '' }
                $captionHtml = if ($pendingTableCaption) { "<caption>$pendingTableCaption</caption>" } else { '' }
                $pendingTableCaption = $null
                [void]$blocks.Add("<table>$captionHtml<thead><tr>$($headerHtml -join '')</tr></thead>$tableBody</table>")
                continue
            }
        }

        # Handle horizontal rules made of three-or-more matching marker runs.
        if ($line -match '^\s{0,3}(?:\*\s*){3,}$' -or $line -match '^\s{0,3}(?:-\s*){3,}$' -or $line -match '^\s{0,3}(?:_\s*){3,}$') {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer
            $pendingTableCaption = $null
            [void]$blocks.Add('<hr />')
            $index++
            continue
        }

        # Handle ATX headings that begin with one-to-six # characters.
        if ($line -match '^(#{1,6})\s+(.*)$') {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer
            $level = $Matches[1].Length
            $headingText = convertHydeInlineMarkdown -Text $Matches[2].Trim() -FootnoteState $footnoteState
            $headingId = newHydeHeadingSlug -HeadingHtml $headingText -SlugState $headingSlugState
            [void]$blocks.Add("<h$level id=`"$headingId`">$headingText</h$level>")
            $index++
            continue
        }

        # Handle unordered list entries that begin with -, *, or + markers.
        if ($line -match '^\s*[-*+]\s+(.*)$') {
            completeHydeParagraphBuffer
            completeHydeOrderedListBuffer
            [void]$listItems.Add($Matches[1].Trim())
            $index++
            continue
        }

        # Handle ordered list entries that begin with a number and period.
        if ($line -match '^\s*\d+\.\s+(.*)$') {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            [void]$orderedListItems.Add($Matches[1].Trim())
            $index++
            continue
        }

        # Raw HTML blocks pass straight through instead of being escaped as markdown paragraphs.
        if ($line.TrimStart().StartsWith('<')) {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer
            [void]$blocks.Add($line)
            $index++
            continue
        }

        # Handle definition lists: a term followed by one or more ": definition" lines.
        if (($index + 1 -lt $lines.Count) -and ([string]$lines[$index + 1]) -match '^:\s+(.+)$') {
            completeHydeParagraphBuffer
            completeHydeListBuffer
            completeHydeOrderedListBuffer
            $pendingTableCaption = $null

            $dlItems = New-Object System.Collections.ArrayList
            $pendingTerm = $line.Trim()
            $index++
            while ($index -lt $lines.Count) {
                $dlLine = [string]$lines[$index]
                if ($dlLine -match '^:\s+(.+)$') {
                    if ($pendingTerm) {
                        [void]$dlItems.Add("<dt>$(convertHydeInlineMarkdown -Text $pendingTerm -FootnoteState $footnoteState)</dt>")
                        $pendingTerm = $null
                    }

                    [void]$dlItems.Add("<dd>$(convertHydeInlineMarkdown -Text $Matches[1].Trim() -FootnoteState $footnoteState)</dd>")
                    $index++
                    continue
                }

                if ([string]::IsNullOrWhiteSpace($dlLine)) {
                    $index++
                    break
                }

                # A non-definition, non-blank line is a new term.
                if ($pendingTerm) {
                    [void]$dlItems.Add("<dt>$(convertHydeInlineMarkdown -Text $pendingTerm -FootnoteState $footnoteState)</dt>")
                }

                $pendingTerm = $dlLine.Trim()
                $index++

                # Only continue as a definition list while the next line is a definition or another term followed by a definition.
                if ($index -ge $lines.Count -or ([string]$lines[$index]) -notmatch '^:\s+') {
                    if ($pendingTerm) {
                        [void]$dlItems.Add("<dt>$(convertHydeInlineMarkdown -Text $pendingTerm -FootnoteState $footnoteState)</dt>")
                        $pendingTerm = $null
                    }

                    break
                }
            }

            if ($pendingTerm) {
                [void]$dlItems.Add("<dt>$(convertHydeInlineMarkdown -Text $pendingTerm -FootnoteState $footnoteState)</dt>")
            }

            if ($dlItems.Count -gt 0) {
                [void]$blocks.Add("<dl>$($dlItems -join '')</dl>")
            }

            continue
        }

        [void]$paragraphLines.Add($line)
        $index++
    }

    if ($inCodeFence) {
        completeHydeCodeFenceBuffer
    } else {
        completeHydeParagraphBuffer
        completeHydeListBuffer
        completeHydeOrderedListBuffer
    }

    $bodyHtml = ($blocks.ToArray() -join [Environment]::NewLine)
    $footnotesHtml = if ($SkipFootnoteParsing) { '' } else { convertHydeMarkdownFootnotesToHtml -FootnoteState $footnoteState -Definitions $footnoteDefinitions }

    $fullHtml = if ([string]::IsNullOrWhiteSpace($footnotesHtml)) {
        $bodyHtml
    } elseif ([string]::IsNullOrWhiteSpace($bodyHtml)) {
        $footnotesHtml.Trim()
    } else {
        $bodyHtml + [Environment]::NewLine + $footnotesHtml.Trim()
    }

    # Abbreviation expansion is only applied at the top level (not in recursive blockquote calls).
    if (-not $AbbreviationDefinitions) {
        $fullHtml = applyHydeMarkdownAbbreviations -Html $fullHtml -Abbreviations $abbreviations
    }

    return $fullHtml
}