Private/Hyde.Config.ps1

# Recursively merge configuration hashtables so site values override defaults.
function mergeHydeConfig {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Existing,

        [Parameter(Mandatory = $true)]
        [hashtable]$Difference
    )

    # Merge nested configuration sections recursively so site config can override Hyde defaults.
    foreach ($key in $Difference.Keys) {
        if ($Existing.ContainsKey($key)) {
            if ($Existing[$key] -is [hashtable] -and $Difference[$key] -is [hashtable]) {
                mergeHydeConfig -Existing $Existing[$key] -Difference $Difference[$key]
            } else {
                $Existing[$key] = $Difference[$key]
            }
        } else {
            $Existing[$key] = $Difference[$key]
        }
    }
}

# Load a YAML configuration file and normalize it to a hashtable.
function readHydeConfigFile {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "Could not validate location of configuration file '$Path'."
    }

    try {
        # Read the whole file at once so YAML parsing sees the original structure.
        $content = Get-Content -LiteralPath $Path -Raw
        if ([string]::IsNullOrWhiteSpace($content)) {
            return @{}
        }

        $parsed = ConvertFrom-Yaml -Yaml $content
        return (convertToHydeHashtable -InputObject $parsed)
    } catch {
        throw "Could not parse configuration file '$Path'. $($_.Exception.Message)"
    }
}

# Resolve a path relative to a base directory with optional non-existent targets.
function resolveHydePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Location,

        [string]$BasePath = (Get-Location).Path,

        [switch]$MayNotExist
    )

    # Resolve relative paths against the current site root while still allowing absolute overrides.
    $candidate = if ([System.IO.Path]::IsPathRooted($Location)) {
        $Location
    } else {
        Join-Path -Path $BasePath -ChildPath $Location
    }

    try {
        if (Test-Path -LiteralPath $candidate -PathType Container) {
            return (Resolve-Path -LiteralPath $candidate).Path
        }

        if ($MayNotExist) {
            $parentPath = Split-Path -Path $candidate -Parent
            if (-not $parentPath) {
                $parentPath = $BasePath
            }

            if (-not (Test-Path -LiteralPath $parentPath -PathType Container)) {
                throw "Could not validate parent directory '$parentPath'."
            }

            return Join-Path -Path (Resolve-Path -LiteralPath $parentPath).Path -ChildPath (Split-Path -Path $candidate -Leaf)
        }
    } catch {
        throw "Could not resolve target path of '$Location'. $($_.Exception.Message)"
    }

    throw "Could not resolve target path of '$Location'."
}

# Resolve the configured theme directory to an absolute local path.
function resolveHydeThemePath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Settings,

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

    if (-not $Settings.ContainsKey('theme_dir')) {
        return ''
    }

    $themeDirectory = [string]$Settings.theme_dir
    if ([string]::IsNullOrWhiteSpace($themeDirectory)) {
        return ''
    }

    $resolvedThemePath = resolveHydePath -Location $themeDirectory -BasePath $SourcePath -MayNotExist
    if (-not (Test-Path -LiteralPath $resolvedThemePath -PathType Container)) {
        throw "Could not find configured theme directory '$themeDirectory' at '$resolvedThemePath'."
    }

    return $resolvedThemePath
}

# Resolve a configured support directory beneath a root if it exists.
function resolveHydeSupportDirectoryPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Settings,

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

        [Parameter(Mandatory = $true)]
        [ValidateSet('includes_dir', 'layouts_dir', 'data_dir', 'plugins_dir')]
        [string]$SettingName,

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

    $directoryName = if ($Settings.ContainsKey($SettingName) -and -not [string]::IsNullOrWhiteSpace([string]$Settings[$SettingName])) {
        [string]$Settings[$SettingName]
    } else {
        $DefaultDirectoryName
    }

    $directoryPath = Join-Path -Path $RootPath -ChildPath $directoryName
    if (-not (Test-Path -LiteralPath $directoryPath -PathType Container)) {
        return ''
    }

    return $directoryPath
}

