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.

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.