Deck.psm1
|
function ConvertFrom-DeckMarkdown { <# .SYNOPSIS Parses markdown file for slide presentation data. .DESCRIPTION Extracts YAML frontmatter settings and parses the markdown content into individual slides. Returns a structured object containing global settings and slide data. .PARAMETER Path Path to the markdown file to parse. .EXAMPLE ConvertFrom-DeckMarkdown -Path ".\presentation.md" Parses the markdown file and returns slide data. .OUTPUTS PSCustomObject with Settings and Slides properties. .NOTES Handles YAML frontmatter extraction and slide splitting by horizontal rules. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$Path ) begin { Write-Verbose "Starting markdown parsing for: $Path" # Default settings $defaultSettings = @{ background = 'black' foreground = 'white' border = 'magenta' header = $null footer = $null pagination = $false paginationStyle = 'minimal' borderStyle = 'rounded' titleFont = 'default' sectionFont = 'default' headerFont = 'default' } } process { try { # Read the entire file $content = Get-Content -Path $Path -Raw # Extract YAML frontmatter $settings = $defaultSettings.Clone() $markdownContent = $content if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---\s*\r?\n(.*)$') { $yamlContent = $Matches[1] $markdownContent = $Matches[2] Write-Verbose "Found YAML frontmatter, parsing settings" # Parse YAML (simple key: value format) foreach ($line in ($yamlContent -split '\r?\n')) { if ($line -match '^\s*([^:]+):\s*(.+?)\s*$') { $key = $Matches[1].Trim() $value = $Matches[2].Trim() # Remove quotes if present $value = $value -replace '^["'']|["'']$', '' # Convert boolean strings if ($value -eq 'true') { $value = $true } elseif ($value -eq 'false') { $value = $false } # Store in settings if ($settings.ContainsKey($key)) { $settings[$key] = $value Write-Verbose " Setting: $key = $value" } else { Write-Warning "Unknown setting in frontmatter: $key" } } } } else { Write-Verbose "No YAML frontmatter found, using defaults" } # Split markdown into slides by horizontal rules (---, ***, ___) Write-Verbose "Splitting markdown into slides" $slidePattern = '(?m)^(?:---|___|\*\*\*)[ \t]*\r?$' $slideContents = $markdownContent -split $slidePattern # Check if any delimiters were found $noDelimiters = ($slideContents.Count -eq 1) if ($noDelimiters) { Write-Warning "No slide delimiters found. Treating entire content as single slide." } # Filter out empty slides and trim whitespace $slides = @() $slideNumber = 1 foreach ($slideContent in $slideContents) { $trimmed = $slideContent.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { Write-Verbose " Skipping empty slide section" continue } # Check for intentionally blank slides if ($trimmed -match '<!--\s*intentionally\s+blank\s*-->') { Write-Verbose " Slide $slideNumber : Intentionally blank" $slides += [PSCustomObject]@{ Number = $slideNumber Content = $trimmed IsBlank = $true } $slideNumber++ continue } Write-Verbose " Slide $slideNumber : $(($trimmed -split '\r?\n')[0].Substring(0, [Math]::Min(50, ($trimmed -split '\r?\n')[0].Length)))..." $slides += [PSCustomObject]@{ Number = $slideNumber Content = $trimmed IsBlank = $false } $slideNumber++ } Write-Verbose "Found $($slides.Count) slides" # Return parsed data [PSCustomObject]@{ Settings = $settings Slides = $slides SourcePath = $Path } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( $_.Exception, 'MarkdownParsingFailed', [System.Management.Automation.ErrorCategory]::ParserError, $Path ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } end { Write-Verbose 'Markdown parsing complete' } } function Get-SlideNavigation { <# .SYNOPSIS Processes keyboard input and returns navigation action. .DESCRIPTION Reads a ConsoleKeyInfo object and determines the appropriate navigation action (Next, Previous, Exit, or None). .PARAMETER KeyInfo The KeyInfo object from $Host.UI.RawUI.ReadKey(). .OUTPUTS [string] One of: 'Next', 'Previous', 'Exit', 'None' .EXAMPLE $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') $action = Get-SlideNavigation -KeyInfo $key #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)] [System.Management.Automation.Host.KeyInfo] $KeyInfo ) # Forward navigation if ($KeyInfo.VirtualKeyCode -in @(39, 40, 32, 13, 34) -or $KeyInfo.Character -eq 'n') { # 39=Right, 40=Down, 32=Space, 13=Enter, 34=PageDown return 'Next' } # Backward navigation if ($KeyInfo.VirtualKeyCode -in @(37, 38, 8, 33) -or $KeyInfo.Character -eq 'p') { # 37=Left, 38=Up, 8=Backspace, 33=PageUp return 'Previous' } # Exit if ($KeyInfo.VirtualKeyCode -eq 27 -or ($KeyInfo.Character -eq 'c' -and $KeyInfo.ControlKeyState -match 'LeftCtrlPressed|RightCtrlPressed')) { # 27=Escape, c with Ctrl return 'Exit' } # Unhandled return 'None' } function Import-DeckDependency { <# .SYNOPSIS Imports PwshSpectreConsole module with automatic installation fallback. .DESCRIPTION Attempts to import the PwshSpectreConsole module. If not found, tries to install it using Install-PSResource. On failure, displays helpful ASCII art and installation instructions before terminating. .EXAMPLE Import-DeckDependency Attempts to load PwshSpectreConsole, installing if necessary. .OUTPUTS None. Terminates script on failure. .NOTES This function will exit the calling script if PwshSpectreConsole cannot be loaded. #> [CmdletBinding()] param() begin { Write-Verbose 'Checking for PwshSpectreConsole module' } process { try { # Try to import the module Import-Module PwshSpectreConsole -ErrorAction Stop Write-Verbose 'PwshSpectreConsole loaded successfully' } catch { Write-Warning 'PwshSpectreConsole module not found. Attempting to install...' try { # Try to install using Install-PSResource (PSResourceGet) Install-PSResource -Name PwshSpectreConsole -Repository PSGallery -TrustRepository -ErrorAction Stop Import-Module PwshSpectreConsole -ErrorAction Stop Write-Verbose 'PwshSpectreConsole installed and loaded successfully' } catch { # Installation failed - show sad face and exit Show-SadFace $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new('Failed to load PwshSpectreConsole module'), 'DependencyLoadFailure', [System.Management.Automation.ErrorCategory]::NotInstalled, 'PwshSpectreConsole' ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } } end { Write-Verbose 'Dependency check complete' } } function Show-ContentSlide { <# .SYNOPSIS Renders a content slide with optional header and body text. .DESCRIPTION Displays a content slide that may contain a ### heading rendered as smaller figlet text followed by content. If no ### heading is present, only content is shown. .PARAMETER Slide The slide object containing the content to render. .PARAMETER Settings The presentation settings hashtable containing colors, fonts, and styling options. .PARAMETER VisibleBullets The number of progressive bullets (*) to show. If not specified, all bullets are shown. .EXAMPLE Show-ContentSlide -Slide $slideObject -Settings $settings .EXAMPLE Show-ContentSlide -Slide $slideObject -Settings $settings -VisibleBullets 2 .NOTES Content slides are the most common slide type for displaying information. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [PSCustomObject]$Slide, [Parameter(Mandatory = $true)] [hashtable]$Settings, [Parameter(Mandatory = $false)] [int]$VisibleBullets = [int]::MaxValue ) begin { Write-Verbose "Rendering content slide #$($Slide.Number)" } process { try { # Clear the screen Clear-Host # Get terminal dimensions # Account for rendering behavior to prevent scrolling $windowWidth = $Host.UI.RawUI.WindowSize.Width $windowHeight = $Host.UI.RawUI.WindowSize.Height - 1 # Determine if slide has a header and extract content $hasHeader = $false $headerText = $null $bodyContent = $null if ($Slide.Content -match '^###\s+(.+?)(?:\r?\n|$)') { $hasHeader = $true $headerText = $Matches[1].Trim() Write-Verbose " Header: $headerText" # Extract content after header $bodyContent = $Slide.Content -replace '^###\s+.+?(\r?\n|$)', '' $bodyContent = $bodyContent.Trim() } else { # No header, use all content $bodyContent = $Slide.Content.Trim() } # Parse and filter bullets based on visibility if ($bodyContent) { $lines = $bodyContent -split "`r?`n" $filteredLines = [System.Collections.Generic.List[string]]::new() $progressiveBulletCount = 0 $visibleProgressiveBullets = 0 foreach ($line in $lines) { # Check if line is a progressive bullet (*) if ($line -match '^\s*\*\s+') { $progressiveBulletCount++ if ($visibleProgressiveBullets -lt $VisibleBullets) { $filteredLines.Add($line) $visibleProgressiveBullets++ } else { # Add blank line placeholder for hidden progressive bullets $filteredLines.Add("") } } # All other lines (including - bullets) are always shown else { $filteredLines.Add($line) } } # Store total progressive bullet count on the slide object for navigation if (-not $Slide.PSObject.Properties['TotalProgressiveBullets']) { Add-Member -InputObject $Slide -NotePropertyName 'TotalProgressiveBullets' -NotePropertyValue $progressiveBulletCount -Force } # Store the full content height for consistent vertical alignment # This ensures content doesn't jump as bullets are revealed if (-not $Slide.PSObject.Properties['FullContentHeight']) { $fullContentText = $lines -join "`n" $fullText = [Spectre.Console.Text]::new($fullContentText) $fullSize = Get-SpectreRenderableSize -Renderable $fullText -ContainerWidth $windowWidth Add-Member -InputObject $Slide -NotePropertyName 'FullContentHeight' -NotePropertyValue $fullSize.Height -Force } # Store the max line length of all content (including hidden bullets) for consistent horizontal alignment if (-not $Slide.PSObject.Properties['MaxLineLength']) { $maxLength = ($lines | Measure-Object -Property Length -Maximum).Maximum Add-Member -InputObject $Slide -NotePropertyName 'MaxLineLength' -NotePropertyValue $maxLength -Force } $bodyContent = $filteredLines -join "`n" } # Get border color and style $borderColor = $null if ($Settings.border) { $borderColorName = (Get-Culture).TextInfo.ToTitleCase($Settings.border.ToLower()) Write-Verbose " Border color: $borderColorName" try { $borderColor = [Spectre.Console.Color]::$borderColorName } catch { Write-Warning "Invalid border color '$($Settings.border)', using default" } } $borderStyle = 'Rounded' if ($Settings.borderStyle) { $borderStyle = (Get-Culture).TextInfo.ToTitleCase($Settings.borderStyle.ToLower()) Write-Verbose " Border style: $borderStyle" } # Build the renderable content $renderables = [System.Collections.Generic.List[object]]::new() # Add header figlet if present if ($hasHeader) { # Convert color name to Spectre.Console.Color $figletColor = $null if ($Settings.foreground) { $colorName = (Get-Culture).TextInfo.ToTitleCase($Settings.foreground.ToLower()) Write-Verbose " Header color: $colorName" try { $figletColor = [Spectre.Console.Color]::$colorName } catch { Write-Warning "Invalid color '$($Settings.foreground)', using default" } } # Create figlet for header $miniFontPath = Join-Path $PSScriptRoot '../Fonts/mini.flf' if (Test-Path $miniFontPath) { $font = [Spectre.Console.FigletFont]::Load($miniFontPath) $figlet = [Spectre.Console.FigletText]::new($font, $headerText) } else { $figlet = [Spectre.Console.FigletText]::new($headerText) } $figlet.Justification = [Spectre.Console.Justify]::Center if ($figletColor) { $figlet.Color = $figletColor } $renderables.Add($figlet) } # Add body content as text if ($bodyContent) { # Manually pad each line to center the block $lines = $bodyContent -split "`r?`n" # Use stored max line length for consistent alignment during bullet reveal if ($Slide.PSObject.Properties['MaxLineLength']) { $maxLineLength = $Slide.MaxLineLength } else { $maxLineLength = ($lines | Measure-Object -Property Length -Maximum).Maximum } # Calculate padding to center the block within the panel $availableWidth = $windowWidth - 8 # Account for panel padding (4 left + 4 right) $leftPadding = [math]::Max(0, [math]::Floor(($availableWidth - $maxLineLength) / 2)) # Rebuild content with padding $paddedLines = $lines | ForEach-Object { (" " * $leftPadding) + $_ } $paddedContent = $paddedLines -join "`n" # Create text with left justification (padding is already in the string) $text = [Spectre.Console.Text]::new($paddedContent) $text.Justification = [Spectre.Console.Justify]::Left $renderables.Add($text) } # Combine renderables into a Rows layout $rows = [Spectre.Console.Rows]::new([object[]]$renderables.ToArray()) # Measure the actual height of the rendered content # (blank placeholder lines maintain consistent height for progressive bullets) $contentSize = Get-SpectreRenderableSize -Renderable $rows -ContainerWidth $windowWidth $actualContentHeight = $contentSize.Height # Calculate padding $borderHeight = 2 $remainingSpace = $windowHeight - $actualContentHeight - $borderHeight $topPadding = [math]::Max(0, [math]::Ceiling($remainingSpace / 2.0)) $bottomPadding = [math]::Max(0, $remainingSpace - $topPadding) Write-Verbose " Content height: $actualContentHeight, top padding: $topPadding, bottom padding: $bottomPadding" # Create panel with internal padding $panel = [Spectre.Console.Panel]::new($rows) $panel.Expand = $true $panel.Padding = [Spectre.Console.Padding]::new(4, $topPadding, 4, $bottomPadding) # Add border style if ($borderStyle) { $panel.Border = [Spectre.Console.BoxBorder]::$borderStyle } # Add border color if ($borderColor) { $panel.BorderStyle = [Spectre.Console.Style]::new($borderColor) } # Render panel Out-SpectreHost $panel } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( $_.Exception, 'ContentSlideRenderFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Slide ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } end { Write-Verbose "Content slide rendered" } } function Show-SadFace { <# .SYNOPSIS Displays sad ASCII art with installation instructions. .DESCRIPTION Shows a sad face ASCII art along with helpful instructions for manually installing PwshSpectreConsole. Called when automatic dependency loading fails. .EXAMPLE Show-SadFace Displays the sad face and installation help. .OUTPUTS None. Writes directly to host. .NOTES This function cannot use PwshSpectreConsole since it's called when that module fails to load. #> [CmdletBinding()] param() process { Write-Host "" Write-Host " ___________" -ForegroundColor Red Write-Host " / \" -ForegroundColor Red Write-Host " / O O \" -ForegroundColor Red Write-Host " | |" -ForegroundColor Red Write-Host " | ___ |" -ForegroundColor Red Write-Host " | / \ |" -ForegroundColor Red Write-Host " \ \___/ /" -ForegroundColor Red Write-Host " \___________/" -ForegroundColor Red Write-Host "" Write-Host " OH NO! Something went wrong!" -ForegroundColor Yellow Write-Host "" Write-Host " The Deck module requires PwshSpectreConsole to run." -ForegroundColor White Write-Host " Unfortunately, we couldn't install it automatically." -ForegroundColor White Write-Host "" Write-Host " To fix this, please run:" -ForegroundColor Cyan Write-Host "" Write-Host " Install-PSResource -Name PwshSpectreConsole -Repository PSGallery" -ForegroundColor Green Write-Host "" Write-Host " Or if you're using PowerShellGet v2:" -ForegroundColor Cyan Write-Host "" Write-Host " Install-Module -Name PwshSpectreConsole -Repository PSGallery" -ForegroundColor Green Write-Host "" Write-Host " Then try running Deck again!" -ForegroundColor White Write-Host "" } } function Show-SectionSlide { <# .SYNOPSIS Renders a section slide with medium figlet text. .DESCRIPTION Displays a full-screen section slide containing a ## heading rendered as medium figlet text. Section slides are centered and use the configured sectionFont setting. .PARAMETER Slide The slide object containing the content to render. .PARAMETER Settings The presentation settings hashtable containing colors, fonts, and styling options. .EXAMPLE Show-SectionSlide -Slide $slideObject -Settings $settings .NOTES Section slides should contain only a single ## heading with no other content. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [PSCustomObject]$Slide, [Parameter(Mandatory = $true)] [hashtable]$Settings ) begin { Write-Verbose "Rendering section slide #$($Slide.Number)" } process { try { # Extract the ## heading text if ($Slide.Content -match '^##\s+(.+)$') { $sectionText = $Matches[1].Trim() Write-Verbose " Section: $sectionText" } else { throw "Section slide does not contain a valid ## heading" } # Clear the screen Clear-Host # Convert colors to Spectre.Console.Color $figletColor = $null if ($Settings.foreground) { $colorName = (Get-Culture).TextInfo.ToTitleCase($Settings.foreground.ToLower()) Write-Verbose " Figlet color: $colorName" try { $figletColor = [Spectre.Console.Color]::$colorName } catch { Write-Warning "Invalid color '$($Settings.foreground)', using default" } } $borderColor = $null if ($Settings.border) { $borderColorName = (Get-Culture).TextInfo.ToTitleCase($Settings.border.ToLower()) Write-Verbose " Border color: $borderColorName" try { $borderColor = [Spectre.Console.Color]::$borderColorName } catch { Write-Warning "Invalid border color '$($Settings.border)', using default" } } # Determine border style $borderStyle = 'Rounded' if ($Settings.borderStyle) { $borderStyle = (Get-Culture).TextInfo.ToTitleCase($Settings.borderStyle.ToLower()) Write-Verbose " Border style: $borderStyle" } # Create figlet text object with small font if available $smallFontPath = Join-Path $PSScriptRoot '../Fonts/small.flf' if (Test-Path $smallFontPath) { $figlet = [Spectre.Console.FigletText]::new([Spectre.Console.FigletFont]::Load($smallFontPath), $sectionText) } else { $figlet = [Spectre.Console.FigletText]::new($sectionText) } $figlet.Justification = [Spectre.Console.Justify]::Center if ($figletColor) { $figlet.Color = $figletColor } # Create panel with internal padding calculated to fill terminal height # Account for rendering behavior to prevent scrolling $windowHeight = $Host.UI.RawUI.WindowSize.Height - 1 $windowWidth = $Host.UI.RawUI.WindowSize.Width # Set panel to expand and measure what we need to fill $panel = [Spectre.Console.Panel]::new($figlet) $panel.Expand = $true # Add border style first if ($borderStyle) { $panel.Border = [Spectre.Console.BoxBorder]::$borderStyle } # Measure figlet with horizontal padding already applied # Horizontal padding is 4 on each side = 8 total $contentWidth = $windowWidth - 8 $figletSize = Get-SpectreRenderableSize -Renderable $figlet -ContainerWidth $contentWidth $actualFigletHeight = $figletSize.Height # Calculate vertical padding needed # Total height = border (2) + top padding + content + bottom padding $borderHeight = 2 $remainingSpace = $windowHeight - $actualFigletHeight - $borderHeight $topPadding = [math]::Max(0, [math]::Ceiling($remainingSpace / 2.0)) $bottomPadding = [math]::Max(0, $remainingSpace - $topPadding) $panel.Padding = [Spectre.Console.Padding]::new(4, $topPadding, 4, $bottomPadding) # Border style already added above if ($borderStyle) { $panel.Border = [Spectre.Console.BoxBorder]::$borderStyle } # Add border color if ($borderColor) { $panel.BorderStyle = [Spectre.Console.Style]::new($borderColor) } # Render panel Out-SpectreHost $panel } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( $_.Exception, 'SectionSlideRenderFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Slide ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } end { Write-Verbose "Section slide rendered" } } function Show-TitleSlide { <# .SYNOPSIS Renders a title slide with large figlet text. .DESCRIPTION Displays a full-screen title slide containing a # heading rendered as large figlet text. Title slides are centered and use the configured titleFont setting. .PARAMETER Slide The slide object containing the content to render. .PARAMETER Settings The presentation settings hashtable containing colors, fonts, and styling options. .EXAMPLE Show-TitleSlide -Slide $slideObject -Settings $settings .NOTES Title slides should contain only a single # heading with no other content. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [PSCustomObject]$Slide, [Parameter(Mandatory = $true)] [hashtable]$Settings, [Parameter()] [switch]$IsFirstSlide ) begin { Write-Verbose "Rendering title slide #$($Slide.Number)" } process { try { # Extract the # heading text if ($Slide.Content -match '^#\s+(.+)$') { $titleText = $Matches[1].Trim() Write-Verbose " Title: $titleText" } else { throw "Title slide does not contain a valid # heading" } # Clear the screen Clear-Host # Convert colors to Spectre.Console.Color $figletColor = $null if ($Settings.foreground) { $colorName = (Get-Culture).TextInfo.ToTitleCase($Settings.foreground.ToLower()) Write-Verbose " Figlet color: $colorName" try { $figletColor = [Spectre.Console.Color]::$colorName } catch { Write-Warning "Invalid color '$($Settings.foreground)', using default" } } $borderColor = $null if ($Settings.border) { $borderColorName = (Get-Culture).TextInfo.ToTitleCase($Settings.border.ToLower()) Write-Verbose " Border color: $borderColorName" try { $borderColor = [Spectre.Console.Color]::$borderColorName } catch { Write-Warning "Invalid border color '$($Settings.border)', using default" } } # Determine border style $borderStyle = 'Rounded' if ($Settings.borderStyle) { $borderStyle = (Get-Culture).TextInfo.ToTitleCase($Settings.borderStyle.ToLower()) Write-Verbose " Border style: $borderStyle" } # Create figlet text object $figlet = [Spectre.Console.FigletText]::new($titleText) $figlet.Justification = [Spectre.Console.Justify]::Center if ($figletColor) { $figlet.Color = $figletColor } # Create panel with internal padding calculated to fill terminal height # Account for rendering behavior to prevent scrolling $windowHeight = $Host.UI.RawUI.WindowSize.Height - 1 $windowWidth = $Host.UI.RawUI.WindowSize.Width # Set panel to expand and measure what we need to fill $panel = [Spectre.Console.Panel]::new($figlet) $panel.Expand = $true # Add border style first if ($borderStyle) { $panel.Border = [Spectre.Console.BoxBorder]::$borderStyle } # Measure figlet with horizontal padding already applied # Horizontal padding is 4 on each side = 8 total $contentWidth = $windowWidth - 8 $figletSize = Get-SpectreRenderableSize -Renderable $figlet -ContainerWidth $contentWidth $actualFigletHeight = $figletSize.Height # Calculate vertical padding needed # Total height = border (2) + top padding + content + bottom padding $borderHeight = 2 $remainingSpace = $windowHeight - $actualFigletHeight - $borderHeight $topPadding = [math]::Max(0, [math]::Ceiling($remainingSpace / 2.0)) $bottomPadding = [math]::Max(0, $remainingSpace - $topPadding) $panel.Padding = [Spectre.Console.Padding]::new(4, $topPadding, 4, $bottomPadding) # Border style already added above if ($borderStyle) { $panel.Border = [Spectre.Console.BoxBorder]::$borderStyle } # Add border color if ($borderColor) { $panel.BorderStyle = [Spectre.Console.Style]::new($borderColor) } # Add help text title for first slide if ($IsFirstSlide) { $panel.Header = [Spectre.Console.PanelHeader]::new("[grey39]press ? for help[/]") } # Render panel Out-SpectreHost $panel } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( $_.Exception, 'TitleSlideRenderFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Slide ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } end { Write-Verbose "Title slide rendered" } } function Show-Deck { <# .SYNOPSIS Displays a Markdown file as an interactive terminal presentation. .DESCRIPTION Converts a Markdown file into a live terminal-based presentation with rich formatting, colors, and ASCII art. Navigate through slides using arrow keys, space, or enter. .PARAMETER Path Path to the Markdown file containing the presentation. .PARAMETER Background Override the background color from the Markdown frontmatter. Accepts Spectre.Console.Color values (e.g., 'Black', 'DarkBlue', 'Grey15'). .PARAMETER Foreground Override the foreground color from the Markdown frontmatter. Accepts Spectre.Console.Color values (e.g., 'White', 'Cyan1', 'Yellow'). .PARAMETER Border Override the border color from the Markdown frontmatter. Accepts Spectre.Console.Color values (e.g., 'Blue', 'Magenta1', 'Green'). .EXAMPLE Show-Deck -Path ./presentation.md Displays the presentation from the specified Markdown file. .EXAMPLE Show-Deck -Path ./presentation.md -Foreground Cyan1 -Background Black Displays the presentation with custom colors. .NOTES Requires PwshSpectreConsole module for terminal rendering. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$Path, [Parameter()] [string]$Background, [Parameter()] [string]$Foreground, [Parameter()] [string]$Border ) begin { Write-Verbose "Starting presentation from: $Path" # Ensure PwshSpectreConsole is loaded Import-DeckDependency } process { try { # Parse the markdown file $presentation = ConvertFrom-DeckMarkdown -Path $Path Write-Verbose "Loaded $($presentation.Slides.Count) slides" # Apply parameter overrides to settings if ($PSBoundParameters.ContainsKey('Background')) { $presentation.Settings.background = $Background } if ($PSBoundParameters.ContainsKey('Foreground')) { $presentation.Settings.foreground = $Foreground } if ($PSBoundParameters.ContainsKey('Border')) { $presentation.Settings.border = $Border } # Hide cursor during presentation using ANSI escape codes Write-Host "`e[?25l" -NoNewline # Hide cursor try { # Full navigation loop (Phase 6) $currentSlide = 0 $totalSlides = $presentation.Slides.Count $shouldExit = $false # Track visible bullets per slide $visibleBullets = @{} while ($true) { # Move cursor to top-left and redraw (no clear to reduce flicker) Write-Host "`e[H" -NoNewline $slide = $presentation.Slides[$currentSlide] # Initialize visible bullets for this slide if not set if (-not $visibleBullets.ContainsKey($currentSlide)) { $visibleBullets[$currentSlide] = 0 } # Detect slide type based on content if ($slide.Content -match '^\s*#\s+.+$' -and $slide.Content -notmatch '\n[^#]') { # Title slide: Only has # heading, no other content Write-Verbose "Rendering title slide $($currentSlide + 1)/$totalSlides" Show-TitleSlide -Slide $slide -Settings $presentation.Settings -IsFirstSlide:($currentSlide -eq 0) } elseif ($slide.Content -match '^\s*##\s+.+$' -and $slide.Content -notmatch '\n[^#]') { # Section slide: Only has ## heading, no other content Write-Verbose "Rendering section slide $($currentSlide + 1)/$totalSlides" Show-SectionSlide -Slide $slide -Settings $presentation.Settings } else { # Content slide: May have ### heading or just content Write-Verbose "Rendering content slide $($currentSlide + 1)/$totalSlides with $($visibleBullets[$currentSlide]) bullets" Show-ContentSlide -Slide $slide -Settings $presentation.Settings -VisibleBullets $visibleBullets[$currentSlide] } # Get user input $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') $action = Get-SlideNavigation -KeyInfo $key # Handle help key if ($key.Character -eq '?') { Write-Host "`e[H" -NoNewline # Fill screen with blank lines to clear previous content $windowHeight = $Host.UI.RawUI.WindowSize.Height $windowWidth = $Host.UI.RawUI.WindowSize.Width for ($i = 0; $i -lt $windowHeight; $i++) { Write-Host (" " * $windowWidth) } # Move cursor back to top and render help text Write-Host "`e[H" -NoNewline Write-Host "`n Navigation Controls`n" -ForegroundColor Cyan Write-Host " Forward: " -ForegroundColor Gray -NoNewline Write-Host "Right, Down, Space, Enter, n, Page Down" -ForegroundColor White Write-Host " Backward: " -ForegroundColor Gray -NoNewline Write-Host "Left, Up, Backspace, p, Page Up" -ForegroundColor White Write-Host " Exit: " -ForegroundColor Gray -NoNewline Write-Host "Esc, Ctrl+C" -ForegroundColor White Write-Host "`n Press any key to return to presentation..." -ForegroundColor DarkGray $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') continue } # Handle navigation switch ($action) { 'Next' { # Check if current slide has hidden bullets if ($slide.PSObject.Properties['TotalProgressiveBullets'] -and $visibleBullets[$currentSlide] -lt $slide.TotalProgressiveBullets) { # Reveal next bullet $visibleBullets[$currentSlide]++ Write-Verbose "Revealed bullet $($visibleBullets[$currentSlide])/$($slide.TotalProgressiveBullets)" } elseif ($currentSlide -lt $totalSlides - 1) { # Move to next slide and reset bullets to 0 $currentSlide++ $visibleBullets[$currentSlide] = 0 Write-Verbose "Advanced to slide $($currentSlide + 1)" } else { # On last slide, trying to go forward shows end screen Write-Host "`e[H" -NoNewline # Center text vertically and horizontally $windowHeight = $Host.UI.RawUI.WindowSize.Height $windowWidth = $Host.UI.RawUI.WindowSize.Width $line1 = "End of Deck" $line2 = "Press ESC to Exit" # Calculate vertical position (center) $verticalPadding = [math]::Floor($windowHeight / 2) - 1 # Fill screen with blank lines to clear previous content for ($i = 0; $i -lt $windowHeight; $i++) { Write-Host (" " * $windowWidth) } # Move cursor back to top and render centered text Write-Host "`e[H" -NoNewline # Print vertical padding Write-Host ("`n" * $verticalPadding) -NoNewline # Print first line centered $padding1 = [math]::Max(0, [math]::Floor(($windowWidth - $line1.Length) / 2)) Write-Host (" " * $padding1) -NoNewline Write-Host $line1 -ForegroundColor White # Blank line between Write-Host "" # Print second line centered $padding2 = [math]::Max(0, [math]::Floor(($windowWidth - $line2.Length) / 2)) Write-Host (" " * $padding2) -NoNewline Write-Host $line2 -ForegroundColor Gray # Wait for ESC or backward navigation do { $exitKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') $exitAction = Get-SlideNavigation -KeyInfo $exitKey if ($exitAction -eq 'Previous') { # Go back to last slide Write-Verbose "Returning to last slide from end screen" break } } while ($exitKey.VirtualKeyCode -ne 27) # 27 = ESC # If ESC was pressed, exit; if backward nav, continue loop if ($exitKey.VirtualKeyCode -eq 27) { Write-Verbose "User exited from end screen" $shouldExit = $true break } } } 'Previous' { # Check if current slide has revealed bullets that can be hidden if ($slide.PSObject.Properties['TotalProgressiveBullets'] -and $visibleBullets[$currentSlide] -gt 0) { # Hide last bullet $visibleBullets[$currentSlide]-- Write-Verbose "Hid bullet, now showing $($visibleBullets[$currentSlide])/$($slide.TotalProgressiveBullets)" } elseif ($currentSlide -gt 0) { # Move to previous slide and show all bullets $currentSlide-- $prevSlide = $presentation.Slides[$currentSlide] if ($prevSlide.PSObject.Properties['TotalProgressiveBullets']) { $visibleBullets[$currentSlide] = $prevSlide.TotalProgressiveBullets } else { $visibleBullets[$currentSlide] = 0 } Write-Verbose "Moved back to slide $($currentSlide + 1)" } } 'Exit' { Write-Verbose "User requested exit" $shouldExit = $true break } 'None' { # Unhandled key, ignore Write-Verbose "Unhandled key: $($key.Key)" } } # Check if we should exit if ($shouldExit) { break } } Write-Verbose "Presentation ended" # Show goodbye message Clear-Host $windowHeight = $Host.UI.RawUI.WindowSize.Height $windowWidth = $Host.UI.RawUI.WindowSize.Width $message = "Goodbye! <3" # Center vertically and horizontally $verticalPadding = [math]::Floor($windowHeight / 2) $horizontalPadding = [math]::Max(0, [math]::Floor(($windowWidth - $message.Length) / 2)) Write-Host ("`n" * $verticalPadding) -NoNewline Write-Host (" " * $horizontalPadding) -NoNewline Write-Host $message -ForegroundColor Magenta Start-Sleep -Milliseconds 800 Clear-Host } finally { # Show cursor again Write-Host "`e[?25h" -NoNewline # Show cursor } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( $_.Exception, 'PresentationFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Path ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } end { Write-Verbose "Show-Deck complete" } } # Export functions and aliases as required Export-ModuleMember -Function @('Show-Deck') -Alias @() |