quillmark.psm1

#requires -Version 5.1

# quillmark - render a quill to PDF/SVG/PNG from PowerShell, fully offline, with
# no Node.js at runtime. A WebView2 control hosted by PowerShell loads the
# bundled @quillmark/wasm build (dist/) and does the actual render.
#
# Public surface (mirrors quillmark's Python API):
# Export-QuillDocument ~ engine.render(quill, doc, fmt) (alias: Invoke-QuillRender)
# Get-Quill ~ Quill.from_path + .metadata/.schema/.blueprint + supported_formats
# Test-QuillDocument ~ quill.validate(doc)
#
# A render host (the 28 MB WASM load + Quill.fromTree) is warmed ONCE per
# pipeline invocation and reused for every piped item, then disposed - so bulk
# generation pays the load cost once without exposing any session object.

$script:ModuleRoot = $PSScriptRoot
$script:DistPath   = Join-Path $script:ModuleRoot 'dist'
$script:QuillsDir  = Join-Path $script:ModuleRoot 'quills'

# kernel32.LoadLibrary, used to pre-load the architecture-correct native
# WebView2 loader process-wide before any WebView2 managed call. Defined in the
# module's own session state (not a child runspace) since the loaded module is
# shared across the whole process/AppDomain.
if (-not ('PoshWasm.Native' -as [type])) {
    Add-Type -Namespace PoshWasm -Name Native -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("kernel32", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
public static extern System.IntPtr LoadLibrary(string lpFileName);
'@

}

# Lets -QuillPath accept a quill OBJECT from Get-Quill (or its catalog), not just
# a string: any non-string object that has a .Path property is unwrapped to that
# path. Strings pass through untouched, so `-QuillPath usaf_memo` and
# `-QuillPath (Get-Quill usaf_memo)` both work. Defined via Add-Type (a compiled
# type) because a PowerShell `class` can't be used as an attribute in its own module.
if (-not ('PoshWasm.QuillPathTransformAttribute' -as [type])) {
    Add-Type -ReferencedAssemblies ([psobject].Assembly.Location) -TypeDefinition @'
using System;
using System.Management.Automation;
namespace PoshWasm {
    public class QuillPathTransformAttribute : ArgumentTransformationAttribute {
        public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) {
            if (inputData == null) return inputData;
            PSObject pso = inputData as PSObject;
            object baseObj = pso != null ? pso.BaseObject : inputData;
            if (baseObj is string) return inputData;
            PSObject view = pso != null ? pso : PSObject.AsPSObject(inputData);
            PSPropertyInfo prop = view.Properties["Path"];
            if (prop != null && prop.Value != null) return prop.Value.ToString();
            return inputData;
        }
    }
}
'@

}

# ==========================================================================
# Private helpers
# ==========================================================================

function Resolve-WebView2Loader {
    # Full path to the WebView2Loader.dll matching the current process arch.
    [CmdletBinding()]
    param()
    try {
        $arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
    } catch {
        $arch = if ([Environment]::Is64BitProcess) { 'X64' } else { 'X86' }
    }
    $sub = switch ($arch) {
        'X64'   { 'win-x64' }
        'X86'   { 'win-x86' }
        'Arm64' { 'win-arm64' }
        default { throw "Unsupported process architecture '$arch'. quillmark ships loaders for x64, x86, and arm64." }
    }
    $path = Join-Path (Join-Path $script:ModuleRoot 'native') (Join-Path $sub 'WebView2Loader.dll')
    if (-not (Test-Path -LiteralPath $path)) { throw "Native WebView2 loader not found for $sub at: $path" }
    (Resolve-Path -LiteralPath $path).Path
}

function Get-BundledQuillName {
    # Names of quills bundled with the module: each folder under quills/ that
    # contains a Quill.yaml. The set grows as more templates are shipped.
    Get-ChildItem -LiteralPath $script:QuillsDir -Directory -ErrorAction SilentlyContinue |
        Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'Quill.yaml') } |
        ForEach-Object { $_.Name } |
        Sort-Object
}

