From 3bc921a6766290ff5b86f08d00f949211c5cb247 Mon Sep 17 00:00:00 2001
From: Leo Visser
+ if ($word.ToLower() -match '^ ') {
+ Write-Debug "[Paragraph] Start found"
+
+ # Add the new Paragraph and go into the value of the Paragraph
+ $currentObject.Value.Content += [PSCustomObject]@{
+ Type = 'Paragraph'
+ Parameters = "-Tags"
+ Parent = $currentObject.Value
+ Content = @()
+ }
+
+ $currentObject = [ref]$currentObject.Value.Content[-1]
+
+ # Continue to the next line
+ continue
+ }
+
+ # Check if the word starts with
+ +This is a tagged paragraph + +
+ +Plain text without paragraph tags + +| Column1 | Column2 | +| - | - | +| Value1 | Value2 | +| Value3 | Value4 | + +# Second Top Level + +More content here +'@ | Set-Content -Path $testFile -NoNewline + $result = ConvertFrom-MarkdownMarkdown -Path $testFile + } + + AfterAll { + if (Test-Path $testFile) { + Remove-Item -Path $testFile -Force + } + } + + Context 'Sections' { + It 'Should parse a level 1 heading with correct type, level and title' { + $result.Content[0].Type | Should -Be 'Header' + $result.Content[0].Level | Should -Be 1 + $result.Content[0].Title | Should -Be 'Main Title' + } + + It 'Should parse a nested level 2 heading inside a level 1 heading' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' -and $_.Level -eq 2 } + $subSection | Should -Not -BeNullOrEmpty + $subSection.Title | Should -Be 'Sub Section' + } + + It 'Should parse multiple level 1 headings as sibling elements' { + $topLevelHeaders = $result.Content | Where-Object { $_.Type -eq 'Header' -and $_.Level -eq 1 } + $topLevelHeaders.Count | Should -Be 2 + $topLevelHeaders[1].Title | Should -Be 'Second Top Level' + } + } + + Context 'Details' { + It 'Should parse a details block with the correct summary title' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } + $details = $subSection.Content | Where-Object { $_.Type -eq 'Details' } | Select-Object -First 1 + $details | Should -Not -BeNullOrEmpty + $details.Title | Should -Be 'Expandable Section' + } + + It 'Should capture string content inside a details block' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } + $details = $subSection.Content | Where-Object { $_.Type -eq 'Details' } | Select-Object -First 1 + $strings = $details.Content | Where-Object { $_ -is [string] } + $strings | Should -Contain 'Details content here' + } + + It 'Should parse nested details blocks' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } + $details = $subSection.Content | Where-Object { $_.Type -eq 'Details' } | Select-Object -First 1 + $nestedDetails = $details.Content | Where-Object { $_.Type -eq 'Details' } + $nestedDetails | Should -Not -BeNullOrEmpty + $nestedDetails.Title | Should -Be 'Nested Details' + } + } + + Context 'Codeblocks' { + It 'Should parse a fenced code block as CodeBlock type' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } + $codeBlock = $subSection.Content | Where-Object { $_.Type -eq 'CodeBlock' } + $codeBlock | Should -Not -BeNullOrEmpty + $codeBlock.Type | Should -Be 'CodeBlock' + } + + It 'Should detect the language of the code block' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } + $codeBlock = $subSection.Content | Where-Object { $_.Type -eq 'CodeBlock' } + $codeBlock.Language | Should -Be 'powershell' + } + + It 'Should capture the code block content lines' { + $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } + $codeBlock = $subSection.Content | Where-Object { $_.Type -eq 'CodeBlock' } + $codeBlock.Content | Should -Contain 'Get-Process' + } + } + + Context 'Paragraphs with tags' { + It 'Should parse tagged content as a Paragraph type' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $paragraph = $subSection.Content | Where-Object { $_.Type -eq 'Paragraph' }
+ $paragraph | Should -Not -BeNullOrEmpty
+ $paragraph.Type | Should -Be 'Paragraph'
+ }
+
+ It 'Should set the Parameters property to -Tags for tagged paragraphs' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $paragraph = $subSection.Content | Where-Object { $_.Type -eq 'Paragraph' }
+ $paragraph.Parameters | Should -Be '-Tags'
+ }
+
+ It 'Should capture the paragraph content text' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $paragraph = $subSection.Content | Where-Object { $_.Type -eq 'Paragraph' }
+ $paragraph.Content | Should -Contain 'This is a tagged paragraph'
+ }
+ }
+
+ Context 'Paragraphs without tags' {
+ It 'Should store plain text as string content in the parent object' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $strings = $subSection.Content | Where-Object { $_ -is [string] }
+ $strings | Should -Contain 'Sub section text'
+ }
+
+ It 'Should not create a Paragraph object for untagged text' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $strings = $subSection.Content | Where-Object { $_ -is [string] }
+ $strings | Should -Contain 'Plain text without paragraph tags'
+ }
+ }
+
+ Context 'Tables' {
+ It 'Should parse a markdown table as Table type' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $table = $subSection.Content | Where-Object { $_.Type -eq 'Table' }
+ $table | Should -Not -BeNullOrEmpty
+ $table.Type | Should -Be 'Table'
+ }
+
+ It 'Should create the correct number of rows as PSCustomObjects' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $table = $subSection.Content | Where-Object { $_.Type -eq 'Table' }
+ $table.Content.Count | Should -Be 2
+ }
+
+ It 'Should map column headings to row values correctly' {
+ $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' }
+ $table = $subSection.Content | Where-Object { $_.Type -eq 'Table' }
+ $table.Content[0].Column1 | Should -Be 'Value1'
+ $table.Content[0].Column2 | Should -Be 'Value2'
+ $table.Content[1].Column1 | Should -Be 'Value3'
+ $table.Content[1].Column2 | Should -Be 'Value4'
+ }
+ }
+}
+
+Describe 'ConvertTo-MarkdownDSL' {
+ Context 'Sections' {
+ It 'Should generate DSL for a heading with level and title' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Header'
+ Level = 1
+ Title = 'Test Heading'
+ Content = @('Heading content')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'Header 1 "Test Heading"'
+ }
+
+ It 'Should include string content inside the heading block' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Header'
+ Level = 2
+ Title = 'Sub Heading'
+ Content = @('Sub heading text')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'Header 2 "Sub Heading"'
+ $dsl | Should -Match 'Sub heading text'
+ }
+ }
+
+ Context 'Details' {
+ It 'Should generate DSL for a details block with title' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Details'
+ Title = 'My Details'
+ Content = @('Details body')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'Details "My Details"'
+ $dsl | Should -Match 'Details body'
+ }
+ }
+
+ Context 'Codeblocks' {
+ It 'Should generate DSL for a code block with language' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'CodeBlock'
+ Language = 'powershell'
+ Content = @('Get-Process')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'CodeBlock "powershell"'
+ $dsl | Should -Match 'Get-Process'
+ }
+
+ It 'Should not wrap code block content in quotes' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'CodeBlock'
+ Language = 'powershell'
+ Content = @('Get-ChildItem')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Not -Match '"Get-ChildItem'
+ }
+ }
+
+ Context 'Paragraphs with tags' {
+ It 'Should generate DSL with -Tags parameter for tagged paragraphs' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Paragraph'
+ Parameters = '-Tags'
+ Content = @('Tagged paragraph text')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'Paragraph'
+ $dsl | Should -Match '-Tags'
+ $dsl | Should -Match 'Tagged paragraph text'
+ }
+ }
+
+ Context 'Paragraphs without tags' {
+ It 'Should generate DSL without -Tags parameter for untagged paragraphs' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Paragraph'
+ Content = @('Untagged paragraph text')
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'Paragraph'
+ $dsl | Should -Not -Match '-Tags'
+ $dsl | Should -Match 'Untagged paragraph text'
+ }
+ }
+
+ Context 'Tables' {
+ It 'Should generate DSL with PSCustomObject syntax for table rows' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Table'
+ Content = @(
+ [PSCustomObject]@{ Name = 'Alice'; Age = '30' }
+ [PSCustomObject]@{ Name = 'Bob'; Age = '25' }
+ )
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match 'Table'
+ $dsl | Should -Match '\[PSCustomObject\]@\{'
+ $dsl | Should -Match "'Name' = 'Alice'"
+ $dsl | Should -Match "'Name' = 'Bob'"
+ }
+
+ It 'Should wrap table content in an array syntax' {
+ $obj = @(
+ [PSCustomObject]@{
+ Level = 0
+ Content = @(
+ [PSCustomObject]@{
+ Type = 'Table'
+ Content = @(
+ [PSCustomObject]@{ Col = 'Val' }
+ )
+ }
+ )
+ }
+ )
+ $dsl = ConvertTo-MarkdownDSL -InputObject $obj -AsString
+ $dsl | Should -Match '@\('
+ $dsl | Should -Match '\)'
+ }
+ }
+}
+
+Describe 'ConvertTo-MarkdownDSL Execution' {
+ BeforeAll {
+ $execTestFile = Join-Path $env:TEMP "PesterMarkdownExecTest_$([guid]::NewGuid().ToString()).md"
+ @'
+# Execution Test
+
+Some text content
+
+Test Details
+
+Details content
+
+
+ +Paragraph content + +
+ +| Name | Value | +| - | - | +| Key1 | Val1 | + +# Another Section + +Closing text +'@ | Set-Content -Path $execTestFile -NoNewline + } + + AfterAll { + if (Test-Path $execTestFile) { + Remove-Item -Path $execTestFile -Force + } + } + + Context 'Executing generated DSL with Invoke-Command' { + It 'Should return a scriptblock by default' { + $parsed = ConvertFrom-MarkdownMarkdown -Path $execTestFile + $dsl = ConvertTo-MarkdownDSL -InputObject $parsed + $dsl | Should -BeOfType [scriptblock] + } + + It 'Should return a string when -AsString is specified' { + $parsed = ConvertFrom-MarkdownMarkdown -Path $execTestFile + $dsl = ConvertTo-MarkdownDSL -InputObject $parsed -AsString + $dsl | Should -BeOfType [string] + } + + It 'Should execute via Invoke-Command without throwing errors' { + $parsed = ConvertFrom-MarkdownMarkdown -Path $execTestFile + $dsl = ConvertTo-MarkdownDSL -InputObject $parsed + { Invoke-Command -ScriptBlock $dsl } | Should -Not -Throw + } + + It 'Should execute without prompting for mandatory parameters' { + $parsed = ConvertFrom-MarkdownMarkdown -Path $execTestFile + $dsl = ConvertTo-MarkdownDSL -InputObject $parsed + # -ErrorAction Stop ensures non-terminating errors also throw + { Invoke-Command -ScriptBlock $dsl -ErrorAction Stop } | Should -Not -Throw + } + + It 'Should produce non-empty output when executed' { + $parsed = ConvertFrom-MarkdownMarkdown -Path $execTestFile + $dsl = ConvertTo-MarkdownDSL -InputObject $parsed + $output = Invoke-Command -ScriptBlock $dsl + $output | Should -Not -BeNullOrEmpty + } + + It 'Should execute a complex markdown with all element types without errors' { + $complexFile = Join-Path $env:TEMP "PesterMarkdownComplex_$([guid]::NewGuid().ToString()).md" + @' +# Complex Doc + +Intro paragraph + +## Nested Heading + +Section content + ++ +Tagged paragraph text + +
+ +Untagged paragraph text + +| Header1 | Header2 | +| - | - | +| Cell1 | Cell2 | +| Cell3 | Cell4 | + +# Final Section + +End content +'@ | Set-Content -Path $complexFile -NoNewline + try { + $parsed = ConvertFrom-MarkdownMarkdown -Path $complexFile + $dsl = ConvertTo-MarkdownDSL -InputObject $parsed + { Invoke-Command -ScriptBlock $dsl -ErrorAction Stop } | Should -Not -Throw + } + finally { + if (Test-Path $complexFile) { + Remove-Item -Path $complexFile -Force + } + } + } + } +} + AfterAll { $PSStyle.OutputRendering = 'Ansi' } From f711471159c49d008963ccb961c4747d44dca4d0 Mon Sep 17 00:00:00 2001 From: "AutoSysOps (Leo Visser)" <96066335+autosysops@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:34:39 +0100 Subject: [PATCH 2/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 index 0ef4f47..285c59c 100644 --- a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 +++ b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 @@ -4,13 +4,13 @@ function ConvertFrom-MarkdownMarkdown { Converts a Markdown file into a structured object representation. .DESCRIPTION - The ConvertFrom-Markdown function reads a Markdown file and processes its content to create a structured object representation. This representation includes headers, paragraphs, code blocks, tables, and details sections. The function uses regular expressions and string manipulation to identify different Markdown elements and organizes them into a hierarchical structure based on their levels. + The ConvertFrom-MarkdownMarkdown function reads a Markdown file and processes its content to create a structured object representation. This representation includes headers, paragraphs, code blocks, tables, and details sections. The function uses regular expressions and string manipulation to identify different Markdown elements and organizes them into a hierarchical structure based on their levels. .PARAMETER Path The path to the Markdown file that needs to be converted. .EXAMPLE - ConvertFrom-Markdown -Path "C:\Docs\example.md" + ConvertFrom-MarkdownMarkdown -Path "C:\Docs\example.md" This example reads the "example.md" file and converts its Markdown content into a structured object representation. .OUTPUTS From 1725007b05a6f62b7dd32eb8037c4b02b80ec501 Mon Sep 17 00:00:00 2001 From: "AutoSysOps (Leo Visser)" <96066335+autosysops@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:35:14 +0100 Subject: [PATCH 3/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 index 285c59c..e30a8b8 100644 --- a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 +++ b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 @@ -47,8 +47,8 @@ function ConvertFrom-MarkdownMarkdown { # Process each line foreach ($line in $lines) { - # Skip empty lines - if ($line -eq '') { + # Skip empty lines only when not inside a code block + if (($line -eq '') -and -not $inCodeBlock) { continue } From e8aad4c9efaa50fa82d88b75cb03dd84515bd50e Mon Sep 17 00:00:00 2001 From: "AutoSysOps (Leo Visser)" <96066335+autosysops@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:42:38 +0100 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../public/ConvertFrom-MarkdownMarkdown.ps1 | 12 ++++++------ src/functions/public/ConvertTo-MarkdownDSL.ps1 | 2 +- tests/Markdown.Tests.ps1 | 5 ++++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 index e30a8b8..21ab20d 100644 --- a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 +++ b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 @@ -42,8 +42,8 @@ function ConvertFrom-MarkdownMarkdown { $inCodeBlock = $false $tableHeaings = @() - # Split the content into lines - $lines = $content.split([System.Environment]::NewLine) + # Split the content into lines in a line-ending-agnostic way + $lines = $content -split '\r?\n' # Process each line foreach ($line in $lines) { @@ -64,8 +64,8 @@ function ConvertFrom-MarkdownMarkdown { # Check if this is the start of a continuation by checking the table headings if($tableHeaings) { Write-Debug "[Table] Continuation row found: $word" - # Get all table values if the value is - ignore it - $tableValues = $words -join ' ' -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' -and $_ -ne '-' } + # Get all table values, ignoring empty cells and markdown table separator cells (e.g. ---, :---:, ---:) + $tableValues = $words -join ' ' -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' -and ($_ -notmatch '^\:?-{3,}\:?$') } # If there are table values create a pscustom object with attributes for every table heading and the values as values if($tableValues) { @@ -252,8 +252,8 @@ function ConvertFrom-MarkdownMarkdown { } } - # If nothing else add the line as text - $currentObject.Value.Content += $words[0..($words.Length - 1)] -join ' ' + # If nothing else add the original line as text without normalizing internal whitespace + $currentObject.Value.Content += $line # Check if the word contains with if so the content should be added to the parent if (($words -join ' ').ToLower() -match '') { diff --git a/src/functions/public/ConvertTo-MarkdownDSL.ps1 b/src/functions/public/ConvertTo-MarkdownDSL.ps1 index c2308da..31b9f57 100644 --- a/src/functions/public/ConvertTo-MarkdownDSL.ps1 +++ b/src/functions/public/ConvertTo-MarkdownDSL.ps1 @@ -67,7 +67,7 @@ function ConvertTo-MarkdownDSL { https://psmodule.io/Markdown/Functions/Set-MarkdownCodeBlock/ #> - [OutputType([scriptblock])] + [OutputType([scriptblock], [string])] [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] diff --git a/tests/Markdown.Tests.ps1 b/tests/Markdown.Tests.ps1 index 201ca68..33cd36a 100644 --- a/tests/Markdown.Tests.ps1 +++ b/tests/Markdown.Tests.ps1 @@ -252,7 +252,7 @@ This is the end of the document Describe 'ConvertFrom-MarkdownMarkdown' { BeforeAll { - $testFile = Join-Path $env:TEMP "PesterMarkdownTest_$([guid]::NewGuid().ToString()).md" + $testFile = Join-Path ([System.IO.Path]::GetTempPath()) "PesterMarkdownTest_$([guid]::NewGuid().ToString()).md" @' # Main Title @@ -401,6 +401,9 @@ More content here $subSection = $result.Content[0].Content | Where-Object { $_.Type -eq 'Header' } $strings = $subSection.Content | Where-Object { $_ -is [string] } $strings | Should -Contain 'Plain text without paragraph tags' + + $paragraphs = $subSection.Content | Where-Object { $_.Type -eq 'Paragraph' } + $paragraphs | Should -BeNullOrEmpty } }