# Build the include root used by Liquid when a theme contributes fallback includes.
function newHydeEffectiveIncludesPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Settings,

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

        [string]$ThemePath
    )

    $siteIncludesPath = resolveHydeSupportDirectoryPath -Settings $Settings -RootPath $SourcePath -SettingName 'includes_dir' -DefaultDirectoryName '_includes'
    if ([string]::IsNullOrWhiteSpace($ThemePath)) {
        return ''
    }

    $themeIncludesPath = resolveHydeSupportDirectoryPath -Settings $Settings -RootPath $ThemePath -SettingName 'includes_dir' -DefaultDirectoryName '_includes'
    if ([string]::IsNullOrWhiteSpace($themeIncludesPath)) {
        return ''
    }

    $effectiveIncludesPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("hyde-includes-{0}" -f [System.Guid]::NewGuid().ToString('N'))
    [void](New-Item -Path $effectiveIncludesPath -ItemType Directory -Force)

    # Copy theme include files first so site files can overwrite them with the same relative path.
    foreach ($themeIncludeItem in Get-ChildItem -LiteralPath $themeIncludesPath -Force) {
        Copy-Item -LiteralPath $themeIncludeItem.FullName -Destination $effectiveIncludesPath -Recurse -Force
    }

    if (-not [string]::IsNullOrWhiteSpace($siteIncludesPath)) {
        foreach ($siteIncludeItem in Get-ChildItem -LiteralPath $siteIncludesPath -Force) {
            Copy-Item -LiteralPath $siteIncludeItem.FullName -Destination $effectiveIncludesPath -Recurse -Force
        }
    }

    return $effectiveIncludesPath
}

# Build the Hyde context from defaults, site config, plugins, and data files.
function initializeHydeBuildContext {
    [CmdletBinding()]
    param(
        [string]$Source,
        [string]$Destination,
        [Parameter(Mandatory = $true)]
        [string]$Environment,
        [Parameter(Mandatory = $true)]
        [string]$ModuleRoot,
        [Parameter(Mandatory = $true)]
        [string]$Version
    )

    $defaultConfigPath = Join-Path -Path $ModuleRoot -ChildPath 'globalConfig.yaml'
    $siteConfigName = '_config.yml'

    # Start with Hyde defaults, then layer site config and command-line overrides on top.
    Write-Verbose "Loading Hyde defaults from '$defaultConfigPath'."
    $settings = readHydeConfigFile -Path $defaultConfigPath

    $sourceSetting = if ($PSBoundParameters.ContainsKey('Source')) { $Source } else { $settings.source }
    $sourcePath = resolveHydePath -Location $sourceSetting
    Write-Verbose "Resolved source path to '$sourcePath'."

    $siteConfigPath = Join-Path -Path $sourcePath -ChildPath $siteConfigName
    $siteConfig = @{}
    if (Test-Path -LiteralPath $siteConfigPath -PathType Leaf) {
        Write-Verbose "Loading site configuration from '$siteConfigPath'."
        $siteConfig = readHydeConfigFile -Path $siteConfigPath
    } else {
        Write-Verbose "No site configuration file found at '$siteConfigPath'."
    }

    $themeConfig = @{}
    $themePath = ''
    if ($siteConfig.ContainsKey('theme_dir') -and -not [string]::IsNullOrWhiteSpace([string]$siteConfig.theme_dir)) {
        $themePath = resolveHydeThemePath -Settings $siteConfig -SourcePath $sourcePath
        $themeConfigPath = Join-Path -Path $themePath -ChildPath $siteConfigName
        if (Test-Path -LiteralPath $themeConfigPath -PathType Leaf) {
            Write-Verbose "Loading theme configuration from '$themeConfigPath'."
            $themeConfig = readHydeConfigFile -Path $themeConfigPath
        } else {
            Write-Verbose "No theme configuration file found at '$themeConfigPath'."
        }
    }

    if ($themeConfig.Count -gt 0) {
        mergeHydeConfig -Existing $settings -Difference $themeConfig
    }

    if ($siteConfig.Count -gt 0) {
        mergeHydeConfig -Existing $settings -Difference $siteConfig
    }

    if ($PSBoundParameters.ContainsKey('Source')) {
        $settings.source = $Source
    }

    if ($PSBoundParameters.ContainsKey('Destination')) {
        $settings.destination = $Destination
    }

    $destinationPath = resolveHydePath -Location $settings.destination -BasePath $sourcePath -MayNotExist
    Write-Verbose "Resolved destination path to '$destinationPath'."

    # Build up the runtime context that the rest of the pipeline will mutate.
    $context = [HydeBuildContext]::new()
    $context.Version = $Version
    $context.Environment = $Environment
    $context.Settings = $settings
    $context.Site = copyHydeValue -InputObject $settings
    $context.SourcePath = $sourcePath
    $context.DestinationPath = $destinationPath
    $context.ThemePath = $themePath
    $context.EffectiveIncludesPath = newHydeEffectiveIncludesPath -Settings $settings -SourcePath $sourcePath -ThemePath $themePath
    $context.PluginRegistry = newHydePluginRegistry
    $context.LiquidRegistry = New-LiquidExtensionRegistry
    Register-LiquidTrustedType -Registry $context.LiquidRegistry -TypeName HydeDocument
    Register-LiquidTrustedType -Registry $context.LiquidRegistry -TypeName HydeStaticFile

    # These values are generated per invocation and do not come from configuration files.
    $context.Site['time'] = Get-Date
    $context.Site['pages'] = New-Object System.Collections.ArrayList
    $context.Site['posts'] = New-Object System.Collections.ArrayList
    $context.Site['documents'] = New-Object System.Collections.ArrayList
    $context.Site['static_files'] = New-Object System.Collections.ArrayList
    $context.Site['collections'] = @{}
    $context.Site['tags'] = @{}
    $context.Site['categories'] = @{}

    initializeHydeCollections -Context $context

    importHydePlugins -Context $context
    importHydeDataFiles -Context $context
    invokeHydePluginHook -Context $context -HookName 'AfterInitialize' -Arguments @{ Context = $context }
    Write-Verbose "Initialized Hyde build context."

    return $context
}

