ShowMarkdown.psm1

<#
.SYNOPSIS
    Renders Markdown text with ANSI colors and formatting in the terminal.
 
.DESCRIPTION
    Converts Markdown to pretty terminal output with:
    - Bold headers (colored by level)
    - Bold and italic text
    - Syntax-highlighted code blocks with language labels
    - Colored inline code
    - Bullet and numbered lists with indentation
    - Horizontal rules
    - Blockquotes
    - Links and images
    - Tables with box-drawing characters
 
.EXAMPLE
    "# Hello`n`nThis is **bold** and ``code``" | Show-Markdown
 
.EXAMPLE
    $response = Read-TurnEvents -Writer $w -Reader $r
    Show-Markdown $response
#>


function Get-DisplayWidth {
    param([string]$Text)
    $w = 0
    foreach ($ch in $Text.ToCharArray()) {
        $cp = [int]$ch
        # CJK, fullwidth, and common wide Unicode ranges
        if (($cp -ge 0x1100 -and $cp -le 0x115F) -or
            ($cp -ge 0x2E80 -and $cp -le 0x303E) -or
            ($cp -ge 0x3040 -and $cp -le 0x9FFF) -or
            ($cp -ge 0xAC00 -and $cp -le 0xD7AF) -or
            ($cp -ge 0xF900 -and $cp -le 0xFAFF) -or
            ($cp -ge 0xFE30 -and $cp -le 0xFE6F) -or
            ($cp -ge 0xFF01 -and $cp -le 0xFF60) -or
            ($cp -ge 0xFFE0 -and $cp -le 0xFFE6)) {
            $w += 2
        }
        else {
            $w += 1
        }
    }
    $w
}

