Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 3 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}

- name: Setup PowerShell modules
run: |
Expand All @@ -36,33 +35,12 @@ jobs:
Install-Module PSSharedGoods -Force -Scope CurrentUser -AllowClobber
shell: pwsh

- name: Refresh module manifest
env:
RefreshPSD1Only: 'true'
- name: Validate module manifest
run: |
./Build/Build-Module.ps1
Test-ModuleManifest -Path ./PSWriteHTML.psd1 -ErrorAction Stop
shell: pwsh

- name: Commit refreshed PSD1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add PSWriteHTML.psd1
git diff --cached --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host "No PSD1 changes to commit."
} elseif ($LASTEXITCODE -eq 1) {
git commit -m "chore: regenerate psd1"
git push origin HEAD:$Env:HEAD_BRANCH
} else {
throw "Unable to inspect staged PSD1 changes (git exit code $LASTEXITCODE)."
}
shell: pwsh

- name: Upload refreshed manifest
- name: Upload validated manifest
uses: actions/upload-artifact@v7
with:
name: psd1
Expand Down
15 changes: 14 additions & 1 deletion CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@
Unfortunetly some code is very old, some code doesn't serve it's purpose anymore, and requires heavy changes that may have an impact on already deployed code.
Please make sure to read changes before updating, as we're undergoing some breaking changes.

## 1.42.0

- Table exports support explicit row scopes with `-ExportScope`. Excel retains long numeric identifiers as text and supports named `-TextColumns`.
- CSV exports quote fields and protect formula-like text. `New-TableButtonCSV -RawData` retains those values for programmatic consumers.
- Column filters preserve header names, restore saved searches, and follow column visibility. Search panes support named column selection with `-SearchPaneColumns`.
- Row grouping supports multiple levels, independent collapse state, and responsive child rows.
- Inline table conditions can read excluded helper properties, and table rendering preserves the caller's arrays and dates.
- Nested tabs initialize their tables correctly and support deep links and keyboard navigation. Top menu headings follow their configured destinations.
- Markdown preserves backslashes and multiline content, embeds relative local images, and supports `-ImageBase` and `-EmbedImages`.
- Text links support parenthesized labels and nested parentheses in URLs. Merged table titles and column names support independent alignment.
- Chart and widget serialization preserves quoted strings and keeps data containing HTML script delimiters inside its script value. Ajax table files use UTF-8 on both PowerShell runtimes.
- Selected-row hover backgrounds apply to odd and even rows.

## 1.36.0
### What's new
- Improvements to null handlings in New-HTMLTable
Expand Down Expand Up @@ -1170,4 +1183,4 @@ New-HTML -TitleText 'Title' -Online -FilePath $PSScriptRoot\Example-TableColumnV

