Plugins/libsass-converter.ps1

<#
.SYNOPSIS
Built-in Hyde plugin that compiles Sass/SCSS static files to CSS.
 
.DESCRIPTION
`libsass-converter` runs during static-file processing and transforms `.scss` and
`.sass` files into `.css` output.
 
The plugin preserves folder structure, remaps the extension to `.css`, and cancels
raw-source copy so transformed output replaces direct file copy.
 
Underscore-prefixed Sass files are treated as partials and are never emitted as
standalone output files.
 
Use `-Install` to download and bundle required LibSassHost binaries directly
from the plugin script.
 
.PARAMETER Context
Plugin execution context supplied by Hyde when the plugin is loaded.
 
This parameter is provided by Hyde's plugin loader and is not intended to be
supplied manually.
 
.PARAMETER Install
Runs plugin installation flow to download and bundle required LibSassHost assets.
 
.PARAMETER Version
NuGet package version to install. Use `latest` (default) to auto-resolve the
current published version.
 
.PARAMETER Force
Rebuilds the local bundle folder when it already exists.
 
.EXAMPLE
plugins:
    - libsass-converter
 
Enable the plugin in `_config.yml` so Hyde compiles Sass assets during build.
 
.EXAMPLE
libsass:
    include_paths:
        - assets/styles
 
Provide optional include paths (relative to site source or absolute paths) for
Sass imports.
 
.EXAMPLE
./tools/Get-LibSassHost.ps1
 
Download and bundle required LibSassHost binaries into the expected plugin path.
 
.EXAMPLE
./src/Plugins/libsass-converter.ps1 -Install
 
Installs required LibSassHost assets into the plugin-local bundle folder.
 
.EXAMPLE
./src/Plugins/libsass-converter.ps1 -Install -Version 2.2.0 -Force
 
Pins version and rebuilds local bundle assets.
 
.NOTES
Bundled LibSassHost assets are required. The plugin expects binaries under:
- `src/Plugins/libsass-converter/lib`
 
Recommended setup:
- Run `./src/Plugins/libsass-converter.ps1 -Install`
 
Alternative setup:
- Run `./tools/Get-LibSassHost.ps1`
 
Manual setup:
1. Download the `LibSassHost` NuGet package (`.nupkg`).
2. Extract the package.
3. Copy `LibSassHost.dll` from an available framework folder under `lib/` into
     `src/Plugins/libsass-converter/lib/...`.
4. Copy native runtime files from `runtimes/win-x64/native/` (and optionally
     `runtimes/win-x86/native/`) into matching plugin runtime paths.
 
If required assets are missing, the plugin fails fast with actionable guidance to
run `./tools/Get-LibSassHost.ps1`.
 
Compatibility note:
- This plugin uses LibSassHost (LibSass).
- Modern Dart Sass module features such as `@use` and `@forward` are not fully
    supported by LibSass.
- A future `dartsass-converter` plugin can coexist as a separate option; configure
    only one Sass converter plugin for a site.
#>

[CmdletBinding()]
param(
    $Context,

    [switch]$Install,

    [string]$Version = 'latest',

    [switch]$Force
)