function Get-QuillIdentity {
    # Cheap identity read straight from a quill's Quill.yaml `quill:` header -- no
    # WebView2/WASM. Used to list the bundled catalog quickly.
    param([Parameter(Mandatory)][string]$QuillDir)
    $id = [pscustomobject]@{ Name = $null; Version = $null; Backend = $null; Description = $null }
    $yaml = Join-Path $QuillDir 'Quill.yaml'
    if (-not (Test-Path -LiteralPath $yaml)) { return $id }
    $inQuill = $false
    foreach ($line in [IO.File]::ReadAllLines($yaml)) {
        if ($line -match '^\s*#') { continue }
        if ($line -match '^quill:\s*$') { $inQuill = $true; continue }
        if ($inQuill) {
            if ($line -match '^\S') { break }   # dedent => left the quill: block
            if     ($line -match '^\s+name:\s*(.+?)\s*$')        { $id.Name        = $matches[1].Trim('"', "'") }
            elseif ($line -match '^\s+version:\s*(.+?)\s*$')     { $id.Version     = $matches[1].Trim('"', "'") }
            elseif ($line -match '^\s+backend:\s*(.+?)\s*$')     { $id.Backend     = $matches[1].Trim('"', "'") }
            elseif ($line -match '^\s+description:\s*(.+?)\s*$') { $id.Description = $matches[1].Trim('"', "'") }
        }
    }
    $id
}

function Resolve-QuillPath {
    # Turn a -QuillPath argument into an absolute quill directory. An existing
    # path (relative or absolute) always wins; a bare name with no separators is
    # resolved against the quills bundled with the module, so `-QuillPath usaf_memo`
    # works the same whether the module is cloned or installed from the Gallery.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$QuillPath)

    if (Test-Path -LiteralPath $QuillPath -PathType Container) {
        return (Resolve-Path -LiteralPath $QuillPath).Path
    }
    if ($QuillPath -notmatch '[\\/]') {
        $bundled = Join-Path $script:QuillsDir $QuillPath
        if (Test-Path -LiteralPath (Join-Path $bundled 'Quill.yaml')) {
            return (Resolve-Path -LiteralPath $bundled).Path
        }
    }
    $names = (Get-BundledQuillName) -join ', '
    $hint = if ($names) { " Bundled quills: $names." } else { '' }
    throw "QuillPath '$QuillPath' is not an existing quill directory or a bundled quill name.$hint"
}

function Get-QuillTree {
    # Walk a quill directory into a hashtable of "<relative/path>" -> base64.
    # Forward-slash keys from the quill ROOT, exactly what Quill.fromTree wants.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$QuillPath)

    if (-not (Test-Path -LiteralPath $QuillPath -PathType Container)) {
        throw "QuillPath is not an existing directory: $QuillPath"
    }
    $root = (Resolve-Path -LiteralPath $QuillPath).Path
    $tree = @{}
    foreach ($f in (Get-ChildItem -LiteralPath $root -Recurse -File -Force)) {
        $rel = $f.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
        $tree[$rel] = [Convert]::ToBase64String([IO.File]::ReadAllBytes($f.FullName))
    }
    if ($tree.Count -eq 0) { throw "No files found under QuillPath: $root" }
    $tree
}

function Format-QuillDiagnostic {
    param($Diag)
    $sev = if ($Diag.severity) { $Diag.severity } else { 'error' }
    $code = if ($Diag.code) { " ($($Diag.code))" } else { '' }
    $loc = ''
    if ($Diag.location) {
        $l = $Diag.location
        $loc = " [$($l.file):$($l.line):$($l.column)]"
    } elseif ($Diag.path) {
        $loc = " [$($Diag.path)]"
    }
    "{0}{1}: {2}{3}" -f $sev, $code, $Diag.message, $loc
}

# -- The render host (runs on a dedicated STA runspace) --------------------
# WinForms/WebView2 require STA and have thread affinity. We run the host on a
# runspace opened STA with ReuseThread so every request hits the same thread,
# keeping the live control valid across the pipeline. Scripts communicate
# through $global: state (PS event handlers do not close over locals).

$script:WarmupScript = @'
param(
    [Parameter(Mandatory)][string] $ModuleRoot,
    [Parameter(Mandatory)][string] $DistPath,
    [Parameter(Mandatory)][hashtable] $Tree,
    [int] $TimeoutSeconds = 120
)
$ErrorActionPreference = 'Stop'
 
Add-Type -AssemblyName System.Windows.Forms
Add-Type -Path (Join-Path $ModuleRoot 'Microsoft.Web.WebView2.Core.dll')
Add-Type -Path (Join-Path $ModuleRoot 'Microsoft.Web.WebView2.WinForms.dll')
 
$global:PW_runtimeHelp = "WebView2 Runtime not found. Install the Microsoft Edge WebView2 Runtime (Evergreen bootstrapper) from https://developer.microsoft.com/microsoft-edge/webview2/ and retry."
$global:PW_dist = (Resolve-Path -LiteralPath $DistPath).Path
$global:PW_tree = $Tree
$global:PW_ready = $false
$global:PW_done = $false
$global:PW_msg = $null
$global:PW_err = $null
$global:PW_expectId = 0
 