function Show-Markdown {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [AllowEmptyString()]
        [string]$Markdown
    )

    begin {
        $allText = [System.Collections.Generic.List[string]]::new()
    }

    process {
        $allText.Add($Markdown)
    }

    end {
        $text = $allText -join "`n"
        if ([string]::IsNullOrWhiteSpace($text)) { return }

        # ── ANSI escape sequences ──
        $esc = [char]27
        $reset = "$esc[0m"
        $bold = "$esc[1m"
        $dim = "$esc[2m"
        $italic = "$esc[3m"
        $underline = "$esc[4m"
        $strike = "$esc[9m"

        # Colors
        $cH1 = "$esc[1;38;5;39m"     # Bold bright blue
        $cH2 = "$esc[1;38;5;114m"    # Bold green
        $cH3 = "$esc[1;38;5;214m"    # Bold orange
        $cH4 = "$esc[1;38;5;183m"    # Bold lavender
        $cCode = "$esc[38;5;223m"      # Warm yellow for inline code
        $cCodeBg = "$esc[48;5;236m"      # Dark background for inline code
        $cBlock = "$esc[38;5;250m"      # Light gray for code blocks
        $cBlockBg = "$esc[48;5;235m"      # Darker background for blocks
        $cBlockLn = "$esc[38;5;240m"      # Line numbers
        $cLang = "$esc[38;5;245m"      # Language label
        $cBullet = "$esc[38;5;75m"       # Blue bullets
        $cQuote = "$esc[38;5;248m"      # Gray quotes
        $cQuoteBar = "$esc[38;5;240m"      # Dark gray bar
        $cLink = "$esc[4;38;5;75m"     # Underlined blue
        $cBold = "$esc[1;38;5;255m"    # Bright white bold
        $cItalic = "$esc[3;38;5;252m"    # Italic light
        $cHR = "$esc[38;5;240m"      # Dim horizontal rule
        $cTable = "$esc[38;5;245m"      # Table borders
        $cTableH = "$esc[1;38;5;75m"     # Table headers

        $lines = $text -split "`n"
        $i = 0
        $inCodeBlock = $false
        $codeLang = ""
        $codeLines = @()

        while ($i -lt $lines.Count) {
            $line = $lines[$i]

            # ── Code blocks ──
            if ($line -match '^```(.*)$') {
                if (-not $inCodeBlock) {
                    $inCodeBlock = $true
                    $codeLang = $Matches[1].Trim()
                    $codeLines = @()
                    $i++
                    continue
                }
                else {
                    # Render the collected code block
                    $inCodeBlock = $false

                    # Trim trailing whitespace, expand tabs, measure display width
                    $codeLines = $codeLines | ForEach-Object { $_.TrimEnd() -replace "`t", " " }
                    $displayWidths = @($codeLines | ForEach-Object { Get-DisplayWidth $_ })
                    $maxDisplay = ($displayWidths | Measure-Object -Maximum).Maximum
                    if ($null -eq $maxDisplay -or $maxDisplay -lt 1) { $maxDisplay = 1 }
                    $boxInner = $maxDisplay + 7  # │ + space + num(3) + space + content + space + │

                    if ($codeLang) {
                        $langExtra = 4 + $codeLang.Length  # ╭─ LANG ╮ minus dashes
                        Write-Host " $cLang╭─ $codeLang $("─" * [Math]::Max(0, $boxInner - $langExtra))╮$reset"
                    }
                    else {
                        Write-Host " $cLang╭$("─" * ($boxInner - 1))╮$reset"
                    }

                    $lineNum = 1
                    $lineIdx = 0
                    foreach ($cl in $codeLines) {
                        $numStr = $lineNum.ToString().PadLeft(3)
                        $dw = $displayWidths[$lineIdx]
                        $pad = $maxDisplay - $dw
                        $padded = $cl + (" " * [Math]::Max(0, $pad))
                        Write-Host " $cLang│$reset $cBlockLn$numStr$reset $cBlockBg$cBlock$padded$reset $cLang│$reset"
                        $lineNum++
                        $lineIdx++
                    }

                    Write-Host " $cLang╰$("─" * ($boxInner - 1))╯$reset"
                    Write-Host ""
                    $i++
                    continue
                }
            }

            if ($inCodeBlock) {
                $codeLines += $line
                $i++
                continue
            }

            # ── Blank lines ──
            if ([string]::IsNullOrWhiteSpace($line)) {
                Write-Host ""
                $i++
                continue
            }

            # ── Headers ──
            if ($line -match '^(#{1,6})\s+(.+)$') {
                $level = $Matches[1].Length
                $title = $Matches[2]
                $color = switch ($level) {
                    1 { $cH1 }
                    2 { $cH2 }
                    3 { $cH3 }
                    default { $cH4 }
                }
                $prefix = switch ($level) {
                    1 { "▌ " }
                    2 { "│ " }
                    3 { " " }
                    default { " " }
                }
                Write-Host ""
                Write-Host "$color$prefix$title$reset"
                if ($level -eq 1) {
                    Write-Host "$color$("─" * ($title.Length + 2))$reset"
                }
                Write-Host ""
                $i++
                continue
            }

            # ── Horizontal rule ──
            if ($line -match '^[-*_]{3,}\s*$') {
                Write-Host " $cHR$("─" * 50)$reset"
                Write-Host ""
                $i++
                continue
            }

            # ── Blockquote ──
            if ($line -match '^>\s?(.*)$') {
                $quoteText = Format-InlineMarkdown $Matches[1] $cCode $cCodeBg $cBold $cItalic $cLink $cStrike $reset $bold $italic $underline $strike
                Write-Host " $cQuoteBar▐$reset $cQuote$quoteText$reset"
                $i++
                continue
            }

            # ── Table detection ──
            if ($line -match '^\|' -and ($i + 1) -lt $lines.Count -and $lines[$i + 1] -match '^\|[\s\-:|]+\|') {
                $tableLines = @()
                while ($i -lt $lines.Count -and $lines[$i] -match '^\|') {
                    if ($lines[$i] -notmatch '^\|[\s\-:|]+\|$') {
                        $tableLines += $lines[$i]
                    }
                    $i++
                }
                Render-Table $tableLines $cTable $cTableH $reset $bold
                Write-Host ""
                continue
            }

            # ── Unordered list ──
            if ($line -match '^(\s*)([-*+])\s+(.+)$') {
                $indent = [Math]::Floor($Matches[1].Length / 2)
                $content = Format-InlineMarkdown $Matches[3] $cCode $cCodeBg $cBold $cItalic $cLink $cStrike $reset $bold $italic $underline $strike
                $pad = " " * $indent
                $bullet = switch ($indent) { 0 { "●" } 1 { "○" } default { "▪" } }
                Write-Host " $pad$cBullet$bullet$reset $content"
                $i++
                continue
            }

            # ── Ordered list ──
            if ($line -match '^(\s*)(\d+)[.)]\s+(.+)$') {
                $indent = [Math]::Floor($Matches[1].Length / 2)
                $num = $Matches[2]
                $content = Format-InlineMarkdown $Matches[3] $cCode $cCodeBg $cBold $cItalic $cLink $cStrike $reset $bold $italic $underline $strike
                $pad = " " * $indent
                Write-Host " $pad$cBullet$num.$reset $content"
                $i++
                continue
            }

            # ── Regular paragraph ──
            $formatted = Format-InlineMarkdown $line $cCode $cCodeBg $cBold $cItalic $cLink $cStrike $reset $bold $italic $underline $strike
            Write-Host " $formatted"
            $i++
        }

        # Handle unclosed code block
        if ($inCodeBlock -and $codeLines.Count -gt 0) {
            foreach ($cl in $codeLines) {
                Write-Host " $cBlockBg$cBlock $cl$reset"
            }
        }
    }
}

