Functions/GenXdev.Coding.PowerShell.Modules/New-ModuleXmlHelp.ps1

<##############################################################################
Part of PowerShell module : GenXdev.Coding.PowerShell.Modules
Original cmdlet filename : New-ModuleXmlHelp.ps1
Original author : René Vaessen / GenXdev
Version : 3.28.2026
################################################################################
Copyright (c) 2026 René Vaessen / GenXdev

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
################################################################################>


###############################################################################
<#
.SYNOPSIS
Generates MAML XML help files for any PowerShell module.

.DESCRIPTION
Generates MAML XML help files for any PowerShell module by extracting
metadata from all cmdlets in a module using Get-CmdletMetaData and producing
both ModuleName.dll-Help.xml (for C# cmdlets) and ModuleName-help.xml (for
.ps1 script cmdlets) in the appropriate language folder under en-US\.

Each help file includes all 9 MAML sections: command details, description,
syntax, parameters, input types, return values, alerts, examples, and
related links. Works with any module that has discoverable cmdlets —
GenXdev modules, community modules, or your own custom modules.

.PARAMETER ModuleName
The name of the PowerShell module to generate help for (e.g., 'Pester',
'PSReadLine', 'GenXdev.AI').

.PARAMETER Language
The language code for the generated help files. Defaults to 'en' for English.
Uses the language dictionary from Get-WebLanguageDictionary for validation.
Supports 140+ languages.

.PARAMETER Force
Overwrite existing help XML files without prompting.

.PARAMETER SkipTranslation
Skip LLM-based translation even when Language is not 'en'. Help content
remains in the source language (English).

.PARAMETER Model
The model identifier or pattern to use for AI translations.

.PARAMETER ApiEndpoint
The API endpoint URL for AI translations.

.PARAMETER ApiKey
The API key for authenticated AI translations.

.PARAMETER LinkPrefix
URL prefix for the \"Online Version:\" link in each cmdlet's related links.
When specified, each cmdlet's -Online URL will be
${LinkPrefix}<CmdletName>.md (e.g.,
`https://github.com/genXdev/genXdev/blob/main/Docs/en-US/` becomes
`https://.../Docs/en-US/Find-Item.md`). When omitted, a placeholder URL
is used for the Online Version link.

.PARAMETER WhatIf
Shows what would happen if the cmdlet runs without actually making changes.

.PARAMETER Confirm
Prompts for confirmation before making changes.

.EXAMPLE
New-ModuleXmlHelp -ModuleName 'Pester'

Generates English help XML for the Pester module in en-US\.

.EXAMPLE
New-ModuleXmlHelp -ModuleName 'GenXdev' -Language 'nl-NL' -Force -Model 'deepseek-v4-pro' -ApiKey 'your-api-key' -ApiEndpoint 'https://api.deepseek.com/chat/completions'

Generates Dutch help XML for GenXdev, overwriting existing files.

.EXAMPLE
New-ModuleXmlHelp -ModuleName 'PSReadLine' -WhatIf

Shows what files would be created without actually writing them.

