Files

498 lines
20 KiB
PowerShell

# Markdown output provider.
#
# Consumes the per-object documentation result (see DocumentationOutputJson.ps1
# header for the field list). Renders HTML-tabled markdown with CSS styling
# from Internal/Documentation/Assets/DefaultMDStyle.css (or a user-supplied .css file).
#
# Differences vs old DocumentationMD.psm1:
# - Drops V1 NewObjectGroup/NewObjectType hooks; V2 (string-arg) is registered
# - Drops the extended/custom property selectors that read $global:cb*/$global:txt*
# UI controls. Always uses DefaultDocumentationProperties or ('Name','Value').
# Phase 2 [DocumentationContext] will reintroduce these via $ctx.Options.
# - Drops the unused commented-out block at end of Invoke-MDPostProcessItems
# - Uses the typed-object API: $PolicyObject.Name + $PolicyObject.PolicyType.Title
# instead of Get-GraphObjectName $obj $objectType + $objectType.Title
function Invoke-InitializeMDOutput {
Add-DocumentationOutputProvider ([PSCustomObject]@{
Name = "Markdown"
Value = "md"
# Path metadata (see DocumentationOutputHTML.ps1 header comment).
# UI browse-button wiring lives in
# UI/<backend>/ClassExtensions/DocumentationOutputMDUIExtension.ps1.
PrimaryPathOption = "MDDocumentName"
PathIsFolder = $false
PreProcess = { Invoke-MDPreProcessItems @args }
NewObjectGroup = { Invoke-MDNewObjectGroup @args }
NewObjectType = { Invoke-MDNewObjectType @args }
Process = { Invoke-MDProcessItem @args }
PostProcess = { Invoke-MDPostProcessItems @args }
ProcessAllObjects = { Invoke-MDProcessAllObjects @args }
})
}
function Invoke-MDProcessAllObjects {
param($allObjectTypeObjects, $objectType)
# Reserved for cross-object aggregation (e.g. consolidated ScopeTags table)
}
function Invoke-MDPreProcessItems {
$script:sectionAnchors = @()
$script:totAnchors = @()
$script:mdStrings = $null
$script:currentItemFileName = $null
$defaultCSSFile = [IO.Path]::Combine($script:AppRootFolder, "Internal", "Documentation", "Assets", "DefaultMDStyle.css")
$mdCssFile = Get-DocumentationOutputOption md "MDCSSFile" $defaultCSSFile
$includeCss = (Get-DocumentationOutputOption md "MDIncludeCSS" $true) -eq $true
if (-not $mdCssFile) {
Write-Log "CSS file not specified. Using default" 2
$mdCssFile = $defaultCSSFile
}
elseif (-not [IO.File]::Exists($mdCssFile)) {
Write-Log "CSS file $mdCssFile not found. Using default" 2
$mdCssFile = $defaultCSSFile
}
if ($includeCss -and [IO.File]::Exists($mdCssFile)) {
Write-Log "Using CSS file $mdCssFile"
$script:cssStyle = ([IO.File]::ReadAllText($mdCssFile)) + [Environment]::NewLine
}
else {
Write-Log "CSS file $mdCssFile not found. No styles applied" 2
$script:cssStyle = ""
}
$fileName = Get-DocumentationOutputOption md "MDDocumentName" ""
if (-not $fileName) { $fileName = "%MyDocuments%\%Organization%-%Date%.md" }
$fileName = Expand-FileName $fileName
$script:outFile = $fileName
$script:documentPath = [IO.Path]::GetDirectoryName($fileName)
$script:outputType = Get-DocumentationOutputOption md "MDDocumentFileType" "Full"
if ($script:outputType -eq "Object") {
Write-Log "Document one file for each object + index file"
}
else {
Write-Log "Document one single file for all objects"
$script:outputType = "Full"
$script:mdStrings = [System.Text.StringBuilder]::new()
}
}
function Invoke-MDPostProcessItems {
$userName = $null
$mail = ""
$me = Get-CurrentUser
if ($me) {
if ($me.givenName -and $me.surname) {
$userName = "$($me.givenName) $($me.surname)"
}
else {
$userName = $me.displayName
}
if ($me.mail) { $mail = " ($($me.mail))" }
}
$orgName = Get-CurrentOrganizationName
$title = Get-DocumentationOutputOption md "MDTitleProperty" "Intune documentation"
if (-not $title) { $title = "Intune documentation" }
$mdContent = [System.Text.StringBuilder]::new()
[void]$mdContent.AppendLine("# $title")
[void]$mdContent.AppendLine("")
[void]$mdContent.AppendLine("")
if (-not ((Get-DocumentationOption "SkipDocumentInfo" $false) -eq $true)) {
if ($orgName) { [void]$mdContent.AppendLine("*Organization:* $orgName`n") }
if ($userName) { [void]$mdContent.AppendLine("*Generated by:* $userName$mail`n") }
$skipDate = (Get-DocumentationOutputOption md "MDDocumentSkipDate" $false) -eq $true
if (-not $skipDate) {
[void]$mdContent.AppendLine("*Generated:* $((Get-Date).ToShortDateString()) $((Get-Date).ToLongTimeString())`n")
}
}
if ($script:sectionAnchors.Count -gt 0) {
[void]$mdContent.AppendLine("")
[void]$mdContent.AppendLine("## Table of Contents")
}
foreach ($header in $script:sectionAnchors) {
$indent = [string]::new(" ", (($header.Level - 1) * 2))
[void]$mdContent.AppendLine("$indent- [$($header.Name)]($($header.FileName)#$($header.Anchor))`n")
}
[void]$mdContent.AppendLine("")
$mdText = $script:cssStyle + $mdContent.ToString()
if ($script:outputType -eq "Full" -and $script:mdStrings) {
$mdText += $script:mdStrings.ToString()
}
Save-DocumentationFile $mdText $script:outFile -OpenFile:((Get-DocumentationOutputOption md "MDOpenFile" $true) -eq $true)
}
function Invoke-MDNewObjectGroup {
param($groupId)
Add-MDHeader (Get-DocObjectTypeString $groupId) -Level 1 -UseHTML
}
function Invoke-MDNewObjectType {
param($objectTypeName)
Add-MDHeader $objectTypeName -Level 2 -UseHTML
}
# Per-object file name for Object mode. Identity, not title: the policy's own
# display name plus its type and id, the shape Get-HTMLObjectFileName uses.
#
# The display name alone was never unique. Five enrollment defaults - device
# limit, platform restrictions, enrollment status page, Windows Hello for
# Business, Windows Restore - are all called "All users and all devices", so
# they all wrote All_users_and_all_devices.md and the last one won. Deriving the
# name from the heading instead would not do either: the heading may be a
# DocumentName override, and a file name has to identify the object, not
# describe it. The type and id settle it. Spaces become underscores and the
# suffix carries no brackets, because this name lands inside Markdown link
# destinations.
function Get-MDObjectFileName {
param($PolicyObject)
$objName = if ($PolicyObject.Name) { [string]$PolicyObject.Name } else { 'Unnamed policy' }
$id = if ($PolicyObject.Id) { [string]$PolicyObject.Id } else { $null }
$typeId = if ($PolicyObject.PolicyType -and $PolicyObject.PolicyType.Id) { [string]$PolicyObject.PolicyType.Id } else { $null }
$suffix = if ($typeId -and $id) { " $typeId-$id" }
elseif ($id) { " $id" }
else { '' }
# A policy name can run past 200 characters and the suffix adds up to
# around 120 more - past what a path may hold, where the name-only file
# still wrote. The suffix is the identity, so it is the name that gives way.
$maxNameLength = 80
if ($objName.Length -gt $maxNameLength) { $objName = $objName.Substring(0, $maxNameLength).TrimEnd() }
return (Remove-InvalidFileNameChars "$objName$suffix.md").Replace(' ', '_')
}
function Invoke-MDProcessItem {
param($PolicyObject, $documentedObj)
if (-not $documentedObj -or -not $PolicyObject) { return }
# A documented object may ask to be titled by something other than its display
# name (see Get-DocumentationDisplayName). Headings and captions follow it; the
# file name below deliberately does not.
$objName = Get-DocumentationDisplayName $PolicyObject $documentedObj
$script:docDisplayName = $objName
$typeTitle = $PolicyObject.PolicyType.Title
if ($script:outputType -eq "Object") {
$script:totAnchors = @()
$script:mdStrings = [System.Text.StringBuilder]::new()
$script:currentItemFileName = "./$(Get-MDObjectFileName $PolicyObject)"
}
Add-MDHeader $objName -Level 3 -UseHTML
[void]$script:mdStrings.AppendLine("")
try {
foreach ($tableType in @("BasicInfo","FilteredSettings")) {
if ($tableType -eq "BasicInfo") {
$properties = @("Name","Value")
$lngId = "SettingDetails.basics"
}
else {
if ($documentedObj.DefaultDocumentationProperties) {
$properties = $documentedObj.DefaultDocumentationProperties
}
else {
$properties = @("Name","Value")
}
$lngId = "TableHeaders.settings"
}
# Custom tables with a negative Order belong ABOVE the settings
# table: the portal shows a MAM app config's "Settings catalog"
# blade above its "Settings" blade.
if ($tableType -eq "FilteredSettings") {
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -lt 0 } | Sort-Object -Property Order)) {
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
}
}
if (($documentedObj.$tableType | Measure-Object).Count -gt 0) {
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.$tableType $properties $lngId -AddCategories -AddSubcategories
}
}
if (($documentedObj.ComplianceActions | Measure-Object).Count -gt 0) {
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ComplianceActions @("Action","Schedule","MessageTemplate","EmailCC") "Category.complianceActionsLabel"
}
if (($documentedObj.ApplicabilityRules | Measure-Object).Count -gt 0) {
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.ApplicabilityRules @("Rule","Property","Value") "SettingDetails.applicabilityRules"
}
Add-MDObjectScripts $documentedObj
# Negative Order already rendered above the settings table.
foreach ($customTable in ($documentedObj.CustomTables | Where-Object { $_.Order -ge 0 } | Sort-Object -Property Order)) {
Add-MDTableItems $PolicyObject $typeTitle $customTable.Values $customTable.Columns $customTable.LanguageId -AddCategories -AddSubcategories
}
if (($documentedObj.Assignments | Measure-Object).Count -gt 0) {
if ($documentedObj.Assignments[0].RawIntent) {
$properties = @("GroupMode","Group","Filter","FilterMode")
$settingsObj = $documentedObj.Assignments | Where-Object { $_.Settings -ne $null } | Select-Object -First 1
if ($settingsObj) {
foreach ($objProp in $settingsObj.Settings.Keys) {
if ($objProp -in $properties) { continue }
if ($objProp -in @("Category","RawIntent")) { continue }
$properties += "Settings.$objProp"
}
}
}
else {
$hasFilter = $false
foreach ($a in $documentedObj.Assignments) {
if ($a.PSObject.Properties.Name -contains "FilterMode") { $hasFilter = $true; break }
}
$properties = @("Group")
if ($hasFilter) { $properties += @("Filter","FilterMode") }
}
Add-MDTableItems $PolicyObject $typeTitle $documentedObj.Assignments $properties "TableHeaders.assignments" -AddCategories
}
}
catch {
Write-LogError "Failed to process object $objName" $_.Exception
}
if ($script:outputType -eq "Object") {
$perObjectText = $script:cssStyle + $script:mdStrings.ToString()
$fileName = Join-Path $script:documentPath $script:currentItemFileName
Save-DocumentationFile $perObjectText $fileName
$script:mdStrings = $null
}
}
function Add-MDTableItems {
param(
$PolicyObject,
[string]$TypeTitle,
$Items,
[string[]]$Properties,
[string]$LngId,
[switch]$AddCategories,
[switch]$AddSubcategories,
$CaptionOverride
)
$objName = Get-DocCaptionName $PolicyObject
if ($CaptionOverride) {
$caption = $CaptionOverride
}
elseif ($LngId) {
$caption = "$((Get-LanguageString $LngId)) - $objName"
}
else {
$caption = "$objName ($TypeTitle)"
}
$tableText = [System.Text.StringBuilder]::new()
[void]$tableText.AppendLine("<table class='table-settings'>")
[void]$tableText.AppendLine("<tr class='table-header1'>")
$columnCount = 0
foreach ($prop in $Properties) {
[void]$tableText.AppendLine("<td>$((Invoke-DocTranslateColumnHeader $prop.Split('.')[-1]))</td>")
$columnCount++
}
[void]$tableText.AppendLine("</tr>")
$curCategory = ""
$curSubCategory = ""
foreach ($itemObj in $Items) {
$additionalRowClass = ""
if ($itemObj.Category -and $curCategory -ne $itemObj.Category -and $AddCategories) {
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level1'>$((Set-MDText $itemObj.Category))</td></tr>")
$curCategory = $itemObj.Category
$curSubCategory = ""
}
if ($itemObj.SubCategory -and $curSubCategory -ne $itemObj.SubCategory -and $AddSubcategories) {
[void]$tableText.AppendLine("<tr><td colspan=`"$columnCount`" class='category-level2'>$((Set-MDText $itemObj.SubCategory))</td></tr>")
$curSubCategory = $itemObj.SubCategory
}
if ($itemObj.PropertyIndex -is [int] -and $itemObj.PropertyIndex -eq 1) {
$additionalRowClass = "row-new-property"
}
try {
[void]$tableText.AppendLine("<tr class='$additionalRowClass'>")
$curCol = 1
foreach ($prop in $Properties) {
try {
$propArr = $prop.Split('.')
$tmpObj = $itemObj
$propName = $propArr[-1]
for ($x = 0; $x -lt ($propArr.Count - 1); $x++) {
$tmpObj = $tmpObj."$($propArr[$x])"
}
if ($propName -eq "Value" -and ($itemObj.FullValueTable | Measure-Object).Count -gt 0) {
[void]$tableText.AppendLine("<td><table class='table-value'><tr>")
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
[void]$tableText.AppendLine("<td class='table-header1'>$($colProp.Name)</td>")
}
[void]$tableText.AppendLine("</tr>")
foreach ($rowVal in $itemObj.FullValueTable) {
[void]$tableText.AppendLine("<tr>")
foreach ($colProp in $itemObj.FullValueTable[0].PSObject.Properties) {
[void]$tableText.AppendLine("<td>$($rowVal."$($colProp.Name)")</td>")
}
[void]$tableText.AppendLine("</tr>")
}
[void]$tableText.AppendLine("</table></td>")
}
else {
$style = ""
if ($curCol -eq 1 -and $itemObj.Level) {
try {
$level = [int]$itemObj.Level
$style = " style='padding-left:$((5 + ($level * 5)))px !important;'"
} catch {}
}
[void]$tableText.AppendLine("<td class='property-column$curCol'$style>$((Set-MDText $tmpObj.$propName -CodeBlock))</td>")
}
}
catch {
Write-LogError "Failed to add property value for $prop" $_.Exception
}
$curCol++
}
}
catch {
Write-Log "Failed to process property" 2
}
finally {
[void]$tableText.AppendLine("</tr>")
}
}
[void]$tableText.AppendLine("</table>")
Add-MDText $tableText.ToString()
Add-MDHeader $caption -Level 6 -ToT -AddParagraph
}
function Add-MDText {
param([string]$Text, [switch]$AddParagraph)
[void]$script:mdStrings.AppendLine($Text)
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("") }
}
function Set-MDText {
param([string]$Text, [switch]$CodeBlock)
if ($null -eq $Text) { return }
$txtSummary = ""
$textOut = ""
if ($Text -and $Text.Length -gt 250) {
$summaryMax = 40
$idx = $Text.IndexOfAny(@("`r","`n"))
if ($idx -gt 10 -and $idx -lt 50) { $summaryMax = $idx }
$txtSummary = $Text.Substring(0, $summaryMax)
}
if ($CodeBlock) {
$trim = $Text.Trim()
if ($trim.StartsWith("<?xml") -or $trim.StartsWith("<xml") -or ($trim.StartsWith("<") -and $trim.EndsWith(">"))) {
$nl = [Environment]::NewLine
$textOut = "$nl$nl``````xml$nl$Text$nl```````$nl$nl"
}
}
if (-not $CodeBlock -or -not $textOut) {
$t = $Text.Replace("|", '`|')
$t = $t.Replace("*", '`*')
$t = $t.Replace("`$", '`$')
$t = $t.Replace("`r`n", "<br />")
$textOut = $t.Replace("`n", "<br />")
}
if ($txtSummary) {
"<details class='description'><summary data-open='Minimize' data-close='$txtSummary...expand'></summary>$textOut</details>"
}
else {
$textOut
}
}
function Add-MDHeader {
param(
[string]$Text,
[int]$Level = 1,
[switch]$AddParagraph,
[switch]$UseHTML,
[switch]$ToT,
[switch]$SkipTOC
)
if ($script:mdStrings) {
$prefix = ""
if ($ToT) { $prefix = "Table $($script:totAnchors.Count + 1). " }
if ($UseHTML) {
if ($ToT) { $sectionAnchor = "table-$($script:totAnchors.Count + 1)" }
else { $sectionAnchor = "section-$($script:sectionAnchors.Count + 1)" }
[void]$script:mdStrings.AppendLine("<h$Level id=`"$prefix$sectionAnchor`">$Text</h$Level>")
}
else {
$Text = "$prefix$Text"
$sectionAnchor = $Text.ToLower().Replace(" ", "-").Replace("[","").Replace("]","")
$mdHeader = [string]::new('#', $Level)
[void]$script:mdStrings.AppendLine("$mdHeader $Text")
}
$fileName = $script:currentItemFileName
}
else {
$sectionAnchor = $null
$fileName = $null
}
if ($ToT) {
$script:totAnchors += [PSCustomObject]@{
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
}
}
elseif (-not $SkipTOC) {
$script:sectionAnchors += [PSCustomObject]@{
Name = $Text; Anchor = $sectionAnchor; FileName = $fileName; Level = $Level
}
}
if ($AddParagraph) { [void]$script:mdStrings.AppendLine("`n") }
}
function Add-MDObjectScripts {
param($documentedObj)
foreach ($scriptItem in $documentedObj.Scripts) {
if (-not $scriptItem.ScriptContent -or -not $scriptItem.Caption) { continue }
[void]$script:mdStrings.AppendLine("~~~powershell")
[void]$script:mdStrings.AppendLine($scriptItem.ScriptContent)
[void]$script:mdStrings.AppendLine("~~~")
Add-MDHeader $scriptItem.Caption -Level 6 -SkipTOC -AddParagraph
}
}
Invoke-InitializeMDOutput