$global:PW_udf = Join-Path $env:TEMP ("quillmark-wv2-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $global:PW_udf | Out-Null
 
$global:PW_form = New-Object System.Windows.Forms.Form
$global:PW_wv = New-Object Microsoft.Web.WebView2.WinForms.WebView2
$global:PW_wv.Dock = 'Fill'
$global:PW_form.Controls.Add($global:PW_wv)
 
$global:PW_envTask = [Microsoft.Web.WebView2.Core.CoreWebView2Environment]::CreateAsync($null, $global:PW_udf, $null)
 
$global:PW_wv.add_CoreWebView2InitializationCompleted({
    param($s, $e)
    try {
        if (-not $e.IsSuccess) {
            $global:PW_err = "WebView2 init failed: " + $e.InitializationException.ToString()
            $global:PW_done = $true; return
        }
        $core = $s.CoreWebView2
        $core.SetVirtualHostNameToFolderMapping(
            'quillmark.local', $global:PW_dist,
            [Microsoft.Web.WebView2.Core.CoreWebView2HostResourceAccessKind]::Allow)
        $core.add_WebMessageReceived({
            param($s2, $e2)
            try {
                $obj = $e2.TryGetWebMessageAsString() | ConvertFrom-Json
                if ($obj.type -eq 'ready') { $global:PW_ready = $true; return }
                if ($obj.type -eq 'error') { $global:PW_err = [string]$obj.error; $global:PW_done = $true; return }
                if ($obj.id -eq $global:PW_expectId) { $global:PW_msg = $obj; $global:PW_done = $true }
            } catch {
                $global:PW_err = "host message handler error: " + $_.Exception.Message
                $global:PW_done = $true
            }
        })
        $core.Navigate('https://quillmark.local/index.html')
    } catch {
        $global:PW_err = "init handler error: " + $_.Exception.Message
        $global:PW_done = $true
    }
})
 
$global:PW_form.Add_Shown({
    try {
        while (-not $global:PW_envTask.IsCompleted) { [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 15 }
        if ($global:PW_envTask.IsFaulted) {
            $ex = $global:PW_envTask.Exception.ToString()
            if ($ex -match 'WebView2|DllNotFound|loader|runtime') { $global:PW_err = $global:PW_runtimeHelp + " (underlying: " + $ex + ")" }
            else { $global:PW_err = "CreateAsync faulted: " + $ex }
            $global:PW_done = $true; return
        }
        $global:PW_wv.EnsureCoreWebView2Async($global:PW_envTask.Result) | Out-Null
    } catch {
        $global:PW_err = "shown handler error: " + $_.Exception.Message
        $global:PW_done = $true
    }
})
 
$global:PW_form.Show()
$global:PW_form.Visible = $false
 
# Wait for the page bridge to come up.
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while (-not $global:PW_ready -and -not $global:PW_err -and (Get-Date) -lt $deadline) {
    [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 15
}
if ($global:PW_err) { return @{ ok = $false; error = $global:PW_err } }
if (-not $global:PW_ready) { return @{ ok = $false; error = "WebView2 page did not become ready within $TimeoutSeconds s." } }
 
# Load the quill once; the bridge caches it for every later request.
$global:PW_done = $false; $global:PW_msg = $null; $global:PW_err = $null; $global:PW_expectId = 1
$req = @{ id = 1; type = 'loadQuill'; tree = $global:PW_tree } | ConvertTo-Json -Depth 8 -Compress
$global:PW_wv.CoreWebView2.PostWebMessageAsJson($req)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while (-not $global:PW_done -and -not $global:PW_err -and (Get-Date) -lt $deadline) {
    [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 15
}
if ($global:PW_err) { return @{ ok = $false; error = $global:PW_err } }
if (-not $global:PW_done) { return @{ ok = $false; error = "loadQuill timed out after $TimeoutSeconds s." } }
if (-not $global:PW_msg.ok) { return @{ ok = $false; error = [string]$global:PW_msg.error; diagnostics = $global:PW_msg.diagnostics } }
return @{ ok = $true; info = $global:PW_msg }
'@


$script:RequestScript = @'
param(
    [Parameter(Mandatory)][string] $ReqJson,
    [Parameter(Mandatory)][int] $ExpectId,
    [int] $TimeoutSeconds = 120
)
$global:PW_done = $false; $global:PW_msg = $null; $global:PW_err = $null; $global:PW_expectId = $ExpectId
$global:PW_wv.CoreWebView2.PostWebMessageAsJson($ReqJson)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while (-not $global:PW_done -and -not $global:PW_err -and (Get-Date) -lt $deadline) {
    [System.Windows.Forms.Application]::DoEvents(); Start-Sleep -Milliseconds 15
}
if ($global:PW_err) { return @{ ok = $false; error = $global:PW_err } }
if (-not $global:PW_done) { return @{ ok = $false; error = "request timed out after $TimeoutSeconds s." } }
if (-not $global:PW_msg.ok) { return @{ ok = $false; error = [string]$global:PW_msg.error; diagnostics = $global:PW_msg.diagnostics } }
return @{ ok = $true; msg = $global:PW_msg }
'@


$script:TeardownScript = @'
try { $global:PW_form.Close() } catch {}
try { $global:PW_wv.Dispose() } catch {}
try { $global:PW_form.Dispose() } catch {}
try { if ($global:PW_udf) { Remove-Item -Recurse -Force -LiteralPath $global:PW_udf -ErrorAction SilentlyContinue } } catch {}
'@


function Invoke-InRunspace {
    param([Parameter(Mandatory)]$Runspace, [Parameter(Mandatory)][string]$ScriptText, [hashtable]$Parameters)
    $ps = [powershell]::Create()
    $ps.Runspace = $Runspace
    [void]$ps.AddScript($ScriptText)
    if ($Parameters) { [void]$ps.AddParameters($Parameters) }
    try {
        $out = $ps.Invoke()
        if ($ps.Streams.Error.Count -gt 0) {
            $msgs = ($ps.Streams.Error | ForEach-Object { $_.ToString() }) -join '; '
            return @{ ok = $false; error = "render host error: $msgs" }
        }
        if ($out.Count -gt 0) { return $out[$out.Count - 1] }
        return @{ ok = $false; error = "render host returned no result." }
    } finally {
        $ps.Dispose()
    }
}

function Open-QuillHost {
    # Warm a render host for one quill and return a session handle. Internal -
    # callers must always pair this with Close-QuillHost.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$QuillPath, [int]$TimeoutSeconds = 120)

    if (-not (Test-Path -LiteralPath $script:DistPath -PathType Container)) {
        throw "Bundled web assets not found at '$script:DistPath'. Rebuild the dist/ folder with: (cd web; npm install; npx vite build)."
    }

    $QuillPath = Resolve-QuillPath -QuillPath $QuillPath
    $tree   = Get-QuillTree -QuillPath $QuillPath
    $loader = Resolve-WebView2Loader

    # Pre-load the native loader process-wide so the managed SDK's
    # DllImport("WebView2Loader.dll") resolves by module name in the runspace.
    $h = [PoshWasm.Native]::LoadLibrary($loader)
    if ($h -eq [IntPtr]::Zero) {
        throw "Failed to load native WebView2 loader '$loader' (LastError=$([Runtime.InteropServices.Marshal]::GetLastWin32Error()))."
    }

    $rs = [runspacefactory]::CreateRunspace()
    $rs.ApartmentState = [Threading.ApartmentState]::STA
    $rs.ThreadOptions  = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread
    $rs.Open()

    $warm = Invoke-InRunspace -Runspace $rs -ScriptText $script:WarmupScript -Parameters @{
        ModuleRoot     = $script:ModuleRoot
        DistPath       = $script:DistPath
        Tree           = $tree
        TimeoutSeconds = $TimeoutSeconds
    }
    if (-not $warm.ok) {
        try { $rs.Close(); $rs.Dispose() } catch {}
        if ($warm.diagnostics) { foreach ($d in $warm.diagnostics) { Write-Error (Format-QuillDiagnostic $d) } }
        throw "Failed to start render host: $($warm.error)"
    }

    [pscustomobject]@{
        Runspace = $rs
        Info     = $warm.info
        Path     = (Resolve-Path -LiteralPath $QuillPath).Path
        NextId   = 1
    }
}

function Close-QuillHost {
    param([Parameter(Mandatory)]$QuillHost)
    try { Invoke-InRunspace -Runspace $QuillHost.Runspace -ScriptText $script:TeardownScript | Out-Null } catch {}
    try { $QuillHost.Runspace.Close(); $QuillHost.Runspace.Dispose() } catch {}
}

function Invoke-QuillHostRequest {
    param([Parameter(Mandatory)]$QuillHost, [Parameter(Mandatory)][hashtable]$Request, [int]$TimeoutSeconds = 120)
    $QuillHost.NextId++
    $Request['id'] = $QuillHost.NextId
    $json = $Request | ConvertTo-Json -Depth 8 -Compress
    Invoke-InRunspace -Runspace $QuillHost.Runspace -ScriptText $script:RequestScript -Parameters @{
        ReqJson = $json; ExpectId = $QuillHost.NextId; TimeoutSeconds = $TimeoutSeconds
    }
}

function Get-QuillFieldRows {
    # Project a card schema's `fields` map into friendly row objects.
    param($CardSchema)
    $rows = @()
    if (-not $CardSchema) { return $rows }
    $fieldsProp = $CardSchema.PSObject.Properties['fields']
    if (-not $fieldsProp -or -not $fieldsProp.Value) { return $rows }
    foreach ($p in $fieldsProp.Value.PSObject.Properties) {
        $fs = $p.Value
        $hasDefault = [bool]($fs.PSObject.Properties['default'])
        $ui = $fs.PSObject.Properties['ui']
        $rows += [pscustomobject]@{
            Name        = $p.Name
            Type        = $fs.type
            HasDefault  = $hasDefault
            Default     = if ($hasDefault) { $fs.default } else { $null }
            Example     = if ($fs.PSObject.Properties['example']) { $fs.example } else { $null }
            Enum        = if ($fs.PSObject.Properties['enum']) { $fs.enum } else { $null }
            Group       = if ($ui -and $ui.Value) { $ui.Value.group } else { $null }
            Description = if ($fs.PSObject.Properties['description']) { $fs.description } else { $null }
        }
    }
    $rows
}

function Resolve-RenderOutputs {
    # Map an artifact list to absolute output paths. Single artifact -> the exact
    # OutputPath (file mode) or "<dir>\<base>.<ext>" (dir mode); multiple ->
    # "<base>-N.<ext>" next to it.
    param($Artifacts, [string]$Mode, [string]$OutputPath, [string]$OutputDirectory, [string]$BaseName, [string]$Format)

    function ConvertTo-AbsPath([string]$p) {
        if ([IO.Path]::IsPathRooted($p)) { $p } else { Join-Path (Get-Location).Path $p }
    }

    $targets = @()
    if ($Mode -eq 'file') {
        $abs = ConvertTo-AbsPath $OutputPath
        $dir = Split-Path -Parent $abs
        if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
        if ($Artifacts.Count -eq 1) {
            $targets = @($abs)
        } else {
            $base = [IO.Path]::GetFileNameWithoutExtension($abs)
            for ($i = 0; $i -lt $Artifacts.Count; $i++) {
                $ext = if ($Artifacts[$i].format) { $Artifacts[$i].format } else { $Format }
                $targets += (Join-Path $dir ("{0}-{1}.{2}" -f $base, ($i + 1), $ext))
            }
        }
    } else {
        $dir = ConvertTo-AbsPath $OutputDirectory
        if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
        if ($Artifacts.Count -eq 1) {
            $ext = if ($Artifacts[0].format) { $Artifacts[0].format } else { $Format }
            $targets = @(Join-Path $dir ("{0}.{1}" -f $BaseName, $ext))
        } else {
            for ($i = 0; $i -lt $Artifacts.Count; $i++) {
                $ext = if ($Artifacts[$i].format) { $Artifacts[$i].format } else { $Format }
                $targets += (Join-Path $dir ("{0}-{1}.{2}" -f $BaseName, ($i + 1), $ext))
            }
        }
    }
    $targets
}

# ==========================================================================
# Public cmdlets
# ==========================================================================

function Export-QuillDocument {
    <#
    .SYNOPSIS
        Render a quill document to PDF/SVG/PNG via @quillmark/wasm, fully offline.
    .DESCRIPTION
        Renders one or many documents against a quill. Pipe markdown files in for
        bulk generation: a single WebView2 render host is warmed once for the
        whole pipeline and reused for every item, so the 28 MB WASM load is paid
        once. With no markdown supplied, the quill's seeded starter document is
        rendered.
    .PARAMETER QuillPath
        Path to the quill version directory (the folder containing Quill.yaml).
    .PARAMETER MarkdownPath
        Path to a markdown file to render. Accepts pipeline input (including
        Get-ChildItem FileInfo via its FullName).
    .PARAMETER Markdown
        Inline markdown content to render.
    .PARAMETER OutputPath
        Output file for a single render. Multi-artifact formats write
        <base>-N.<ext> next to it.
    .PARAMETER OutputDirectory
        Output folder; each output is named from its input file's base name (or
        the quill name for a seeded render) plus the format extension. Defaults to
        the current directory when neither -OutputPath nor -OutputDirectory is given.
    .PARAMETER Format
        Output format: pdf (default), svg, or png.
    .PARAMETER TimeoutSeconds
        Per-request timeout (also the host warm-up timeout). Default 120.
    .EXAMPLE
        Export-QuillDocument -QuillPath usaf_memo
        # -> .\usaf_memo.pdf in the current directory
    .EXAMPLE
        Export-QuillDocument -QuillPath usaf_memo -OutputPath .\memo.pdf
    .EXAMPLE
        Get-ChildItem .\inbox\*.md | Export-QuillDocument -QuillPath usaf_memo -OutputDirectory .\out
    #>

    [CmdletBinding(DefaultParameterSetName = 'ToPath')]
    [OutputType([PSCustomObject])]
    [Alias('Invoke-QuillRender')]
    param(
        [Parameter(Mandatory)]
        [PoshWasm.QuillPathTransformAttribute()]
        [ValidateNotNullOrEmpty()]
        [string]$QuillPath,

        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'Path')]
        [string]$MarkdownPath,

        [string]$Markdown,

        [Parameter(ParameterSetName = 'ToPath')]
        [string]$OutputPath,

        [Parameter(ParameterSetName = 'ToDir', Mandatory)]
        [string]$OutputDirectory,

        [ValidateSet('pdf', 'svg', 'png')]
        [string]$Format = 'pdf',

        [ValidateRange(1, 3600)]
        [int]$TimeoutSeconds = 120
    )

    begin {
        if ($PSBoundParameters.ContainsKey('Markdown') -and $PSBoundParameters.ContainsKey('MarkdownPath')) {
            throw "Specify only one of -Markdown or -MarkdownPath."
        }
        # -OutputPath => write that exact file. Otherwise write to a directory,
        # naming each output after its input. With neither -OutputPath nor
        # -OutputDirectory, default to the current directory (KISS).
        $mode = if ($PSBoundParameters.ContainsKey('OutputPath')) { 'file' } else { 'dir' }
        if ($mode -eq 'dir' -and -not $PSBoundParameters.ContainsKey('OutputDirectory')) {
            $OutputDirectory = (Get-Location).Path
        }
        $qhost = Open-QuillHost -QuillPath $QuillPath -TimeoutSeconds $TimeoutSeconds
        $itemCount = 0
    }

    process {
        $md = $null
        $src = '(seed)'
        if ($PSBoundParameters.ContainsKey('MarkdownPath') -and $MarkdownPath) {
            if (-not (Test-Path -LiteralPath $MarkdownPath -PathType Leaf)) {
                Write-Error "MarkdownPath not found: $MarkdownPath"; return
            }
            $md = [IO.File]::ReadAllText((Resolve-Path -LiteralPath $MarkdownPath).Path)
            $src = [IO.Path]::GetFileNameWithoutExtension($MarkdownPath)
        } elseif ($PSBoundParameters.ContainsKey('Markdown')) {
            $md = $Markdown; $src = '(inline)'
        }

        $itemCount++
        if ($mode -eq 'file' -and $itemCount -gt 1) {
            Write-Error "Multiple inputs were piped but -OutputPath targets a single file. Use -OutputDirectory for bulk rendering."
            return
        }

        $req = @{ type = 'render'; format = $Format }
        if ($null -ne $md) { $req['markdown'] = $md }

        $r = Invoke-QuillHostRequest -QuillHost $qhost -Request $req -TimeoutSeconds $TimeoutSeconds
        if (-not $r.ok) {
            if ($r.diagnostics) { foreach ($d in $r.diagnostics) { Write-Error (Format-QuillDiagnostic $d) } }
            Write-Error "Render failed for '$src': $($r.error)"
            return
        }

        $result = $r.msg.result
        if ($result.warnings) { foreach ($w in $result.warnings) { Write-Warning ("[$src] " + (Format-QuillDiagnostic $w)) } }

        $artifacts = @($result.artifacts)
        if ($artifacts.Count -eq 0) { Write-Error "Render for '$src' returned no artifacts."; return }

        $baseName = if ($src -in '(seed)', '(inline)') { if ($qhost.Info.metadata) { $qhost.Info.metadata.name } else { 'document' } } else { $src }
        $targets = @(Resolve-RenderOutputs -Artifacts $artifacts -Mode $mode -OutputPath $OutputPath -OutputDirectory $OutputDirectory -BaseName $baseName -Format $Format)

        for ($i = 0; $i -lt $artifacts.Count; $i++) {
            [IO.File]::WriteAllBytes($targets[$i], [Convert]::FromBase64String($artifacts[$i].base64))
        }

        $out = [pscustomobject]@{
            Source        = $src
            OutputFiles   = $targets
            Format        = $result.outputFormat
            ArtifactCount = $artifacts.Count
            RenderTimeMs  = $result.renderTimeMs
            Warnings      = @($result.warnings).Count
            QuillName     = if ($qhost.Info.metadata) { $qhost.Info.metadata.name } else { $null }
        }
        $out.PSObject.TypeNames.Insert(0, 'PoshWasm.RenderResult')
        $out
    }

    end {
        if ($qhost) { Close-QuillHost -QuillHost $qhost }
    }
}

function Get-Quill {
    <#
    .SYNOPSIS
        Inspect a quill: identity, supported formats, fields, schema, blueprint.
    .DESCRIPTION
        With no -QuillPath, lists the quills bundled with the module (a cheap
        catalog: Name/Version/Backend/Description, read from each Quill.yaml).
 
        With a -QuillPath (a bundled quill name or a directory), loads that quill
        and returns a rich object: the default view shows
        Name/Version/Backend/SupportedFormats/Description, and the full object
        also carries Fields (a projected field table), CardKinds, Schema (raw),
        and Blueprint (starter markdown).
    .PARAMETER QuillPath
        Bundled quill name (e.g. usaf_memo) or path to a quill directory. Accepts
        pipeline input. Omit to list the bundled quills.
    .PARAMETER TimeoutSeconds
        Host warm-up timeout. Default 120.
    .EXAMPLE
        Get-Quill
    .EXAMPLE
        Get-Quill usaf_memo
    .EXAMPLE
        (Get-Quill usaf_memo).Fields | Where-Object { -not $_.HasDefault }
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [PoshWasm.QuillPathTransformAttribute()]
        [Alias('FullName', 'Path')]
        [string]$QuillPath,

        [ValidateRange(1, 3600)]
        [int]$TimeoutSeconds = 120
    )

    process {
        # No quill specified -> emit the bundled catalog (cheap, no WebView2).
        if ([string]::IsNullOrEmpty($QuillPath)) {
            foreach ($name in (Get-BundledQuillName)) {
                $dir = Join-Path $script:QuillsDir $name
                $id = Get-QuillIdentity -QuillDir $dir
                $row = [pscustomobject]@{
                    Name        = if ($id.Name) { $id.Name } else { $name }
                    Version     = $id.Version
                    Backend     = $id.Backend
                    Description = $id.Description
                    Path        = $dir
                }
                $row.PSObject.TypeNames.Insert(0, 'PoshWasm.QuillSummary')
                $row
            }
            return
        }

        $qhost = Open-QuillHost -QuillPath $QuillPath -TimeoutSeconds $TimeoutSeconds
        try {
            $info = $qhost.Info
            $meta = $info.metadata
            $schema = $info.schema

            $fields = @()
            $cardKinds = @()
            if ($schema) {
                if ($schema.PSObject.Properties['main']) { $fields = @(Get-QuillFieldRows $schema.main) }
                if ($schema.PSObject.Properties['card_kinds'] -and $schema.card_kinds) {
                    $cardKinds = @($schema.card_kinds.PSObject.Properties.Name)
                }
            }

            $name    = if ($meta) { $meta.name } else { $null }
            $version = if ($meta) { $meta.version } else { $null }

            $obj = [pscustomobject]@{
                Name             = $name
                Version          = $version
                QuillRef         = if ($name -and $version) { "$name@$version" } else { $name }
                Backend          = if ($meta) { $meta.backend } else { $null }
                Description      = if ($meta) { $meta.description } else { $null }
                Author           = if ($meta) { $meta.author } else { $null }
                SupportedFormats = @($info.supportedFormats)
                Fields           = $fields
                CardKinds        = $cardKinds
                Schema           = $schema
                Blueprint        = $info.blueprint
                Path             = $qhost.Path
            }
            $obj.PSObject.TypeNames.Insert(0, 'PoshWasm.Quill')

            $defaultProps = [string[]]('Name', 'Version', 'Backend', 'SupportedFormats', 'Description')
            $dm = New-Object System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet', $defaultProps)
            $obj | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value ([System.Management.Automation.PSMemberInfo[]]@($dm))
            $obj
        } finally {
            Close-QuillHost -QuillHost $qhost
        }
    }
}

