utilities/pipelines/sharedScripts/Set-ModuleReadMe.ps1 (1,849 lines of code) (raw):

#requires -version 7.3 #region helper functions <# .SYNOPSIS Test if an URL points to a valid online endpoint .DESCRIPTION Test if an URL points to a valid online endpoint .PARAMETER URL Mandatory. The URL to check .PARAMETER Retries Optional. The amount of times to retry .EXAMPLE Test-URl -URL 'www.github.com' Returns $true if the 'www.github.com' is valid, $false otherwise #> function Test-Url { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $URL, [Parameter(Mandatory = $false)] [int] $Retries = 3 ) $currentAttempt = 1 while ($currentAttempt -le $Retries) { try { $null = Invoke-WebRequest -Uri $URL return $true } catch { $currentAttempt++ Start-Sleep -Seconds 1 } } return $false } <# .SYNOPSIS Update the 'Resource Types' section of the given readme file .DESCRIPTION Update the 'Resource Types' section of the given readme file .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Resource Types' .PARAMETER ResourceTypesToExclude Optional. The resource types to exclude from the list. By default excludes 'Microsoft.Resources/deployments' .EXAMPLE Set-ResourceTypesSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Resource Types' section based on the given template file content #> function Set-ResourceTypesSection { [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ResourceTypesToExclude', Justification = 'Variable used inside Where-Object block.')] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Resource Types', [Parameter(Mandatory = $false)] [string[]] $ResourceTypesToExclude = @('Microsoft.Resources/deployments') ) $RelevantResourceTypeObjects = Get-NestedResourceList $TemplateFileContent | Where-Object { $_.type -notin $ResourceTypesToExclude -and $_ } | Select-Object 'Type', 'ApiVersion' -Unique | Sort-Object -Culture 'en-US' -Property 'Type' if (-not $RelevantResourceTypeObjects) { # no resource types in the template $SectionContent = '_None_' } else { # Process content $SectionContent = [System.Collections.ArrayList]@( '| Resource Type | API Version |', '| :-- | :-- |' ) $ProgressPreference = 'SilentlyContinue' $VerbosePreference = 'SilentlyContinue' foreach ($resourceTypeObject in $RelevantResourceTypeObjects) { $ProviderNamespace, $ResourceType = $resourceTypeObject.Type -split '/', 2 # Validate if Reference URL is working $TemplatesBaseUrl = 'https://learn.microsoft.com/en-us/azure/templates' $ResourceReferenceUrl = '{0}/{1}/{2}/{3}' -f $TemplatesBaseUrl, $ProviderNamespace, $resourceTypeObject.ApiVersion, $ResourceType if (-not (Test-Url $ResourceReferenceUrl)) { # Validate if Reference URL is working using the latest documented API version (with no API version in the URL) $ResourceReferenceUrl = '{0}/{1}/{2}' -f $TemplatesBaseUrl, $ProviderNamespace, $ResourceType } if (-not (Test-Url $ResourceReferenceUrl)) { # Check if the resource is a child resource if ($ResourceType.Split('/').length -gt 1) { $ResourceReferenceUrl = '{0}/{1}/{2}' -f $TemplatesBaseUrl, $ProviderNamespace, $ResourceType.Split('/')[0] } else { # Use the default Templates URL (Last resort) $ResourceReferenceUrl = '{0}' -f $TemplatesBaseUrl } } $SectionContent += ('| `{0}` | [{1}]({2}) |' -f $resourceTypeObject.type, $resourceTypeObject.apiVersion, $ResourceReferenceUrl) } $ProgressPreference = 'Continue' $VerbosePreference = 'Continue' } # Build result if ($PSCmdlet.ShouldProcess('Original file with new resource type content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Update the 'parameters' section of the given readme file .DESCRIPTION Update the 'parameters' section of the given readme file .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER currentFolderPath Mandatory. The current folder path .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Parameters' .PARAMETER ColumnsInOrder Optional. The order of parameter categories to show in the readme parameters section. .EXAMPLE Set-ParametersSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Parameters' section based on the given template file content #> function Set-ParametersSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory)] [string] $currentFolderPath, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Parameters', [Parameter(Mandatory = $false)] [string[]] $ColumnsInOrder = @('Required', 'Conditional', 'Optional', 'Generated') ) # Invoking recursive function to resolve parameters $newSectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -ColumnsInOrder $ColumnsInOrder # Build result if ($PSCmdlet.ShouldProcess('Original file with new parameters content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $newSectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Reformat a given string to a markdown-compatible header reference .DESCRIPTION This function removes characters that are not part of a markdown header and adds a header reference to the front .PARAMETER StringToFormat Mandatory. The string to format .EXAMPLE Get-MarkdownHeaderReferenceFormattedString 'Parameter: dataCollectionRuleProperties.kind-AgentSettings.description' The given string is reformatted to: '#parameter-datacollectionrulepropertieskind-agentsettingsdescription'. #> function Get-MarkdownHeaderReferenceFormattedString { [CmdletBinding()] param ( [Parameter()] [string] $StringToFormat ) return ('#{0}' -f ($StringToFormat -replace '^#+ ', '' -replace '\s', '-' -replace '`|\:|\.', '').ToLower()) } <# .SYNOPSIS Update parts of the 'parameters' section of the given readme file, if user defined types are used .DESCRIPTION Adds user defined types to the 'parameters' section of the given readme file .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER Properties Optional. Hashtable of the user defined properties .PARAMETER ParentName Optional. Name of the parameter, that has the user defined types .PARAMETER ColumnsInOrder Optional. The order of parameter categories to show in the readme parameters section. .EXAMPLE Set-DefinitionSection -TemplateFileContent @{ resource = @{}; ... } -ColumnsInOrder @('Required', 'Optional') Top-level invocation. Will start from the TemplateFile's parameters object and recursively crawl through all children. Tables will be ordered by 'Required' first and 'Optional' after. .EXAMPLE Set-DefinitionSection -TemplateFileContent @{ resource = @{}; ... } -Properties @{ @{ name = @{ type = 'string'; 'allowedValues' = @('A1','A2','A3','A4','A5','A6'); 'nullable' = $true; (...) } -ParentName 'diagnosticSettings' Child-level invocation during recursion. .NOTES The function is recursive and will also output grand, great grand children, ... . #> function Set-DefinitionSection { param ( [Parameter(Mandatory = $true)] [hashtable] $TemplateFileContent, [Parameter(Mandatory = $false)] [hashtable] $Properties, [Parameter(Mandatory = $false)] [string] $ParentName, [Parameter(Mandatory = $false)] [string[]] $ColumnsInOrder = @('Required', 'Conditional', 'Optional', 'Generated') ) if (-not $Properties -and -not $TemplateFileContent.parameters) { # no Parameters / properties on this level or in the template return '_None_' } elseif (-not $Properties) { # Top-level invocation # Get all descriptions $descriptions = $TemplateFileContent.parameters.Values.metadata.description # Add name as property for later reference $TemplateFileContent.parameters.Keys | ForEach-Object { $TemplateFileContent.parameters[$_]['name'] = $_ } # Error handling: Throw error if any parameter is missing a category if ($paramsWithoutCategory = $TemplateFileContent.parameters.Values | Where-Object { $_.metadata.description -notmatch '^\w+?\.' }) { $formattedParam = $paramsWithoutCategory | ForEach-Object { [PSCustomObject]@{ name = $_.name; description = $_.metadata.description } } | ConvertTo-Json -Compress Write-Error ("Each parameter description should start with a category like [Required. / Optional. / Conditional. ]. The following parameters are missing such a category: `n$formattedParam`n") return } } else { $descriptions = $Properties.Values.metadata.description # Add name as property for later reference $Properties.Keys | ForEach-Object { $Properties[$_]['name'] = $_ } # Error handling: Throw error if any parameter is missing a category if ($paramsWithoutCategory = $Properties.Values | Where-Object { $_.metadata.description -notmatch '^\w+?\.' }) { $formattedParam = $paramsWithoutCategory | ForEach-Object { [PSCustomObject]@{ name = $_.name; description = $_.metadata.description } } | ConvertTo-Json -Compress Write-Error ("Each parameter description should start with a category like [Required. / Optional. / Conditional. ]. The following parameters are missing such a category: `n$formattedParam`n") return } } # Get the module parameter categories $paramCategories = $descriptions | ForEach-Object { $_.Split('.')[0] } | Select-Object -Unique # Sort categories $sortedParamCategories = $ColumnsInOrder | Where-Object { $paramCategories -contains $_ } # Add all others that exist but are not specified in the columnsInOrder parameter $sortedParamCategories += $paramCategories | Where-Object { $ColumnsInOrder -notcontains $_ } $newSectionContent = [System.Collections.ArrayList]@() $tableSectionContent = [System.Collections.ArrayList]@() $listSectionContent = [System.Collections.ArrayList]@() foreach ($category in $sortedParamCategories) { # 1. Prepare # Filter to relevant items if (-not $Properties) { # Top-level invocation [array] $categoryParameters = $TemplateFileContent.parameters.Values | Where-Object { $_.metadata.description -like "$category. *" } | Sort-Object -Culture 'en-US' -Property 'Name' } else { $categoryParameters = $Properties.Values | Where-Object { $_.metadata.description -like "$category. *" } | Sort-Object -Culture 'en-US' -Property 'Name' } $tableSectionContent += @( ('**{0} parameters**' -f $category), '', '| Parameter | Type | Description |', '| :-- | :-- | :-- |' ) foreach ($parameter in $categoryParameters) { ###################### # Gather details # ###################### $paramIdentifier = (-not [String]::IsNullOrEmpty($ParentName)) ? '{0}.{1}' -f $ParentName, $parameter.name : $parameter.name $paramHeader = '### Parameter: `{0}`' -f $paramIdentifier $paramIdentifierLink = Get-MarkdownHeaderReferenceFormattedString $paramHeader # definition type (if any) if ($parameter.Keys -contains '$ref') { $identifier = Split-Path $parameter.'$ref' -Leaf $definition = $TemplateFileContent.definitions[$identifier] $type = $definition['type'] $rawAllowedValues = $definition['allowedValues'] } elseif ($parameter.Keys -contains 'items' -and $parameter.items.type -in @('object', 'array') -or $parameter.type -eq 'object') { # Array has nested non-primitive type (array/object) - and if array, the the UDT itself is declared as the array $definition = $parameter $type = $parameter.type $rawAllowedValues = $parameter.allowedValues } elseif ($parameter.Keys -contains 'items' -and $parameter.items.keys -contains '$ref') { # Array has nested non-primitive type (array) - and the parameter is defined as an array of the UDT $identifier = Split-Path $parameter.items.'$ref' -Leaf $definition = $TemplateFileContent.definitions[$identifier] $type = $parameter.type $rawAllowedValues = $definition['allowedValues'] } else { $definition = $null $type = $parameter.type $rawAllowedValues = $parameter.allowedValues } $isRequired = (Get-IsParameterRequired -TemplateFileContent $TemplateFileContent -Parameter $parameter) ? 'Yes' : 'No' $description = $parameter.ContainsKey('metadata') ? $parameter['metadata']['description'].substring("$category. ".Length).Replace("`n- ", '<li>').Replace("`r`n", '<p>').Replace("`n", '<p>') : $null $example = ($parameter.ContainsKey('metadata') -and $parameter['metadata'].ContainsKey('example')) ? $parameter['metadata']['example'] : $null ##################### # Table content # ##################### # build table for definition properties $tableSectionContent += ('| [`{0}`]({1}) | {2} | {3} |' -f $parameter.name, $paramIdentifierLink, $type, $description) #################### # List content # #################### # Format default values # ===================== if ($parameter.defaultValue -is [array]) { if ($parameter.defaultValue.count -eq 0) { $defaultValue = '[]' } else { $bicepJSONDefaultParameterObject = @{ $parameter.name = ($parameter.defaultValue ?? @()) } # Wrapping on object to work with formatted Bicep script $bicepRawformattedDefault = ConvertTo-FormattedBicep -JSONParameters $bicepJSONDefaultParameterObject $leadingSpacesToTrim = ($bicepRawformattedDefault -match '^(\s+).+') ? $matches[1].Length : 0 $bicepCleanedFormattedDefault = $bicepRawformattedDefault -replace ('{0}: ' -f $parameter.name) # Unwrapping the object $defaultValue = $bicepCleanedFormattedDefault -split '\n' | ForEach-Object { $_ -replace "^\s{$leadingSpacesToTrim}" } # Removing excess leading spaces } } elseif ($parameter.defaultValue -is [hashtable]) { if ($parameter.defaultValue.count -eq 0) { $defaultValue = '{}' } else { $bicepDefaultValue = ConvertTo-FormattedBicep -JSONParameters $parameter.defaultValue $defaultValue = "{`n$bicepDefaultValue`n}" } } elseif ($parameter.defaultValue -is [string] -and ($parameter.defaultValue -notmatch '\[\w+\(.*\).*\]')) { $defaultValue = '''' + $parameter.defaultValue + '''' } else { $defaultValue = $parameter.defaultValue } if (-not [String]::IsNullOrEmpty($defaultValue)) { if (($defaultValue -split '\n').count -eq 1) { $formattedDefaultValue = '- Default: `{0}`' -f $defaultValue } else { $formattedDefaultValue = @( '- Default:', ' ```Bicep', ($defaultValue -split '\n' | ForEach-Object { " $_" } | Out-String).TrimEnd(), ' ```' ) } } else { $formattedDefaultValue = $null # Reset value for future iterations } # Format allowed values # ===================== if ($rawAllowedValues -is [array]) { $bicepJSONAllowedParameterObject = @{ $parameter.name = ($rawAllowedValues ?? @()) } # Wrapping on object to work with formatted Bicep script $bicepRawformattedAllowed = ConvertTo-FormattedBicep -JSONParameters $bicepJSONAllowedParameterObject $leadingSpacesToTrim = ($bicepRawformattedAllowed -match '^(\s+).+') ? $matches[1].Length : 0 $bicepCleanedFormattedAllowed = $bicepRawformattedAllowed -replace ('{0}: ' -f $parameter.name) # Unwrapping the object $allowedValues = $bicepCleanedFormattedAllowed -split '\n' | ForEach-Object { $_ -replace "^\s{$leadingSpacesToTrim}" } # Removing excess leading spaces } elseif ($rawAllowedValues -is [hashtable]) { $bicepAllowedValues = ConvertTo-FormattedBicep -JSONParameters $rawAllowedValues $allowedValues = "{`n$bicepAllowedValues`n}" } else { $allowedValues = $rawAllowedValues } if (-not [String]::IsNullOrEmpty($allowedValues)) { if (($allowedValues -split '\n').count -eq 1) { $formattedAllowedValues = '- Default: `{0}`' -f $allowedValues } else { $formattedAllowedValues = @( '- Allowed:', ' ```Bicep', ($allowedValues -split '\n' | Where-Object { -not [String]::IsNullOrEmpty($_) } | ForEach-Object { " $_" } | Out-String).TrimEnd(), ' ```' ) } } else { $formattedAllowedValues = $null # Reset value for future iterations } # add MinValue and maxValue to the description if ($parameter.Keys -contains 'minValue') { $formattedMinValue = "- MinValue: $($parameter['minValue'])" } else { $formattedMinValue = $null # Reset value for future iterations } if ($parameter.Keys -contains 'maxValue') { $formattedMaxValue = "- MaxValue: $($parameter['maxValue'])" } else { $formattedMaxValue = $null # Reset value for future iterations } # Special case for 'roleAssignments' parameter if (($parameter.name -eq 'roleAssignments') -and ($TemplateFileContent.variables.keys -contains 'builtInRoleNames')) { if ([String]::IsNullOrEmpty($ParentName)) { # Top-level invocation $roles = $TemplateFileContent.variables.builtInRoleNames.Keys } else { # Nested-invocation (requires e.g., roles for of nested private endpoint template) $flattendResources = Get-NestedResourceList -TemplateFileContent $TemplateFileContent if ($resourceIdentifier = $flattendResources.identifier | Where-Object { $_ -match "^.*_$ParentName`$" }) { $roles = ($flattendResources | Where-Object { $_.identifier -eq $resourceIdentifier }).properties.template.variables.builtInRoleNames.Keys } else { Write-Warning ('Failed to identify roles for parameter [{0}] of type [{1}] as resource with identifier [{2}] was not found in the corresponding linked template.' -f $parameter.name, $ParentName, "*_$ParentName") } } $formattedRoleNames = $roles.count -gt 0 ? @( '- Roles configurable by name:', ($roles | ForEach-Object { " - ``'$_'``" } | Out-String).TrimEnd() ) : $null } else { $formattedRoleNames = $null # Reset value for future iterations } # Format example # ============== if (-not [String]::IsNullOrEmpty($example)) { # allign content to the left by removing trailing whitespaces $leadingSpacesToTrim = ($example -match '^(\s+).+') ? $matches[1].Length : 0 $exampleLines = $example -split '\n' # Removing excess leading spaces $example = ($exampleLines | Where-Object { -not [String]::IsNullOrEmpty($_) } | ForEach-Object { " $_" -replace "^\s{$leadingSpacesToTrim}" } | Out-String).TrimEnd() if ($exampleLines.count -eq 1) { $formattedExample = '- Example: `{0}`' -f $example.TrimStart() } else { $formattedExample = @( '- Example:', ' ```Bicep', $example, ' ```' ) } } else { $formattedExample = $null } # Build list item # =============== $listSectionContent += @( $paramHeader, ($parameter.ContainsKey('metadata') ? '' : $null), $description ($parameter.ContainsKey('metadata') ? '' : $null), ('- Required: {0}' -f $isRequired), ('- Type: {0}' -f $type), ((-not [String]::IsNullOrEmpty($formattedDefaultValue)) ? $formattedDefaultValue : $null), ((-not [String]::IsNullOrEmpty($formattedAllowedValues)) ? $formattedAllowedValues : $null), ((-not [String]::IsNullOrEmpty($formattedMinValue)) ? $formattedMinValue : $null), ((-not [String]::IsNullOrEmpty($formattedMaxValue)) ? $formattedMaxValue : $null), ((-not [String]::IsNullOrEmpty($formattedRoleNames)) ? $formattedRoleNames : $null), ((-not [String]::IsNullOrEmpty($formattedExample)) ? $formattedExample : $null), (($definition.discriminator.propertyName) ? ('- Discriminator: `{0}`' -f $definition.discriminator.propertyName) : $null), '' ) | Where-Object { $null -ne $_ } #recursive call for children if ($definition) { # 'items' refers to an array # 'properties' is the default for UDTs, 'additionalProperties' represents a used '*' identifier if ($definition.Keys -contains 'items' -and ($definition.items.properties.Keys -or $definition.items.additionalProperties.Keys)) { if ($definition.items.properties.Keys) { $childProperties = $definition.items.properties $sectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -Properties $childProperties -ParentName $paramIdentifier -ColumnsInOrder $ColumnsInOrder $listSectionContent += $sectionContent } if ($definition.items.additionalProperties.Keys) { $childProperties = $definition.items.additionalProperties $formattedProperties = @{ '>Any_other_property<' = $childProperties } $sectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -Properties $formattedProperties -ParentName $paramIdentifier -ColumnsInOrder $ColumnsInOrder $listSectionContent += $sectionContent } } elseif ($definition.type -in @('object', 'secureObject') -and ($definition.properties.Keys -or $definition.additionalProperties.Keys)) { if ($definition.properties.Keys) { $childProperties = $definition.properties $sectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -Properties $childProperties -ParentName $paramIdentifier -ColumnsInOrder $ColumnsInOrder $listSectionContent += $sectionContent } if ($definition.additionalProperties.Keys) { $childProperties = $definition.additionalProperties $formattedProperties = @{ '>Any_other_property<' = $childProperties } $sectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -Properties $formattedProperties -ParentName $paramIdentifier -ColumnsInOrder $ColumnsInOrder $listSectionContent += $sectionContent } } elseif ($definition.type -in @('object', 'secureObject') -and $definition.keys -contains 'discriminator') { <# Discriminator type. E.g., @discriminator('kind') type mainType = subTypeA | subTypeB | subTypeC #> $variantTableSectionContent += @( '<h4>The available variants are:</h4>', '' '| Variant | Description |', '| :-- | :-- |' ) $variantContent = @() foreach ($typeVariantName in $definition.discriminator.mapping.Keys) { $typeVariant = $definition.discriminator.mapping[$typeVariantName] $resolvedTypeVariant = $TemplateFileContent.definitions[(Split-Path $typeVariant.'$ref' -Leaf)] $variantDescription = ($resolvedTypeVariant.metadata.description ?? '').Replace("`r`n", '<p>').Replace("`n", '<p>') $variantIdentifier = '{0}.{1}-{2}' -f $paramIdentifier, $definition.discriminator.propertyName, $typeVariantName $variantIdentifierHeader = "### Variant: ``$variantIdentifier``" $variantIdentifierLink = Get-MarkdownHeaderReferenceFormattedString $variantIdentifierHeader $variantContent += @( $variantIdentifierHeader, $variantDescription, '', ('To use this variant, set the property `{0}` to `{1}`.' -f $definition.discriminator.propertyName, $typeVariantName), '' ) $variantTableSectionContent += ('| [`{0}`]({1}) | {2} |' -f $typeVariantName, $variantIdentifierLink, $variantDescription) $definitionSectionInputObject = @{ TemplateFileContent = $TemplateFileContent Properties = $resolvedTypeVariant.properties ParentName = $variantIdentifier ColumnsInOrder = $ColumnsInOrder } $sectionContent = Set-DefinitionSection @definitionSectionInputObject $variantContent += $sectionContent } $variantTableSectionContent += '' $listSectionContent += $variantTableSectionContent $listSectionContent += $variantContent } } } $tableSectionContent += '' } $newSectionContent += $tableSectionContent $newSectionContent += $listSectionContent return $newSectionContent } <# .SYNOPSIS Update the 'outputs' section of the given readme file .DESCRIPTION Update the 'outputs' section of the given readme file .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Outputs' .EXAMPLE Set-OutputsSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Outputs' section based on the given template file content #> function Set-OutputsSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Outputs' ) # Process content if (-not $TemplateFileContent.outputs) { # no outputs in the template $SectionContent = '_None_' } elseif ($TemplateFileContent.outputs.Values.metadata) { # Template has output descriptions $SectionContent = [System.Collections.ArrayList]@( '| Output | Type | Description |', '| :-- | :-- | :-- |' ) foreach ($outputName in ($templateFileContent.outputs.Keys | Sort-Object -Culture 'en-US')) { $output = $TemplateFileContent.outputs[$outputName] $description = $output.metadata.description.Replace("`r`n", '<p>').Replace("`n", '<p>') $SectionContent += ("| ``{0}`` | {1} | {2} |" -f $outputName, $output.type, $description) } } else { $SectionContent = [System.Collections.ArrayList]@( '| Output | Type |', '| :-- | :-- |' ) foreach ($outputName in ($templateFileContent.outputs.Keys | Sort-Object -Culture 'en-US')) { $output = $TemplateFileContent.outputs[$outputName] $SectionContent += ("| ``{0}`` | {1} |" -f $outputName, $output.type) } } # Build result if ($PSCmdlet.ShouldProcess('Original file with new output content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Update the 'functions' section of the given readme file .DESCRIPTION Update the 'functions' section of the given readme file .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'functions' section. Defaults to '## Functions' .EXAMPLE Set-FunctionsSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Functions' section based on the given template file content #> function Set-FunctionsSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Exported functions' ) # Process content $noFunctions = -not $TemplateFileContent.functions $noExportedFunctions = $templateFileContent.functions.members.Values.metadata.'__bicep_export!' -notcontains 'True' if ($noFunctions -or $noExportedFunctions) { # no exported/available functions in the template return $ReadMeFileContent } elseif ($TemplateFileContent.functions.members.Values.metadata) { # Template has function descriptions $SectionContent = [System.Collections.ArrayList]@( '| Function | Description |', '| :-- | :-- |' ) foreach ($functionName in ($templateFileContent.functions.members.Keys | Sort-Object -Culture 'en-US')) { $function = $TemplateFileContent.functions.members[$functionName] $description = $function.metadata.description.Replace("`r`n", '<p>').Replace("`n", '<p>') $SectionContent += ("| ``{0}`` | {1} |" -f $functionName, $description) } } else { $SectionContent = [System.Collections.ArrayList]@( '| Function | Description |', '| :-- | :-- |' ) foreach ($functionName in ($templateFileContent.functions.members.Keys | Sort-Object -Culture 'en-US')) { $SectionContent += ("| ``{0}`` |" -f $functionName) } } # Build result if ($PSCmdlet.ShouldProcess('Original file with new functions content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Update the 'Data Collection' section of the given readme file .DESCRIPTION Update the 'Data Collection' section of the given readme file .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the section. Defaults to '## Data Collection' .PARAMETER PreLoadedContent Optional. Pre-Loaded content. May be used to reuse the same data for multiple invocations. For example: @{ TelemetryFileContent = @() // Optional. The text of the telemetry notice to add to each readme. } .EXAMPLE Set-DataCollectionSection -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Data Collection' section #> function Set-DataCollectionSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [hashtable] $PreLoadedContent = @{}, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Data Collection' ) if ($TemplateFileContent.parameters.Keys -notcontains 'enableTelemetry') { # no telemetry in the template return $ReadMeFileContent } # Load content, if required if ($PreLoadedContent.Keys -notcontains 'TelemetryFileContent' -or -not $PreLoadedContent['TelemetryFileContent']) { $telemetryUrl = 'https://aka.ms/avm/static/telemetry' try { $rawResponse = Invoke-WebRequest -Uri $telemetryUrl if (($rawResponse.Headers['Content-Type'] | Out-String) -like '*text/plain*') { $telemetryFileContent = $rawResponse.Content -split '\n' } else { Write-Warning "Failed to fetch telemetry information from [$telemetryUrl]." # Incorrect Url (e.g., points to HTML) return $ReadMeFileContent } } catch { Write-Warning "Failed to fetch telemetry information from [$telemetryUrl]." # Invalid url return $ReadMeFileContent } } else { $telemetryFileContent = $PreLoadedContent.TelemetryFileContent } # Build result if ($PSCmdlet.ShouldProcess('Original file with new output content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $telemetryFileContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Add module references (cross-references) to the module's readme .DESCRIPTION Add module references (cross-references) to the module's readme. This includes both local (i.e., file path), as well as remote references (e.g., ACR) .PARAMETER ModuleRoot Mandatory. The file path to the module's root .PARAMETER FullModuleIdentifier Mandatory. The full identifier of the module (i.e., ProviderNamespace + ResourceType) .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Cross-referenced modules' .PARAMETER PreLoadedContent Optional. Pre-Loaded content. May be used to reuse the same data for multiple invocations. For example: @{ CrossReferencedModuleList = @{} // Optional. Cross Module References to consider when refreshing the readme. Can be provided to speed up the generation. If not provided, is fetched by this script. } .EXAMPLE Set-CrossReferencesSection -ModuleRoot 'C:/key-vault/vault' -FullModuleIdentifier 'key-vault/vault' -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) -PreLoadedContent @{ CrossReferencedModuleList = @{ ... } } Update the given readme file's 'Cross-referenced modules' section based on the given template file content #> function Set-CrossReferencesSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ModuleRoot, [Parameter(Mandatory = $true)] [string] $FullModuleIdentifier, [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [hashtable] $PreLoadedContent = @{}, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Cross-referenced modules' ) # Load content, if required if ($PreLoadedContent.Keys -notcontains 'CrossReferencedModuleList') { $CrossReferencedModuleList = Get-CrossReferencedModuleList } else { $CrossReferencedModuleList = $PreLoadedContent.CrossReferencedModuleList } $dependencies = $CrossReferencedModuleList[$FullModuleIdentifier] if (-not $dependencies -or ($dependencies -and -not $dependencies['localPathReferences'] -and -not $dependencies['remoteReferences'])) { # no cross references in the template return $ReadMeFileContent } # Process content $SectionContent = [System.Collections.ArrayList]@( 'This section gives you an overview of all local-referenced module files (i.e., other modules that are referenced in this module) and all remote-referenced files (i.e., Bicep modules that are referenced from a Bicep Registry or Template Specs).', '', '| Reference | Type |', '| :-- | :-- |' ) if ($dependencies.Keys -contains 'localPathReferences' -and $dependencies['localPathReferences']) { foreach ($reference in ($dependencies['localPathReferences'] | Sort-Object -Culture 'en-US')) { $SectionContent += ("| ``{0}`` | {1} |" -f $reference, 'Local reference') } } if ($dependencies.Keys -contains 'remoteReferences' -and $dependencies['remoteReferences']) { foreach ($reference in ($dependencies['remoteReferences'] | Sort-Object -Culture 'en-US')) { $SectionContent += ("| ``{0}`` | {1} |" -f $reference, 'Remote reference') } } # Build result if ($PSCmdlet.ShouldProcess('Original file with new output content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Add comments to indicate required & non-required parameters to the given Bicep example .DESCRIPTION Add comments to indicate required & non-required parameters to the given Bicep example. 'Required' is only added if the example has at least one required parameter 'Non-Required' is only added if the example has at least one required parameter and at least one non-required parameter .PARAMETER BicepParams Mandatory. The Bicep parameter block to add the comments to (i.e., should contain everything in between the brackets of a 'params: {...} block) .PARAMETER AllParametersList Mandatory. A list of all top-level (i.e. non-nested) parameter names .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE Add-BicepParameterTypeComment -AllParametersList @('name', 'lock') -RequiredParametersList @('name') -BicepParams "name: 'module'\nlock: 'CanNotDelete'" Add type comments to given bicep params string, using one required parameter 'name'. Would return: ' // Required parameters name: 'module' // Non-required parameters lock: { kind: 'CanNotDelete' name: 'myCustomLockName' } ' #> function Add-BicepParameterTypeComment { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $BicepParams, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $AllParametersList = @(), [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) if ($RequiredParametersList.Count -ge 1 -and $AllParametersList.Count -ge 2) { $BicepParamsArray = $BicepParams -split '\n' # [1/4] Check where the 'last' required parameter is located in the example (and what its indent is) $parameterToSplitAt = $RequiredParametersList[-1] $requiredParameterIndent = ([regex]::Match($BicepParamsArray[0], '^(\s+).*')).Captures.Groups[1].Value.Length # [2/4] Add a comment where the required parameters start $BicepParamsArray = @('{0}// Required parameters' -f (' ' * $requiredParameterIndent)) + $BicepParamsArray[(0 .. ($BicepParamsArray.Count))] # [3/4] Find the location if the last required parameter $requiredParameterStartIndex = ($BicepParamsArray | Select-String ('^[\s]{0}{1}:.+' -f "{$requiredParameterIndent}", $parameterToSplitAt) | ForEach-Object { $_.LineNumber - 1 })[0] # [4/4] If we have more than only required parameters, let's add a corresponding comment if ($AllParametersList.Count -gt $RequiredParametersList.Count) { $nextLineIndent = ([regex]::Match($BicepParamsArray[$requiredParameterStartIndex + 1], '^(\s+).*')).Captures.Groups[1].Value.Length if ($nextLineIndent -gt $requiredParameterIndent) { # Case Param is object/array: Search in rest of array for the next closing bracket with the same indent - and then add the search index (1) & initial index (1) count back in $requiredParameterEndIndex = ($BicepParamsArray[($requiredParameterStartIndex + 1)..($BicepParamsArray.Count)] | Select-String "^[\s]{$requiredParameterIndent}\S+" | ForEach-Object { $_.LineNumber - 1 })[0] + 1 + $requiredParameterStartIndex } else { # Case Param is single line bool/string/int: Add an index (1) for the 'required' comment $requiredParameterEndIndex = $requiredParameterStartIndex } # Add a comment where the non-required parameters start $BicepParamsArray = $BicepParamsArray[0..$requiredParameterEndIndex] + ('{0}// Non-required parameters' -f (' ' * $requiredParameterIndent)) + $BicepParamsArray[(($requiredParameterEndIndex + 1) .. ($BicepParamsArray.Count))] } return ($BicepParamsArray | Out-String).TrimEnd() } return $BicepParams } <# .SYNOPSIS Sort the given JSON paramters into required & non-required parameters, each sorted alphabetically .DESCRIPTION Sort the given JSON paramters into required & non-required parameters, each sorted alphabetically .PARAMETER ParametersJSON Mandatory. The JSON parameters block to process (ideally already without 'value' property) .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE Get-OrderedParametersJSON -RequiredParametersList @('name') -ParametersJSON '{ "lock": "CanNotDelete","name": "module" }' Order the given JSON object alphabetically. Would result into: @{ name: 'module' lock: { kind: 'CanNotDelete' name: 'myCustomLockName' } } #> function Get-OrderedParametersJSON { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ParametersJSON, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) # [1/3] Get all parameters from the parameter object and order them recursively $orderedContentInJSONFormat = ConvertTo-OrderedHashtable -JSONInputObject $parametersJSON # [2/3] Sort 'required' parameters to the front $orderedJSONParameters = [ordered]@{} $orderedTopLevelParameterNames = $orderedContentInJSONFormat.psbase.Keys # We must use PS-Base to handle conflicts of HashTable properties & keys (e.g. for a key 'keys'). # [2.1] Add required parameters first $orderedTopLevelParameterNames | Where-Object { $_ -in $RequiredParametersList } | ForEach-Object { $orderedJSONParameters[$_] = $orderedContentInJSONFormat[$_] } # [2.2] Add rest after $orderedTopLevelParameterNames | Where-Object { $_ -notin $RequiredParametersList } | ForEach-Object { $orderedJSONParameters[$_] = $orderedContentInJSONFormat[$_] } # [3/3] Handle empty dictionaries (in case the parmaeter file was empty) if ($orderedJSONParameters.count -eq 0) { $orderedJSONParameters = '' } return $orderedJSONParameters } <# .SYNOPSIS Sort the given JSON parameters into a new JSON parameter object, all parameter sorted into required & non-required parameters, each sorted alphabetically .DESCRIPTION Sort the given JSON parameters into a new JSON parameter object, all parameter sorted into required & non-required parameters, each sorted alphabetically. The location where required & non-required parameters start is highlighted with by a corresponding comment .PARAMETER ParametersJSON Mandatory. The parameter JSON object to process .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE Build-OrderedJSONObject -RequiredParametersList @('name') -ParametersJSON '{ "lock": { "value": "CanNotDelete" }, "name": { "value": "module" } }' Build a formatted Parameter-JSON object with one required parameter. Would result into: '{ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { // Required parameters "name": { "value": "module" }, // Non-required parameters "lock": { "value": "CanNotDelete" } } }' #> function Build-OrderedJSONObject { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ParametersJSON, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) # [1/9] Sort parameter alphabetically $orderedJSONParameters = Get-OrderedParametersJSON -ParametersJSON $ParametersJSON -RequiredParametersList $RequiredParametersList # [2/9] Build the ordered parameter file syntax back up $jsonExample = ([ordered]@{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' contentVersion = '1.0.0.0' parameters = (-not [String]::IsNullOrEmpty($orderedJSONParameters)) ? $orderedJSONParameters : @{} } | ConvertTo-Json -Depth 99) # [3/8] If we have at least one required and one other parameter we want to add a comment if ($RequiredParametersList.Count -ge 1 -and $OrderedJSONParameters.Keys.Count -ge 2) { $jsonExampleArray = $jsonExample -split '\n' # [4/8] Check where the 'last' required parameter is located in the example (and what its indent is) $parameterToSplitAt = $RequiredParametersList[-1] $parameterStartIndex = ($jsonExampleArray | Select-String '.*"parameters": \{.*' | ForEach-Object { $_.LineNumber - 1 })[0] $requiredParameterIndent = ([regex]::Match($jsonExampleArray[($parameterStartIndex + 1)], '^(\s+).*')).Captures.Groups[1].Value.Length # [5/8] Add a comment where the required parameters start $jsonExampleArray = $jsonExampleArray[0..$parameterStartIndex] + ('{0}// Required parameters' -f (' ' * $requiredParameterIndent)) + $jsonExampleArray[(($parameterStartIndex + 1) .. ($jsonExampleArray.Count))] # [6/8] Find the location if the last required parameter $requiredParameterStartIndex = ($jsonExampleArray | Select-String "^[\s]{$requiredParameterIndent}`"$parameterToSplitAt`": \{.*" | ForEach-Object { $_.LineNumber - 1 })[0] # [7/8] If we have more than only required parameters, let's add a corresponding comment if ($orderedJSONParameters.Keys.Count -gt $RequiredParametersList.Count ) { # Search in rest of array for the next closing bracket with the same indent - and then add the search index (1) & initial index (1) count back in $requiredParameterEndIndex = ($jsonExampleArray[($requiredParameterStartIndex + 1)..($jsonExampleArray.Count)] | Select-String "^[\s]{$requiredParameterIndent}\}" | ForEach-Object { $_.LineNumber - 1 })[0] + 1 + $requiredParameterStartIndex # Add a comment where the non-required parameters start $jsonExampleArray = $jsonExampleArray[0..$requiredParameterEndIndex] + ('{0}// Non-required parameters' -f (' ' * $requiredParameterIndent)) + $jsonExampleArray[(($requiredParameterEndIndex + 1) .. ($jsonExampleArray.Count))] } # [8/8] Convert the processed array back into a string return $jsonExampleArray | Out-String } return $jsonExample } <# .SYNOPSIS Convert the given Bicep parameter block to JSON parameter block .DESCRIPTION Convert the given Bicep parameter block to JSON parameter block .PARAMETER BicepParamBlock Mandatory. The Bicep parameter block to process .PARAMETER CurrentFilePath Mandatory. The Path of the file containing the param block .EXAMPLE ConvertTo-FormattedJSONParameterObject -BicepParamBlock "name: 'module'\nlock: 'CanNotDelete'" -CurrentFilePath 'c:/main.test.bicep' Convert the Bicep string "name: 'module'\nlock: 'CanNotDelete'" into a parameter JSON object. Would result into: @{ lock = @{ value = 'module' } lock = @{ value = 'CanNotDelete' } } #> function ConvertTo-FormattedJSONParameterObject { [CmdletBinding()] param ( [Parameter()] [string] $BicepParamBlock, [Parameter()] [string] $CurrentFilePath ) if ([String]::IsNullOrEmpty($BicepParamBlock)) { # Case: No mandatory parameters return @{} } # [1/4] Detect top level params for later processing $bicepParamBlockArray = $BicepParamBlock -split '\n' $topLevelParamIndent = ([regex]::Match($bicepParamBlockArray[0], '^(\s+).*')).Captures.Groups[1].Value.Length $topLevelParams = $bicepParamBlockArray | Where-Object { $_ -match "^\s{$topLevelParamIndent}[0-9a-zA-Z]+:.*" } | ForEach-Object { ($_ -split ':')[0].Trim() } # [2/4] Add JSON-specific syntax to the Bicep param block to enable us to treat is as such # [2.1] Syntax: Outer brackets $paramInJsonFormat = @( '{', $BicepParamBlock '}' ) | Out-String # [2.2] Syntax: All double quotes must be escaped & single-quotes are double-quotes $paramInJsonFormat = $paramInJsonFormat -replace '"', '\"' $paramInJsonFormat = $paramInJsonFormat -replace "'", '"' # [2.3] Split the object to format line-by-line (& also remove any empty lines) $paramInJSONFormatArray = $paramInJsonFormat -split '\n' | Where-Object { -not [String]::IsNullOrEmpty($_.Trim()) } for ($index = 0; $index -lt $paramInJSONFormatArray.Count; $index++) { $line = $paramInJSONFormatArray[$index] if ($line -match '^\s*\/\/.*') { # Line is comment continue } # [2.4] Syntax: # - Everything left of a leftest ':' should be wrapped in quotes (as a parameter name is always a string) # - However, we don't want to accidently catch something like "CriticalAddonsOnly=true:NoSchedule" [regex]$pattern = '^\s*\"{0}([0-9a-zA-Z_]+):' $line = $pattern.replace($line, '"$1":', 1) # [2.5] Syntax: Replace DSL-specific syntax $mayHaveValue = $line -match '^\s*.+?:\s+' if ($mayHaveValue) { $lineValue = ($line -split '^\s*.+?:\s+')[1].Trim() # i.e., optional spaces, followed by a name ("xzy"), followed by ':', folowed by at least a space # Individual checks $isLineWithEmptyObjectValue = $line -match '^.+:\s*{\s*}\s*$' # e.g., test: {} $isLineWithObjectPropertyReferenceValue = $lineValue -match '(?<=[^"])\b\.\b(?=[^"]*$)' # e.g., resourceGroupResources.outputs.virtualWWANResourceId, but not "domainName": "onmicrosoft.com" $isLineWithReferenceInLineKey = ($line -split ':')[0].Trim() -like '*.*' $isLineWithStringNestedReference = $lineValue -match "['|`"]{1}.*(?<!\\)\$\{.+" # e.g., "Download ${initializeSoftwareScriptName}" or '${last(...)}', but NOT "abc: \${xyz}" $isLineWithStringValue = $lineValue -match '^".+"$' # e.g. "value" $isLineWithFunction = $lineValue -match '^[a-zA-Z0-9]+\(.+' # e.g., split(something) or loadFileAsBase64("./test.pfx") $isLineWithPlainValue = $lineValue -match '^\w+$' # e.g. adminPassword: password $isLineWithPrimitiveValue = $lineValue -match '^\s*(true|false|[0-9])+$' # e.g., isSecure: true $isLineContainingCondition = $lineValue -match '^\w+ [=!?|&]{2} .+\?.+\:.+$' # e.g., iteration == "init" ? "A" : "B" # Special case: Multi-line function $isLineWithMultilineFunction = $lineValue -match '[a-zA-Z]+\s*\([^\)]*\){0}\s*$' # e.g., roleDefinitionIdOrName: subscriptionResourceId( \n 'Microsoft.Authorization/roleDefinitions', \n 'acdd72a7-3385-48ef-bd42-f606fba81ae7' \n ) if ($isLineWithMultilineFunction) { # Search leading indent so that we can use it to identify at which line the function ends $indent = ([regex]::Match($paramInJSONFormatArray[$index], '^(\s+)')).Captures.Groups[1].Value.Length $functionStartIndex = $index $functionEndIndex = $functionStartIndex do { $functionEndIndex++ } while ($paramInJSONFormatArray[$functionEndIndex] -match "^\s{$($indent+1),}" -and $functionEndIndex -lt $paramInJSONFormatArray.Count) if ($functionEndIndex -eq $paramInJSONFormatArray.Count) { throw "End index of a multi-line function block for test file [$CurrentFilePath] not found." } # Overwrite the first line with a default value (i.e., "property": "<property>") $line = '{0}: "<{1}>"' -f ($line -split ':')[0], ([regex]::Match(($line -split ':')[0], '"(.+)"')).Captures.Groups[1].Value $linesOfFunction = $functionEndIndex - $functionStartIndex # Nullify all but first line for ($functionIndex = 1; $functionIndex -le $linesOfFunction; $functionIndex++) { $functionLineIndex = $index + $functionIndex $paramInJSONFormatArray[$functionLineIndex] = $null } # Increase index to skip the function lines $index += $indexToIncrease } else { # Combined checks # In case of an output reference like '"virtualWanId": resourceGroupResources.outputs.virtualWWANResourceId' we'll only show "<virtualWanId>" (but NOT e.g. 'reference': {}) $isLineWithObjectPropertyReference = -not $isLineWithEmptyObjectValue -and -not $isLineWithStringValue -and $isLineWithObjectPropertyReferenceValue # In case of a parameter/variable reference like 'adminPassword: password' we'll only show "<adminPassword>" (but NOT e.g. enableMe: true) $isLineWithParameterOrVariableReferenceValue = $isLineWithPlainValue -and -not $isLineWithPrimitiveValue # In case of any contained line like ''${resourceGroupResources.outputs.managedIdentityResourceId}': {}' we'll only show "managedIdentityResourceId: {}" $isLineWithObjectReferenceKeyAndEmptyObjectValue = $isLineWithEmptyObjectValue -and $isLineWithReferenceInLineKey # In case of any contained function like '"backupVaultResourceGroup": (split(resourceGroupResources.outputs.recoveryServicesVaultResourceId, "/"))[4]' we'll only show "<backupVaultResourceGroup>" if ($isLineWithObjectPropertyReference -or $isLineWithStringNestedReference -or $isLineWithFunction -or $isLineWithParameterOrVariableReferenceValue -or $isLineContainingCondition) { $line = '{0}: "<{1}>"' -f ($line -split ':')[0], ([regex]::Match(($line -split ':')[0], '"(.+)"')).Captures.Groups[1].Value } elseif ($isLineWithObjectReferenceKeyAndEmptyObjectValue) { $line = '"<{0}>": {1}' -f (($line -split ':')[0] -split '\.')[-1].TrimEnd('}"'), $lineValue } } } else { if ($line -notlike '*"*"*' -and $line -like '*.*') { # In case of a array value like '[ \n -> resourceGroupResources.outputs.managedIdentityPrincipalId <- \n ]' we'll only show "<managedIdentityPrincipalId>" $line = '"<{0}>"' -f $line.Split('.')[-1].Trim() } elseif ($line -match '^\s*[a-zA-Z]+\s*$') { # If there is simply only a value such as a variable reference, we'll wrap it as a string to replace. For example a reference of a variable `addressPrefix` will be replaced with `"<addressPrefix>"` $line = '"<{0}>"' -f $line.Trim() } elseif ($line -match "['|`"]{1}.*\$\{.+") { # If the line contains a string with a reference, we're replacing the reference with a placeholder. For example "pwsh \"${initializeSoftwareScriptName}\"" would only show "pwsh <value>" $line = $line -replace '\$\{.+\}', '<value>' } } # Escape characters that would be invalid in JSON $line = $line -replace '\\\$', '\\$' # Replace "abc: \${xzy}" with "abc: \\${xzy}" # Overwrite value $paramInJSONFormatArray[$index] = $line } # [2.6] Remove empty lines $paramInJSONFormatArray = $paramInJSONFormatArray | Where-Object { $_ } # [2.7] Syntax: Add comma everywhere unless: # - the current line has an opening 'object: {' or 'array: [' character # - the line after the current line has a closing 'object: }' or 'array: ]' character # - it's the last closing bracket # - is a comment for ($index = 0; $index -lt $paramInJSONFormatArray.Count; $index++) { $isOpeningObjectOrArray = $paramInJSONFormatArray[$index] -match '[\{|\[]\s*$' $nextLineIsClosingObjectOrArray = ($index -lt $paramInJSONFormatArray.Count - 1) -and $paramInJSONFormatArray[$index + 1] -match '^\s*[\]|\}]\s*$' $isLastLine = $index -eq $paramInJSONFormatArray.Count - 1 $isComment = $paramInJSONFormatArray[$index] -match '^\s*\/\/.*' if ($isOpeningObjectOrArray -or $nextLineIsClosingObjectOrArray -or $isLastLine -or $isComment) { continue } if ( $paramInJSONFormatArray[$index] -match '(?<![:\/])\/\/.*$' ) { # Has inline comment (i.e., a situation where you have '//' not enclosed by quotes) $lineElements = $paramInJSONFormatArray[$index] -split '(?<![:\/])\/\/.*$' $paramInJSONFormatArray[$index] = '{0}, // {1}' -f $lineElements[0].Trim(), $lineElements[1].Trim() } else { $paramInJSONFormatArray[$index] = '{0},' -f $paramInJSONFormatArray[$index].Trim() } } # [2.8] Format the final JSON string to an object to enable processing try { $paramInJsonFormatObject = $paramInJSONFormatArray | Out-String | ConvertFrom-Json -AsHashtable -Depth 99 -ErrorAction 'Stop' } catch { throw ('Failed to process file [{0}]. Please check if it properly formatted. Original error message: [{1}]' -f $CurrentFilePath, $_.Exception.Message) } # [3/4] Inject top-level 'value`' properties $paramInJsonFormatObjectWithValue = @{} foreach ($paramKey in $topLevelParams) { $paramInJsonFormatObjectWithValue[$paramKey] = @{ value = $paramInJsonFormatObject[$paramKey] } } # [4/4] Return result return $paramInJsonFormatObjectWithValue } <# .SYNOPSIS Convert the given parameter JSON object into a formatted Bicep object (i.e., sorted & with required/non-required comments) .DESCRIPTION Convert the given parameter JSON object into a formatted Bicep object (i.e., sorted & with required/non-required comments) .PARAMETER JSONParameters Mandatory. The parameter JSON object to process. .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE ConvertTo-FormattedBicep -RequiredParametersList @('name') -JSONParameters @{ lock = @{ value = 'module' }; lock = @{ value = 'CanNotDelete' } } Convert the given JSONParameters object with one required parameter to a formatted Bicep object. Would result into: ' // Required parameters name: 'module' // Non-required parameters lock: { kind: 'CanNotDelete' name: 'myCustomLockName' } ' #> function ConvertTo-FormattedBicep { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [hashtable] $JSONParameters, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) # [0/5] Remove 'value' parameter property, if any (e.g. when dealing with a classic parameter file) $JSONParametersWithoutValue = @{} foreach ($parameterName in $JSONParameters.psbase.Keys) { $keysOnLevel = $JSONParameters[$parameterName].Keys if ($keysOnLevel.count -eq 1 -and $keysOnLevel -eq 'value') { $JSONParametersWithoutValue[$parameterName] = $JSONParameters[$parameterName].value } else { $JSONParametersWithoutValue[$parameterName] = $JSONParameters[$parameterName] } } # [1/5] Order parameters recursively if ($JSONParametersWithoutValue.psbase.Keys.Count -gt 0) { $orderedJSONParameters = Get-OrderedParametersJSON -ParametersJSON ($JSONParametersWithoutValue | ConvertTo-Json -Depth 99) -RequiredParametersList $RequiredParametersList } else { $orderedJSONParameters = @{} } # [2/5] Remove any JSON specific formatting $templateParameterObject = $orderedJSONParameters | ConvertTo-Json -Depth 99 if ($templateParameterObject -ne '{}') { $bicepParamsArray = $templateParameterObject -split '\r?\n' | ForEach-Object { $line = $_ $line = $line -replace "'", "\'" # Update any [ "field": "[[concat('tags[', parameters('tagName'), ']')]"] to [ "field": "[[concat(\'tags[\', parameters(\'tagName\'), \']\')]"] $line = $line -replace '"', "'" # Update any [xyz: "xyz"] to [xyz: 'xyz'] $line = $line -replace ',$', '' # Update any [xyz: abc,xyz,] to [xyz: abc,xyz] $line = $line -replace "'(\w+)':", '$1:' # Update any ['xyz': xyz] to [xyz: xyz] $line = $line -replace "'(.+.getSecret\('.+'\))'", '$1' # Update any [xyz: 'xyz.GetSecret()'] to [xyz: xyz.GetSecret()] $line = $line -replace '\\\\\$', '\$' # Replace JSON-escaped "\\${aValue}" with Bicep counterpart "\${aValue}" $line } $bicepParamsArray = $bicepParamsArray[1..($bicepParamsArray.count - 2)] # [3/5] Format 'getSecret' references $bicepParamsArray = $bicepParamsArray | ForEach-Object { if ($_ -match ".+: '(\w+)\.getSecret\(\\'([0-9a-zA-Z-<>]+)\\'\)'") { # e.g. change [pfxCertificate: 'kv1.getSecret(\'<certSecretName>\')'] to [pfxCertificate: kv1.getSecret('<certSecretName>')] "{0}: {1}.getSecret('{2}')" -f ($_ -split ':')[0], $matches[1], $matches[2] } else { $_ } } } else { $bicepParamsArray = @() } # [4/5] Format params with indent $bicepParams = ($bicepParamsArray | ForEach-Object { " $_" } | Out-String).TrimEnd() # [5/5] Add comment where required & optional parameters start $splitInputObject = @{ BicepParams = $bicepParams RequiredParametersList = $RequiredParametersList AllParametersList = $JSONParameters.psBase.Keys } $commentedBicepParams = Add-BicepParameterTypeComment @splitInputObject return $commentedBicepParams } <# .SYNOPSIS Generate 'Usage examples' for the ReadMe out of the parameter files currently used to test the template .DESCRIPTION Generate 'Usage examples' for the ReadMe out of the parameter files currently used to test the template .PARAMETER ModuleRoot Mandatory. The file path to the module's root .PARAMETER FullModuleIdentifier Mandatory. The full identifier of the module (i.e., ProviderNamespace + ResourceType) .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Usage examples' .PARAMETER addJson Optional. A switch to control whether or not to add a ARM-JSON-Parameter file example. Defaults to true. .PARAMETER addBicep Optional. A switch to control whether or not to add a Bicep usage example. Defaults to true. .EXAMPLE Set-UsageExamplesSection -ModuleRoot 'C:/key-vault/vault' -FullModuleIdentifier 'key-vault/vault' -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Usage Examples' section based on the given template file content #> function Set-UsageExamplesSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ModuleRoot, [Parameter(Mandatory = $true)] [string] $FullModuleIdentifier, [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory = $true)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [bool] $addJson = $true, [Parameter(Mandatory = $false)] [bool] $addBicep = $true, [Parameter(Mandatory = $false)] [bool] $addBicepParametersFile = $true, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Usage examples' ) $brLink = Get-BRMRepositoryName -TemplateFilePath $TemplateFilePath $targetVersion = '<version>' # Process content $SectionContent = [System.Collections.ArrayList]@( "The following section provides usage examples for the module, which were used to validate and deploy the module successfully. For a full reference, please review the module's test folder in its repository.", '', '>**Note**: Each example lists all the required parameters first, followed by the rest - each in alphabetical order.', '', ('>**Note**: To reference the module, please use the following syntax `br/public:{0}:{1}`.' -f $brLink, $targetVersion), '' ) ##################### ## Init values ## ##################### $specialConversionHash = @{ 'public-ip-addresses' = 'publicIPAddresses' 'public-ip-prefixes' = 'publicIPPrefixes' } # Get moduleName as $fullModuleIdentifier leaf $moduleName = $fullModuleIdentifier.Split('/')[1] if ($specialConversionHash.ContainsKey($moduleName)) { # Convert moduleName using specialConversionHash $moduleNameCamelCase = $specialConversionHash[$moduleName] } else { # Convert moduleName from kebab-case to camelCase $First, $Rest = $moduleName -Split '-', 2 $moduleNameCamelCase = $First.Tolower() + (Get-Culture).TextInfo.ToTitleCase($Rest) -Replace '-' } $testFilePaths = (Get-ChildItem -Path $ModuleRoot -Recurse -Filter 'main.test.bicep').FullName | Sort-Object -Culture 'en-US' if ($TemplateFileContent.parameters.Count -gt 0) { $RequiredParametersList = $TemplateFileContent.parameters.Keys | Where-Object { Get-IsParameterRequired -TemplateFileContent $TemplateFileContent -Parameter $TemplateFileContent.parameters[$_] } | Sort-Object -Culture 'en-US' } else { $RequiredParametersList = @() } ############################ ## Process test files ## ############################ # Prepare data (using thread-safe multithreading) to consume later $buildTestFileMap = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new() $testFilePaths | ForEach-Object -Parallel { $dict = $using:buildTestFileMap $folderName = Split-Path (Split-Path -Path $_) -Leaf $builtTemplate = (bicep build $_ --stdout 2>$null) | Out-String if ([String]::IsNullOrEmpty($builtTemplate)) { throw "Failed to build template [$_]. Try running the command ``bicep build $_ --stdout`` locally for troubleshooting. Make sure you have the latest Bicep CLI installed." } $templateHashTable = ConvertFrom-Json $builtTemplate -AsHashtable $null = $dict.TryAdd($folderName, $templateHashTable) } # Process data $pathIndex = 1 $usageExampleSectionHeaders = @() $testFilesContent = @() foreach ($testFilePath in $testFilePaths) { # Read content $rawContentArray = Get-Content -Path $testFilePath $folderName = Split-Path (Split-Path -Path $testFilePath) -Leaf $compiledTestFileContent = $buildTestFileMap[$folderName] $rawContent = Get-Content -Path $testFilePath -Encoding 'utf8' | Out-String # Format example header if ($compiledTestFileContent.metadata.Keys -contains 'name') { $exampleTitle = $compiledTestFileContent.metadata.name } else { if ((Split-Path (Split-Path $testFilePath -Parent) -Leaf) -ne '.test') { $exampleTitle = Split-Path (Split-Path $testFilePath -Parent) -Leaf } else { $exampleTitle = ((Split-Path $testFilePath -LeafBase) -replace '\.', ' ') -replace ' parameters', '' } $textInfo = (Get-Culture -Name 'en-US').TextInfo $exampleTitle = $textInfo.ToTitleCase($exampleTitle) } $fullTestFileTitle = '### Example {0}: _{1}_' -f $pathIndex, $exampleTitle $testFilesContent += @( $fullTestFileTitle ) $usageExampleSectionHeaders += @{ title = $exampleTitle header = $fullTestFileTitle } # If a description is added in the template's metadata, we can add it too if ($compiledTestFileContent.metadata.Keys -contains 'description') { $testFilesContent += @( '', $compiledTestFileContent.metadata.description, '' ) } # ------------------------- # # Prepare Bicep to JSON # # ------------------------- # $isModuleDeploymentRegex = "^module testDeployment '..\/.*main.bicep' = " if ($rawContentArray -match $isModuleDeploymentRegex) { # Classic module deployment # [1/6] Search for the relevant parameter start & end index $bicepTestStartIndex = ($rawContentArray | Select-String ("^module testDeployment '..\/.*main.bicep' = ") | ForEach-Object { $_.LineNumber - 1 })[0] $bicepTestEndIndex = $bicepTestStartIndex do { $bicepTestEndIndex++ } while ($rawContentArray[$bicepTestEndIndex] -notin @('}', '}]', ']') -and $bicepTestEndIndex -lt $rawContentArray.Count) if ($bicepTestEndIndex -eq $rawContentArray.Count) { throw "End index of test block for test file [$testFilePath] not found." } $rawBicepExample = $rawContentArray[$bicepTestStartIndex..$bicepTestEndIndex] if (-not ($rawBicepExample | Select-String ('\s+params:.*'))) { # Handle case where params are not provided $paramsBlockArray = @() } else { # Extract params block out of the Bicep example $paramsStartIndex = ($rawBicepExample | Select-String ('\s+params:.*') | ForEach-Object { $_.LineNumber - 1 })[0] $paramsIndent = ($rawBicepExample[$paramsStartIndex] | Select-String '(\s+).*').Matches.Groups[1].Length # Handle case where params are empty if ($rawBicepExample[$paramsStartIndex] -match '^.*params:\s*\{\s*\}\s*$') { $paramsBlockArray = @() } else { # Handle case where params are provided $paramsEndIndex = $paramsStartIndex do { $paramsEndIndex++ } while ($rawBicepExample[$paramsEndIndex] -notMatch "^\s{$paramsIndent}\}" -and $rawBicepExample[$paramsEndIndex] -notMatch "^\s{$paramsIndent}\}\]" -and $rawBicepExample[$paramsEndIndex] -notMatch "^\s{$paramsIndent}\]" -and $paramsEndIndex -lt $rawBicepExample.Count) if ($paramsEndIndex -eq $rawBicepExample.Count) { throw "End index of 'params' block for test file [$testFilePath] not found." } $paramsBlock = $rawBicepExample[($paramsStartIndex + 1) .. ($paramsEndIndex - 1)] $paramsBlockArray = $paramsBlock -replace "^\s{$paramsIndent}" # Remove excess leading spaces # [2/6] Replace placeholders $serviceShort = ([regex]::Match($rawContent, "(?m)^param serviceShort string = '(.+)'\s*$")).Captures.Groups[1].Value $paramsBlockString = ($paramsBlockArray | Out-String) $paramsBlockString = $paramsBlockString -replace '\$\{serviceShort\}', $serviceShort $paramsBlockString = $paramsBlockString -replace '\$\{namePrefix\}[-|\.|_]?', '' # Replacing with empty to not expose prefix and avoid potential deployment conflicts $paramsBlockString = $paramsBlockString -replace '(?m):\s*location\s*$', ': ''<location>''' # [3/6] Format header, remove scope property & any empty line $paramsBlockArray = $paramsBlockString -split '\n' | Where-Object { -not [String]::IsNullOrEmpty($_) } $paramsBlockArray = $paramsBlockArray | ForEach-Object { " $_" } } } # [4/6] Convert Bicep parameter block to JSON parameter block to enable processing $conversionInputObject = @{ BicepParamBlock = ($paramsBlockArray | Out-String).TrimEnd() CurrentFilePath = $testFilePath } $paramsInJSONFormat = ConvertTo-FormattedJSONParameterObject @conversionInputObject # [5/6] Convert JSON parameters back to Bicep and order & format them $conversionInputObject = @{ JSONParameters = $paramsInJSONFormat RequiredParametersList = $RequiredParametersList } $bicepExample = ConvertTo-FormattedBicep @conversionInputObject # [6/6] Convert the Bicep format to a Bicep parameters file format if ($bicepExample.length -gt 0) { $bicepParamBlockArray = $bicepExample -split '\r?\n' $topLevelParamIndent = ([regex]::Match($bicepParamBlockArray[0], '^(\s+).*')).Captures.Groups[1].Value.Length $bicepParametersFileExample = $bicepParamBlockArray | ForEach-Object { $line = $_ $line = $line -replace "^(\s{$topLevelParamIndent})([a-zA-Z]*)(:)(.*)", 'param $2 =$4' # Update any [ xyz: abc] to [param xyz = abc] $line = $line -replace "^\s{$topLevelParamIndent}", '' # Update any [ xyz: abc] to [xyz: abc] $line } } # --------------------- # # Add Bicep example # # --------------------- # if ($addBicep) { $formattedBicepExample = @( "module $moduleNameCamelCase 'br/public:$($brLink):$($targetVersion)' = {", " name: '$($moduleNameCamelCase)Deployment'" ' params: {' ) + $bicepExample + @( ' }', '}' ) # Build result $testFilesContent += @( '', '<details>' '' '<summary>via Bicep module</summary>' '' '```bicep', ($formattedBicepExample | ForEach-Object { "$_" }).TrimEnd(), '```', '', '</details>', '<p>' ) } # -------------------- # # Add JSON example # # -------------------- # if ($addJson) { # [1/2] Get all parameters from the parameter object and order them recursively $orderingInputObject = @{ ParametersJSON = $paramsInJSONFormat | ConvertTo-Json -Depth 99 RequiredParametersList = $RequiredParametersList } $orderedJSONExample = Build-OrderedJSONObject @orderingInputObject # [2/2] Create the final content block $testFilesContent += @( '', '<details>' '' '<summary>via JSON parameters file</summary>' '' '```json', $orderedJSONExample.Trim() '```', '', '</details>', '<p>' ) } # ---------------------------------------- # # Add Bicep parameters file example # # ---------------------------------------- # if ($addBicepParametersFile) { $formattedbicepParametersFileExample = @( "using 'br/public:$($brLink):$($targetVersion)'" '' ) + $bicepParametersFileExample # Build result $testFilesContent += @( '', '<details>' '' '<summary>via Bicep parameters file</summary>' '' '```bicep-params', ($formattedbicepParametersFileExample | ForEach-Object { "$_" }).TrimEnd(), '```', '', '</details>', '<p>' ) } } else { # Non-module deployment (e.g., utility deployment) # ----------------------------- # # Add non-formatted example # # ----------------------------- # $testFilesContent += @( '', '<details>' '' '<summary>via Bicep module</summary>' '' '```bicep', $rawContentArray, '```', '', '</details>', '<p>' ) } $testFilesContent += @( '' ) $pathIndex++ } foreach ($rawHeader in $usageExampleSectionHeaders) { $navigationHeader = (($rawHeader.header -replace '<\/?.+?>|[^A-Za-z0-9\s-]').Trim() -replace '\s+', '-').ToLower() # Remove any html and non-identifer elements $SectionContent += '- [{0}](#{1})' -f $rawHeader.title, $navigationHeader } $SectionContent += '' $SectionContent += $testFilesContent ###################### ## Built result ## ###################### if ($SectionContent) { if ($PSCmdlet.ShouldProcess('Original file with new template references content', 'Merge')) { return Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -ContentType 'nextH2' } } else { return $ReadMeFileContent } } <# .SYNOPSIS Generate a table of content section for the given readme file .DESCRIPTION Generate a table of content section for the given readme file .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'navigation' section. Defaults to '## Navigation' .EXAMPLE Set-TableOfContent -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme's '## Navigation' section to reflect the latest file structure #> function Set-TableOfContent { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Navigation' ) $newSectionContent = [System.Collections.ArrayList]@() $contentPointer = 1 while ($ReadMeFileContent[$contentPointer] -notlike '#*') { $contentPointer++ } $headers = $ReadMeFileContent.Split('\n') | Where-Object { $_ -like '## *' } if ($headers -notcontains $SectionStartIdentifier) { $beforeContent = $ReadMeFileContent[0 .. ($contentPointer - 1)] $afterContent = $ReadMeFileContent[$contentPointer .. ($ReadMeFileContent.Count - 1)] $ReadMeFileContent = $beforeContent + @($SectionStartIdentifier, '') + $afterContent } $headers | Where-Object { $_ -ne $SectionStartIdentifier } | ForEach-Object { $newSectionContent += '- [{0}](#{1})' -f $_.Replace('#', '').Trim(), $_.Replace('#', '').Trim().Replace(' ', '-').Replace('.', '') } # Build result if ($PSCmdlet.ShouldProcess('Original file with new navigation content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $newSectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Initialize the readme file .DESCRIPTION Create the initial skeleton of the section headers, name & description. .PARAMETER ReadMeFilePath Required. The path to the readme file to initialize. .PARAMETER FullModuleIdentifier Required. The full identifier of the module. For example: 'sql/managed-instance/administrator' .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .EXAMPLE Initialize-ReadMe -ReadMeFilePath 'C:/ResourceModules/modules/sql/managed-instances/administrators/readme.md' -FullModuleIdentifier 'sql/managed-instance/administrator' -TemplateFileContent @{ resource = @{}; ... } Initialize the readme of the 'sql/managed-instance/administrator' module #> function Initialize-ReadMe { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ReadMeFilePath, [Parameter(Mandatory = $true)] [string] $FullModuleIdentifier, [Parameter(Mandatory = $true)] [hashtable] $TemplateFileContent ) $moduleName = $TemplateFileContent.metadata.name $moduleDescription = $TemplateFileContent.metadata.description if ($ReadMeFilePath -match 'avm.(?:res)') { # Resource module $formattedResourceType = Get-SpecsAlignedResourceName -ResourceIdentifier $FullModuleIdentifier $inTemplateResourceType = (Get-NestedResourceList $TemplateFileContent).type | Select-Object -Unique | Where-Object { $_ -match "^$formattedResourceType$" } if ($inTemplateResourceType) { $headerType = $inTemplateResourceType } else { Write-Warning "No resource type like [$formattedResourceType] found in template. Falling back to it as identifier." $headerType = $formattedResourceType } } else { # Non-resource modules always need a custom identifier $parentIdentifierName, $childIdentifierName = $FullModuleIdentifier -Split '[\/|\\]', 2 # e.g. 'lz' & 'sub-vending' $formattedParentIdentifierName = (Get-Culture).TextInfo.ToTitleCase(($parentIdentifierName -Replace '[^0-9A-Z]', ' ')) -Replace ' ' $formattedChildIdentifierName = (Get-Culture).TextInfo.ToTitleCase(($childIdentifierName -Replace '[^0-9A-Z]', ' ')) -Replace ' ' $headerType = "$formattedParentIdentifierName/$formattedChildIdentifierName" } # Orphaned readme existing? $orphanedReadMeFilePath = Join-Path (Split-Path $ReadMeFilePath -Parent) 'ORPHANED.md' if (Test-Path $orphanedReadMeFilePath) { $orphanedReadMeContent = Get-Content -Path $orphanedReadMeFilePath | ForEach-Object { "> $_" } } # Moved readme existing? $movedReadMeFilePath = Join-Path (Split-Path $ReadMeFilePath -Parent) 'MOVED-TO-AVM.md' if (Test-Path $movedReadMeFilePath) { $movedReadMeContent = Get-Content -Path $movedReadMeFilePath | ForEach-Object { "> $_" } } $initialContent = @( "# $moduleName ``[$headerType]``", '', ((Test-Path $orphanedReadMeFilePath) ? $orphanedReadMeContent : $null), ((Test-Path $orphanedReadMeFilePath) ? '' : $null), ((Test-Path $movedReadMeFilePath) ? $movedReadMeContent : $null), ((Test-Path $movedReadMeFilePath) ? '' : $null), $moduleDescription, '' ) | Where-Object { $null -ne $_ } # Filter null values $readMeFileContent = $initialContent return $readMeFileContent } #endregion <# .SYNOPSIS Update/add the readme that matches the given template file .DESCRIPTION Update/add the readme that matches the given template file Supports both ARM & bicep templates. .PARAMETER TemplateFilePath Mandatory. The path to the template to update .PARAMETER ReadMeFilePath Optional. The path to the readme to update. If not provided assumes a 'README.md' file in the same folder as the template .PARAMETER SectionsToRefresh Optional. The sections to update. By default it refreshes all that are supported. .PARAMETER PreLoadedContent Optional. Pre-Loaded content. May be used to reuse the same data for multiple invocations. For example: @{ CrossReferencedModuleList = @{} // Optional. Cross Module References to consider when refreshing the readme. Can be provided to speed up the generation. If not provided, is fetched by this script. TemplateFileContent = @{} // Optional. The template file content to process. If not provided, the template file content will be read from the TemplateFilePath file. TelemetryFileContent = @() // Optional. The text of the telemetry notice to add to each readme. } .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:\main.bicep' Update the readme in path 'C:\README.md' based on the bicep template in path 'C:\main.bicep' .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:/network/load-balancer/main.bicep' -SectionsToRefresh @('Parameters', 'Outputs') Generate the Module ReadMe only for specific sections. Updates only the sections `Parameters` & `Outputs`. Other sections remain untouched. .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:/network/load-balancer/main.bicep' -PreLoadedContent @{ TemplateFileContent = @{...} } (Re)Generate the readme file for template 'loadBalancer' based on the content provided in the PreLoadedContent.TemplateFileContent parameter .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:/network/load-balancer/main.bicep' -ReadMeFilePath 'C:/differentFolder' Generate the Module ReadMe files into a specific folder path .EXAMPLE $templatePaths = (Get-ChildItem 'C:/network' -Filter 'main.bicep' -Recurse).FullName $templatePaths | ForEach-Object -Parallel { . '<PathToRepo>/utilities/tools/Set-ModuleReadMe.ps1' ; Set-ModuleReadMe -TemplateFilePath $_ } Generate the Module ReadMe for any template in a folder path #> function Set-ModuleReadMe { [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory = $true)] [string] $TemplateFilePath, [Parameter(Mandatory = $false)] [string] $ReadMeFilePath = (Join-Path (Split-Path $TemplateFilePath -Parent) 'README.md'), [Parameter(Mandatory = $false)] [hashtable] $PreLoadedContent = @{}, [Parameter(Mandatory = $false)] [ValidateSet( 'Resource Types', 'Usage examples', 'Parameters', 'Functions', 'Outputs', 'CrossReferences', 'Template references', 'Navigation', 'DataCollection' )] [string[]] $SectionsToRefresh = @( 'Resource Types', 'Usage examples', 'Parameters', 'Functions', 'Outputs', 'CrossReferences', 'Template references', 'Navigation', 'DataCollection' ) ) # Load external functions . (Join-Path $PSScriptRoot 'Get-NestedResourceList.ps1') . (Join-Path $PSScriptRoot 'helper' 'Merge-FileWithNewContent.ps1') . (Join-Path $PSScriptRoot 'helper' 'Get-IsParameterRequired.ps1') . (Join-Path $PSScriptRoot 'helper' 'Get-SpecsAlignedResourceName.ps1') . (Join-Path $PSScriptRoot 'helper' 'ConvertTo-OrderedHashtable.ps1') . (Join-Path $PSScriptRoot 'Get-BRMRepositoryName.ps1') . (Join-Path $PSScriptRoot 'helper' 'Get-CrossReferencedModuleList.ps1') # Check template & make full path $TemplateFilePath = Resolve-Path -Path $TemplateFilePath -ErrorAction Stop if (-not (Test-Path $TemplateFilePath -PathType 'Leaf')) { throw "[$TemplateFilePath] is no valid file path." } # Build template, if required if ($PreLoadedContent.Keys -notcontains 'TemplateFileContent') { if ((Split-Path -Path $TemplateFilePath -Extension) -eq '.bicep') { $templateFileContent = bicep build $TemplateFilePath --stdout | ConvertFrom-Json -AsHashtable } else { $templateFileContent = ConvertFrom-Json (Get-Content $TemplateFilePath -Encoding 'utf8' -Raw) -ErrorAction 'Stop' -AsHashtable } } else { $templateFileContent = $PreLoadedContent.TemplateFileContent } if (-not $templateFileContent) { throw "Failed to compile [$TemplateFilePath]" } $moduleRoot = Split-Path $TemplateFilePath -Parent $fullModuleIdentifier = ($moduleRoot -split '[\/|\\]avm[\/|\\](res|ptn|utl)[\/|\\]')[2] -replace '\\', '/' # Custom modules are modules having the same resource type but different properties based on the name # E.g., web/site/config--appsetting vs web/site/config--authsettingv2 $customModuleSeparator = '--' if ($fullModuleIdentifier.Contains($customModuleSeparator)) { $fullModuleIdentifier = $fullModuleIdentifier.split($customModuleSeparator)[0] } # ===================== # # Preparation steps # # ===================== # # Read original readme, if any. Then delete it to build from scratch if ((Test-Path $ReadMeFilePath) -and -not ([String]::IsNullOrEmpty((Get-Content $ReadMeFilePath -Raw)))) { $readMeFileContent = Get-Content -Path $ReadMeFilePath -Encoding 'utf8' } # Make sure we preserve any manual notes a user might have added in the corresponding section if ($match = $readMeFileContent | Select-String -Pattern '## Notes') { $startIndex = $match.LineNumber if ($readMeFileContent[($startIndex + 1)] -notlike '## *') { $endIndex = $startIndex + 1 } else { $endIndex = $startIndex } while (-not (($endIndex + 1) -gt $readMeFileContent.count) -and $readMeFileContent[($endIndex + 1)] -notlike '## *') { $endIndex++ } $notes = $readMeFileContent[($startIndex - 1)..$endIndex] } else { $notes = @() } # Initialize readme $inputObject = @{ ReadMeFilePath = $ReadMeFilePath FullModuleIdentifier = $FullModuleIdentifier TemplateFileContent = $templateFileContent } $readMeFileContent = Initialize-ReadMe @inputObject # =============== # # Set content # # =============== # if ($SectionsToRefresh -contains 'Resource Types') { # Handle [Resource Types] section # =============================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-ResourceTypesSection @inputObject } $hasTests = (Get-ChildItem -Path $moduleRoot -Recurse -Filter 'main.test.bicep' -File -Force).count -gt 0 if ($SectionsToRefresh -contains 'Usage examples' -and $hasTests) { # Handle [Usage examples] section # =================================== $inputObject = @{ ModuleRoot = $ModuleRoot FullModuleIdentifier = $fullModuleIdentifier ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-UsageExamplesSection @inputObject } if ($SectionsToRefresh -contains 'Parameters') { # Handle [Parameters] section # =========================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent currentFolderPath = (Split-Path $TemplateFilePath -Parent) } $readMeFileContent = Set-ParametersSection @inputObject } if ($SectionsToRefresh -contains 'Functions') { # Handle [Functions] section # =========================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-FunctionsSection @inputObject } if ($SectionsToRefresh -contains 'Outputs') { # Handle [Outputs] section # ======================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-OutputsSection @inputObject } if ($SectionsToRefresh -contains 'CrossReferences') { # Handle [CrossReferences] section # ======================== $inputObject = @{ ModuleRoot = $ModuleRoot FullModuleIdentifier = $fullModuleIdentifier ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent PreLoadedContent = $PreLoadedContent } $readMeFileContent = Set-CrossReferencesSection @inputObject } # Handle [Notes] section # ======================== if ($notes) { $readMeFileContent += @( '' ) $readMeFileContent += $notes } if ($SectionsToRefresh -contains 'DataCollection') { # Handle [DataCollection] section # ======================== $inputObject = @{ TemplateFileContent = $templateFileContent ReadMeFileContent = $readMeFileContent PreLoadedContent = $PreLoadedContent } $readMeFileContent = Set-DataCollectionSection @inputObject } if ($SectionsToRefresh -contains 'Navigation') { # Handle [Navigation] section # =================================== $inputObject = @{ ReadMeFileContent = $readMeFileContent } $readMeFileContent = Set-TableOfContent @inputObject } Write-Verbose 'New content:' Write-Verbose '============' Write-Verbose ($readMeFileContent | Out-String) if (Test-Path $ReadMeFilePath) { if ($PSCmdlet.ShouldProcess("File in path [$ReadMeFilePath]", 'Overwrite')) { Set-Content -Path $ReadMeFilePath -Value $readMeFileContent -Force -Encoding 'utf8' } Write-Verbose "File [$ReadMeFilePath] updated" -Verbose } else { if ($PSCmdlet.ShouldProcess("File in path [$ReadMeFilePath]", 'Create')) { $null = New-Item -Path $ReadMeFilePath -Value ($readMeFileContent | Out-String) -Force } Write-Verbose "File [$ReadMeFilePath] created" -Verbose } }