Documentarian.MicrosoftDocs.psm1

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

#region Classes.Public

class LearnLocales {

    ## This list of locales is based on the language selection page on the Learn platform.

    static [string[]]$SupportedLocales = 'ar-sa', 'bg-bg', 'bs-latn-ba', 'ca-es', 'cs-cz', 'da-dk',
    'de-at', 'de-ch', 'de-de', 'el-gr', 'en-au', 'en-ca', 'en-gb', 'en-ie', 'en-in', 'en-my',
    'en-nz', 'en-sg', 'en-us', 'en-za', 'es-es', 'es-mx', 'et-ee', 'eu-es', 'fi-fi', 'fil-ph',
    'fr-be', 'fr-ca', 'fr-ch', 'fr-fr', 'ga-ie', 'gl-es', 'he-il', 'hi-in', 'hr-hr', 'hu-hu',
    'id-id', 'is-is', 'it-ch', 'it-it', 'ja-jp', 'ka-ge', 'kk-kz', 'ko-kr', 'lb-lu', 'lt-lt',
    'lv-lv', 'ms-my', 'mt-mt', 'nb-no', 'nl-be', 'nl-nl', 'pl-pl', 'pt-br', 'pt-pt', 'ro-ro',
    'ru-ru', 'sk-sk', 'sl-si', 'sr-cyrl-rs', 'sr-latn-rs', 'sv-se', 'th-th', 'tr-tr', 'uk-ua',
    'vi-vn', 'zh-cn', 'zh-hk', 'zh-tw'

    ## Most docs are trnaslated into a subset of 19 locales.

    static [string[]]$CommonLocales = 'en-us', 'cs-cz', 'de-de', 'es-es', 'fr-fr', 'hu-hu', 'id-id',
    'it-it', 'ja-jp', 'ko-kr', 'nl-nl', 'pl-pl', 'pt-br', 'pt-pt', 'ru-ru', 'sv-se', 'tr-tr',
    'zh-cn', 'zh-tw'

}

#endregion Classes.Public

#region Functions.Public

function Export-MonikerContent {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [SupportsWildcards()]
        [Alias("PSPath")]
        [string[]]$Path,

        [Parameter(Mandatory, Position = 1)]
        [string]$Moniker,

        [Parameter(Position = 2)]
        [string]$OutputPath = '.'
    )

    begin {
        if (Test-Path -Path $OutputPath) {
            $OutputPath = (Resolve-Path -Path $OutputPath).Path
        } else {
            $OutputPath = New-Item -ItemType Directory -Path $OutputPath -Force
        }
        $monikerStartPattern = '::: moniker range="(?<comparison>[=\>\<]{1,2})\s*(?<name>.+)"'
        $monikerEndPattern = '::: moniker-end'

        function parseMoniker {
            param (
                [string]$mdString
            )
            if ($mdString -match $monikerStartPattern) {
                @{
                    Name = $matches['name'].Trim()
                    Comparison = $matches['comparison']
                }
            }
        }
    }

    process {
        foreach ($path in $Path) {
            $getChildItemSplat = @{
                Path        = $path
                File        = $true
                ErrorAction = 'SilentlyContinue'
            }
            $resolvedFiles = Get-ChildItem @getChildItemSplat

            foreach ($file in $resolvedFiles) {
                if ($file.Extension -ne '.md') {
                    Write-Verbose "Skipping non-markdown file: '$($file.FullName)'"
                    continue
                }
                $outFilePath = Join-Path -Path $OutputPath "$($file.BaseName)_$Moniker.md"
                $mdContent = Get-Content -Path $file.FullName
                $currentMoniker = @{
                    Name = ''
                    Comparison = '='
                }
                $filteredContent = @()
                foreach ($line in $mdContent) {
                    if ($line -match $monikerStartPattern) {
                        $currentMoniker = parseMoniker $line
                    }
                    if ($currentMoniker.Name -eq '') {
                        $filteredContent += $line
                    } else {
                        # check if the current moniker range includes the specified moniker
                        switch ($currentMoniker.Comparison) {
                            '=' {
                                if ($Moniker -eq $currentMoniker.Name) {
                                    $filteredContent += $line
                                }
                            }
                            '>=' {
                                if ($Moniker -ge $currentMoniker.Name) {
                                    $filteredContent += $line
                                }
                            }
                            '>' {
                                if ($Moniker -gt $currentMoniker.Name) {
                                    $filteredContent += $line
                                }
                            }
                            '<=' {
                                if ($Moniker -le $currentMoniker.Name) {
                                    $filteredContent += $line
                                }
                            }
                            '<' {
                                if ($Moniker -lt $currentMoniker.Name) {
                                    $filteredContent += $line
                                }
                            }
                        }
                    } # end else for moniker range check
                    if ($line -match $monikerEndPattern) {
                        $currentMoniker = @{
                            Name = ''
                            Comparison = '='
                        }
                    }
                } #end foreach line
                Write-Verbose "Exported filtered content to '$outFilePath'"
                $filteredContent | Out-File -FilePath $outFilePath -Encoding utf8
                Get-Item -Path $outFilePath
            } #end foreach file
        } #end foreach path
    } # end process block
}