.EXAMPLE
New-ModuleXmlHelp -ModuleName 'GenXdev' -LinkPrefix `
    'https://github.com/genXdev/genXdev/blob/main/Docs/en-US/' -Force

Generates English help XML with per-cmdlet Online Version URLs pointing
to GitHub Markdown docs. Get-Help <cmdlet> -Online opens the browser.
#>

function New-ModuleXmlHelp {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([System.IO.FileInfo[]])]
    param (
        #######################################################################
        [Parameter(
            Position = 0,
            Mandatory = $true,
            HelpMessage = 'The name of the PowerShell module to generate help for'
        )]
        [ValidateScript({
                $m = Microsoft.PowerShell.Core\Get-Module -Name $_ `
                    -ErrorAction SilentlyContinue
                if (-not $m) {
                    $m = Microsoft.PowerShell.Core\Get-Module -Name $_ `
                        -ListAvailable -ErrorAction SilentlyContinue
                }
                if (-not $m) {
                    throw "Module '$_' not found. " +
                    'Provide a valid module name.'
                }
                $true
            })]
        [string] $ModuleName,
        #######################################################################
        [ValidateSet(
            'ar-SA',
            'bg-BG',
            'bn-IN',
            'ca-ES',
            'cs-CZ',
            'da-DK',
            'de-DE',
            'el-GR',
            'en-US',
            'es-ES',
            'et-EE',
            'fi-FI',
            'fr-FR',
            'gu-IN',
            'he-IL',
            'hi-IN',
            'hr-HR',
            'hu-HU',
            'id-ID',
            'it-IT',
            'ja-JP',
            'kn-IN',
            'ko-KR',
            'lt-LT',
            'lv-LV',
            'ml-IN',
            'mr-IN',
            'ms-MY',
            'nb-NO',
            'nl-NL',
            'pa-IN',
            'pl-PL',
            'pt-BR',
            'ro-RO',
            'ru-RU',
            'sk-SK',
            'sl-SI',
            'sr-RS',
            'sv-SE',
            'ta-IN',
            'te-IN',
            'th-TH',
            'tr-TR',
            'uk-UA',
            'vi-VN',
            'zh-Hans-CN'
        )]
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'BCP 47 language tag for the generated help ' +
                '(e.g., en-US, nl-NL, de-DE)'
        )]
        [string] $Language = 'en-US',
        #######################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Overwrite existing help XML files without prompting'
        )]
        [switch] $Force,
        #######################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Skip LLM translation; keep help in source language'
        )]
        [switch] $SkipTranslation,
        #######################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = ('The model identifier or pattern to use for AI translations')
        )]
        [string] $Model,
        ###########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The API endpoint URL for AI translations'
        )]
        [string] $ApiEndpoint,
        ###########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'The API key for authenticated AI translations'
        )]
        [string] $ApiKey,
        ###################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Indicates that LLM has no support for JSON schemas'
        )]
        [string] $NoSupportForJsonSchema,
        ###########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = ('URL prefix for the Online Version link in each ' +
                'cmdlet related links (e.g., ' +
                'https://github.com/genXdev/genXdev/blob/main/Docs/en-US/)')
        )]
        [string] $LinkPrefix,
        ###########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Custom instructions for the LLM translation ' +
                '(passed to Get-TextTranslation)'
        )]
        [string] $TranslationInstructions

    )

    begin {
        ###############################################################################
        #region MAML XML Generation
        function GenerateMamlXml {
            param(
                [array]$CmdletMetaList,
                [string]$LinkPrefix = '',
                [hashtable]$SubModuleLinkMap = $null
            )

            $ns = @{
                maml    = 'http://schemas.microsoft.com/maml/2004/10'
                command = 'http://schemas.microsoft.com/maml/dev/command/2004/10'
                dev     = 'http://schemas.microsoft.com/maml/dev/2004/10'
                MSHelp  = 'http://msdn.microsoft.com/mshelp'
            }

            # Build a lookup: cmdlet name -> sub-module name
            $cmdletToSubModule = @{}
            if ($SubModuleLinkMap) {
                foreach ($subModule in $SubModuleLinkMap.Keys) {
                    foreach ($cmdletName in $SubModuleLinkMap[$subModule]) {
                        $cmdletToSubModule[$cmdletName] = $subModule
                    }
                }
            }

            # Use XmlWriter for precise formatting
            $settings = [System.Xml.XmlWriterSettings]::new()
            $settings.Indent = $true
            $settings.IndentChars = ' '
            $settings.Encoding = [System.Text.Encoding]::UTF8
            $settings.OmitXmlDeclaration = $false
            $settings.ConformanceLevel = [System.Xml.ConformanceLevel]::Document

            $stream = [System.IO.MemoryStream]::new()
            $writer = [System.Xml.XmlWriter]::Create($stream, $settings)

            $writer.WriteStartDocument()
            $writer.WriteStartElement('helpItems', 'http://msh')
            $writer.WriteAttributeString('schema', 'maml')
            $writer.WriteAttributeString('xmlns', 'http://msh')

            foreach ($meta in $CmdletMetaList) {
                $subModule = $cmdletToSubModule[$meta.CmdletName]
                $siblingCmdlets = if ($subModule -and $SubModuleLinkMap.ContainsKey($subModule)) {
                    @($SubModuleLinkMap[$subModule] |
                        Microsoft.PowerShell.Core\Where-Object { $_ -ne $meta.CmdletName } |
                        Microsoft.PowerShell.Utility\Sort-Object)
                } else { @() }

                WriteCmdletCommandElement -Writer $writer -Meta $meta -Ns $ns `
                    -SiblingCmdlets $siblingCmdlets -LinkPrefix $LinkPrefix
            }

            $writer.WriteEndElement() # helpItems
            $writer.WriteEndDocument()
            $writer.Flush()

            # Reset stream and load as XDocument
            $stream.Position = 0
            $reader = [System.IO.StreamReader]::new($stream)
            $xml = $reader.ReadToEnd()
            $reader.Dispose()
            $writer.Dispose()
            $stream.Dispose()

            return [xml]$xml
        }

        function WriteCmdletCommandElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                $Meta,
                [hashtable]$Ns,
                [string[]]$SiblingCmdlets = @(),
                [string]$LinkPrefix = ''
            )

            # Derive verb and noun from cmdlet name
            $dashPos = $Meta.CmdletName.IndexOf('-')
            $verb = if ($dashPos -gt 0) { $Meta.CmdletName.Substring(0, $dashPos) } else { $Meta.CmdletName }
            $noun = if ($dashPos -gt 0) { $Meta.CmdletName.Substring($dashPos + 1) } else { $Meta.CmdletName }

            $Writer.WriteStartElement('command', 'command', $Ns['command'])
            $Writer.WriteAttributeString('xmlns', 'maml', $null, $Ns['maml'])
            $Writer.WriteAttributeString('xmlns', 'command', $null, $Ns['command'])
            $Writer.WriteAttributeString('xmlns', 'dev', $null, $Ns['dev'])
            $Writer.WriteAttributeString('xmlns', 'MSHelp', $null, $Ns['MSHelp'])

            # 1. command:details
            WriteDetailsElement -Writer $Writer -CmdletName $Meta.CmdletName -Verb $verb -Noun $noun `
                -Synopsis $Meta.Synopsis -Ns $Ns

            # 2. maml:description
            WriteFullDescriptionElement -Writer $Writer -Description $Meta.Description -Ns $Ns

            # 3. command:syntax
            WriteSyntaxElement -Writer $Writer -CmdletName $Meta.CmdletName -Parameters $Meta.Parameters -Ns $Ns

            # 4. command:parameters
            WriteParametersElement -Writer $Writer -Parameters $Meta.Parameters -Ns $Ns

            # 5. command:inputTypes
            WriteInputTypesElement -Writer $Writer -Parameters $Meta.Parameters -Ns $Ns

            # 6. command:returnValues
            WriteReturnValuesElement -Writer $Writer -OutputType $Meta.OutputType -Ns $Ns

            # 7. maml:alertSet
            WriteAlertSetElement -Writer $Writer -Ns $Ns

            # 8. command:examples
            WriteExamplesElement -Writer $Writer -Examples $Meta.Examples -Ns $Ns

            # 9. command:relatedLinks
            WriteRelatedLinksElement -Writer $writer -CmdletName $Meta.CmdletName `
                -Ns $ns -SiblingCmdlets $SiblingCmdlets -LinkPrefix $LinkPrefix

            $Writer.WriteEndElement() # command:command
        }
        ###############################################################################
        #region Section Writers

        function WriteDetailsElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [string]$CmdletName, [string]$Verb, [string]$Noun,
                [string]$Synopsis, [hashtable]$Ns
            )
            $Writer.WriteStartElement('command', 'details', $Ns['command'])
            $Writer.WriteElementString('command', 'name', $Ns['command'], $CmdletName)
            $Writer.WriteElementString('command', 'verb', $Ns['command'], $Verb)
            $Writer.WriteElementString('command', 'noun', $Ns['command'], $Noun)
            $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
            $Writer.WriteElementString('maml', 'para', $Ns['maml'], (Get-SynopsisText $Synopsis))
            $Writer.WriteEndElement() # maml:description
            $Writer.WriteEndElement() # command:details
        }

        function WriteFullDescriptionElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [string]$Description, [hashtable]$Ns
            )

            $Writer.WriteStartElement('maml', 'description', $Ns['maml'])

            $text = if ([string]::IsNullOrWhiteSpace($Description)) {
                '{{ Fill in the Description }}'
            }
            else { $Description }

            # Split description into paragraphs on double-newlines or sentences
            $paras = $text -split '\r?\n\s*\r?\n' | Microsoft.PowerShell.Core\Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
            if ($paras.Count -eq 0) {
                $Writer.WriteElementString('maml', 'para', $Ns['maml'], $text)
            }
            else {
                foreach ($p in $paras) {
                    $Writer.WriteElementString('maml', 'para', $Ns['maml'], $p.Trim())
                }
            }

            $Writer.WriteEndElement() # maml:description
        }

        function WriteSyntaxElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [string]$CmdletName,
                [array]$Parameters,
                [hashtable]$Ns
            )

            $Writer.WriteStartElement('command', 'syntax', $Ns['command'])
            $Writer.WriteStartElement('command', 'syntaxItem', $Ns['command'])
            $Writer.WriteElementString('maml', 'name', $Ns['maml'], $CmdletName)

            if ($Parameters -and $Parameters.Count -gt 0) {
                # Group by ParameterSetName; if all are same set, just list all
                $sortedParams = $Parameters |
                    Microsoft.PowerShell.Utility\Sort-Object { if ($_.Position -match '^\d+$') { [int]$_.Position } else { 2147483647 } },
                @{ Expression = 'Name'; Descending = $false }

                foreach ($p in $sortedParams) {
                    WriteSyntaxParameterElement -Writer $Writer -Param $p -Ns $Ns
                }
            }

            $Writer.WriteEndElement() # command:syntaxItem
            $Writer.WriteEndElement() # command:syntax
        }

        function WriteSyntaxParameterElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                $Param, [hashtable]$Ns
            )

            $aliasesText = if ($Param.Aliases -and $Param.Aliases.Count -gt 0) {
                ($Param.Aliases -join ', ')
            }
            else { 'none' }

            $pipelineText = if ($Param.ValueFromPipeline) {
                if ($Param.ValueFromPipelineByPropertyName) { 'True (ByValue, ByPropertyName)' }
                else { 'True (ByValue)' }
            }
            elseif ($Param.ValueFromPipelineByPropertyName) {
                'True (ByPropertyName)'
            }
            else { 'False' }

            $pos = if ($Param.Position -match '^\d+$') { $Param.Position } else { 'named' }
            $required = if ($Param.Mandatory) { 'true' } else { 'false' }
            $varLen = if ($Param.ValueFromRemainingArguments) { 'true' } else { 'true' }
            $globbing = if ($Param.SupportsWildcards) { 'true' } else { 'false' }

            $Writer.WriteStartElement('command', 'parameter', $Ns['command'])
            $Writer.WriteAttributeString('required', $required)
            $Writer.WriteAttributeString('variableLength', $varLen)
            $Writer.WriteAttributeString('globbing', $globbing)
            $Writer.WriteAttributeString('pipelineInput', $pipelineText)
            $Writer.WriteAttributeString('position', $pos)
            $Writer.WriteAttributeString('aliases', $aliasesText)

            $Writer.WriteElementString('maml', 'name', $Ns['maml'], $Param.Name)
            $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
            $Writer.WriteElementString('maml', 'para', $Ns['maml'],
                (Get-ParamDescription $Param))
            $Writer.WriteEndElement() # maml:description

            # parameterValue (only for non-switch with ValueFromRemainingArguments or
            # for mandatory non-switch params)
            if ($Param.ParameterType -ne 'System.Management.Automation.SwitchParameter') {
                $paramValueType = Get-ParameterValueTypeName $Param.ParameterType
                $Writer.WriteStartElement('command', 'parameterValue', $Ns['command'])
                $Writer.WriteAttributeString('required', 'true')
                $Writer.WriteAttributeString('variableLength', 'false')
                $Writer.WriteString($paramValueType)
                $Writer.WriteEndElement() # command:parameterValue
            }

            # dev:type
            $Writer.WriteStartElement('dev', 'type', $Ns['dev'])
            $Writer.WriteElementString('maml', 'name', $Ns['maml'],
                (Get-MamlTypeName $Param.ParameterType))
            $Writer.WriteElementString('maml', 'uri', $Ns['maml'], '')
            $Writer.WriteEndElement() # dev:type

            # dev:defaultValue
            $defVal = if ($null -ne $Param.DefaultValue -and $Param.DefaultValue -ne '') {
                $Param.DefaultValue
            }
            elseif ($Param.ParameterType -eq 'System.Management.Automation.SwitchParameter') {
                'False'
            }
            else { 'None' }
            $Writer.WriteElementString('dev', 'defaultValue', $Ns['dev'], $defVal)

            $Writer.WriteEndElement() # command:parameter
        }

        function WriteParametersElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [array]$Parameters,
                [hashtable]$Ns
            )

            $Writer.WriteStartElement('command', 'parameters', $Ns['command'])

            if ($Parameters -and $Parameters.Count -gt 0) {
                $sortedParams = $Parameters |
                    Microsoft.PowerShell.Utility\Sort-Object { if ($_.Position -match '^\d+$') { [int]$_.Position } else { 2147483647 } },
                @{ Expression = 'Name'; Descending = $false }

                foreach ($p in $sortedParams) {
                    WriteParameterDetailElement -Writer $Writer -Param $p -Ns $Ns
                }
            }

            $Writer.WriteEndElement() # command:parameters
        }

        function WriteParameterDetailElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                $Param, [hashtable]$Ns
            )

            $aliasesText = if ($Param.Aliases -and $Param.Aliases.Count -gt 0) {
                ($Param.Aliases -join ', ')
            }
            else { 'none' }

            $pipelineText = if ($Param.ValueFromPipeline) {
                if ($Param.ValueFromPipelineByPropertyName) { 'True (ByValue, ByPropertyName)' }
                else { 'True (ByValue)' }
            }
            elseif ($Param.ValueFromPipelineByPropertyName) {
                'True (ByPropertyName)'
            }
            else { 'False' }

            $pos = if ($Param.Position -match '^\d+$') { $Param.Position } else { 'named' }
            $required = if ($Param.Mandatory) { 'true' } else { 'false' }
            $varLen = if ($Param.ValueFromRemainingArguments) { 'true' } else { 'true' }
            $globbing = if ($Param.SupportsWildcards) { 'true' } else { 'false' }

            $Writer.WriteStartElement('command', 'parameter', $Ns['command'])
            $Writer.WriteAttributeString('required', $required)
            $Writer.WriteAttributeString('variableLength', $varLen)
            $Writer.WriteAttributeString('globbing', $globbing)
            $Writer.WriteAttributeString('pipelineInput', $pipelineText)
            $Writer.WriteAttributeString('position', $pos)
            $Writer.WriteAttributeString('aliases', $aliasesText)

            $Writer.WriteElementString('maml', 'name', $Ns['maml'], $Param.Name)
            $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
            $Writer.WriteElementString('maml', 'para', $Ns['maml'],
                (Get-ParamDescription $Param))
            $Writer.WriteEndElement() # maml:description

            # parameterValue
            if ($Param.ParameterType -ne 'System.Management.Automation.SwitchParameter') {
                $paramValueType = Get-ParameterValueTypeName $Param.ParameterType
                $Writer.WriteStartElement('command', 'parameterValue', $Ns['command'])
                $Writer.WriteAttributeString('required', 'true')
                $Writer.WriteAttributeString('variableLength', 'false')
                $Writer.WriteString($paramValueType)
                $Writer.WriteEndElement() # command:parameterValue
            }
            else {
                $Writer.WriteStartElement('command', 'parameterValue', $Ns['command'])
                $Writer.WriteAttributeString('required', 'false')
                $Writer.WriteAttributeString('variableLength', 'false')
                $Writer.WriteString('SwitchParameter')
                $Writer.WriteEndElement() # command:parameterValue
            }

            # dev:type
            $Writer.WriteStartElement('dev', 'type', $Ns['dev'])
            $Writer.WriteElementString('maml', 'name', $Ns['maml'],
                (Get-MamlTypeName $Param.ParameterType))
            $Writer.WriteElementString('maml', 'uri', $Ns['maml'], '')
            $Writer.WriteEndElement() # dev:type

            # dev:defaultValue
            $defVal = if ($null -ne $Param.DefaultValue -and $Param.DefaultValue -ne '') {
                $Param.DefaultValue
            }
            elseif ($Param.ParameterType -eq 'System.Management.Automation.SwitchParameter') {
                'False'
            }
            else { 'None' }
            $Writer.WriteElementString('dev', 'defaultValue', $Ns['dev'], $defVal)

            $Writer.WriteEndElement() # command:parameter
        }

        function WriteInputTypesElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [array]$Parameters,
                [hashtable]$Ns
            )

            $Writer.WriteStartElement('command', 'inputTypes', $Ns['command'])

            # Collect types that accept pipeline input
            $pipelineParams = @($Parameters | Microsoft.PowerShell.Core\Where-Object {
                    $_.ValueFromPipeline -or $_.ValueFromPipelineByPropertyName
                })

            if ($pipelineParams.Count -eq 0) {
                # No pipeline input
                $Writer.WriteStartElement('command', 'inputType', $Ns['command'])
                $Writer.WriteStartElement('dev', 'type', $Ns['dev'])
                $Writer.WriteElementString('maml', 'name', $Ns['maml'], 'None')
                $Writer.WriteEndElement() # dev:type
                $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
                $Writer.WriteElementString('maml', 'para', $Ns['maml'],
                    "You can't pipe input to this cmdlet.")
                $Writer.WriteEndElement() # maml:description
                $Writer.WriteEndElement() # command:inputType
            }
            else {
                foreach ($pp in ($pipelineParams | Microsoft.PowerShell.Utility\Sort-Object ParameterType -Unique)) {
                    $Writer.WriteStartElement('command', 'inputType', $Ns['command'])
                    $Writer.WriteStartElement('dev', 'type', $Ns['dev'])
                    $Writer.WriteElementString('maml', 'name', $Ns['maml'], $pp.ParameterType)
                    $Writer.WriteEndElement() # dev:type
                    $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
                    $Writer.WriteElementString('maml', 'para', $Ns['maml'],
                        "Accepts $($pp.ParameterType) objects via pipeline.")
                    $Writer.WriteEndElement() # maml:description
                    $Writer.WriteEndElement() # command:inputType
                }
            }

            $Writer.WriteEndElement() # command:inputTypes
        }

        function WriteReturnValuesElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [array]$OutputType,
                [hashtable]$Ns
            )

            $Writer.WriteStartElement('command', 'returnValues', $Ns['command'])

            if ($OutputType -and $OutputType.Count -gt 0) {
                foreach ($ot in $OutputType) {
                    $Writer.WriteStartElement('command', 'returnValue', $Ns['command'])
                    $Writer.WriteStartElement('dev', 'type', $Ns['dev'])
                    $Writer.WriteElementString('maml', 'name', $Ns['maml'], $ot)
                    $Writer.WriteEndElement() # dev:type
                    $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
                    $Writer.WriteElementString('maml', 'para', $Ns['maml'],
                        "Returns $ot objects.")
                    $Writer.WriteEndElement() # maml:description
                    $Writer.WriteEndElement() # command:returnValue
                }
            }
            else {
                $Writer.WriteStartElement('command', 'returnValue', $Ns['command'])
                $Writer.WriteStartElement('dev', 'type', $Ns['dev'])
                $Writer.WriteElementString('maml', 'name', $Ns['maml'], 'None')
                $Writer.WriteEndElement() # dev:type
                $Writer.WriteStartElement('maml', 'description', $Ns['maml'])
                $Writer.WriteElementString('maml', 'para', $Ns['maml'], '')
                $Writer.WriteEndElement() # maml:description
                $Writer.WriteEndElement() # command:returnValue
            }

            $Writer.WriteEndElement() # command:returnValues
        }

        function WriteAlertSetElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [hashtable]$Ns
            )
            $Writer.WriteStartElement('maml', 'alertSet', $Ns['maml'])
            $Writer.WriteStartElement('maml', 'alert', $Ns['maml'])
            $Writer.WriteElementString('maml', 'para', $Ns['maml'], '')
            $Writer.WriteEndElement() # maml:alert
            $Writer.WriteEndElement() # maml:alertSet
        }

        function WriteExamplesElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [array]$Examples,
                [hashtable]$Ns
            )

            $Writer.WriteStartElement('command', 'examples', $Ns['command'])

            if ($Examples -and $Examples.Count -gt 0) {
                $exNum = 1
                foreach ($ex in $Examples) {
                    $exampleText = "$ex"

                    # Parse fenced code block: ```powershell...``` is code,
                    # first non-blank line after ``` is title, rest is remarks.
                    $code = ''
                    $title = "---------- Example ${exNum} ----------"
                    $remarks = ''

                    if ($exampleText -match '(?s)```powershell\s*\r?\n(.*?)```') {
                        $code = $matches[1].TrimEnd()
                        $afterFence = $exampleText.Substring(
                            $exampleText.IndexOf($matches[0]) + $matches[0].Length).Trim()

                        if ($afterFence) {
                            $afterLines = $afterFence -split '\r?\n' |
                                Microsoft.PowerShell.Core\ForEach-Object { $_.TrimEnd() }
                            # First non-blank line is title
                            $titleIndex = -1
                            for ($i = 0; $i -lt $afterLines.Count; $i++) {
                                if ($afterLines[$i] -ne '') {
                                    $titleIndex = $i
                                    $title = $afterLines[$i]
                                    break
                                }
                            }
                            # Remaining lines are remarks
                            if ($titleIndex -ge 0 -and $titleIndex -lt $afterLines.Count - 1) {
                                $remarks = ($afterLines[($titleIndex + 1)..($afterLines.Count - 1)] |
                                    Microsoft.PowerShell.Core\Where-Object { $_ -ne '' }) -join ' '
                            }
                        }
                    }
                    else {
                        # No fenced code block — use the entire text as code
                        $code = $exampleText
                    }

                    $Writer.WriteStartElement('command', 'example', $Ns['command'])
                    $Writer.WriteElementString('maml', 'title', $Ns['maml'], $title)
                    $Writer.WriteElementString('dev', 'code', $Ns['dev'], $code)
                    $Writer.WriteStartElement('dev', 'remarks', $Ns['dev'])
                    $Writer.WriteElementString('maml', 'para', $Ns['maml'], $remarks)
                    $Writer.WriteEndElement() # dev:remarks
                    $Writer.WriteEndElement() # command:example

                    $exNum++
                }
            }
            else {
                # Placeholder example
                $Writer.WriteStartElement('command', 'example', $Ns['command'])
                $Writer.WriteElementString('maml', 'title', $Ns['maml'],
                    '-------------------------- Example 1 --------------------------')
                $Writer.WriteElementString('dev', 'code', $Ns['dev'],
                    'PS C:\> {{ Add example code here }}')
                $Writer.WriteStartElement('dev', 'remarks', $Ns['dev'])
                $Writer.WriteElementString('maml', 'para', $Ns['maml'],
                    '{{ Add example description here }}')
                $Writer.WriteEndElement() # dev:remarks
                $Writer.WriteEndElement() # command:example
            }

            $Writer.WriteEndElement() # command:examples
        }

        function WriteRelatedLinksElement {
            param(
                [System.Xml.XmlWriter]$Writer,
                [string]$CmdletName,
                [hashtable]$Ns,
                [string[]]$SiblingCmdlets = @(),
                [string]$LinkPrefix = ''
            )

            $Writer.WriteStartElement('command', 'relatedLinks', $Ns['command'])

            # 1. "Online Version:" link for Get-Help -Online
            $Writer.WriteStartElement('maml', 'navigationLink', $Ns['maml'])
            $Writer.WriteElementString('maml', 'linkText', $Ns['maml'], 'Online Version:')
            $onlineUri = if ($LinkPrefix) {
                "${LinkPrefix}${CmdletName}.md"
            } else {
                "https://docs.github.com/en/rest/search"
            }
            $Writer.WriteElementString('maml', 'uri', $Ns['maml'], $onlineUri)
            $Writer.WriteEndElement() # maml:navigationLink

            # 2. Sibling cmdlet cross-references (empty URIs, PowerShell convention)
            foreach ($sibling in $SiblingCmdlets) {
                $Writer.WriteStartElement('maml', 'navigationLink', $Ns['maml'])
                $Writer.WriteElementString('maml', 'linkText', $Ns['maml'], $sibling)
                $Writer.WriteElementString('maml', 'uri', $Ns['maml'], '')
                $Writer.WriteEndElement() # maml:navigationLink
            }

            $Writer.WriteEndElement() # command:relatedLinks
        }
        #endregion Section Writers
        ###############################################################################
        #region Helper Functions

        function Get-SynopsisText {
            param([string]$Synopsis)

            if ([string]::IsNullOrWhiteSpace($Synopsis)) {
                return ''
            }
            return $Synopsis
        }

        function Get-ParamDescription {
            param($Param)

            if (-not [string]::IsNullOrWhiteSpace($Param.HelpMessage)) {
                return $Param.HelpMessage
            }
            return "The $($Param.Name) parameter."
        }

        function Get-MamlTypeName {
            param([string]$ParameterType)

            switch -Regex ($ParameterType) {
                'System\.String\[\]' { return 'String[]' }
                'System\.String' { return 'String' }
                'System\.Int32' { return 'Int32' }
                'System\.Int64' { return 'Int64' }
                'System\.Boolean' { return 'Boolean' }
                'System\.Double' { return 'Double' }
                'System\.DateTime' { return 'DateTime' }
                'System\.Object' { return 'Object' }
                'System\.Object\[\]' { return 'Object[]' }
                'Hashtable' { return 'Hashtable' }
                'Hashtable\[\]' { return 'Hashtable[]' }
                'SwitchParameter|System\.Management\.Automation\.SwitchParameter' { return 'SwitchParameter' }
                'System\.Collections\.IEnumerable' { return 'IEnumerable' }
                'System\.Char\[\]' { return 'Char[]' }
                'GenXdev\.Helpers\.' { return ($ParameterType -replace 'GenXdev\.Helpers\.', '') }
                default { return ($ParameterType -replace 'System\.', '') }
            }
        }

        function Get-ParameterValueTypeName {
            param([string]$ParameterType)

            switch -Regex ($ParameterType) {
                'System\.String\[\]' { return 'String[]' }
                'System\.String' { return 'String' }
                'System\.Int32' { return 'Int32' }
                'System\.Int64' { return 'Int64' }
                'System\.Boolean' { return 'Boolean' }
                'System\.Double' { return 'Double' }
                'System\.DateTime' { return 'DateTime' }
                'System\.Object' { return 'Object' }
                'System\.Object\[\]' { return 'Object[]' }
                'Hashtable' { return 'Hashtable' }
                'Hashtable\[\]' { return 'Hashtable[]' }
                'SwitchParameter|System\.Management\.Automation\.SwitchParameter' { return 'SwitchParameter' }
                'System\.Collections\.IEnumerable' { return 'IEnumerable' }
                'System\.Char\[\]' { return 'Char[]' }
                'GenXdev\.Helpers\.' { return ($ParameterType -replace 'GenXdev\.Helpers\.', '') }
                default { return ($ParameterType -replace 'System\.', '') }
            }
        }
        #endregion Helper Functions

