Sharpdown.psm1

Set-StrictMode -Version Latest
#### <h1 style="color: rgb(220, 166, 87);">🎆 Sharpdown</h1>
####
#### > A polyglot commenting language for generated markdown from code comments
####
#### ---
####
#### | Language | File Types |
#### | --- | --- |
$script:SharpDownConfig = @{
    CSharp = @{
        Marker       = '////'
        Fence        = 'csharp'
        DeclPattern  = '^(public|protected|internal|static|abstract|sealed|override|async)\b'
        #### | `CSharp` | .cs |
        Extensions   = @('.cs')
        ExcludeRegex = '[\\/](bin|obj)[\\/]'
    }
    Sql = @{
        Marker       = '----'
        Fence        = 'sql'
        DeclPattern  = '^(CREATE|ALTER|DROP|SELECT|INSERT|UPDATE|DELETE|GRANT|REVOKE|WITH|COMMENT|CALL|TRUNCATE|MERGE)\b'
        #### | `Sql` | .sql |
        Extensions   = @('.sql')
        ExcludeRegex = '[\\/](bin|obj)[\\/]'
    }
    JavaScript = @{
        Marker       = '////'
        Fence        = 'javascript'
        DeclPattern  = '^(export|import|class|function|async\s+function|type|interface)\b'
        #### | `JavaScript` | .js, .mjs, .ts, .tsx |
        Extensions   = @('.js', '.mjs', '.ts', '.tsx')
        ExcludeRegex = '[\\/](node_modules|dist|build|\.next|coverage)[\\/]'
    }
    PowerShell = @{
        Marker       = '####'
        Fence        = 'powershell'
        DeclPattern  = '^(function|filter|class|enum|workflow|describe|it)\b'
        #### | `PowerShell` | .ps1, .psm1 |
        Extensions   = @('.ps1', '.psm1')
        ExcludeRegex = '[\\/](bin|obj)[\\/]'
    }
}

#### <h2 style="color: #DCA657;">ConvertTo-RedactedPath</h2>
####
function ConvertTo-RedactedPath {
    #### Swap the user's home directory for `~` so log lines never spill private paths.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    #### - `[string]`: __Path__
    #### - *Path to redact.*
    ####
    param([Parameter(Mandatory)][string]$Path)
    #### <b style="color: #369FFF;">Returns</b>
    #### - `[string]`
    #### - *Same path with `$HOME` replaced by `~`.*
    $Path.Replace($HOME, '~')
}
#### ---

#### <h2 style="color: #DCA657;">Convert-SharpDownContent</h2>
####
function Convert-SharpDownContent {
    #### Stream a source file's marker comments and declaration lines as Markdown.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    ####
    param(
        ####
        #### - `[string]`: __Source__
        #### - *Path to the source file.*
        [Parameter(Mandatory)][string]$Source,
        #### - `[hashtable]`: __Config__
        #### - *Language entry from `$script:SharpDownConfig`.*
        [Parameter(Mandatory)][hashtable]$Config,
        #### - `[switch]`: __API__
        #### - *Suppress auto-generated declaration fences. Only marker lines emit.*
        [switch]$API
    )
    ####

    #### | Key | Description |
    #### | --- | --- |
    #### | `Marker` | The line prefix that signals a doc comment. |
    $marker      = $Config.Marker
    #### | `Fence` | The code fence label applied to declarations. |
    $fence       = $Config.Fence
    #### | `DeclPattern` | The regex that marks a line as a declaration. |
    $declPattern = $Config.DeclPattern
    ####
    #### ---
    #### Read and trim each source line so the tests ignore indentation.
    ####
    Get-Content $Source | ForEach-Object { $_.Trim() } | ForEach-Object {
        #### ---
        #### - A marker line is doc text: emit it with the prefix removed.
        ####
        if ($_.StartsWith($marker)) { $_.Substring($marker.Length) }
        #### - Otherwise a declaration line becomes a fenced signature, unless `-API`.
        ####
        elseif (-not $API -and $_ -match $declPattern) {
            #### - Drop the trailing brace so the fence holds just the declaration.
            ####
            $line = $_ -replace '\s*\{\s*$', ''
            #### - Fence the cleaned signature with the language's label.
            "``````$fence`n$line`n``````"
            ####
        }
    }
    #### <b style="color: #369FFF;">Returns</b>
    #### - `[string[]]`
    #### - *Doc lines, with declarations fenced unless `-API` is set.*
}
#### ---