# Convert configured collections into a normalized definition list.
function getHydeCollectionDefinitions {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context
    )

    # Collections are configured under the Jekyll-style collections map in _config.yml.
    if (-not $Context.Settings.ContainsKey('collections') -or -not $Context.Settings.collections) {
        return @()
    }

    $definitions = New-Object System.Collections.ArrayList
    foreach ($collectionName in $Context.Settings.collections.Keys) {
        $collectionSettings = if ($Context.Settings.collections[$collectionName] -is [hashtable]) {
            $Context.Settings.collections[$collectionName]
        } else {
            @{}
        }

        [void]$definitions.Add([pscustomobject]@{
            Label     = [string]$collectionName
            Directory = '_' + [string]$collectionName
            Output    = ($collectionSettings.ContainsKey('output') -and [bool]$collectionSettings.output)
            Settings  = $collectionSettings
        })
    }

    return @($definitions.ToArray())
}

# Find a single collection definition by name.
function getHydeCollectionDefinition {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context,

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

    foreach ($definition in getHydeCollectionDefinitions -Context $Context) {
        if ($definition.Label -ieq $CollectionName) {
            return $definition
        }
    }

    return $null
}

# Initialize site collection bags on the build context.
function initializeHydeCollections {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context
    )

    foreach ($definition in getHydeCollectionDefinitions -Context $Context) {
        # Expose collections through both site.collections.<label> and site.<label>.
        $Context.Site.collections[$definition.Label] = @{
            label     = $definition.Label
            directory = $definition.Directory
            output    = $definition.Output
            docs      = New-Object System.Collections.ArrayList
        }

        $Context.Site[$definition.Label] = New-Object System.Collections.ArrayList
    }
}

# Refresh site.posts and collections.posts.docs from published post documents.
function syncHydePosts {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context
    )

    if (-not $Context.Site.ContainsKey('posts') -or -not $Context.Site.ContainsKey('collections') -or -not $Context.Site.collections.ContainsKey('posts')) {
        return
    }

    $eligiblePosts = @(
        $Context.Documents |
            Where-Object {
                $_.CollectionName -eq 'posts' -and
                $_.Published
            } |
            Sort-Object -Property @{ Expression = { $_.PostDate } ; Descending = $true }, @{ Expression = { $_.RelativePath } ; Descending = $false }
    )

    $postLimit = 0
    if ($Context.Settings.ContainsKey('limit_posts') -and $Context.Settings.limit_posts) {
        $postLimit = [int]$Context.Settings.limit_posts
    }

    if ($postLimit -gt 0 -and $eligiblePosts.Count -gt $postLimit) {
        $eligiblePosts = @($eligiblePosts | Select-Object -First $postLimit)
    }

    $Context.Site.posts.Clear()
    $Context.Site.collections.posts.docs.Clear()

    foreach ($post in $eligiblePosts) {
        [void]$Context.Site.posts.Add($post)
        [void]$Context.Site.collections.posts.docs.Add($post)
    }
}

