MarkX turns Markdown into XML you can query with PowerShell, and my first thought was to point it at this blog. It doesn’t fit. Hugo already renders every post I write, and the files I author are the source of truth, so a tool that reads Markdown back into objects has nothing to add to my publish process. I saw a post on Bluesky saying it included support for Hugo and Astro and decided to do a dive on it to see if it suited my current posting process.
That left a better question: where does parsing Markdown beat rendering it? I installed version 0.1.4 (the PowerShell Gallery lists it as published October 27, 2026), read the source, and ran it against my own posts. Everything with an output block below was run.
Markdown, then HTML, then XML
The idea in the README is a small trick: Markdown becomes nicely balanced, escaped HTML, and well-formed HTML can be loaded as XML. Once it’s XML you can ask for //a or //table or //h2 the way you would against any document, and PowerShell already knows how to do that.
Install and import look like any other module:
For the runs below I skipped Install-Module and imported the cloned repo from a temp folder, on PowerShell 7.6.9. Version 0.1.4 loaded without complaint. The manifest declares no minimum PowerShell version, and the Gallery page says the module has no dependencies, though front matter needs one, covered below. I only tested 7.x.
The module is an eponym: MarkX.psm1 loads MarkX.ps1 and exports it twice, as MarkX and as Get-MarkX, with Markdown and Get-Markdown as aliases. Whatever you hand it comes back as a MarkX object. A string is treated as Markdown, a path to a .md file is read as a file, and the script’s own help says a directory becomes a site. The script gathers everything with $input at once, so piping an array of strings gives you a single object holding all of them. After the last time pipelines bit me, I checked that first.
The object’s surface, trimmed from Get-Member to the properties and methods I’ll use:
Assigning to .Markdown re-parses everything, which the module’s own Pester test covers: change the text and InnerText follows.
Links and images from a pile of Markdown
The README’s first real example pulls hyperlinks out of a bullet list. I ran it with two of its links:
.Links returns the XML <a> nodes themselves, so there’s more than the URL on each one. The first node, dumped with Format-List * and trimmed to six of its 28 properties:
.Images works the same way against <img> nodes, and both properties only return elements that have an href or src. If a document can’t be converted to XML at all, the source says they return nothing, so an empty result and a failed parse look alike. Keep that in mind before trusting a zero.
There’s a second way in. FindHyperlink() skips the XML and runs a regular expression over the raw text, with named groups for IsImage, Text and Uri. Its own notes say it matches simple links and doesn’t handle ones with a title ([text](url "title")). The XML route has no such limit, because Markdig has already resolved the syntax by then.
A link audit is the obvious job for either: walk a docs folder, collect every href, group by host, then check the ones you care about.
| |
I ran that loop over two of my recent posts, the memberOf one and the piping one:
The blank name is the seven relative links to my own posts, so they have no host. FindHyperlink() returned 10 matches on the piping post, the same count as .Links. Passing a file path to Get-MarkX gives you an object typed as both MarkX and System.IO.FileInfo, so the file’s own properties are still there.
Tables you can query
This is the feature that sold me on the module, even with nothing for it to do on my site. Every table in the Markdown goes into a .NET DataSet, available as .DB. The README says the nearest heading above a table becomes its name and a blockquote becomes its description. The numbers are made up:
The sum is right (16 + 8 + 4), and the filter dropped yunyun at 32 GB. What I saw, and one thing I read in the source:
- The rows came back as
megumin, thenvanir, thoughvaniris first in the table.DataTable.Selecttakes a sort expression as a second argument, so ask for one if you need a particular order. ramGB > 40compared as numbers, so the columns have types. Inget_DataSet.ps1each column is cast to the first type that fits every cell, trying float, double, decimal, long, ulong, timespan and datetime in that order, and falling back to string.- That cast order is a gotcha for identifiers. I fed it a table with
007and010in anidcolumn and1.10and2.20in avercolumn. Both columns came back typedSingle, and the rows read7 1.1and10 2.2. Leading zeros and trailing zeros are gone, so a version or ID column needs checking before you trust it.
Because it’s a DataSet, anything that works on one works here: Compute for aggregates, Select for filters, DefaultView for sorting. If a README or runbook holds a table that is the real inventory, you can query it without exporting it to CSV first.
My homelab repo has that shape, split in two. inventory.yml is the Ansible inventory and holds every address, and docs/hosts.md holds the reasoning, with a table of the physical machines that repeats each host’s last octet in the name cell. Two files describing the same hosts can drift apart. Stand-ins for both, with the names and addresses changed:
The check reads the table through MarkX, the YAML through ConvertFrom-Yaml, and prints every host where the two disagree:
| |
Each hit is one of the two ways the files can disagree: nas moved and the doc kept the old octet, and pbs was added to the inventory and never written up. The table is picked by its Host column because the heading-becomes-name rule only fires when the heading sits directly above the table. Here a sentence sits between them, so the table arrived as MarkdownTable1. The YAML half needs powershell-yaml. MarkX wants it for front matter anyway, covered below.
Objects in, tables out
The conversion runs the other direction too. Pipe objects in and MarkX writes a Markdown table:
| |
The README also pipes two hashtables with different keys, which is where I hit a bug. I ran @{n1=1;n2=2}, @{n1=2;n3=3} | MarkX:
The headers come from the first hashtable, in whatever order it enumerates (n2 before n1 on this run, the other way round on another). Every row after that is written in its own property order, with no lookup against the header, so the second row’s n3=3 lands under one of the first hashtable’s columns and n3 never gets one. In ToTable.ps1 the property list is rebuilt inside the row loop, and only the first pass writes the header. Two objects with the same properties in a different order go wrong the same way: [pscustomobject]@{a=1;b=2}, [pscustomobject]@{b=3;a=4} renders the second row as |3|4| under an a, b header. Objects that share a property set in the same order are safe. Anything else needs a normalizing Select-Object first, which fixes the order as well as the set. I filed it as issue 113.
PowerShell has no built-in cmdlet that turns objects into a Markdown table, and this does it in one pipeline step. GitHub renders pipe tables in job summaries, so a workflow step can write this output straight to the file named in $env:GITHUB_STEP_SUMMARY. The repo also ships a GitHub Action called UseMarkX. I didn’t try it.
I ran that on my own machine with the variable pointed at a temp file, since the only difference in a workflow is who sets it. The file came out as:
Front matter, tags and code blocks
Pointed at one of my own posts, Get-MarkX read the YAML front matter:
.FrontMatter is the raw string and .Header is the parsed object, with .Tags and .Title reading from it. .Title falls back to the first h1 when the header has none. The catch is in get_Header.ps1: for YAML it calls ConvertFrom-Yaml and, if that command isn’t found, returns an error record instead of a header. MarkX doesn’t install or list a YAML module as a dependency. powershell-yaml was already on my machine, so my run didn’t hit it. Without a YAML module, expect .Tags to come back empty or odd.
With that module loaded, a tag audit is a few lines. The allowed list is the one my blog uses for new posts:
| |
Run against my whole posts folder it reported nothing. That is the right answer: the repo’s own Test-Post.ps1 already enforces this list on every push, so for my blog the check is a duplicate. To watch it work I dropped Identity from the list and ran it again, and it flagged the 24 posts carrying that tag.
.CodeBlock returns an ordered dictionary of nodes keyed by fence language. On the piping post it had two keys:
The 52 under unknown is bigger than the count of fences without a language. The source selects every //code element, so inline code spans land there too. The powershell key matched with ['PowerShell'] as well as the lowercase spelling, so the lookup isn’t case sensitive. That makes it possible to pull every PowerShell block out of a post and run the parser over it:
| |
All six blocks parsed with no errors, which is the result I wanted for my own post and tells you nothing about whether it catches a typo. I planted one to find out, with a closing brace missing:
| |
| |
The inner fence uses tildes so the example survives sitting inside a backtick fence on this page. Markdig reads both the same way.
.Headings returned nine heading nodes for that post, and .TableOfContents builds a nested list of anchor links from the heading ids:
.Images returned zero on that post, and it has no Markdown images. Hugo shortcodes like {{< figure >}} are my image syntax elsewhere, so I tried a post that has four of them:
| |
| |
Markdig leaves the shortcode as paragraph text, so there’s no <img> for MarkX to find.
Editing: Replace and Save
Replace() takes a pattern and a replacement, which can be a string or a script block, and returns the object, so calls chain. The smallest check:
| |
| |
The pattern runs over .Content, which the source describes as front matter and Markdown combined, so a pattern meant for body text will also rewrite the header if it matches there. Save() writes the content to a path, and with a dated post it can build yyyy-MM-dd-title.md or yyyy/MM/dd/title.md names from the date and title. I haven’t run Save(). For a bulk fix like changing an old domain across a docs folder, I’d still use Select-String and review the diff in git before letting anything write.
When rendering already wins
For my Hugo posts it doesn’t fit, because Hugo renders them. It’s also a 0.1.x module. The changelog for 0.1.4 lists dozens of changes, and I didn’t time it on a large folder, so I’d run a sample before pointing it at a few thousand files.
It fits when Markdown is the data. A table in a README that doubles as an inventory, a folder of docs where you want every link or code block listed, objects that need to become a table in a job summary: in those cases you want the document’s structure, not its HTML, and Select-Xml over Markdig’s output gives you that without writing a parser.
Trying it
The last line should give you an object with Markdown and XML properties. From there, Get-Help Get-MarkX -Examples has the module’s own list of examples.
Sources
- MarkX on GitHub, README, CHANGELOG and the
Types/MarkXscripts, read for version 0.1.4 - MarkX on the PowerShell Gallery, version and publish date