## 0.0.33 - 2019.05.28
- New-HTMLTable - Sorting ordered for DateTime - PR by Ferdi Weiner (ferwe)
- Removed New-HTMLChart temporary as it will be rebuilt
- Removed New-HTMLChart temporary as it will be rebuilt
18 changes: 9 additions & 9 deletions Private/ChartsAll/New-ApexChart.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function New-ApexChart {
function New-ApexChart {
<#
.SYNOPSIS
Creates a new ApexChart with the specified options and events.
Expand Down Expand Up @@ -53,24 +53,24 @@ function New-ApexChart {
# Convert Dictionary to JSON and return chart within SCRIPT tag
# Make sure to return with additional empty string
Remove-EmptyValue -Hashtable $Options -Recursive -Rerun 2
$JSON = $Options | ConvertTo-JsonLiteral -Depth 5 -AdvancedReplace @{ '.' = '\.'; '$' = '\$' }
# replaces stuff for TImeLineCharts
$JSON = $JSON.Replace('"new Date(', 'new Date(').Replace(').getTime()"', ').getTime()')
$JSON = $Options | ConvertTo-HTMLScriptJson -Depth 20

# We replace Events on JSON because there's no other way that I can think of
if ($Options.chart.events) {
$JSON = $JSON -replace ('"events":"EventsReplaceMe"', $Events)
$JSON = [regex]::Replace($JSON, '"events"\s*:\s*"EventsReplaceMe"', { $Events })
}

$JSON = $JSON | ForEach-Object { [System.Text.RegularExpressions.Regex]::Unescape($_) }

"var options = $JSON"
"var chart = new ApexCharts(document.querySelector('#$ID'),
# Timeline producers return date expressions as data. Parse their date
# argument without evaluating text or unescaping the whole JSON object.
'if (options.chart.type === "rangeBar") { options.series.forEach(function (series) { series.data.forEach(function (point) { if (Array.isArray(point.y)) { point.y = point.y.map(function (value) { var match = typeof value === "string" && /^new Date\(''([\s\S]*)''\)\.getTime\(\)$/.exec(value); return match ? new Date(match[1]).getTime() : value; }); } }); }); }'
$IDJson = ConvertTo-HTMLScriptJson -InputObject $Options.chart.id -Compress
"var chart = new ApexCharts(document.getElementById($IDJson),
options
);"
"chart.render();"
} -NewLine
$Div
# we need to move it to the end of the code therefore using additional vesel
$Script:HTMLSchema.Charts.Add($Script)
}
}
13 changes: 13 additions & 0 deletions Private/ConvertFrom-HTMLTextLink.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
function ConvertFrom-HTMLTextLink {
<#
.SYNOPSIS
Converts inline text links with independently captured labels and balanced destinations.
#>
[CmdletBinding()]
param([AllowEmptyString()][string] $Text)
$Pattern = '\[(?<Label>[^\]]+)\]\((?<Url>(?:[^()\s]|\((?<Depth>)|\)(?<-Depth>))+)(?(Depth)(?!))\)'
[regex]::Replace($Text, $Pattern, [System.Text.RegularExpressions.MatchEvaluator]{
param($Match)
New-HTMLAnchor -HrefLink $Match.Groups['Url'].Value -Text $Match.Groups['Label'].Value
})
}
22 changes: 22 additions & 0 deletions Private/ConvertTo-HTMLScriptJson.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
function ConvertTo-HTMLScriptJson {
<#
.SYNOPSIS
Serializes data for an inline HTML script without changing its string values.
.DESCRIPTION
Uses PowerShell JSON serialization and escapes HTML raw-text delimiters and
JavaScript line separators. Executable callbacks must be added separately.
#>
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline)][AllowNull()][object] $InputObject,
[ValidateRange(1, 100)][int] $Depth = 20,
[switch] $Compress
)
begin { $Items = [System.Collections.Generic.List[object]]::new() }
process { $Items.Add($InputObject) }
end {
if ($Items.Count -eq 1) { $Value = $Items[0] } elseif ($Items.Count -gt 1) { $Value = $Items.ToArray() } else { $Value = $null }
$Json = ConvertTo-Json -InputObject $Value -Depth $Depth -Compress:$Compress
Protect-HTMLScriptData -Json $Json
}
}
45 changes: 45 additions & 0 deletions Private/Get-HTMLMarkdownImageMap.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
function Get-HTMLMarkdownImageMap {
<#
.SYNOPSIS
Maps relative Markdown image URLs to embedded images beneath an explicit source directory.
#>
[CmdletBinding()]
param([string] $Content, [string] $ImageBase)
$BasePath = [IO.Path]::GetFullPath((Convert-Path -LiteralPath $ImageBase))
$Prefix = $BasePath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
$Comparison = if ([IO.Path]::DirectorySeparatorChar -eq '\') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
$ImageReferences = @(foreach ($Reference in [regex]::Matches($Content, '!\[(?<Label>[^\]]*)\](?:\[(?<Reference>[^\]]*)\])?(?!\()')) {
if ($Reference.Groups['Reference'].Value) { $Reference.Groups['Reference'].Value } else { $Reference.Groups['Label'].Value }
})
# Collect candidate URLs without rewriting Markdown; Showdown identifies
# rendered images separately from code samples and escaped syntax.
$Pattern = '(?<Prefix>!\[[^\]]*\]\(\s*|(?m:^\s*\[[^\]]+\]:\s*))(?<Path><[^>]+>|(?:[^()\s]|\((?<Depth>)|\)(?<-Depth>))+)(?(Depth)(?!))'
$Images = [System.Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal)
foreach ($Match in [regex]::Matches($Content, $Pattern)) {
if (-not $Match.Groups['Prefix'].Value.StartsWith('!')) {
$Reference = [regex]::Match($Match.Groups['Prefix'].Value, '\[([^\]]+)\]').Groups[1].Value
if ($ImageReferences -notcontains $Reference) { continue }
}
$Path = $Match.Groups['Path'].Value.Trim('<', '>')
if ($Path -match '^[a-z][a-z0-9+.-]*:|^[/\\]' -or $Path.StartsWith('#')) { continue }
$Relative = [Uri]::UnescapeDataString(($Path -split '[?#]', 2)[0]).Replace('/', [IO.Path]::DirectorySeparatorChar)
$Candidate = [IO.Path]::GetFullPath((Join-Path $BasePath $Relative))
if (-not $Candidate.StartsWith($Prefix, $Comparison) -or -not [IO.File]::Exists($Candidate)) { continue }
if ([IO.Path]::GetExtension($Candidate) -notin '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.tif', '.tiff') { continue }
# Do not follow junctions or symlinks outside the permitted image source tree.
$Item = Get-Item -LiteralPath $Candidate
if ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
$Directory = $Item.Directory
$Linked = $false
while ($Directory -and $Directory.FullName.StartsWith($Prefix, $Comparison)) {
if ($Directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { $Linked = $true; break }
$Directory = $Directory.Parent
}
if ($Linked) { continue }
if ($Images.ContainsKey($Path)) { continue }
$DataUri = Convert-ImageToBinary -ImageFile $Candidate
if ($DataUri -notlike 'data:image/*') { continue }
$Images[$Path] = $DataUri
}
return $Images
}
6 changes: 3 additions & 3 deletions Private/New-InternalDiagram.ps1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function New-InternalDiagram {
function New-InternalDiagram {
<#
.SYNOPSIS
Creates a new internal diagram with specified nodes, edges, events, options, dimensions, and styling.
Expand Down Expand Up @@ -188,7 +188,7 @@ function New-InternalDiagram {
" }; "

if ($Options) {
$ConvertedOptions = $Options | ConvertTo-Json -Depth 5 | ForEach-Object { [System.Text.RegularExpressions.Regex]::Unescape($_) }
$ConvertedOptions = $Options | ConvertTo-HTMLScriptJson -Depth 20
"var options = $ConvertedOptions; "
} else {
"var options = { }; "
Expand All @@ -208,4 +208,4 @@ function New-InternalDiagram {

$Div
$Script:HTMLSchema.Diagrams.Add($Script)
}
}
9 changes: 5 additions & 4 deletions Private/New-TableServerSide.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@
data = $DataTable
}

$Data | ConvertTo-JsonLiteral -Depth 1 `
$Json = $Data | ConvertTo-JsonLiteral -Depth 1 `
-NumberAsString:$Script:HTMLSchema['TableOptions']['DataStoreOptions'].NumberAsString `
-BoolAsString:$Script:HTMLSchema['TableOptions']['DataStoreOptions'].BoolAsString `
-DateTimeFormat $Script:HTMLSchema['TableOptions']['DataStoreOptions'].DateTimeFormat | Out-File -FilePath $FilePath
$Options['ajax'] = -join ('data', '\', "$DataTableID.json")
-DateTimeFormat $Script:HTMLSchema['TableOptions']['DataStoreOptions'].DateTimeFormat
[System.IO.File]::WriteAllText($FilePath, $Json, [System.Text.UTF8Encoding]::new($false))
$Options['ajax'] = "data/$DataTableID.json"
} else {
# there is possibility for array without column names, not sure if it's worth the time
}
Expand All @@ -51,4 +52,4 @@
@{ data = $Property.Replace('.', '\.') }
}
$Options['deferRender'] = $true
}
}
11 changes: 11 additions & 0 deletions Private/Protect-HTMLScriptData.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
function Protect-HTMLScriptData {
<#
.SYNOPSIS
Escapes HTML raw-text boundaries in already serialized script data.
#>
[CmdletBinding()]
param([Parameter(ValueFromPipeline)][AllowEmptyString()][string] $Json)
process {
$Json.Replace('<', '\u003c').Replace('>', '\u003e').Replace('&', '\u0026').Replace([string][char]0x2028, '\u2028').Replace([string][char]0x2029, '\u2029')
}
}
108 changes: 5 additions & 103 deletions Private/Tables/Add-TableContent.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,111 +36,13 @@
[System.Collections.Generic.List[PSCUstomObject]] $ContentTop,
[System.Collections.Generic.List[PSCUstomObject]] $ContentFormattingInline,
[string[]] $HeaderNames,
[Array] $Table
[Array] $Table,
[Array] $SourceRows
)

