Private/Show-XKCDExplanationText.ps1

Function Show-XKCDExplanationText {
    <#
    .SYNOPSIS
        Displays a comic's title and image above its retrieved explainxkcd sections, formatted for the console.
    #>

    [cmdletbinding()]
    Param(
        # The explanation object returned by Get-XKCDExplanation (must have Num, Title, Url, and Explanation
        # properties, and may also have Transcript and/or Discussion properties).
        [Parameter(Mandatory)]
        [pscustomobject]
        $Explanation,

        # The comic object returned by the xkcd API (must have a num property, and may have year, month, and
        # day), used to show the comic's publish date and a hyperlink to it above the explanation.
        [pscustomobject]
        $Comic,

        # The raw bytes of the comic image (e.g. PNG or JPEG).
        [byte[]]
        $ImageBytes
    )

    try {
        $esc = [char]27
        $titleStyle = "$esc[1;4;96m"
        $headingStyle = "$esc[1;4;93m"
        $signatureStyle = "$esc[1;95m"
        $codeStyle = "$esc[92m"
        $linkStyle = "$esc[4;94m"
        $boldStyle = "$esc[1m"
        $italicStyle = "$esc[3m"
        $boldItalicStyle = "$esc[1;3m"
        $dim = "$esc[2m"
        $reset = "$esc[0m"
        $newLine = [Environment]::NewLine
        $SignaturePattern = '\S+(?:\s+\(talk\))?\s+\d{1,2}:\d{2},\s+\d{1,2}\s+\w+\s+\d{4}\s+\(UTC\)'

        # Matches a Markdown-style "[words](url)" link (see ConvertTo-XKCDPlainText) or a bare url on its own.
        # For the former, the words themselves become the clickable hyperlink and the url is never printed; for
        # the latter, the url is the only text available so it's shown as-is. The url in a "[words](url)" link
        # may itself contain one level of parentheses (e.g. a Wikipedia disambiguation page like
        # "Installer_(OS_X)#Installer_package"), so that's matched as a balanced group rather than stopping at
        # its first closing paren, which would otherwise cut the url short and leak the rest as plain text.
        $LinkPattern = '\[([^\]]+)\]\((https?://(?:[^()]|\([^()]*\))+)\)|(https?://[^\s()`]+)'
        $LinkEvaluator = [System.Text.RegularExpressions.MatchEvaluator] {
            param($Match)
            if ($Match.Groups[2].Success) {
                $LinkText = $Match.Groups[1].Value
                $Url = $Match.Groups[2].Value
            }
            else {
                $LinkText = $Match.Groups[3].Value
                $Url = $Match.Groups[3].Value
            }
            "$linkStyle$esc]8;;$Url$esc\$LinkText$esc]8;;$esc\$reset"
        }

        # Strips ANSI/OSC escape sequences so word-wrapping measures how wide a styled or hyperlinked word
        # actually looks on screen, rather than its much longer raw string length (which would otherwise wrap
        # far too early, since e.g. a short hyperlinked word carries dozens of invisible characters).
        $AnsiPattern = "$esc\[[0-9;]*[A-Za-z]|$esc\][^$esc]*$esc\\"
        Function Get-VisibleLength([string]$Text) {
            ($Text -replace $AnsiPattern, '').Length
        }

        $width = 80
        try {
            if ([Console]::WindowWidth -gt 0) { $width = [Console]::WindowWidth }
        }
        catch {
            Write-Verbose 'No console window available (e.g. output redirected), falling back to the default width'
        }

        [Console]::Out.Write($newLine)
        [Console]::Out.Write("$titleStyle#$($Explanation.Num): $($Explanation.Title)$reset")
        [Console]::Out.Write($newLine + $newLine)

        if ($Explanation.Url) {
            $ExplanationHyperlink = "$esc]8;;$($Explanation.Url)$esc\$($Explanation.Url)$esc]8;;$esc\"
            [Console]::Out.Write("$dim$ExplanationHyperlink$reset")
            [Console]::Out.Write($newLine + $newLine)
        }

        if ($Comic) {
            $MetaText = Get-XKCDComicMetaText -Comic $Comic
            [Console]::Out.Write("$dim$MetaText$reset")
            [Console]::Out.Write($newLine + $newLine)
        }

        if ($ImageBytes) {
            Show-XKCDComicImage -Comic $Comic -ImageBytes $ImageBytes -Width $width
        }

        $Sections = [ordered]@{}
        if ($Explanation.PSObject.Properties.Name -contains 'Explanation') { $Sections.Explanation = $Explanation.Explanation }
        if ($Explanation.PSObject.Properties.Name -contains 'Transcript') { $Sections.Transcript = $Explanation.Transcript }
        if ($Explanation.PSObject.Properties.Name -contains 'Discussion') { $Sections.Discussion = $Explanation.Discussion }

        foreach ($SectionName in $Sections.Keys) {
            [Console]::Out.Write("$headingStyle$SectionName$reset")
            [Console]::Out.Write($newLine + $newLine)

            $Text = $Sections[$SectionName]
            if ([string]::IsNullOrWhiteSpace($Text)) { $Text = "No $($SectionName.ToLower()) is available yet." }

            foreach ($Paragraph in ($Text -split '\n{2,}')) {
                # Turn any URL into a working, clickable hyperlink first, while the text is still plain -- every
                # highlighting step below inserts ANSI codes containing literal "[" characters (e.g. "<esc>[3m"),
                # which would otherwise be misread as the start of a "[text](url)" link. This matters especially
                # for wikitext like "''[[Page|Display]]''", where a link sits directly inside bold/italic markup.
                $Paragraph = [regex]::Replace($Paragraph, $LinkPattern, $LinkEvaluator)

                # Highlight Markdown-style bold/italic markup (see ConvertTo-XKCDPlainText) next, while what's
                # left is still otherwise plain. Longest marker first, since e.g. "**bold**" left over after
                # "***" is consumed must not be re-matched as two separate "*"s.
                $Paragraph = $Paragraph -replace '\*\*\*([^*]+)\*\*\*', "$boldItalicStyle`$1$reset"
                $Paragraph = $Paragraph -replace '\*\*([^*]+)\*\*', "$boldStyle`$1$reset"
                $Paragraph = $Paragraph -replace '\*([^*]+)\*', "$italicStyle`$1$reset"

                # Highlight backtick-wrapped code (see ConvertTo-XKCDPlainText).
                $Paragraph = $Paragraph -replace '`([^`]+)`', "$codeStyle`$1$reset"

                # In the Discussion section, highlight each message's trailing "User (talk) HH:MM, D Month YYYY
                # (UTC)" signature so it's easy to see at a glance who wrote what.
                if ($SectionName -eq 'Discussion') {
                    $Paragraph = $Paragraph -replace $SignaturePattern, "$signatureStyle`$0$reset"
                }

                # Preserve each paragraph's leading indentation (used to show reply nesting in a discussion) as a
                # hanging indent, so wrapped lines still line up under it instead of collapsing back to the margin.
                $null = $Paragraph -match '^(?<indent>[ ]*)'
                $Indent = $Matches['indent']
                $WrapWidth = [Math]::Max(20, $width - $Indent.Length)

                $Words = $Paragraph.Substring($Indent.Length) -split '\s+'
                $Lines = [System.Collections.Generic.List[string]]::new()
                $CurrentLine = ''
                $CurrentVisibleLength = 0

                foreach ($Word in $Words) {
                    $WordVisibleLength = Get-VisibleLength $Word

                    if (-not $CurrentLine) {
                        $CurrentLine = $Word
                        $CurrentVisibleLength = $WordVisibleLength
                    }
                    elseif (($CurrentVisibleLength + 1 + $WordVisibleLength) -le $WrapWidth) {
                        $CurrentLine = "$CurrentLine $Word"
                        $CurrentVisibleLength += 1 + $WordVisibleLength
                    }
                    else {
                        $Lines.Add($CurrentLine)
                        $CurrentLine = $Word
                        $CurrentVisibleLength = $WordVisibleLength
                    }
                }
                if ($CurrentLine) { $Lines.Add($CurrentLine) }

                [Console]::Out.Write((($Lines | ForEach-Object { "$Indent$_" }) -join $newLine))
                [Console]::Out.Write($newLine + $newLine)
            }
        }
    }
    catch {
        # Console access can fail in non-interactive/headless hosts (e.g. CI build agents) in ways that vary by
        # environment. Displaying an explanation is inherently best-effort there, so degrade to a warning rather than throw.
        Write-Warning "Unable to display the explanation in this console: $_"
    }
}