if ($Install) {
    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    Write-Warning 'Installing LibSassHost downloads binaries from NuGet over the network and does not currently perform package integrity verification.'
    if ([string]::IsNullOrWhiteSpace($Version) -or $Version -eq 'latest') {
        Write-Warning "Version is set to 'latest'. For predictable and safer installs, pin an explicit version with -Version."
    }

    if (-not $Force) {
        $confirmation = Read-Host 'Continue with LibSassHost download and installation? [y/N]'
        if ($confirmation -notmatch '^(?i:y|yes)$') {
            throw 'LibSassHost installation cancelled by user.'
        }
    } else {
        Write-Warning 'Skipping install prompt because -Force was specified.'
    }

    function Get-LibSassLatestVersion {
        $indexUrl = 'https://api.nuget.org/v3-flatcontainer/libsasshost/index.json'
        $indexPayload = Invoke-RestMethod -Uri $indexUrl -Method Get
        if ($null -eq $indexPayload -or -not $indexPayload.versions -or $indexPayload.versions.Count -eq 0) {
            throw 'Could not discover LibSassHost versions from NuGet.'
        }

        return [string]($indexPayload.versions | Select-Object -Last 1)
    }

    function Resolve-LibSassInstallVersion {
        param([string]$RequestedVersion)

        if ([string]::IsNullOrWhiteSpace($RequestedVersion) -or $RequestedVersion -eq 'latest') {
            return Get-LibSassLatestVersion
        }

        return $RequestedVersion.Trim()
    }

    function Copy-LibSassInstallAsset {
        param(
            [Parameter(Mandatory = $true)]
            [string]$SourcePath,

            [Parameter(Mandatory = $true)]
            [string]$TargetPath
        )

        $targetDirectory = Split-Path -Path $TargetPath -Parent
        if (-not (Test-Path -LiteralPath $targetDirectory -PathType Container)) {
            [void](New-Item -Path $targetDirectory -ItemType Directory -Force)
        }

        Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force
    }

    function downloadLibSassNuGetPackage {
        param(
            [Parameter(Mandatory = $true)]
            [string]$PackageId,

            [Parameter(Mandatory = $true)]
            [string]$PackageVersion,

            [Parameter(Mandatory = $true)]
            [string]$DownloadRoot
        )

        $packageRoot = Join-Path -Path $DownloadRoot -ChildPath ("{0}.{1}" -f $PackageId, $PackageVersion)
        $packageFile = Join-Path -Path $packageRoot -ChildPath ("{0}.{1}.nupkg" -f $PackageId, $PackageVersion)
        $extractRoot = Join-Path -Path $packageRoot -ChildPath 'pkg'

        [void](New-Item -Path $packageRoot -ItemType Directory -Force)

        $lowerId = $PackageId.ToLowerInvariant()
        $lowerVersion = $PackageVersion.ToLowerInvariant()
        $packageUrl = "https://api.nuget.org/v3-flatcontainer/$lowerId/$lowerVersion/$lowerId.$lowerVersion.nupkg"

        Invoke-WebRequest -Uri $packageUrl -OutFile $packageFile
        Expand-Archive -LiteralPath $packageFile -DestinationPath $extractRoot -Force

        return $extractRoot
    }

    $resolvedVersion = Resolve-LibSassInstallVersion -RequestedVersion $Version
    $tempRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("hyde-libsass-install-{0}" -f [System.Guid]::NewGuid().ToString('N'))
    $packagePath = Join-Path -Path $tempRoot -ChildPath 'libsasshost.nupkg'
    $extractPath = Join-Path -Path $tempRoot -ChildPath 'pkg'

    # Installation paths are rooted to this plugin script so it is self-contained.
    $pluginScriptDirectory = Split-Path -Path $PSCommandPath -Parent
    $pluginRoot = Join-Path -Path $pluginScriptDirectory -ChildPath ([System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath))

    try {
        [void](New-Item -Path $tempRoot -ItemType Directory -Force)

        $packageUrl = "https://api.nuget.org/v3-flatcontainer/libsasshost/$resolvedVersion/libsasshost.$resolvedVersion.nupkg"
        Invoke-WebRequest -Uri $packageUrl -OutFile $packagePath
        Expand-Archive -LiteralPath $packagePath -DestinationPath $extractPath -Force

        if ((Test-Path -LiteralPath $pluginRoot -PathType Container) -and $Force) {
            Remove-Item -LiteralPath $pluginRoot -Recurse -Force
        }

        $bundleRoot = $pluginRoot
        [void](New-Item -Path $bundleRoot -ItemType Directory -Force)

        $managedCandidates = @(
            (Join-Path -Path $extractPath -ChildPath 'lib\net10.0\LibSassHost.dll'),
            (Join-Path -Path $extractPath -ChildPath 'lib\net9.0\LibSassHost.dll'),
            (Join-Path -Path $extractPath -ChildPath 'lib\net8.0\LibSassHost.dll'),
            (Join-Path -Path $extractPath -ChildPath 'lib\net7.0\LibSassHost.dll'),
            (Join-Path -Path $extractPath -ChildPath 'lib\netstandard2.0\LibSassHost.dll')
        )

        $managedAssemblyPath = $managedCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
        if ([string]::IsNullOrWhiteSpace([string]$managedAssemblyPath)) {
            throw 'Could not find LibSassHost.dll in NuGet package contents.'
        }

        $relativeManagedPath = $managedAssemblyPath.Substring($extractPath.Length).TrimStart([char[]]@('\', '/'))
        $managedTargetPath = Join-Path -Path $bundleRoot -ChildPath $relativeManagedPath
        Copy-LibSassInstallAsset -SourcePath $managedAssemblyPath -TargetPath $managedTargetPath

        $dependencySpecs = @(
            @{ Id = 'AdvancedStringBuilder'; Version = '0.1.1' }
            @{ Id = 'System.Buffers'; Version = '4.5.1' }
        )

        $managedTargetDirectory = Split-Path -Path $managedTargetPath -Parent
        foreach ($dependencySpec in $dependencySpecs) {
            try {
                $dependencyExtractRoot = downloadLibSassNuGetPackage -PackageId $dependencySpec.Id -PackageVersion $dependencySpec.Version -DownloadRoot $tempRoot

                $dependencyCandidates = @(
                    (Join-Path -Path $dependencyExtractRoot -ChildPath 'lib\netstandard2.0'),
                    (Join-Path -Path $dependencyExtractRoot -ChildPath 'lib\netstandard1.3'),
                    (Join-Path -Path $dependencyExtractRoot -ChildPath 'lib\netstandard1.0')
                )

                $dependencyLibFolder = $dependencyCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Container } | Select-Object -First 1
                if ($null -eq $dependencyLibFolder) {
                    continue
                }

                foreach ($dependencyDll in Get-ChildItem -LiteralPath $dependencyLibFolder -Filter '*.dll' -File) {
                    Copy-LibSassInstallAsset -SourcePath $dependencyDll.FullName -TargetPath (Join-Path -Path $managedTargetDirectory -ChildPath $dependencyDll.Name)
                }
            } catch {
                Write-Warning ("Could not bundle dependency {0} {1}. {2}" -f $dependencySpec.Id, $dependencySpec.Version, $_.Exception.Message)
            }
        }

        foreach ($runtimeFolder in @('win-x64', 'win-x86')) {
            $nativeSourcePath = Join-Path -Path $extractPath -ChildPath ("runtimes\\$runtimeFolder\\native")
            if (-not (Test-Path -LiteralPath $nativeSourcePath -PathType Container)) {
                continue
            }

            $nativeTargetPath = Join-Path -Path $bundleRoot -ChildPath ("runtimes\\$runtimeFolder\\native")
            if (-not (Test-Path -LiteralPath $nativeTargetPath -PathType Container)) {
                [void](New-Item -Path $nativeTargetPath -ItemType Directory -Force)
            }

            foreach ($nativeAsset in Get-ChildItem -LiteralPath $nativeSourcePath -File) {
                Copy-LibSassInstallAsset -SourcePath $nativeAsset.FullName -TargetPath (Join-Path -Path $nativeTargetPath -ChildPath $nativeAsset.Name)
            }
        }

        $nativePackageSpecs = @(
            @{ Id = 'LibSassHost.Native.win-x64'; Version = $resolvedVersion; Runtime = 'win-x64' }
            @{ Id = 'LibSassHost.Native.win-x86'; Version = $resolvedVersion; Runtime = 'win-x86' }
        )

        foreach ($nativePackageSpec in $nativePackageSpecs) {
            try {
                $nativeExtractRoot = downloadLibSassNuGetPackage -PackageId $nativePackageSpec.Id -PackageVersion $nativePackageSpec.Version -DownloadRoot $tempRoot

                $nativeCandidates = @(
                    (Join-Path -Path $nativeExtractRoot -ChildPath ("runtimes\\{0}\\native" -f $nativePackageSpec.Runtime)),
                    (Join-Path -Path $nativeExtractRoot -ChildPath 'native')
                )

                $nativeSourceFolder = $nativeCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Container } | Select-Object -First 1
                if ($null -eq $nativeSourceFolder) {
                    continue
                }

                $nativeTargetFolder = Join-Path -Path $bundleRoot -ChildPath ("runtimes\\{0}\\native" -f $nativePackageSpec.Runtime)
                if (-not (Test-Path -LiteralPath $nativeTargetFolder -PathType Container)) {
                    [void](New-Item -Path $nativeTargetFolder -ItemType Directory -Force)
                }

                foreach ($nativeAsset in Get-ChildItem -LiteralPath $nativeSourceFolder -File) {
                    Copy-LibSassInstallAsset -SourcePath $nativeAsset.FullName -TargetPath (Join-Path -Path $nativeTargetFolder -ChildPath $nativeAsset.Name)
                }
            } catch {
                Write-Warning ("Could not bundle native package {0} {1}. {2}" -f $nativePackageSpec.Id, $nativePackageSpec.Version, $_.Exception.Message)
            }
        }

        if ($VerbosePreference -eq 'Continue' -or $VerbosePreference -eq 'Inquire') {
            Write-Verbose ("Installed LibSassHost {0} assets to '{1}'." -f $resolvedVersion, $bundleRoot)
        }

        return $true
    } finally {
        if (Test-Path -LiteralPath $tempRoot -PathType Container) {
            Remove-Item -LiteralPath $tempRoot -Recurse -Force
        }
    }
}