# This converts inline conditonal formatting into style. It's intensive because it actually scans whole Table
# During scan it tries to match things and when it finds a match it prepares it for ContentStyling feature
if ($ContentFormattingInline.Count -gt 0) {
[Array] $AddStyles = for ($RowCount = 1; $RowCount -lt $Table.Count; $RowCount++) {
[string[]] $RowData = $Table[$RowCount] -replace '</td></tr>' -replace '<tr><td>' -split '</td><td>'
foreach ($ConditionalFormatting in $ContentFormattingInline) {
$Pass = $false
if ($ConditionalFormatting.ConditionType -eq 'Condition') {
$ColumnIndexHeader = [array]::indexof($HeaderNames.ToUpper(), $($ConditionalFormatting.Name).ToUpper())
for ($ColumnCount = 0; $ColumnCount -lt $RowData.Count; $ColumnCount++) {
#Write-Color "1ColumnCount $ColumnCount / $RowCount"
if ($ColumnIndexHeader -eq $ColumnCount) {
$Pass = New-TableConditionalFormattingInline -HeaderNames $HeaderNames -ColumnIndexHeader $ColumnIndexHeader -RowCount $RowCount -ColumnCount $ColumnCount -RowData $RowData -ConditionalFormatting $ConditionalFormatting
break
}
}
} else {
[Array] $IsConditionTrue = foreach ($SubCondition in $ConditionalFormatting.Conditions.Output) {
$ColumnIndexHeader = [array]::indexof($HeaderNames.ToUpper(), $($SubCondition.Name).ToUpper())
for ($ColumnCount = 0; $ColumnCount -lt $RowData.Count; $ColumnCount++) {
#Write-Color "2ColumnCount $ColumnCount / $RowCount"
if ($ColumnIndexHeader -eq $ColumnCount) {
New-TableConditionalFormattingInline -HeaderNames $HeaderNames -ColumnIndexHeader $ColumnIndexHeader -RowCount $RowCount -ColumnCount $ColumnCount -RowData $RowData -ConditionalFormatting $SubCondition
}
}
}
if ($ConditionalFormatting.Logic -eq 'AND') {
if ($IsConditionTrue -contains $true -and $IsConditionTrue -notcontains $false) {
$Pass = $true
}
} elseif ($ConditionalFormatting.Logic -eq 'OR') {
if ($IsConditionTrue -contains $true) {
$Pass = $true
}
} elseif ($ConditionalFormatting.Logic -eq 'NONE') {
if ($IsConditionTrue -contains $false -and $IsConditionTrue -notcontains $true) {
$Pass = $true
}
}
}
if ($Pass) {
# If we want to make conditional formatting for row it requires a bit diff approach
if ($ConditionalFormatting.Row) {
for ($i = 0; $i -lt $RowData.Count; $i++) {
[PSCustomObject]@{
RowIndex = $RowCount
ColumnIndex = ($i + 1) # Since it's 0 based index and we count from 1 we need to add 1
Style = $ConditionalFormatting.Style
}
}
} elseif ($ConditionalFormatting.HighlightHeaders) {
foreach ($Name in $ConditionalFormatting.HighlightHeaders) {
$ColumnIndexHighlight = [array]::indexof($HeaderNames.ToUpper(), $($Name).ToUpper())
[PSCustomObject]@{
RowIndex = $RowCount
ColumnIndex = ($ColumnIndexHighlight + 1) # Since it's 0 based index and we count from 1 we need to add 1
Style = $ConditionalFormatting.Style
}
}
} else {
[PSCustomObject]@{
RowIndex = $RowCount
ColumnIndex = ($ColumnIndexHeader + 1) # Since it's 0 based index and we count from 1 we need to add 1
Style = $ConditionalFormatting.Style
}
}
} else {
if ($ConditionalFormatting.FailStyle.Keys.Count -gt 0) {
if ($ConditionalFormatting.Row) {
for ($i = 0; $i -lt $RowData.Count; $i++) {
[PSCustomObject]@{
RowIndex = $RowCount
ColumnIndex = ($i + 1) # Since it's 0 based index and we count from 1 we need to add 1
Style = $ConditionalFormatting.FailStyle
}
}
} elseif ($ConditionalFormatting.HighlightHeaders) {
foreach ($Name in $ConditionalFormatting.HighlightHeaders) {
$ColumnIndexHighlight = [array]::indexof($HeaderNames.ToUpper(), $($Name).ToUpper())
[PSCustomObject]@{
RowIndex = $RowCount
ColumnIndex = ($ColumnIndexHighlight + 1) # Since it's 0 based index and we count from 1 we need to add 1
Style = $ConditionalFormatting.FailStyle
}
}
} else {
[PSCustomObject]@{
RowIndex = $RowCount
ColumnIndex = ($ColumnIndexHeader + 1) # Since it's 0 based index and we count from 1 we need to add 1
Style = $ConditionalFormatting.FailStyle
}
}
}
}
}
}
# This makes conditional forwarding a ContentStyle
foreach ($Style in $AddStyles) {
$ContentStyle.Add($Style)
}
foreach ($Style in (Get-TableInlineStyles -Table $Table -HeaderNames $HeaderNames -SourceRows $SourceRows -Conditions $ContentFormattingInline)) {
$ContentStyle.Add($Style)
}

# Prepopulate hashtable with rows
$TableRows = @{ }
if ($ContentStyle) {
Expand Down Expand Up @@ -293,4 +195,4 @@
}
}
$NewTable
}
}
Loading
Loading