From 3bc921a6766290ff5b86f08d00f949211c5cb247 Mon Sep 17 00:00:00 2001 From: Leo Visser Date: Sun, 8 Mar 2026 03:31:58 +0100 Subject: [PATCH 1/4] Add option to convert from markdown and to convert to DSL --- README.md | 36 ++ .../public/ConvertFrom-MarkdownMarkdown.ps1 | 277 ++++++++++ .../public/ConvertTo-MarkdownDSL.ps1 | 177 +++++++ tests/Markdown.Tests.ps1 | 492 ++++++++++++++++++ 4 files changed, 982 insertions(+) create mode 100644 src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 create mode 100644 src/functions/public/ConvertTo-MarkdownDSL.ps1 diff --git a/README.md b/README.md index 7380886..b1183d6 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,42 @@ This is the end of the document ```` +## Conversion Functions + +### ConvertFrom-MarkdownMarkdown + +Parses a Markdown file into a structured object tree containing headers, paragraphs, code blocks, tables, and details sections — enabling programmatic inspection and transformation. + +```powershell +$obj = ConvertFrom-MarkdownMarkdown -Path ".\README.md" +$obj.Content # Top-level elements +$obj.Content[0].Title # First heading title +``` + +### ConvertTo-MarkdownDSL + +Converts a structured Markdown object (from `ConvertFrom-MarkdownMarkdown`) back into a DSL script block (or string with `-AsString`). This enables **round-tripping**: read a Markdown file, then regenerate or modify it as DSL. + +```powershell +# Object → executable DSL script block +$dsl = ConvertTo-MarkdownDSL -InputObject $obj +& $dsl # Produces the original Markdown output + +# Object → DSL string (useful for saving or inspecting) +$dslString = ConvertTo-MarkdownDSL -InputObject $obj -AsString +``` + +Use `-DontQuoteString` to keep content unquoted (e.g. for raw code). + +### Round-Trip Example + +```powershell +# Parse an existing Markdown file into an object, convert to DSL, and execute it +$obj = ConvertFrom-MarkdownMarkdown -Path ".\example.md" +$dsl = ConvertTo-MarkdownDSL -InputObject $obj +& $dsl # Regenerates the Markdown +``` + ## Contributing Whether you’re a user with valuable feedback or a developer with innovative ideas, your contributions are welcome. Here’s how you can get involved: diff --git a/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 new file mode 100644 index 0000000..0ef4f47 --- /dev/null +++ b/src/functions/public/ConvertFrom-MarkdownMarkdown.ps1 @@ -0,0 +1,277 @@ +function ConvertFrom-MarkdownMarkdown { + <# + .SYNOPSIS + 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. + + .PARAMETER Path + The path to the Markdown file that needs to be converted. + + .EXAMPLE + ConvertFrom-Markdown -Path "C:\Docs\example.md" + This example reads the "example.md" file and converts its Markdown content into a structured object representation. + + .OUTPUTS + Object + + .LINK + https://psmodule.io/Markdown/Functions/Set-MarkdownCodeBlock/ + #> + + [OutputType([Object])] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, Position = 0)] + [string] $Path + ) + + # Get the file content + $content = Get-Content -Path $Path -Raw + + # Create a PowerShell object to hold the content of the file + $returnObject = [PSCustomObject]@{ + Level = 0 + Content = @() + } + + # Create a variable to hold the current working object in the converstion and helper variables + $currentObject = [ref]$returnObject + $currentLevel = 0 + $inCodeBlock = $false + $tableHeaings = @() + + # Split the content into lines + $lines = $content.split([System.Environment]::NewLine) + + # Process each line + foreach ($line in $lines) { + # Skip empty lines + if ($line -eq '') { + continue + } + + # Split the line up in each word + $words = $line.split(' ') + + # Get the first word in the line + $word = $words[0] + Write-Debug "[Line] Processing: $line" + + # Check if word starts with a | symbol which indicates a table row + if ($word -like '|*') { + # 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 '-' } + + # If there are table values create a pscustom object with attributes for every table heading and the values as values + if($tableValues) { + Write-Debug "[Table] Values found: $($tableValues -join ', ')" + $tableObject = [PSCustomObject]@{} + for ($i = 0; $i -lt $tableHeaings.Count; $i++) { + $heading = $tableHeaings[$i] + $value = if ($i -lt $tableValues.Count) { $tableValues[$i] } else { '' } + $tableObject | Add-Member -NotePropertyName $heading -NotePropertyValue $value + } + # Add the table object to the current object + $currentObject.Value.Content += $tableObject + } + } + else { + Write-Debug "[Table] Start found: $word" + # Get all table headings + $tableHeaings = $words -join ' ' -split '\|' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } + + # Add a object to the current value with the type Table and an empty array as content and go into the content of the table + $currentObject.Value.Content += [PSCustomObject]@{ + Type = 'Table' + Parent = $currentObject.Value + Content = @() + } + + $currentObject = [ref]$currentObject.Value.Content[-1] + } + + # Continue to the next line + continue + } + else { + # Clear the tableHeadings + $tableHeaings = @() + + # Go back to the parent if we are in a table + if($currentObject.Value.Type -eq 'Table') { + Write-Debug "[Table] End reached, returning to parent" + $currentObject = [ref]$currentObject.Value.Parent + } + } + + # Check with regex if the word contains any numbers of # symbols + if ($word -match '^(#+)') { + Write-Debug "[Header] Start found: $word" + #Check if the level is lower than the parent level + $level = $matches[1].Length + + Write-Debug "[Header] Level: $level, Current level: $currentLevel" + + if ($level -le $currentLevel) { + # Loop through the return object to find the right value + Write-Debug "[Header] Navigating to parent at appropriate level" + while ($currentObject.Value.Level -ge $level -or $null -eq $currentObject.Value.Level) { + Write-Debug "[Header] Traversing to parent with level: $($currentObject.Value.Parent.Level)" + $currentObject = [ref]$currentObject.Value.Parent + } + } + + Write-Debug "[Header] Adding header to current level" + # Add the new header and go into the value of the header + $currentObject.Value.Content += [PSCustomObject]@{ + Type = 'Header' + Level = $level + Title = $words[1..($words.Length - 1)] -join ' ' + Parent = $currentObject.Value + Content = @() + } + + $currentObject = [ref]$currentObject.Value.Content[-1] + $currentLevel = $level + + # Continue to the next line + continue + } + + # Check if the word starts with ``` + if ($word -match '^```') { + if ($inCodeBlock) { + Write-Debug "[CodeBlock] End found, returning to parent" + # Go to the parent + $currentObject = [ref]$currentObject.Value.Parent + + $inCodeBlock = $false + + # Continue to the next line + continue + } + else { + Write-Debug "[CodeBlock] Start found, language: $($word.Substring(3))" + # Get the language of the code block + $language = $word.Substring(3) + + # Add the new code block and go into the value of the code block + $currentObject.Value.Content += [PSCustomObject]@{ + Type = 'CodeBlock' + Language = $language + Parent = $currentObject.Value + Content = @() + } + + $currentObject = [ref]$currentObject.Value.Content[-1] + $inCodeBlock = $true + + # Continue to the next line + continue + } + } + + # Check if the word starts with