# -- Resolve module --------------------------------------------------
        # Prefer the actually-loaded module (has ExportedCommands).
        # Otherwise fall back to the available module with the highest
        # version number.
        $module = @(Microsoft.PowerShell.Core\Get-Module -Name $ModuleName `
                -ErrorAction SilentlyContinue |
            Microsoft.PowerShell.Core\Where-Object {
                $_.ExportedCommands.Count -gt 0 })[0]

        if (-not $module) {
            $module = @(Microsoft.PowerShell.Core\Get-Module `
                    -Name $ModuleName -ListAvailable `
                    -ErrorAction SilentlyContinue |
                Microsoft.PowerShell.Utility\Sort-Object Version -Descending)[0]
        }

        if (-not $module) {
            Microsoft.PowerShell.Utility\Write-Error `
                "Module '${ModuleName}' not found."
            return
        }

        $moduleRoot = $module.ModuleBase
        $helpDir = Microsoft.PowerShell.Management\Join-Path $moduleRoot $Language

        Microsoft.PowerShell.Utility\Write-Verbose "Module root: ${moduleRoot}"

        # -- Track generated files for pipeline output -----------------------
        $generatedFiles = [System.Collections.Generic.List[string]]::new()

        # -- Cache module-level metadata params ------------------------------
        $metaModuleParams = GenXdev\Copy-IdenticalParamValues `
            -BoundParameters $PSBoundParameters `
            -FunctionName "GenXdev\Get-ModuleCmdletMetaData" ;
    }

    process {
        # -- Collect metadata for all cmdlets --------------------------------
        if (-not $PSCmdlet.ShouldProcess(
                $ModuleName,
                'Retrieve cmdlet metadata for all cmdlets')) {
            return
        }

        try {
            $cmdletMeta = @(GenXdev\Get-ModuleCmdletMetaData `
                    @metaModuleParams -ErrorAction Stop)
        }
        catch {
            Microsoft.PowerShell.Utility\Write-Error (
                "Failed to get metadata for module '${ModuleName}': $_")
            return
        }

        if ($cmdletMeta.Count -eq 0) {
            Microsoft.PowerShell.Utility\Write-Warning "No cmdlet metadata retrieved for '${ModuleName}'."
            return
        }

        Microsoft.PowerShell.Utility\Write-Verbose "Retrieved metadata for $($cmdletMeta.Count) cmdlet(s)."

        # -- Build sub-module link map for related links --------------------
        $subModuleLinkMap = @{}
        foreach ($meta in $cmdletMeta) {
            $subModule = if ($meta.SubModuleName) { $meta.SubModuleName } else { 'General' }
            if (-not $subModuleLinkMap.ContainsKey($subModule)) {
                $subModuleLinkMap[$subModule] = @()
            }
            $subModuleLinkMap[$subModule] += $meta.CmdletName
        }
        Microsoft.PowerShell.Utility\Write-Verbose (
            "Built link map with $($subModuleLinkMap.Count) sub-module(s).")

        # Separate into C# and script cmdlets based on CmdletType
        $csCmdlets = @($cmdletMeta | Microsoft.PowerShell.Core\Where-Object { $_.CmdletType -eq 'Cmdlet' })
        $scriptCmdlets = @($cmdletMeta | Microsoft.PowerShell.Core\Where-Object { $_.CmdletType -ne 'Cmdlet' })

        Microsoft.PowerShell.Utility\Write-Verbose "C# cmdlets: $($csCmdlets.Count), Script cmdlets: $($scriptCmdlets.Count)"

        # Generate dll-Help.xml for C# cmdlets
        if ($csCmdlets.Count -gt 0) {
            $dllHelpPath = Microsoft.PowerShell.Management\Join-Path $helpDir "${ModuleName}.dll-Help.xml"
            if ($PSCmdlet.ShouldProcess($dllHelpPath, 'Generate C# help XML')) {
                if ((Microsoft.PowerShell.Management\Test-Path $dllHelpPath -PathType Leaf) -and -not $Force) {
                    Microsoft.PowerShell.Utility\Write-Warning "File '${dllHelpPath}' exists. Use -Force to overwrite."
                }
                else {
                    $xml = GenerateMamlXml -CmdletMetaList $csCmdlets `
                        -LinkPrefix $LinkPrefix -SubModuleLinkMap $subModuleLinkMap
                    $null = Microsoft.PowerShell.Management\New-Item -ItemType Directory -Path $helpDir -Force -ErrorAction SilentlyContinue
                    $xml.Save($dllHelpPath)
                    $null = $generatedFiles.Add($dllHelpPath)
                    Microsoft.PowerShell.Utility\Write-Verbose "Generated: ${dllHelpPath}"
                }
            }
        }

        # Generate help.xml for .ps1 script cmdlets
        if ($scriptCmdlets.Count -gt 0) {
            $scriptHelpPath = Microsoft.PowerShell.Management\Join-Path $helpDir "${ModuleName}-help.xml"
            if ($PSCmdlet.ShouldProcess($scriptHelpPath, 'Generate script help XML')) {
                if ((Microsoft.PowerShell.Management\Test-Path $scriptHelpPath -PathType Leaf) -and -not $Force) {
                    Microsoft.PowerShell.Utility\Write-Warning "File '${scriptHelpPath}' exists. Use -Force to overwrite."
                }
                else {
                    $xml = GenerateMamlXml -CmdletMetaList $scriptCmdlets `
                        -LinkPrefix $LinkPrefix -SubModuleLinkMap $subModuleLinkMap
                    $null = Microsoft.PowerShell.Management\New-Item -ItemType Directory -Path $helpDir -Force -ErrorAction SilentlyContinue
                    $xml.Save($scriptHelpPath)
                    $null = $generatedFiles.Add($scriptHelpPath)
                    Microsoft.PowerShell.Utility\Write-Verbose "Generated: ${scriptHelpPath}"
                }
            }
        }

        if ($generatedFiles.Count -gt 0) { Microsoft.PowerShell.Management\Get-ChildItem $generatedFiles }
    }
}