Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
June 17, 2025

Creating a Markdown Tooling System Part 3

Welcome back. Over the last several articles, I've been sharing my scripting experiences in building a Markdown Tooling System. Last time, I shared my function and ways that I extended the object output to add value. I have some additional features I want to add. Remember, when reviewing the output of your command, consider what would make it more valuable or more straightforward to use. You don't have to include everything in the object. Some features can be added later, as I demonstrated last time. Don't forget that your custom object output needs to have a defined type name.

[PSCustomObject]@{
    PSTypeName = 'ButtondownMetadata'
    Title      = $meta.subject
    Published  = $meta.publish_date -as [datetime]
    Category   = $meta.email_type
    Link       = $web + $meta.slug
    Path       = $Path
    Status     = $meta.status
    isImported = $meta.Status -eq 'imported'
}

Let's add even more features to this.

Adding ScriptMethods

One feature that comes to mind is to add a script method to open the source Markdown file. It isn't that difficult to do this manually. Either of these expressions will open the file in Notepad:

Notepad $r[0].path
$r[0].path | Notepad

However, I want to add a script method to the object so that I can call it like this:

$r[0].Open()

An argument could be made to use the name Edit instead of Open, but I prefer Open because it is more generic.

Creating a script method also allows me to include additional logic, such as checking if the file exists before trying to open it. Here's how I can do that:

Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptMethod -MemberName Open -value {
    #it is possible the file no longer exists or has been moved
    if (Test-Path -Path $this.Path) {
        #open the file in the default editor
        Write-Host "$($PSStyle.Italic)Opening $($this.Path) in the default editor$($PSStyle.Reset)" -ForegroundColor Cyan
        Start-Process -FilePath $this.Path
    }
    else {
        Write-Warning "The file $($this.Path) cannot be found."
    }
} -force

The Write-Host line is unnecessary, but it provides a helpful indication that the file is being opened. For variety, I am using $PSStyle to format the message.

Invoke the Open() method
figure 1

For me, this action opens the file in VS Code.

I'd like to do the same thing with the Link property so that I can open the link in my default browser. Before I do that, I'm also going to define an alias property called Online, which might be more intuitive.

Update-TypeData -TypeName ButtondownMetadata -MemberType AliasProperty -MemberName Online -value Link -force

Once this is defined, I can create the script method to open the link in the default browser:

Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptMethod -MemberName OpenOnline -value {
    #open the link in the default browser
    Write-Host "$($PSStyle.Italic)Opening $($this.Link) in the default browser$($PSStyle.Reset)" -ForegroundColor Cyan
    Start-Process -FilePath $this.Link
} -force

!> I could have used my alias, online, instead of Link in the script method, but I prefer to use the original property names in script methods.

Open online
figure 2

Customizing Property Values

I started this newsletter on Substack, then migrated it to Buttondown. I can see that in the Status property.

PS C:\> $r[0]

Title     : A Changelog for the Better
Published : 1/3/2023 1:05:58 PM
Category  : premium
Link      : https://buttondown.com/behind-the-powershell-pipeline/archive/a-chan...
Path      : D:\buttondown\emails\a-changelog-for-the-better.md
Status    : imported
Year      : 2023
YearMonth : 2023 January
Words     : 281
Month     : 1
MonthName : January
Online    : https://buttondown.com/behind-the-powershell-pipeline/archive/a-chan...

An imported email also implies that it was sent, and that is more important to me. I would like the Status property to reflect that. However, I shouldn't lose the original status of Imported, just in case I need it later.

What I need is a new Boolean property called isImported. This can be a static property defined at run time and not a type extension. I can also add logic in the function to set the Status property to Sent if the email was imported.

Now that I think of it, I might as well add similar boolean properties for IsScheduled and IsSent. Here's the revised function:

Function Get-ButtondownMetadata {
    [CmdletBinding()]
    [Alias('gbm')]
    [OutputType('ButtondownMetadata')]
    param (
        [Parameter(
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            HelpMessage = 'Enter the path to the Markdown file containing the Buttondown newsletter metadata.'
        )]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({ Test-Path $_ }, ErrorMessage = 'Cannot find or verify the path {0}.')]
        [ValidatePattern('^.*\.md$', ErrorMessage = 'The path must point to a Markdown file.')]
        [string]$Path
    )

    Begin {
        #create a timer to measure the function runtime
        $timer = [System.Diagnostics.Stopwatch]::new()
        $timer.start()
        Write-Information "Starting $($MyInvocation.MyCommand)" -Tags runtime
        Write-Verbose "Starting $($MyInvocation.MyCommand)"
        $list = [System.Collections.Generic.List[string]]::new()
        #initialize a counter
        $k = 0
        $web = 'https://buttondown.com/behind-the-powershell-pipeline/archive/'
    } #begin
    Process {
        Write-Verbose "Processing $Path"
        Write-Information "Processing $Path" -Tags runtime
        $k++
        $content = Get-Content -Path $Path
        $list.AddRange([string[]]$content)
        $i = $list.FindIndex(0, { $args[0] -eq '---' }) + 1
        $j = $list.FindIndex($i, { $args[0] -match '---' }) - 1
        #only process if both markers found
        if ($i -AND $j) {
            Write-Debug "Processing metadata from line $i to $j"
            $lines = $list[$i..$j]
            Write-Information "Detected metadata header:`n---`n$(($lines | Out-String).Trim())`n---" -Tags data
            $lines | Where-Object { $_ -match ':' } |
            ForEach-Object -Begin {
                #initialize the hashtable
                $meta = @{}
            } -Process {
                #split into two strings
                $split = $_ -split ':', 2
                $meta.Add($split[0].Trim(), $split[1].Trim())
            }
            Write-Information $meta -Tags data
            If ($meta.subject -AND $meta.email_type) {
                [PSCustomObject]@{
                    PSTypeName  = 'ButtondownMetadata'
                    Title       = $meta.subject
                    Published   = $meta.publish_date -as [datetime]
                    Category    = $meta.email_type
                    Link        = $web + $meta.slug
                    Path        = $Path
                    Status      = $meta.status -match 'imported|sent' ? 'Sent' : $meta.status
                    isImported  = $meta.Status -eq 'imported'
                    isSent      = $meta.Status -match 'sent|imported'
                    isScheduled = $meta.Status -eq 'scheduled'
                }
                $list.Clear()
            } #if meta info found
            else {
                Write-Warning "No Buttondown metadata found in $Path"
            }
        }
        else {
            Write-Warning "No Buttondown metadata found in $Path"
        }
        #clear the list
        $list.Clear()
    } #process
    End {
        $timer.stop()
        Write-Verbose "Processed $k files in $($timer.Elapsed)"
        Write-Verbose "Ending $($MyInvocation.MyCommand)"
        Write-Information "Ending $($MyInvocation.MyCommand)" -Tags runtime
    } #end
}

I am using the PowerShell 7 ternary operator to define the Status property.

Status      = $meta.status -match 'imported|sent' ? 'Sent' : $meta.status

If the status is either imported or sent, it will be set to Sent. Otherwise, it will retain its original value.

PS C:\> $r | Group Status