function Test-QuillDocument {
    <#
    .SYNOPSIS
        Validate document(s) against a quill's schema without rendering.
    .DESCRIPTION
        Returns a result per document with IsValid and the diagnostics list.
        Pipe many markdown files in to validate a batch fast (one warm host)
        before committing to expensive renders. With no markdown, the quill's
        seeded document is validated.
 
        IsValid mirrors renderability: the non-fatal completeness signal
        (validation::field_absent, which render zero-fills) does not make a
        document invalid - those absent fields are reported in AbsentFields.
    .PARAMETER QuillPath
        Path to the quill version directory.
    .PARAMETER MarkdownPath
        Markdown file to validate. Accepts pipeline input.
    .PARAMETER Markdown
        Inline markdown to validate.
    .PARAMETER TimeoutSeconds
        Per-request / warm-up timeout. Default 120.
    .EXAMPLE
        Get-ChildItem .\inbox\*.md | Test-QuillDocument -QuillPath usaf_memo |
            Where-Object { -not $_.IsValid }
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [PoshWasm.QuillPathTransformAttribute()]
        [ValidateNotNullOrEmpty()]
        [string]$QuillPath,

        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('FullName', 'Path')]
        [string]$MarkdownPath,

        [string]$Markdown,

        [ValidateRange(1, 3600)]
        [int]$TimeoutSeconds = 120
    )

    begin {
        if ($PSBoundParameters.ContainsKey('Markdown') -and $PSBoundParameters.ContainsKey('MarkdownPath')) {
            throw "Specify only one of -Markdown or -MarkdownPath."
        }
        $qhost = Open-QuillHost -QuillPath $QuillPath -TimeoutSeconds $TimeoutSeconds
    }

    process {
        $md = $null
        $src = '(seed)'
        if ($PSBoundParameters.ContainsKey('MarkdownPath') -and $MarkdownPath) {
            if (-not (Test-Path -LiteralPath $MarkdownPath -PathType Leaf)) {
                Write-Error "MarkdownPath not found: $MarkdownPath"; return
            }
            $md = [IO.File]::ReadAllText((Resolve-Path -LiteralPath $MarkdownPath).Path)
            $src = [IO.Path]::GetFileNameWithoutExtension($MarkdownPath)
        } elseif ($PSBoundParameters.ContainsKey('Markdown')) {
            $md = $Markdown; $src = '(inline)'
        }

        $req = @{ type = 'validate' }
        if ($null -ne $md) { $req['markdown'] = $md }

        $r = Invoke-QuillHostRequest -QuillHost $qhost -Request $req -TimeoutSeconds $TimeoutSeconds
        if (-not $r.ok) {
            Write-Error "Validation failed for '$src': $($r.error)"; return
        }

        $diags = @($r.msg.diagnostics)
        # `validation::field_absent` is a non-fatal completeness signal: the
        # render path zero-fills the field, so it must not count against
        # renderability. IsValid mirrors what `render` would accept.
        $absent   = @($diags | Where-Object { $_.code -eq 'validation::field_absent' })
        $errors   = @($diags | Where-Object { $_.severity -eq 'error' -and $_.code -ne 'validation::field_absent' })
        $warnings = @($diags | Where-Object { $_.severity -eq 'warning' })

        $out = [pscustomobject]@{
            Source       = $src
            IsValid      = ($errors.Count -eq 0)
            ErrorCount   = $errors.Count
            WarningCount = $warnings.Count
            AbsentFields = @($absent | ForEach-Object { if ($_.path) { $_.path } else { $_.message } })
            Diagnostics  = $diags
        }
        $out.PSObject.TypeNames.Insert(0, 'PoshWasm.ValidationResult')
        $out
    }

    end {
        if ($qhost) { Close-QuillHost -QuillHost $qhost }
    }
}

# Tab-complete -QuillPath with the bundled quill names (suggestions only; any
# path is still accepted). Module-scoped scriptblock, so $script:QuillsDir and
# Get-BundledQuillName are in scope.
$quillPathCompleter = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    Get-BundledQuillName |
        Where-Object { $_ -like "$wordToComplete*" } |
        ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
}
Register-ArgumentCompleter -CommandName 'Export-QuillDocument', 'Get-Quill', 'Test-QuillDocument' -ParameterName 'QuillPath' -ScriptBlock $quillPathCompleter

Export-ModuleMember -Function Export-QuillDocument, Get-Quill, Test-QuillDocument -Alias Invoke-QuillRender