+ 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

+ if ($word.ToLower() -match '^

') { + Write-Debug "[Paragraph] End found, returning to parent" + # Go to the parent + $currentObject = [ref]$currentObject.Value.Parent + + # Continue to the next line + continue + } + + # Check if the word starts with
+ if ($word.ToLower() -match '^
') { + Write-Debug "[Details] Start found" + + # Add the new Details and go into the value of the Details + $currentObject.Value.Content += [PSCustomObject]@{ + Type = 'Details' + Title = $null + Parent = $currentObject.Value + Content = @() + } + + $currentObject = [ref]$currentObject.Value.Content[-1] + + # Continue to the next line if there is no summary in the word, else continue processing + if (($words -join ' ') -notmatch ' + if ($word.ToLower() -match '^
') { + Write-Debug "[Details] End found, returning to parent" + # Go to the parent + $currentObject = [ref]$currentObject.Value.Parent + + # Continue to the next line + continue + } + + # Check if the word contains with + if (($words -join ' ').ToLower() -match '') { + Write-Debug "[Summary] Start found" + + #Create a temp object to store the value in summary + $tempObject = [PSCustomObject]@{ + Content = '' + Parent = $currentObject.Value + } + + $currentObject = [ref]$tempObject + + # Continue to the next line unless if so the content should be added to the parent + if (($words -join ' ').ToLower() -match '') { + Write-Debug "[Summary] End found, setting title and returning to parent" + # Set the title + $title = $currentObject.Value.Content + if($title.toLower() -match '') {$title = $title.Substring(($title.toLower().IndexOf('')+9))} + if($title.toLower() -match '') {$title = $title.Substring(0,($title.toLower().IndexOf('')))} + $currentObject.Value.Parent.Title = $title + + # Go to the parent + $currentObject = [ref]$currentObject.Value.Parent + + # Continue to the next line + continue + } + } + + # Return the created object + $returnObject +} \ No newline at end of file diff --git a/src/functions/public/ConvertTo-MarkdownDSL.ps1 b/src/functions/public/ConvertTo-MarkdownDSL.ps1 new file mode 100644 index 0000000..c2308da --- /dev/null +++ b/src/functions/public/ConvertTo-MarkdownDSL.ps1 @@ -0,0 +1,177 @@ +function ConvertTo-MarkdownDSL { + <# + .SYNOPSIS + Converts a structured object representation of Markdown content into a Markdown DSL script block. + + .DESCRIPTION + The ConvertTo-MarkdownDSL function takes an object that represents Markdown content, including its type, level, title, language, and parameters. It processes this object recursively to generate a string that represents the Markdown content in a DSL format. The resulting string can be returned as a script block or as a plain string based on the parameters provided. + + .PARAMETER InputObject + The structured object representing the Markdown content to be converted. + + .PARAMETER AsString + If specified, the output will be returned as a plain string instead of a script block. + + .PARAMETER DontQuoteString + If specified, string content will not be wrapped in quotes. This is useful for content that should be treated as raw Markdown or code. + + .EXAMPLE + $markdownObject = @{ + Type = 'Heading' + Level = 1 + Title = 'My Heading' + Content = 'This is a heading' + } + ConvertTo-MarkdownDSL -InputObject $markdownObject -AsString + + Output: + Heading 1 "My Heading" { "This is a heading" } + This example converts a simple Markdown heading object into a DSL string format. + + .EXAMPLE + $markdownObject = @{ + Type = 'CodeBlock' + Language = 'powershell' + Content = 'Get-Process' + } + ConvertTo-MarkdownDSL -InputObject $markdownObject -DontQuoteString + Output: + CodeBlock "powershell" { Get-Process } + This example converts a code block object into a DSL format without quoting the content. + + .EXAMPLE + $markdownObject = @{ + Type = 'Table' + Content = @( + @{ Name = 'Process1'; ID = 1234 }, + @{ Name = 'Process2'; ID = 5678 } + ) + } + ConvertTo-MarkdownDSL -InputObject $markdownObject + Output: + Table { + [PSCustomObject]@{ + 'Name' = 'Process1'; 'ID' = 1234; + } + [PSCustomObject]@{ + 'Name' = 'Process2'; 'ID' = 5678; + } + } + This example converts a table object with custom objects as content into a DSL format. + You can execute this DSL via Invoke-Command to generate the corresponding Markdown output. + + .OUTPUTS + string or scriptblock + + .LINK + https://psmodule.io/Markdown/Functions/Set-MarkdownCodeBlock/ + #> + + [OutputType([scriptblock])] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, Position = 0)] + [Object[]] $InputObject, + + [Parameter(Mandatory = $false, Position = 1)] + [Switch] $AsString, + + [Parameter(Mandatory = $false, Position = 2)] + [Switch] $DontQuoteString + ) + + # First the file is converted to a string containing the syntaxt for the markdown file then it will be invoked to be converted by the other functions. + $markDownString = '' + + # Determine what to use for a newline + $newline = [System.Environment]::NewLine + + # Check if the level of the inputObject is 0 if so go into the content. + if ($InputObject.Level -eq 0) { + Write-Debug "[Root] Level is 0, descending into content" + $InputObject = $InputObject.Content + } + + + # Loop through all childeren of the input object + foreach ($child in $InputObject) { + # Check if the input object is just a string + If ($null -ne $child.Content) { + Write-Debug "[Element] Processing type: $($child.Type), Title: $($child.Title), Level: $($child.Level)" + # Create a string based on the input object + $markDownString += "$($child.Type) " + if ($child.Level) { $markDownString += "$($child.Level) " } + if ($child.Title) { $markDownString += "`"$($child.Title)`" " } + if ($child.Language) { $markDownString += "`"$($child.Language)`" " } + $markDownString += "{$newline" + + # Check if DontQuoteString should be enabled + $dqs = $DontQuoteString + + # Text inside a codeblock should be treated special + if ($child.Type -eq "CodeBlock") { + $dqs = $true + } + + # If the type is a table add the start of an array to the markdown string + if ($child.Type -eq "Table") { + Write-Debug "[Table] Adding array start for table content" + $markDownString += "@($newline" + } + + # Add the content of the child to the string + Write-Debug "[Element] Recursing into child content of type: $($child.Type)" + $markDownString += (ConvertTo-MarkdownDSL -InputObject $child.Content -AsString -DontQuoteString:$dqs) + + # If the type is a table add the end of an array to the markdown string + if ($child.Type -eq "Table") { + $markDownString += ")$newline" + } + + # Close the content of the child object + $markDownString += "}" + + # Add extra parameters + if ($child.Parameters) { $markDownString += " $($child.Parameters)" } + + # Add a new line + $markDownString += "$newline" + } + else { + if ($DontQuoteString) { + # The content is special and should be kept as is + Write-Debug "[Content] Adding raw string (DontQuoteString): $($child)" + $markDownString += "$($child)$newline" + } + else { + # Check if the child is of the type PsCustomObject if so it needs to be printed in the syntax of an array with pscustomobjects + if ($child -is [PsCustomObject]) { + Write-Debug "[Table] Serializing PSCustomObject row" + foreach ($item in $child) { + $markDownString += "[PSCustomObject]@{" + foreach ($property in $item.PSObject.Properties) { + $markDownString += "'$($property.Name)' = `'$($property.Value)`'; " + } + $markDownString += "}$newline" + } + continue + } + else { + # If the content is just a string add it to the markdown string so add an extra line and quotes around it + Write-Debug "[Content] Adding quoted string: $($child.trim())" + $markDownString += "`"$($child.trim())$newline`"$newline" + } + } + } + } + + # return the right value + if ($AsString) { + Write-Debug "[Output] Returning as string" + return $markDownString + } + else { + Write-Debug "[Output] Returning as scriptblock" + return [scriptblock]::Create($markDownString) + } +} \ No newline at end of file diff --git a/tests/Markdown.Tests.ps1 b/tests/Markdown.Tests.ps1 index 32c2bd3..201ca68 100644 --- a/tests/Markdown.Tests.ps1 +++ b/tests/Markdown.Tests.ps1 @@ -250,6 +250,498 @@ This is the end of the document } } +Describe 'ConvertFrom-MarkdownMarkdown' { + BeforeAll { + $testFile = Join-Path $env:TEMP "PesterMarkdownTest_$([guid]::NewGuid().ToString()).md" + @' +# Main Title + +Some introductory text + +## Sub Section + +Sub section text + +
Expandable Section + +Details content here + +
Nested Details + +Nested details content + +
+ +
+ +```powershell +Get-Process +``` + +

+ +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 + +
+ +```powershell +Get-Process +``` + +

+ +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 + +
Collapsible + +Hidden content + +
Deeply Nested + +Deep content + +
+ +
+ +```powershell +Write-Output "test" +``` + +

+ +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 } }