MarkdownPointer.psm1
|
# MarkdownPointer PowerShell Module $script:PipeName = "MarkdownPointer_Pipe" $script:ExePath = Join-Path $PSScriptRoot "bin\mdp.exe" function Send-MarkdownPointerCommand { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Message, [int]$Retries = 3, [int]$TimeoutMs = 10000 ) $json = $Message | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) $ErrorActionPreference = 'SilentlyContinue' for ($i = 0; $i -lt $Retries; $i++) { $client = $null try { $client = [System.IO.Pipes.NamedPipeClientStream]::new(".", $script:PipeName, [System.IO.Pipes.PipeDirection]::InOut) $client.Connect($TimeoutMs) $client.Write($bytes, 0, $bytes.Length) $client.Flush() # Read response $buffer = [byte[]]::new(4096) $bytesRead = $client.Read($buffer, 0, $buffer.Length) $client.Close() if ($bytesRead -gt 0) { $responseJson = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $bytesRead) return $responseJson | ConvertFrom-Json } return $null } catch { # Silently retry on timeout if ($client) { try { $client.Close() } catch { } } if ($i -lt $Retries - 1) { Start-Sleep -Milliseconds 500 } } } # Return null silently instead of throwing error return $null } function Start-MarkdownPointer { [CmdletBinding()] param() if (-not (Test-Path $script:ExePath)) { throw "mdp.exe not found at: $script:ExePath" } Start-Process -FilePath $script:ExePath -WindowStyle Normal # Wait for the pipe to become available $timeout = 5 $elapsed = 0 while ($elapsed -lt $timeout) { Start-Sleep -Milliseconds 200 $elapsed += 0.2 $proc = $null $proc = Get-Process -Name mdp -ErrorAction Ignore if ($proc) { Start-Sleep -Milliseconds 500 # Extra wait for pipe initialization return } } throw "MarkdownPointer failed to start within $timeout seconds" } function Show-MarkdownPointer { <# .SYNOPSIS Opens a Markdown file or content in MarkdownPointer. .DESCRIPTION Opens the specified Markdown file or renders Markdown content directly in MarkdownPointer. If MarkdownPointer is not running, it will be started automatically. When a string is piped, it's treated as Markdown content if it doesn't exist as a file path. .PARAMETER Path The path to the Markdown file to open, or Markdown content as a string. .PARAMETER Line The line number to scroll to after opening the file. .PARAMETER Title Custom title for the tab when displaying Markdown content directly. Defaults to "Preview". .EXAMPLE Show-Markdown .\README.md .EXAMPLE Show-Markdown .\README.md -Line 50 .EXAMPLE Get-ChildItem *.md | Show-Markdown .EXAMPLE "# Hello World`n`nThis is **bold** text." | Show-Markdown .EXAMPLE @" # Report | Item | Value | |------|-------| | CPU | 80% | "@ | Show-Markdown -Title "System Report" #> [CmdletBinding()] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)] [Alias("FullName")] [string[]]$Path, [Parameter(Position = 1)] [int]$Line, [Parameter()] [string]$Title = "Preview" ) begin { # Check if MarkdownPointer is running $process = Get-Process -Name mdp -ErrorAction Ignore if (-not $process) { Start-MarkdownPointer } # Collect paths and content lines $filePaths = [System.Collections.Generic.List[string]]::new() $contentLines = [System.Collections.Generic.List[string]]::new() $isContentMode = $false } process { if (-not $Path) { return } foreach ($p in $Path) { $resolved = @(Resolve-Path -Path $p -ErrorAction Ignore) if ($resolved.Count -gt 0) { foreach ($r in $resolved) { $filePaths.Add($r.Path) } } elseif ($MyInvocation.ExpectingInput) { # Pipeline input that's not a valid path - treat as markdown content $isContentMode = $true $contentLines.Add($p) } else { Write-Error "File not found: $p" -Category ObjectNotFound -TargetObject $p } } } end { # No files or content - just bring window to front if ($filePaths.Count -eq 0 -and -not $isContentMode) { Send-MarkdownPointerCommand -Message @{ Command = "activate" } | Out-Null return } # Open collected file paths in a single pipe call if ($filePaths.Count -gt 0) { $message = @{ Command = "open" Paths = [string[]]$filePaths } if ($PSBoundParameters.ContainsKey('Line')) { $message.Line = $Line } $result = Send-MarkdownPointerCommand -Message $message if ($result) { if ($result.Errors) { $result.Errors | ForEach-Object { Write-Warning $_ } } $filePaths | ForEach-Object { "Opened: $_" } foreach ($w in $result.Windows) { foreach ($t in $w.Tabs) { if ($t.Errors) { $t.Errors | ForEach-Object { Write-Warning "$($t.Path): $_" } } } } } } # Handle inline markdown content if ($isContentMode -and $contentLines.Count -gt 0) { $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "MarkdownPointer" if (-not (Test-Path $tempDir)) { New-Item -ItemType Directory -Path $tempDir -Force | Out-Null } $safeTitle = $Title -replace '[\\/:*?"<>|]', '_' $tempFile = Join-Path $tempDir "$safeTitle.md" $contentLines -join "`n" | Set-Content -Path $tempFile -Encoding UTF8 $message = @{ Command = "openTemp" Path = $tempFile Title = $Title } if ($PSBoundParameters.ContainsKey('Line')) { $message.Line = $Line } $result = Send-MarkdownPointerCommand -Message $message if ($result) { if ($result.Errors) { $result.Errors | ForEach-Object { Write-Warning $_ } } "Opened preview: $Title" } } } } function Get-MarkdownPointerMCPPath { <# .SYNOPSIS Returns the path to the MarkdownPointer MCP server executable. .DESCRIPTION Returns the full path to mdp-mcp.exe bundled with this module. Use this to register MarkdownPointer as an MCP server in Claude Code. .PARAMETER Escape Escape backslashes in the path (e.g. for JSON config files). .EXAMPLE claude mcp add MarkdownPointer -s user -- "$(Get-MarkdownPointerMCPPath)" .EXAMPLE Get-MarkdownPointerMCPPath -Escape # Returns: C:\\program files\\powershell\\7\\Modules\\MarkdownPointer\\bin\\mdp-mcp.exe #> [CmdletBinding()] param( [switch]$Escape ) $mcpPath = Join-Path (Get-Module MarkdownPointer).ModuleBase "bin\mdp-mcp.exe" if (-not (Test-Path $mcpPath)) { throw "mdp-mcp.exe not found at: $mcpPath" } if ($Escape) { return $mcpPath -replace '\\', '\\' } return $mcpPath } function ConvertTo-Docx { <# .SYNOPSIS Convert files to .docx using Pandoc. .DESCRIPTION Converts one or more files to Word documents (.docx) using Pandoc. Input format is auto-detected by Pandoc from the file extension. Supports wildcards. Output files are placed alongside the source files by default. .PARAMETER Path Path(s) to Markdown files. Supports wildcards. .PARAMETER OutputDirectory Optional output directory. Defaults to each source file's directory. .EXAMPLE ConvertTo-Docx .\README.md .EXAMPLE ConvertTo-Docx .\docs\*.md .EXAMPLE ConvertTo-Docx .\docs\*.md -OutputDirectory .\out #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline)] [string[]]$Path, [string]$OutputDirectory ) begin { $pandoc = Get-Command pandoc -ErrorAction SilentlyContinue if (-not $pandoc) { throw "Pandoc is not installed. Install from https://pandoc.org/installing.html" } if ($OutputDirectory) { $OutputDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory) if (-not (Test-Path $OutputDirectory)) { New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null } } } process { foreach ($p in $Path) { $resolved = @(Resolve-Path -Path $p -ErrorAction SilentlyContinue) if ($resolved.Count -eq 0) { Write-Warning "No files found: $p" continue } foreach ($file in $resolved) { $filePath = $file.Path if ($OutputDirectory) { $outPath = Join-Path $OutputDirectory ([System.IO.Path]::ChangeExtension([System.IO.Path]::GetFileName($filePath), '.docx')) } else { $outPath = [System.IO.Path]::ChangeExtension($filePath, '.docx') } $result = & pandoc -t docx -o $outPath $filePath 2>&1 if ($LASTEXITCODE -eq 0) { [PSCustomObject]@{ Source = $filePath Output = $outPath } } else { Write-Error "Failed to convert ${filePath}: $result" } } } } } New-Alias -Name mdp -Value Show-MarkdownPointer Export-ModuleMember -Function Show-MarkdownPointer, Get-MarkdownPointerMCPPath, ConvertTo-Docx -Alias mdp |