# Populate site.tags and site.categories from published posts.
function syncHydeTaxonomies {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context
    )

    $Context.Site.tags = @{}
    $Context.Site.categories = @{}

    foreach ($document in $Context.Documents) {
        if (-not $document.Published -or $document.CollectionName -ne 'posts') {
            continue
        }

        foreach ($tag in @($document.Tags)) {
            if ([string]::IsNullOrWhiteSpace($tag)) {
                continue
            }

            if (-not $Context.Site.tags.ContainsKey($tag)) {
                $Context.Site.tags[$tag] = New-Object System.Collections.ArrayList
            }

            [void]$Context.Site.tags[$tag].Add($document)
        }

        foreach ($category in @($document.Categories)) {
            if ([string]::IsNullOrWhiteSpace($category)) {
                continue
            }

            if (-not $Context.Site.categories.ContainsKey($category)) {
                $Context.Site.categories[$category] = New-Object System.Collections.ArrayList
            }

            [void]$Context.Site.categories[$category].Add($document)
        }
    }
}

# Normalize the defaults array from configuration for consistent matching.
function getHydeFrontMatterDefaults {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context
    )

    # Normalize configured defaults into a consistent internal shape.
    if (-not $Context.Settings.ContainsKey('defaults') -or -not $Context.Settings.defaults) {
        return @()
    }

    $defaults = New-Object System.Collections.ArrayList
    $index = 0
    foreach ($entry in $Context.Settings.defaults) {
        if ($null -eq $entry) {
            $index++
            continue
        }

        $scope = if ($entry.scope -is [hashtable]) { $entry.scope } else { @{} }
        $values = if ($entry.values -is [hashtable]) { copyHydeValue -InputObject $entry.values } else { @{} }

        [void]$defaults.Add([pscustomobject]@{
            Index = $index
            Scope = @{
                path = if ($scope.ContainsKey('path') -and $null -ne $scope.path) { ([string]$scope.path).Replace('\', '/').TrimStart('/') } else { '' }
                type = if ($scope.ContainsKey('type') -and $null -ne $scope.type) { [string]$scope.type } else { '' }
            }
            Values = $values
        })

        $index++
    }

    return @($defaults.ToArray())
}

# Score scope paths to prefer more specific defaults.
function getHydeDefaultScopePathSpecificity {
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [string]$ScopePath
    )

    if ([string]::IsNullOrWhiteSpace($ScopePath)) {
        return 0
    }

    return ($ScopePath -replace '\*', '').Length
}

# Check whether an item's relative path matches a default scope path.
function testHydeDefaultScopePath {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [string]$ScopePath,

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

    if ([string]::IsNullOrWhiteSpace($ScopePath)) {
        return $true
    }

    $normalizedScopePath = $ScopePath.Replace('\', '/').Trim('/').Trim()
    $normalizedRelativePath = $RelativePath.Replace('\', '/').TrimStart('/')

    if ($normalizedScopePath.Contains('*')) {
        return ($normalizedRelativePath -like $normalizedScopePath)
    }

    return (
        $normalizedRelativePath -eq $normalizedScopePath -or
        $normalizedRelativePath.StartsWith($normalizedScopePath.TrimEnd('/') + '/', [System.StringComparison]::OrdinalIgnoreCase)
    )
}

# Check whether an item's type matches a default scope type.
function testHydeDefaultScopeType {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [string]$ScopeType,
        [string]$ItemType
    )

    if ([string]::IsNullOrWhiteSpace($ScopeType)) {
        return $true
    }

    return ($ScopeType -ieq $ItemType)
}

# Map a content item to its default scope type label.
function getHydeItemDefaultType {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [HydeContentItem]$Item
    )

    switch ($Item.Kind) {
        'Page' { return 'pages' }
        'CollectionDocument' { return $Item.CollectionName }
        default { return '' }
    }
}