# Built-in plugin that compiles SCSS/Sass static files to CSS during static copy.
# All logic is inlined to avoid scope issues when hooks are executed in different contexts.
$null = $Context
@{
    Name = 'libsass-converter'
    Hooks = @{
        AfterDiscoverStaticFile = {
            param($Invocation)

            if ($null -ne $Invocation.StaticFile -and $Invocation.StaticFile.Extension -in @('.scss', '.sass')) {
                # Sass convention: underscore-prefixed files are partials
                $Invocation.StaticFile.Metadata['hyde_libsass_is_partial'] = $Invocation.StaticFile.BaseName.StartsWith('_', [System.StringComparison]::Ordinal)
            }
        }

        ResolveStaticFileOutputPath = {
            param($CurrentValue, $Invocation)

            $staticFile = $Invocation.StaticFile
            if ($null -eq $staticFile -or $staticFile.Extension -notin @('.scss', '.sass')) {
                return $CurrentValue
            }

            # Don't emit partial files (they're dependencies only)
            if ($staticFile.BaseName.StartsWith('_', [System.StringComparison]::Ordinal)) {
                return $CurrentValue
            }

            # Compile-to-css: change extension and normalize path separators
            return ([System.IO.Path]::ChangeExtension($CurrentValue, '.css').Replace('\\', '/'))
        }

        BeforeCopyStaticFile = {
            param($Invocation)

            $staticFile = $Invocation.StaticFile
            if ($null -eq $staticFile -or $staticFile.Extension -notin @('.scss', '.sass')) {
                return
            }

            # Partials are never emitted to output
            if ($staticFile.BaseName.StartsWith('_', [System.StringComparison]::Ordinal)) {
                $Invocation.CancelCopy = $true
                return
            }

            # === INLINE COMPILATION LOGIC ===

            # Step 1: Load LibSassHost assembly (cached on repeat).
            $libSassAssembly = [AppDomain]::CurrentDomain.GetAssemblies() |
                Where-Object { $_.GetName().Name -eq 'LibSassHost' } |
                Select-Object -First 1

            if ($null -eq $libSassAssembly) {
                $moduleBase = if ($ExecutionContext.SessionState.Module -and $ExecutionContext.SessionState.Module.ModuleBase) {
                    $ExecutionContext.SessionState.Module.ModuleBase
                } else {
                    Split-Path -Path $PSScriptRoot -Parent
                }

                $bundleRoot = Join-Path -Path $moduleBase -ChildPath 'Plugins\\libsass-converter'
                $candidates = @(
                    (Join-Path -Path $bundleRoot -ChildPath 'LibSassHost.dll'),
                    (Join-Path -Path $bundleRoot -ChildPath 'lib\net10.0\LibSassHost.dll'),
                    (Join-Path -Path $bundleRoot -ChildPath 'lib\net9.0\LibSassHost.dll'),
                    (Join-Path -Path $bundleRoot -ChildPath 'lib\net8.0\LibSassHost.dll'),
                    (Join-Path -Path $bundleRoot -ChildPath 'lib\net7.0\LibSassHost.dll'),
                    (Join-Path -Path $bundleRoot -ChildPath 'lib\netstandard2.0\LibSassHost.dll')
                )

                $selectedDll = $null
                foreach ($candidate in $candidates) {
                    if (Test-Path -LiteralPath $candidate -PathType Leaf) {
                        $selectedDll = $candidate
                        break
                    }
                }

                if ($null -eq $selectedDll) {
                    throw "libsass-converter requires LibSassHost binaries. Run: .\\tools\\Get-LibSassHost.ps1"
                }

                # Add runtime native path before loading the managed assembly.
                $runtimeFolder = if ([System.Environment]::Is64BitProcess) { 'win-x64' } else { 'win-x86' }
                $nativePath = Join-Path -Path $bundleRoot -ChildPath "runtimes\$runtimeFolder\native"
                if (Test-Path -LiteralPath $nativePath) {
                    $pathItems = @($env:PATH -split ';')
                    if ($pathItems -notcontains $nativePath) {
                        $env:PATH = "$nativePath;$env:PATH"
                    }
                }

                try {
                    $managedDirectory = Split-Path -Path $selectedDll -Parent
                    foreach ($dependencyDll in Get-ChildItem -LiteralPath $managedDirectory -Filter '*.dll' -File) {
                        if ($dependencyDll.Name -ieq 'LibSassHost.dll') {
                            continue
                        }

                        try {
                            [void][System.Reflection.Assembly]::LoadFrom($dependencyDll.FullName)
                        } catch {
                            # Ignore optional dependency load failures and let compiler load surface hard requirements.
                            Write-Verbose ("Could not load dependency assembly '{0}'. Trying in a different context. {1}" -f $dependencyDll.FullName, $_.Exception.Message)
                        }
                    }

                    $libSassAssembly = [System.Reflection.Assembly]::LoadFrom($selectedDll)
                } catch {
                    throw "Cannot load LibSassHost from '$selectedDll': $($_.Exception.Message)"
                }
            }

            # Step 2: Prepare compilation options using reflection.
            # Newer LibSassHost versions expose CompilationOptions; older builds used SassOptions.
            $optionsType = $libSassAssembly.GetType('LibSassHost.CompilationOptions', $false)
            if ($null -eq $optionsType) {
                $optionsType = $libSassAssembly.GetType('LibSassHost.SassOptions', $true)
            }
            $outputStyleType = $libSassAssembly.GetType('LibSassHost.OutputStyle', $true)
            $options = [System.Activator]::CreateInstance($optionsType)

            # Set output style (expanded for dev, compressed otherwise)
            $style = if ($Invocation.Context.Environment -eq 'development') { 'Expanded' } else { 'Compressed' }
            $styleProp = $optionsType.GetProperty('OutputStyle')
            if ($styleProp) {
                $styleProp.SetValue($options, [System.Enum]::Parse($outputStyleType, $style))
            }

            # Set include paths (always include source dir for relative imports)
            $sourceDir = Split-Path -Path $staticFile.SourcePath -Parent
            $includePathsProp = $optionsType.GetProperty('IncludePaths')
            if ($includePathsProp) {
                $includePathList = [System.Collections.Generic.List[string]]::new()
                [void]$includePathList.Add([string]$sourceDir)

                # Honor Jekyll-style sass.sass_dir when configured.
                if ($Invocation.Context.Settings.ContainsKey('sass') -and
                    $Invocation.Context.Settings.sass -is [hashtable] -and
                    $Invocation.Context.Settings.sass.ContainsKey('sass_dir') -and
                    -not [string]::IsNullOrWhiteSpace([string]$Invocation.Context.Settings.sass.sass_dir)) {
                    $sassDirectoryPath = Join-Path -Path $Invocation.Context.SourcePath -ChildPath ([string]$Invocation.Context.Settings.sass.sass_dir)
                    [void]$includePathList.Add($sassDirectoryPath)

                    if (-not [string]::IsNullOrWhiteSpace($Invocation.Context.ThemePath)) {
                        $themeSassDirectoryPath = Join-Path -Path $Invocation.Context.ThemePath -ChildPath ([string]$Invocation.Context.Settings.sass.sass_dir)
                        if (Test-Path -LiteralPath $themeSassDirectoryPath -PathType Container) {
                            [void]$includePathList.Add($themeSassDirectoryPath)
                        }
                    }
                }

                # Allow plugin-specific include paths from _config.yml under libsass.include_paths.
                if ($Invocation.Context.Settings.ContainsKey('libsass') -and
                    $Invocation.Context.Settings.libsass -is [hashtable] -and
                    $Invocation.Context.Settings.libsass.ContainsKey('include_paths') -and
                    $Invocation.Context.Settings.libsass.include_paths) {
                    foreach ($configuredIncludePath in @($Invocation.Context.Settings.libsass.include_paths)) {
                        if ([string]::IsNullOrWhiteSpace([string]$configuredIncludePath)) {
                            continue
                        }

                        $resolvedIncludePath = if ([System.IO.Path]::IsPathRooted([string]$configuredIncludePath)) {
                            [string]$configuredIncludePath
                        } else {
                            Join-Path -Path $Invocation.Context.SourcePath -ChildPath ([string]$configuredIncludePath)
                        }

                        [void]$includePathList.Add($resolvedIncludePath)
                    }
                }

                $includePathsProp.SetValue($options, $includePathList)
            }

            # Step 3: Compile
            $compilerType = $libSassAssembly.GetType('LibSassHost.SassCompiler', $true)
            $compileMethods = @($compilerType.GetMethods() |
                Where-Object { $_.Name -eq 'CompileFile' -and $_.IsStatic })

            # Some themes include YAML front matter in SCSS files. Strip it before compilation.
            $compileSourcePath = $staticFile.SourcePath
            $tempCompilePath = $null
            $rawSass = Get-Content -LiteralPath $staticFile.SourcePath -Raw
            $frontMatterMatch = [System.Text.RegularExpressions.Regex]::Match(
                $rawSass,
                '\A---\s*\r?\n(.*?)^---\s*(?:\r?\n|$)',
                [System.Text.RegularExpressions.RegexOptions]::Singleline -bor [System.Text.RegularExpressions.RegexOptions]::Multiline
            )

            if ($frontMatterMatch.Success) {
                $sourceDirectory = Split-Path -Path $staticFile.SourcePath -Parent
                $tempCompilePath = Join-Path -Path $sourceDirectory -ChildPath (".hyde-scss-{0}{1}" -f [System.Guid]::NewGuid().ToString('N'), $staticFile.Extension)
                $strippedContent = $rawSass.Substring($frontMatterMatch.Length)
                Set-Content -LiteralPath $tempCompilePath -Encoding UTF8 -Value $strippedContent
                $compileSourcePath = $tempCompilePath
            }

            # Prefer the modern 4-parameter overload: inputPath, outputPath, sourceMapPath, options.
            $method = $compilerType.GetMethod(
                'CompileFile',
                [System.Reflection.BindingFlags]'Public, Static',
                $null,
                [Type[]]@([string], [string], [string], $optionsType),
                $null
            )

            $invokeCompile = {
                if ($null -ne $method) {
                    return $method.Invoke($null, @([string]$compileSourcePath, [string]::Empty, [string]::Empty, $options))
                }

                # Fall back to older 2-parameter overloads where arg2 is *Options.
                $fallbackMethod = $compileMethods |
                    Where-Object {
                        $_.GetParameters().Count -eq 2 -and
                        $_.GetParameters()[0].ParameterType -eq [string] -and
                        $_.GetParameters()[1].ParameterType.Name -like '*Options'
                    } |
                    Select-Object -First 1

                if ($null -ne $fallbackMethod) {
                    return $fallbackMethod.Invoke($null, @([string]$compileSourcePath, $options))
                }

                # Final fallback: single-argument CompileFile(inputPath).
                $fallbackMethod = $compileMethods | Where-Object { $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType -eq [string] } | Select-Object -First 1
                if ($null -eq $fallbackMethod) {
                    throw 'No supported LibSassHost CompileFile overload found.'
                }

                return $fallbackMethod.Invoke($null, @([string]$compileSourcePath))
            }

            $isVerboseBuild = $VerbosePreference -eq 'Continue' -or $VerbosePreference -eq 'Inquire'
            if ($isVerboseBuild) {
                $result = & $invokeCompile
            } else {
                # LibSass can emit warnings through native std handles. Mute those by temporarily
                # redirecting process stdout/stderr to NUL for non-verbose builds.
                if (-not ('Hyde.NativeStdHandle' -as [type])) {
                    Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Hyde_NativeStdHandle {
    [DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr GetStdHandle(int nStdHandle);
    [DllImport("kernel32.dll", SetLastError=true)] public static extern bool SetStdHandle(int nStdHandle, IntPtr handle);
    [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern IntPtr CreateFileW(string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile);
    [DllImport("kernel32.dll", SetLastError=true)] public static extern bool CloseHandle(IntPtr hObject);
}
"@
 -ErrorAction Stop
                }

                $stdOutId = -11
                $stdErrId = -12
                $genericWrite = [uint32]0x40000000
                $fileShareReadWrite = [uint32]0x3
                $openExisting = [uint32]3
                $fileAttributeNormal = [uint32]0x80

                $oldStdOut = [Hyde_NativeStdHandle]::GetStdHandle($stdOutId)
                $oldStdErr = [Hyde_NativeStdHandle]::GetStdHandle($stdErrId)
                $nulHandle = [Hyde_NativeStdHandle]::CreateFileW('NUL', $genericWrite, $fileShareReadWrite, [IntPtr]::Zero, $openExisting, $fileAttributeNormal, [IntPtr]::Zero)

                if ($nulHandle -eq [IntPtr]::Zero -or $nulHandle -eq [IntPtr]::op_Explicit(-1)) {
                    # If NUL handle creation fails, fall back to normal behavior instead of failing build.
                    $result = & $invokeCompile
                }

                if ($nulHandle -ne [IntPtr]::Zero -and $nulHandle -ne [IntPtr]::op_Explicit(-1)) {
                    try {
                        [void][Hyde_NativeStdHandle]::SetStdHandle($stdOutId, $nulHandle)
                        [void][Hyde_NativeStdHandle]::SetStdHandle($stdErrId, $nulHandle)
                        $result = & $invokeCompile
                    } finally {
                        [void][Hyde_NativeStdHandle]::SetStdHandle($stdOutId, $oldStdOut)
                        [void][Hyde_NativeStdHandle]::SetStdHandle($stdErrId, $oldStdErr)
                        [void][Hyde_NativeStdHandle]::CloseHandle($nulHandle)
                    }
                }
            }

            # Extract CSS from result
            $css = if ($result -is [string]) { $result } else { $result.CompiledContent }
            if ([string]::IsNullOrWhiteSpace($css)) {
                throw "Compilation of ' $($staticFile.RelativePath)' returned empty CSS"
            }

            # Step 4: Write output
            $outputPath = Join-Path -Path $Invocation.Context.DestinationPath -ChildPath $staticFile.OutputRelativePath
            $outputDir = Split-Path -Path $outputPath -Parent
            if (-not (Test-Path -LiteralPath $outputDir)) {
                [void](New-Item -Path $outputDir -ItemType Directory -Force)
            }

            Set-Content -LiteralPath $outputPath -Encoding UTF8 -Value $css
            if ($null -ne $tempCompilePath -and (Test-Path -LiteralPath $tempCompilePath -PathType Leaf)) {
                Remove-Item -LiteralPath $tempCompilePath -Force
            }
            $Invocation.CancelCopy = $true
        }
    }
}