function Get-CmdletXref {
    <#
    .SYNOPSIS
    Gets a cross-reference link for a command.
    #>

    [CmdletBinding()]
    [OutputType('System.String[]')]
    param(
        # The name of the command to get a cross-reference link for.
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$Name
    )

    begin {
        $ProgressPreference = 'SilentlyContinue'
    }

    process {
        foreach ($cmdname in $Name) {
            try {
                $cmd = Get-Command $cmdname -ErrorAction Stop
                if ($cmd.CommandType -eq 'Alias') {
                    Write-Verbose "$cmdname is an alias for $($cmd.ResolvedCommand)"
                    $cmd = Get-Command $cmd.ResolvedCommand -ErrorAction Stop
                }
                $modulename  = $cmd.ModuleName
                $commandname = $cmd.Name
                $commandtype = $cmd.CommandType
                if (@('Cmdlet', 'Function') -notcontains $commandtype) {
                    Write-Verbose "$commandname is a(n) $commandtype"
                    continue
                } elseif ($modulename -eq '') {
                    $help = Get-Help $cmdname
                    if ($help.modulename -ne '') {
                       $modulename = $help.ModuleName
                       $commandname = $help.Name
                       $commandtype = $help.Category
                    }
                }
                if ($modulename -eq '') {
                    Write-Verbose "$commandname is an anonymous $commandtype."
                } else {
                    "[$commandname](xref:${modulename}.$commandname)"
                }
            } catch [System.Management.Automation.CommandNotFoundException] {
                Write-Warning "Unable to find command $cmdname"
            }
        }
    }
}

function Get-HtmlMetaTags {

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [uri]$ArticleUrl,

        [switch]$ShowRequiredMetadata
    )

    $hash = [ordered]@{}

    $x = Invoke-WebRequest $ArticleUrl
    $lines = (($x -split "`n").trim() | Select-String -Pattern '\<meta').line | ForEach-Object {
        $_.trimstart('<meta ').trimend(' />') | Sort-Object
    }
    $pattern = '(name|property)="(?<key>[^"]+)"\s*content="(?<value>[^"]+)"'
    foreach ($line in $lines) {
        if ($line -match $pattern) {
            if ($hash.Contains($Matches.key)) {
                $hash[($Matches.key)] += ',' + $Matches.value
            } else {
                $hash.Add($Matches.key, $Matches.value)
            }
        }
    }

    $result = New-Object -type psobject -prop ($hash)
    if ($ShowRequiredMetadata) {
        $result | Select-Object title, 'og:title', description, 'ms.manager', 'ms.author', author, 'ms.service', 'ms.date', 'ms.topic', 'ms.subservice', 'ms.prod', 'ms.technology', 'ms.custom', 'ROBOTS'
    } else {
        $result
    }

}