#### <h2 style="color: #DCA657;">Resolve-SharpDownTarget</h2>
####
function Resolve-SharpDownTarget {
    #### Compute the mirrored Markdown output path for a source file under `-Recurse`.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    ####
    param(
        ####
        #### - `[string]`: __Root__
        #### - *Walk root passed to `-Recurse`.*
        [Parameter(Mandatory)][string]$Root,
        #### - `[string]`: __Source__
        #### - *Absolute path of the source file.*
        [Parameter(Mandatory)][string]$Source,
        #### - `[string]`: __ProjectName__
        #### - *Leaf of `Root`. Stripped if it leads the relative path.*
        [Parameter(Mandatory)][string]$ProjectName,
        #### - `[string]`: __OutRoot__
        #### - *Output root passed as `-OutPath`.*
        [Parameter(Mandatory)][string]$OutRoot
    )
    ####

    $sep   = [System.IO.Path]::DirectorySeparatorChar
    $parts = [System.IO.Path]::GetRelativePath($Root, $Source).Split($sep)

    if ($parts.Length -gt 1 -and $parts[0].ToLower() -eq 'src') {
        $parts = $parts[1..($parts.Length - 1)]
    }
    if ($parts.Length -gt 1 -and $parts[0] -eq $ProjectName) {
        $parts = $parts[1..($parts.Length - 1)]
    }

    Join-Path $OutRoot ([System.IO.Path]::ChangeExtension([string]::Join($sep, $parts), '.md'))
    #### <b style="color: #369FFF;">Returns</b>
    #### - `[string]`
    #### - *Absolute path of the `.md` to write.*
}

#### ---


#### <h2 style="color: #DCA657;">Write-SharpDownFile</h2>
####
function Write-SharpDownFile {
    #### Convert a single source file and write the result. Skips with a warning when the source has no SharpDown content. Creates the output directory on demand.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    ####
    param(
        ####
        #### - `[string]`: __Source__
        #### - *Path to the source file.*
        [Parameter(Mandatory)][string]$Source,
        #### - `[string]`: __Target__
        #### - *Path of the `.md` to write.*
        [Parameter(Mandatory)][string]$Target,
        #### - `[hashtable]`: __Config__
        #### - *Language entry from `$script:SharpDownConfig`.*
        [Parameter(Mandatory)][hashtable]$Config,
        #### - `[switch]`: __API__
        #### - *Forwarded to `Convert-SharpDownContent`. Suppresses auto-fenced declarations.*
        [switch]$API
    )
    ####
    $output = Convert-SharpDownContent -Source $Source -Config $Config -API:$API
    if (-not $output) {
        Write-Warning "No SharpDown content in $(ConvertTo-RedactedPath $Source)"
        return
    }

    $dir = Split-Path -Parent $Target
    if ($dir -and -not (Test-Path $dir)) {
        [void](New-Item -ItemType Directory -Force -Path $dir)
    }
    $output | Out-File -FilePath $Target -Encoding UTF8

    [PSCustomObject]@{
        Source = ConvertTo-RedactedPath $Source
        Target = ConvertTo-RedactedPath $Target
        Lines  = @($output).Count
    }
    #### <b style="color: #369FFF;">Returns</b>
    #### - `[PSCustomObject]`
    #### - `[string]`: __Source__
    #### - *Redacted source path.*
    #### - `[string]`: __Target__
    #### - *Redacted target path.*
    #### - `[int]`: __Lines__
    #### - *Number of lines emitted to the target.*
}

#### ---

#### <h2 style="color: #DCA657;">Invoke-SharpDownFile</h2>
####
function Invoke-SharpDownFile {
    #### Validate the File-mode contract and convert one source.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    ####
    param(
        ####
        #### - `[string]`: __Path__
        #### - *Source file path. Must be a leaf.*
        [Parameter(Mandatory)][string]$Path,
        #### - `[string]`: __OutPath__
        #### - *Target `.md` path. Must not be an existing directory.*
        [Parameter(Mandatory)][string]$OutPath,
        #### - `[hashtable]`: __Config__
        #### - *Language entry from `$script:SharpDownConfig`.*
        [Parameter(Mandatory)][hashtable]$Config,
        #### - `[switch]`: __API__
        #### - *Forwarded to `Write-SharpDownFile`.*
        [switch]$API
    )
    ####

    #### <b style="color: #C22514;">Throws</b>
    #### - When `Path` is not a file.
    #### - When `OutPath` resolves to an existing directory.
    if (-not (Test-Path $Path -PathType Leaf)) {
        throw "Path '$(ConvertTo-RedactedPath $Path)' is not a file."
    }
    if (Test-Path $OutPath -PathType Container) {
        throw "OutPath '$(ConvertTo-RedactedPath $OutPath)' is a directory. File mode writes to a file path."
    }

    Write-SharpDownFile -Source (Resolve-Path $Path).ProviderPath -Target $OutPath -Config $Config -API:$API
}