function Format-InlineMarkdown {
    param(
        [string]$Text,
        [string]$cCode, [string]$cCodeBg,
        [string]$cBold, [string]$cItalic, [string]$cLink, [string]$cStrike,
        [string]$reset, [string]$bold, [string]$italic, [string]$underline, [string]$strike
    )

    $esc = [char]27

    # Bold + italic ***text***
    $Text = [regex]::Replace($Text, '\*{3}(.+?)\*{3}', "$cBold$($esc)[3m`$1$reset")

    # Bold **text** or __text__
    $Text = [regex]::Replace($Text, '\*{2}(.+?)\*{2}', "$cBold`$1$reset")
    $Text = [regex]::Replace($Text, '__(.+?)__', "$cBold`$1$reset")

    # Italic *text* or _text_ (but not inside words with underscores)
    $Text = [regex]::Replace($Text, '(?<!\w)\*(.+?)\*(?!\w)', "$cItalic`$1$reset")
    $Text = [regex]::Replace($Text, '(?<!\w)_(.+?)_(?!\w)', "$cItalic`$1$reset")

    # Strikethrough ~~text~~
    $Text = [regex]::Replace($Text, '~~(.+?)~~', "$($esc)[9m`$1$reset")

    # Inline code `text`
    $Text = [regex]::Replace($Text, '`([^`]+)`', "$cCodeBg$cCode `$1 $reset")

    # Links [text](url)
    $Text = [regex]::Replace($Text, '\[([^\]]+)\]\(([^)]+)\)', "$cLink`$1$reset $($esc)[38;5;240m(`$2)$reset")

    # Images ![alt](url)
    $Text = [regex]::Replace($Text, '!\[([^\]]*)\]\(([^)]+)\)', "$($esc)[38;5;240m[img: `$1]$reset")

    return $Text
}

function Render-Table {
    param(
        [string[]]$Lines,
        [string]$cTable, [string]$cTableH,
        [string]$reset, [string]$bold
    )

    # Parse cells
    $rows = foreach ($line in $Lines) {
        $cells = ($line.Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
        , $cells
    }

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

    # Calculate column widths
    $colCount = $rows[0].Count
    $widths = @(0) * $colCount
    foreach ($row in $rows) {
        for ($c = 0; $c -lt [Math]::Min($row.Count, $colCount); $c++) {
            if ($row[$c].Length -gt $widths[$c]) { $widths[$c] = $row[$c].Length }
        }
    }

    # Top border
    $top = ($widths | ForEach-Object { "─" * ($_ + 2) }) -join "┬"
    Write-Host " $cTable┌$top┐$reset"

    for ($r = 0; $r -lt $rows.Count; $r++) {
        $row = $rows[$r]
        $color = if ($r -eq 0) { $cTableH } else { $reset }
        $cells = for ($c = 0; $c -lt $colCount; $c++) {
            $val = if ($c -lt $row.Count) { $row[$c] } else { "" }
            " $color$($val.PadRight($widths[$c]))$reset "
        }
        Write-Host " $cTable│$reset$($cells -join "$cTable│$reset")$cTable│$reset"

        # Header separator
        if ($r -eq 0) {
            $sep = ($widths | ForEach-Object { "─" * ($_ + 2) }) -join "┼"
            Write-Host " $cTable├$sep┤$reset"
        }
    }

    # Bottom border
    $bottom = ($widths | ForEach-Object { "─" * ($_ + 2) }) -join "┴"
    Write-Host " $cTable└$bottom┘$reset"
}

Export-ModuleMember -Function Show-Markdown