function Get-LocaleFreshness {

    [CmdletBinding()]
    [OutputType('DocumentLocaleInfo')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [uri]$Uri,

        [Parameter(Position = 1)]
        [ValidateScript({$_ -in [LearnLocales]::SupportedLocales})]
        [string[]]$Locale = [LearnLocales]::CommonLocales
    )

    $localeInUrl = $uri.Segments[1].Trim('/')
    if ($localeInUrl -notin [LearnLocales]::SupportedLocales) {
        Write-Error "URL does not contain a supported locale: $localeInUrl"
        return
    } else {

        $url = $uri.OriginalString
        if ($Locale -notcontains 'en-us') { $Locale += 'en-us' }
        $Locale | ForEach-Object {
            $locPath = $_
            $result = Get-HtmlMetaTags ($url -replace $localeInUrl, $locPath) |
                Select-Object @{n = 'locpath'; e = { $locPath } }, locale, 'ms.contentlocale',
                'ms.translationtype', 'ms.date', 'loc_version', 'updated_at', 'loc_source_id',
                'loc_file_id', 'original_content_git_url'
                $result.pstypenames.Insert(0, 'DocumentLocaleInfo')
                $result
            } | Sort-Object 'updated_at', 'ms.contentlocale'
    }

}

function Sync-BeyondCompare {

    [cmdletbinding()]
    param (
        [Parameter(Mandatory, Position = 0)]
        [string]$Path
    )

    ### Get-GitStatus comes from the posh-git module.
    $gitStatus = Get-GitStatus
    if ($gitStatus) {
        $reponame = $GitStatus.RepoName
    } else {
        Write-Warning 'Not a git repo.'
        return
    }
    $repoPath = $global:git_repos[$reponame].path
    $ops = Get-Content $repoPath\.openpublishing.publish.config.json |
        ConvertFrom-Json -Depth 10 -AsHashtable
    $srcPath = $ops.docsets_to_publish.build_source_folder
    if ($srcPath -eq '.') { $srcPath = '' }

    $basePath = Join-Path $repoPath $srcPath '\'
    $mapPath = Join-Path $basePath $ops.docsets_to_publish.monikerPath
    $monikers = Get-Content $mapPath | ConvertFrom-Json -Depth 10 -AsHashtable
    $startPath = (Get-Item $Path).fullname

    $vlist = $monikers.keys | ForEach-Object { $monikers[$_].packageRoot }
    if ($startpath) {
        $relPath = $startPath -replace [regex]::Escape($basepath)
        $version = ($relPath -split '\\')[0]
        foreach ($v in $vlist) {
            if ($v -ne $version) {
                $target = $startPath -replace [regex]::Escape($version), $v
                if (Test-Path $target) {
                    Start-Process -Wait "${env:ProgramFiles}\Beyond Compare 4\BComp.exe" -ArgumentList $startpath, $target
                }
            }
        }
    } else {
        Write-Error "Invalid path: $Path"
    }

}

function Sync-VSCode {

    [cmdletbinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Path
    )

    ### Get-GitStatus comes from the posh-git module.
    $gitStatus = Get-GitStatus
    if ($gitStatus) {
        $reponame = $GitStatus.RepoName
    } else {
        Write-Warning 'Not a git repo.'
        return
    }
    $repoPath = $global:git_repos[$reponame].path
    $ops = Get-Content $repoPath\.openpublishing.publish.config.json | ConvertFrom-Json -Depth 10 -AsHashtable
    $srcPath = $ops.docsets_to_publish.build_source_folder
    if ($srcPath -eq '.') { $srcPath = '' }
    $basePath = Join-Path $repoPath $srcPath '\'
    $mapPath = Join-Path $basePath $ops.docsets_to_publish.monikerPath
    $monikers = Get-Content $mapPath | ConvertFrom-Json -Depth 10 -AsHashtable
    $startPath = (Get-Item $Path).fullname

    $vlist = $monikers.keys | ForEach-Object { $monikers[$_].packageRoot }
    if ($startpath) {
        $relPath = $startPath -replace [regex]::Escape($basepath)
        $version = ($relPath -split '\\')[0]
        foreach ($v in $vlist) {
            if ($v -ne $version) {
                $target = $startPath -replace [regex]::Escape($version), $v
                if (Test-Path $target) {
                    Start-Process -Wait -WindowStyle Hidden 'code' -ArgumentList '--diff', '--wait', '--reuse-window', $startpath, $target
                }
            }
        }
    } else {
        Write-Error "Invalid path: $Path"
    }

}