# Gather all front matter defaults that apply to the given item.
function getHydeMatchingDefaults {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context,

        [Parameter(Mandatory = $true)]
        [HydeContentItem]$Item
    )

    $defaults = getHydeFrontMatterDefaults -Context $Context
    if (-not $defaults) {
        return @()
    }

    $itemType = getHydeItemDefaultType -Item $Item
    $matchingDefaults = @(
        $defaults | Where-Object {
            (testHydeDefaultScopePath -ScopePath $_.Scope.path -RelativePath $Item.RelativePath) -and
            (testHydeDefaultScopeType -ScopeType $_.Scope.type -ItemType $itemType)
        } | Sort-Object `
            @{ Expression = { getHydeDefaultScopePathSpecificity -ScopePath $_.Scope.path } ; Descending = $true },
            @{ Expression = { if ([string]::IsNullOrWhiteSpace($_.Scope.type)) { 0 } else { 1 } } ; Descending = $true },
            @{ Expression = { $_.Index } ; Descending = $true }
    )

    return @($matchingDefaults)
}

# Apply missing default values to a front matter hashtable.
function mergeHydeFrontMatterDefaults {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Target,

        [Parameter(Mandatory = $true)]
        [hashtable]$Defaults
    )

    # Defaults only fill missing values; explicit front matter still wins.
    foreach ($key in $Defaults.Keys) {
        if (-not $Target.ContainsKey($key)) {
            $Target[$key] = copyHydeValue -InputObject $Defaults[$key]
        }
    }
}

# Build the list of generated paths Hyde Clean should remove.
function getHydeCleanTargets {
    [CmdletBinding()]
    [OutputType([System.Collections.ArrayList])]
    param(
        [Parameter(Mandatory = $true)]
        [HydeBuildContext]$Context
    )

    # Hyde clean intentionally targets the same generated artifacts that Jekyll clean removes.
    $targets = New-Object System.Collections.ArrayList
    $candidatePaths = @(
        @{ Kind = 'destination folder'; Path = $Context.DestinationPath }
        @{ Kind = 'metadata file'; Path = Join-Path -Path $Context.SourcePath -ChildPath '.jekyll-metadata' }
        @{ Kind = 'Jekyll cache'; Path = Join-Path -Path $Context.SourcePath -ChildPath '.jekyll-cache' }
        @{ Kind = 'Sass cache'; Path = Join-Path -Path $Context.SourcePath -ChildPath '.sass-cache' }
    )

    foreach ($candidate in $candidatePaths) {
        if ([string]::IsNullOrWhiteSpace($candidate.Path)) {
            continue
        }

        [void]$targets.Add([pscustomobject]@{
            Kind = $candidate.Kind
            Path = $candidate.Path
        })
    }

    return $targets
}

# Identify a Hyde site root by the presence of _config.yml.
function testHydeSiteRootPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )

    # A Hyde site root is identified by the presence of the normal site configuration file.
    return (Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath '_config.yml') -PathType Leaf)
}

# Remove a generated file or folder with safety checks.
function removeHydeGeneratedPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

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

        [string]$SourcePath
    )

    # Resolve paths first so the safety checks operate on normalized absolute paths.
    $resolvedTargetPath = [System.IO.Path]::GetFullPath($Path)

    if ($Kind -eq 'destination folder') {
        $normalizedTargetPath = $resolvedTargetPath.TrimEnd([char[]]@('\', '/'))
        $normalizedDriveRoot = [System.IO.Path]::GetPathRoot($resolvedTargetPath).TrimEnd([char[]]@('\', '/'))

        # Cleaning a drive root would be catastrophic if destination is misconfigured.
        if (-not [string]::IsNullOrWhiteSpace($normalizedDriveRoot) -and $normalizedTargetPath.Equals($normalizedDriveRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
            throw "Refusing to remove destination folder path '$resolvedTargetPath' because it resolves to a drive root."
        }
    }

    if (($Kind -eq 'destination folder') -and -not [string]::IsNullOrWhiteSpace($SourcePath)) {
        $resolvedSourcePath = [System.IO.Path]::GetFullPath($SourcePath)

        $normalizedTargetPath = $resolvedTargetPath.TrimEnd([char[]]@('\', '/'))
        $normalizedSourcePath = $resolvedSourcePath.TrimEnd([char[]]@('\', '/'))

        # Clean must not remove a destination folder that is a parent of the source tree.
        if (
            $normalizedSourcePath.StartsWith($normalizedTargetPath + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -or
            $normalizedSourcePath.StartsWith($normalizedTargetPath + [System.IO.Path]::AltDirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
        ) {
            throw "Refusing to remove destination folder path '$resolvedTargetPath' because it is a parent of source path '$resolvedSourcePath'."
        }
    }

    # Clean must never remove an actual site source directory, even if the destination points at it.
    if (($Kind -eq 'destination folder') -and (testHydeSiteRootPath -Path $resolvedTargetPath)) {
        throw "Refusing to remove destination folder path '$resolvedTargetPath' because it is the source of a site."
    }

    if (-not (Test-Path -LiteralPath $resolvedTargetPath)) {
        Write-Verbose "Skipping missing $Kind at '$resolvedTargetPath'."
        return
    }

    # Remove files and directories with the appropriate PowerShell cmdlet shape.
    $item = Get-Item -LiteralPath $resolvedTargetPath -Force
    if ($item.PSIsContainer) {
        Remove-Item -LiteralPath $resolvedTargetPath -Recurse -Force
    } else {
        Remove-Item -LiteralPath $resolvedTargetPath -Force
    }

    Write-Verbose "Removed $Kind at '$resolvedTargetPath'."
}