#### <h2 style="color: #DCA657;">Invoke-SharpDownTree</h2>
####
function Invoke-SharpDownTree {
    #### Validate the Directory-mode contract and walk a source tree.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    ####
    param(
        ####
        #### - `[string]`: __Path__
        #### - *Source root. Must be a directory.*
        [Parameter(Mandatory)][string]$Path,
        #### - `[string]`: __OutPath__
        #### - *Output root. Must not be an existing file.*
        [Parameter(Mandatory)][string]$OutPath,
        #### - `[hashtable]`: __Config__
        #### - *Language entry from `$script:SharpDownConfig`.*
        [Parameter(Mandatory)][hashtable]$Config,
        #### - `[string]`: __Language__
        #### - *Language name used in the no-files warning.*
        [Parameter(Mandatory)][string]$Language,
        #### - `[switch]`: __API__
        #### - *Forwarded to `Write-SharpDownFile` for each source file under the tree.*
        [switch]$API
    )
    ####

    #### <b style="color: #C22514;">Throws</b>
    #### - When `Path` is not a directory.
    #### - When `OutPath` resolves to an existing file.
    if (-not (Test-Path $Path -PathType Container)) {
        throw "Path '$(ConvertTo-RedactedPath $Path)' is not a directory."
    }
    if (Test-Path $OutPath -PathType Leaf) {
        throw "OutPath '$(ConvertTo-RedactedPath $OutPath)' is a file. -Recurse writes a tree under a directory."
    }

    $root        = (Resolve-Path $Path).ProviderPath
    $projectName = Split-Path -Leaf $root

    #### | Key | Description |
    #### | --- | --- |
    $files = Get-ChildItem -Path $root -Recurse -File |
        #### | `Extensions` | The set of file extensions walked under `-Recurse`. |
        Where-Object { $_.Extension -in $Config.Extensions } |
        #### | `ExcludeRegex` | The directory pattern skipped under `-Recurse`. |
        Where-Object { $_.FullName -notmatch $Config.ExcludeRegex }

    if (-not $files) {
        Write-Warning "No $Language files found under $(ConvertTo-RedactedPath $root)"
        return
    }

    Write-Host "SharpDown ($Language): $(@($files).Count) file(s) -> $(ConvertTo-RedactedPath $OutPath)"
    foreach ($file in $files) {
        $target = Resolve-SharpDownTarget -Root $root -Source $file.FullName -ProjectName $projectName -OutRoot $OutPath
        Write-SharpDownFile -Source $file.FullName -Target $target -Config $Config -API:$API
    }
}

#### <h2 style="color: #DCA657;">ConvertTo-SharpDown</h2>
####
function ConvertTo-SharpDown {
    #### Public entry point. Convert a single source or a tree of sources to Markdown.
    ####
    #### Two parameter sets:
    ####
    #### - **File** is the default. `-Path` is a single source file, `-OutPath` is the `.md` target.
    #### - **Directory** requires `-Recurse`. `-Path` is a source directory, `-OutPath` is the output root. `-Path` accepts pipeline input from `Get-Item` and `Get-ChildItem -Directory`.
    ####
    #### <b style="color: #D2A8FF;">Parameters</b>
    ####
    [CmdletBinding(DefaultParameterSetName = 'File')]
    param(
        #### - `[string]`: __Language__
        #### - *One of `CSharp`, `Sql`, `JavaScript`, `PowerShell`. Defaults to `CSharp`.*
        [ValidateSet('CSharp', 'Sql', 'JavaScript', 'PowerShell')]
        [string]$Language = 'CSharp',

        #### - `[string]`: __Path__
        #### - *Source file in File set, source directory in Directory set. Pipeline-bound only in Directory set.*
        [Parameter(Mandatory = $true, ParameterSetName = 'File')]
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Directory')]
        [Alias('FullName', 'PSPath')]
        [string]$Path,

        #### - `[string]`: __OutPath__
        #### - *Output file in File set, output root in Directory set.*
        [Parameter(Mandatory = $true)]
        [string]$OutPath,

        #### - `[switch]`: __Recurse__
        #### - *Selects the Directory set.*
        [Parameter(ParameterSetName = 'Directory')]
        [switch]$Recurse,

        #### - `[switch]`: __API__
        #### - *Suppresses auto-generated declaration fences. Use for HTTP endpoint files where the doc IS the wire contract, not the language signatures.*
        [switch]$API
    )
    ####

    begin {
        $config = $script:SharpDownConfig[$Language]
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'File'      { Invoke-SharpDownFile -Path $Path -OutPath $OutPath -Config $config -API:$API }
            'Directory' { Invoke-SharpDownTree -Path $Path -OutPath $OutPath -Config $config -Language $Language -API:$API }
        }
    }
    #### <b style="color: #369FFF;">Returns</b>
    #### - `[PSCustomObject[]]`
    #### - `[string]`: __Source__
    #### - *Redacted source path.*
    #### - `[string]`: __Target__
    #### - *Redacted target path.*
    #### - `[int]`: __Lines__
    #### - *Number of lines emitted to the target.*
}

Export-ModuleMember -Function 'ConvertTo-SharpDown'