function Test-YamlTOC {

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateScript({ Test-Path $_ -PathType Container })]
        [string]$Path
    )

    $Path = (Resolve-Path $Path).Path

    # Find the TOC files
    $tocList = Get-ChildItem $Path -Filter 'toc.yml' -Recurse -File |
        Where-Object { $_.FullName -notmatch 'bread' }

    if ($tocList.Count -eq 0) {
        Write-Warning "No TOC.yml files found in path: $Path"
        return
    }

    # Find all content files
    $fileList = Get-ChildItem $Path -Include *.md, *.yml -Recurse -File |
        Where-Object { $_.FullName -notmatch 'bread.+\\toc.yml' }

    $statusList = foreach ($file in $fileList) {
        [pscustomobject]@{
            FileExists  = $true
            IsInTOC     = $false
            FileName    = $file.FullName
            TOCFileName = ''
        }
    }

    #Process TOC files
    $hrefPattern = '\s*href:\s+([\w\-\._/]+)\s*$'
    foreach ($toc in $tocList) {
        $tocBasePath = Split-Path $toc.FullName -Parent
        $hrefList = (Select-String -Pattern $hrefPattern -Path $toc.FullName).Matches |
            ForEach-Object { $_.Groups[1].Value }

        foreach ($href in $hrefList) {
            $filePath = Join-Path $tocBasePath $href
            if ($filePath -in $fileList.FullName) {
                $statusList.Where({ $_.FileName -eq $filePath }).ForEach({
                    $_.IsInTOC = $true
                    $_.TOCFileName = $toc.FullName
                })
            } else {
                $statusList += [pscustomobject]@{
                    FileExists  = $false
                    IsInTOC     = $true
                    FileName    = $filePath
                    TOCFileName = $toc.FullName
                }
            }
        }
    }

    # Output results
    $statusList | Where-Object { -not $_.FileExists -or -not $_.IsInTOC }
}

#endregion Functions.Public

# Define the types to export with type accelerators.
$ExportableTypes =@(
    [LearnLocales]
)

# Get the internal TypeAccelerators class to use its static methods.
$TypeAcceleratorsClass = [psobject].Assembly.GetType(
'System.Management.Automation.TypeAccelerators'
)
# Ensure none of the types would clobber an existing type accelerator.
# If a type accelerator with the same name exists, throw an exception.
$ExistingTypeAccelerators = $TypeAcceleratorsClass::Get
foreach ($Type in $ExportableTypes) {
    if ($Type -in $ExistingTypeAccelerators.Keys) {
        $Message = @(
            "Unable to register type accelerator '$($Type.FullName)'"
            'Accelerator already exists'
        ) -join ' '

        throw [System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new($Message),
            'TypeAcceleratorAlreadyExists',
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $Type.FullName
        )
    }
}

# Add the type accelerators for every exportable type.
foreach ($Type in $ExportableTypes) {
    Write-Verbose "Registering type accelerator for [$Type]"
    $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    foreach ($Type in $ExportableTypes) {
        Write-Verbose "Unregistering type accelerator for [$Type]"
        $null = $TypeAcceleratorsClass::Remove($Type.FullName)
    }
}.GetNewClosure()

$ExportableFunctions = @(
  'Export-MonikerContent'
  'Get-CmdletXref'
  'Get-HtmlMetaTags'
  'Get-LocaleFreshness'
  'Sync-BeyondCompare'
  'Sync-VSCode'
  'Test-YamlTOC'
)

Export-ModuleMember -Alias * -Function $ExportableFunctions