Behind the PowerShell Pipeline logo

Behind the PowerShell Pipeline

Subscribe
Archives
June 13, 2025

Creating a Markdown Tooling System Part 2

Let's continue examining my process for building a Markdown tooling system. Up to now, I've been running my code in the console. Technically, selecting the code in VS Studio and using F8 to run it. Now that I have a working prototype, the next step is to create a function that I can call from the console. I recommend you follow this process. Start with code that you run in the console, then create a function. If you are writing related functions, the last step would be to wrap them in a module. I don't think I will need to go that far for this project.

Creating a Function

Here's the function I have so far, written for PowerShell 7 so that I can take advantage of the ErrorMessage feature. My function also defines an alias, gbm, so I can call it quickly.

#requires -version 7.5
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-Verbose "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 { $_ -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
                }
                $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
}

One thing I didn't focus on during the prototype phase was error handling. I'm always telling you to think about who will run your code. In this case, I am the only one who will run it and will most likely always run it on valid Markdown files imported from Buttondown. However, there are a few potential issues that could arise. I might pass a non-Markdown file. The markdown file might not have a metadata header. Or if it does, it may be different metadata. On the off chance I make a mistake, I don't want PowerShell to yell at me with ugly error messages. That's why you'll see parameter validation and error handling in the code.

Testing error handling
figure 1

The function includes my typical verbose messaging. I also added a few Write-Information messages. These are useful for troubleshooting. I saved them to a variable in the last example.

PS C:\> $v
Starting Get-ButtondownMetadata
Processing D:\buttondown\emails\a-changelog-for-the-better.md
Detected metadata header:
---
id: 666fc836-58cd-4100-b466-046a45c15bc1
subject: A Changelog for the Better
status: imported
email_type: premium
slug: a-changelog-for-the-better
publish_date: 2023-01-03T18:05:58.410000Z
---
System.Collections.Hashtable
Ending Get-ButtondownMetadata

The items in the variable are InformationRecord objects.

PS C:\> $v[2] | Select *

MessageData     : Detected metadata header:
                  ---
                  id: 666fc836-58cd-4100-b466-046a45c15bc1
                  subject: A Changelog for the Better
                  status: imported
                  email_type: premium
                  slug: a-changelog-for-the-better
                  publish_date: 2023-01-03T18:05:58.410000Z
                  ---
Source          : D:\OneDrive\behind\2025\buttondown-email-tooling\Get-ButtondownMetadata.ps1
TimeGenerated   : 6/7/2025 10:02:35 AM
Tags            : {data}
User            : PROSPERO\Jeff
Computer        : Prospero
ProcessId       : 4984
NativeThreadId  : 41908
ManagedThreadId : 8

Tagging is optional, but I like to use it to categorize my messages. I can filter the messages by tag later if needed.

Extending the Object

The function writes a rich object to the pipeline. Hopefully, you noticed that I'm defining a type name for the object. Having a unique type name gives me options. As I developed this, I began thinking about how I might utilize the data. What would make it easier to work with?

Let's grab a few sample objects to test with.

$r = dir d:\buttondown\emails\*.md | select -first 5 | gbm

If you recall the HTML index page I built in a previous article, I organized the content by year and month. I can make this even easier by adding custom properties to provide this information.

Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptProperty -MemberName Year -Value {
    $this.Published.Year
} -force
Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptProperty -MemberName Month -Value {
    $this.Published.Month
} -force
Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptProperty -MemberName MonthName -Value {
    $monthNames = (Get-Culture).DateTimeFormat.MonthNames
    $monthNames[[int]$this.Published.Month - 1]

} -force
Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptProperty -MemberName YearMonth -Value {
    "{0} {1}" -f $this.Published.Year, $this.MonthName
} -force

Working with the output is now much easier.

PS C:\> $r | Group Year -NoElement

Count Name
----- ----
    1 2022
    2 2023
    2 2025

PS C:\> $r | Sort Year,Month | Select Published,Title

Published             Title
---------             -----
9/15/2022 1:05:34 PM  A PowerShell Parsing Problem
12/21/2023 1:07:10 PM A Final REST API Course
1/3/2023 1:05:58 PM   A Changelog for the Better
1/28/2025 1:09:00 PM  A PowerShell Scripting Solution for December 2024
5/27/2025 1:09:00 PM  A Module Measurement Scripting Solution

I also thought it might be interesting to get a word count.

PS C:\> $content = Get-Content D:\buttondown\emails\wpf-powershell-applications.md -raw
PS C:\> $content | Measure-Object -word

Lines Words Characters Property
----- ----- ---------- --------
       1875

However, the content includes a lot of HTML formatting. I should strip that out to get a more accurate count. While I'm at it, I should also strip out any Markdown code fences

PS C:\> $content -replace '(\<.*\>)|(```(\w+)?)', '' | Measure-Object -Word

Lines Words Characters Property
----- ----- ---------- --------
       1805

This is close enough. There's still the metadata header, but I can live with including that. I'll add a new property.

Update-TypeData -TypeName ButtondownMetadata -MemberType ScriptProperty -MemberName Words -Value {
    ((Get-Content -path $this.Path -Raw) -replace '(\<.*\>)|(```(\w+)?)', '' |
    Measure-Object -Word).Words
} -force

This is definitely useful.

PS C:\> $r | Select Title,Words

Title                                             Words
-----                                             -----
A Changelog for the Better                          281
A Final REST API Course                            1572
A Module Measurement Scripting Solution            1517
A PowerShell Parsing Problem                       1215
A PowerShell Scripting Solution for December 2024  1648

PS C:\> dir d:\buttondown\emails\*.md | gbm | Group Year |
Select @{Name="Year";Expression={$_.Name}},Count,
@{Name="TotalWords";Expression = {($_.group.words | Measure-Object -sum).sum -as [int]}} |
Select-Object *,@{Name="AverageWords";Expression={[int]($_.TotalWords / $_.Count)}}

Year Count TotalWords AverageWords
---- ----- ---------- ------------
2022    80      33285          416
2023    87      82894          953
2024   103     153425         1490
2025    46      67962         1477

I can put all of the Update-TypeData commands at the end of the script file that defines the function. When I dot-source the file, the type data is updated. Don't forget to use the -force switch. Otherwise, PowerShell will complain that the type member already exists.

Use Get-Member to verify.

PS C:\> $r[0] | Get-Member -MemberType Properties

   TypeName: ButtondownMetadata

Name      MemberType     Definition
----      ----------     ----------
Category  NoteProperty   string Category=premium
Link      NoteProperty   string Link=https://buttondown.com/behind-the-powershell-pipeline/archiv…
Path      NoteProperty   string Path=D:\buttondown\emails\a-changelog-for-the-better.md
Published NoteProperty   datetime Published=1/3/2023 1:05:58 PM
Status    NoteProperty   string Status=imported
Title     NoteProperty   string Title=A Changelog for the Better
Month     ScriptProperty System.Object Month {get=…
MonthName ScriptProperty System.Object MonthName {get=…
Words     ScriptProperty System.Object Words {get=…
Year      ScriptProperty System.Object Year {get=…
YearMonth ScriptProperty System.Object YearMonth {get=…

Summary

Obviously my use case and what I've shown you doesn't apply to use. However, the concepts and scripting techniques do. The coding philosophy is the take-away, not the code itself. I hope it gets you thinking about how to extend your work and add value to your code. I have a few more ideas for this project, but I'll save them for one final article.

(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.