Count Name                      Group
----- ----                      -----
    2 scheduled                 {@{Title=Creating a Markdown Tooling System; Published=6/10/2025 1…
  314 Sent                      {@{Title=A Changelog for the Better; Published=1/3/2023 1:05:58 PM…

PS C:\> $r | Where IsImported | Measure-Object | Select-Object Count

Count
-----
  175

PS C:\> $r | Where IsSent | Measure-Object -Property Words -Average -Sum |
Select-Object Count,Sum,Average

Count       Sum Average
-----       --- -------
  314 334753.00 1066.09

The custom properties make it much easier to write PowerShell expressions without forcing the user to use complicated Where-Object filters or ForEach-Object.

Adding Tags

One other significant feature I want to add is the ability to tag the content. This will let me find content based on the tags I assign. What I have in mind is to search the content for a key word. If found, I want to add that word as a tag. I can use Select-String to search the content for a key word.

PS C:\> Select-String -InputObject $content -Pattern '\b(WPF)\b' |
Select-Object -expand Matches

Groups    : {0, 1}
Success   : True
Name      : 0
Captures  : {0}
Index     : 54
Length    : 3
Value     : WPF
ValueSpan :

My regex pattern is looking for the word WPF as a whole word. I can extend the pattern to search for multiple words.

PS C:\> $p = '\b((WPF)|(Foo)|(CimInstance))\b'
PS C:\> Select-String -InputObject $content -Pattern $p -AllMatches |
Select-Object -expand Matches | Group-Object -Property Value -NoElement

Count Name
----- ----
    2 CimInstance
   25 WPF

This requires me to get all matches. I can make this pattern as long as necessary. However, instead me manually typing it, I can define an array of terms and let PowerShell build the pattern for me.

$tags = @(
    'WPF','CimInstance','array',
    'git','GitHub','hashtable',
    'PowerShell module','AST','REST',
    'Generic\.List','Update-TypeData',
    'Update-FormatData','Add-Type',
    'HTML','Markdown','JSON',
    'WebRequest','Dynamic Parameter','Write-Information',
    'PSStyle','Pester','CimSession',
    'PSSession','PSReadLine','Scope','runspace',
    'Profile','XML','Platyps','PowerShell ISE','Credential',
    'regex','\.NET')
$rxTag = "\b({0})\b" -f $($tags.Foreach({"($_)"}) -join "|")

I'll update my function to include this new information.

 Process {
        Write-Verbose "Processing $Path"
        Write-Information "Processing $Path" -Tags runtime
        $k++
        $content = Get-Content -Path $Path
        Write-Debug "Getting tags from content"
        $tags = Select-String -InputObject $content -Pattern $rxTag -AllMatches |
        Select-Object -ExpandProperty matches | Group Value -NoElement |
        Select-Object -ExpandProperty Name
        ...
            [PSCustomObject]@{
                PSTypeName  = 'ButtondownMetadata'
                Title       = $meta.subject
                Published   = $meta.publish_date -as [datetime]
                Category    = $meta.email_type
                Link        = $web + $meta.slug
                Path        = $Path
                Status      = $meta.status -match 'imported|sent' ? 'Sent' : $meta.status
                isImported  = $meta.Status -eq 'imported'
                isSent      = $meta.Status -match 'sent|imported'
                isScheduled = $meta.Status -eq 'scheduled'
                Tags        = $tags
            }
            ...

This adds a little processing time, but it is worth it. I can now filter the results based on tags.

PS C:\ $r = dir d:\buttondown\emails\*.md | Get-ButtondownMetadata
PS C:\> $r | group Tags -NoElement | Sort Count -Descending | Where Count -gt 1

Count Name
----- ----
  267 {git, github, hashtable,…
   23
    3 scope
    3 regex
    3 CimInstance
    2 Credential
    2 Pester

PS C:\> $r | Where {$_.tags -contains 'scope' -AND $_.Year -gt 2024} | Select Published,Title,Online

Published            Title                                 Online
---------            -----                                 ------
1/24/2025 1:10:00 PM Module and Package Management Tools   https://buttondown.com/behind-the-power…
2/4/2025 1:09:00 PM  More Module Management                https://buttondown.com/behind-the-power…
5/16/2025 1:06:09 PM More PowerShell Parameter Validations https://buttondown.com/behind-the-power…
6/17/2025 1:05:00 PM Writing Lessons                       https://buttondown.com/behind-the-power…

Look how far we've come from the original metadata parsing.

Custom Format File

Because I've extended the object so much, I should create a custom format file. You've seen me do this before with New-PSFormatXML

PS C:> $r[0] | New-PSFormatXml -Path c:\scripts\buttondownmetadata.format.ps1xml  -GroupBy Title -Properties Published,Category,Tags

I customized the grouping.

Custom formatting
figure 3

After loading the file with Update-FormatData, I get custom formatting.

Custom format sample
figure 4

Summary

I'm sure I can find even more ways to fine-tune this project, but I think I have made my point. Once again, the takeaways are not the code samples, but the techniques and concepts that guided my code development. These are things you should consider when building your own PowerShell tool.

(c) 2022-2025 JDH Information Technology Solutions, Inc. - all rights reserved
Don't miss what's next. Subscribe to Behind the PowerShell Pipeline:
Start the conversation:
GitHub Bluesky LinkedIn About Jeff
Powered by Buttondown, the easiest way to start and grow your newsletter.