You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The Markdown module today only writes markdown. The Set-Markdown* DSL functions emit strings, and there is no object representation of a markdown document that can be inspected, queried, or transformed. Automation that needs to read an existing document — extract a section, rewrite a link, validate structure, or merge generated content into a hand-written file — has to fall back to regular expressions against raw text.
Every capability the module could offer on top of markdown depends on one thing existing first: a complete, typed object hierarchy that models what a markdown document is. Validation (#22), normalization (#24), file I/O (#23), frontmatter (#21), and dialect support (#30) all build on it. This issue delivers that object hierarchy and the two functions that convert between it and markdown text, targeting v1.3.
Request
Desired capability
A full object hierarchy for markdown, rooted at a document object, in which every construct the markdown specification defines has a corresponding typed object. It is an abstract syntax tree in the ordinary sense — parse a document into objects, query them, manipulate them, and convert the result back out — designed for PowerShell rather than ported from an existing implementation. Two functions expose it, following the established PowerShell conversion verb pair:
Together they enable round-tripping: read a document into objects, inspect or transform it programmatically, and write it back out as markdown.
Scope — CommonMark only
The base grammar is CommonMark, and nothing else. The commonmark-spec repository is the canonical reference: its spec.txt defines the grammar and supplies the example set used to prove conformance. cmark, the reference C implementation, is the model for both the parsing strategy and the node type names.
Everything beyond CommonMark is an extension and ships in a later minor release:
Version
Delivers
Tracked by
1.3
The object hierarchy, CommonMark parsing and rendering, ConvertFrom-Markdown, ConvertTo-Markdown
Each of these is a minor bump, not a major one. That is a design constraint on 1.3, not a happy accident — see the next section.
Why the later versions are additive, not breaking
Adding frontmatter in 1.4 would be a breaking change if it forced the root of the hierarchy to change shape — for example if 1.3 returned a bare collection of blocks and 1.4 had to wrap it in a document, or if frontmatter arrived as a new first child that shifted every index in Children.
The design avoids that by settling the root in 1.3:
MarkdownDocument is the top-level object from 1.3 onward.ConvertFrom-Markdown always returns one, even for a document with no metadata and a single paragraph. Callers bind to a stable type.
FrontMatter exists as a property on it from 1.3 onward, typed and always $null. 1.4 populates it. A property that changes from always-$null to sometimes-populated breaks nobody.
Frontmatter is a property, never a child node. Putting it in Children would shift indices and force every existing traversal to skip a non-markdown node. As a property it is invisible to anything that walks the tree.
Dialect follows the same pattern in 1.5 — a new property with a backward-compatible default, plus a new optional parameter, plus new node types. All additive.
The rule this encodes: anything that changes the shape of MarkdownDocument must be decided in 1.3. Anything that only adds node types or populates reserved properties can wait.
Document shape
The top-level object is the document. It has two parts: an optional metadata part (frontmatter) and the markdown part (the block content). The markdown part is a tree of sections — a heading together with everything that belongs to it — because that is the unit documents are read and edited in. The section model is specified in #32 and in docs/markdown-object-model/.
MarkdownDocument <- top-level object, one per document
├── FrontMatter <- the metadata part, always $null in this issue (see #21)
└── Children <- the markdown part
├── MarkdownParagraph <- content before the first heading
│ └── Children <- inline nodes
│ ├── MarkdownText
│ ├── MarkdownEmphasis
│ │ └── Children
│ └── MarkdownLink
│ └── Children
└── MarkdownSection
├── Heading <- MarkdownHeading, a property rather than a child
│ └── Children <- inline nodes
└── Children <- the section's own blocks, then its nested sections
├── MarkdownList
│ └── MarkdownListItem
│ └── Children <- nested block nodes
├── MarkdownBlockQuote
├── MarkdownFencedCodeBlock
└── MarkdownSection <- recursive, empty for a leaf section
Every node — block or inline — has a Children collection, so one recursive walk covers the whole tree. A section additionally carries the heading that opens it as a property, and Descendants() yields that heading before the section's children.
Usage sketch:
$doc=Get-Content-Raw 'README.md'|ConvertFrom-Markdown# Traversal is uniform: no special cases for which node types have children.$doc.Descendants('Heading') |Where-Object Level -EQ2|ForEach-Object { $_.GetText() }
# A section is a first-class object, addressable by heading rather than by index arithmetic.$doc.GetSection('Usage','Parameters').Children =$generated.Children# Every construct is a typed object, so the document is queryable ...$doc.Descendants('Link') |Select-Object Destination, Title
# ... and mutable ...$doc.Descendants('Heading') |ForEach-Object { $_.Level++ }
# ... and renders back to spec-valid markdown, whole or in part.$doc|ConvertTo-Markdown|Set-Content'README.md'$doc.Children[0].ToString()
# The hierarchy is plain objects, so any serializer can take it from here.$doc|ConvertTo-Yaml$doc|ConvertTo-Json-Depth 100
Acceptance criteria
Every block and inline construct defined by CommonMark has a corresponding typed object in the hierarchy.
MarkdownDocument is the top-level object and exposes both a frontmatter part and a markdown content part.
The markdown content part is a tree of sections: a section owns the heading that opens it, its own blocks, and the sections nested inside it. A section with no nested sections is the same type holding an empty collection. Specified in #32.
The document is the same container as a section, without a heading and with frontmatter, so one piece of code walks both.
Heading level is preserved independently of nesting depth, so a skipped level nests without inventing a section and re-renders at its original level.
Every node exposes a Children collection — empty for leaves — so a single recursive walk traverses the entire tree without type-specific branching in caller code.
A MarkdownFrontMatter type exists in the hierarchy but is never populated or emitted in this issue.
Every node exposes traversal and rendering helpers: Descendants(), GetText(), and ToString().
Node classes carry content, structure, and source style only — no rendering logic.
The object graph is acyclic and free of duplicated node references, so ConvertTo-Yaml, ConvertTo-Json, and Export-Clixml produce complete output from a parsed document without special handling.
Markdown emitted by ConvertTo-Markdown is valid per the specification — correct escaping, sufficient fence lengths, correct list indentation — not merely text this module can read back.
Documents can be constructed from scratch without parsing, using constructors on the node classes.
Every parsed node records its source position, so downstream tooling can report diagnostics against line numbers.
Every example in the commonmark-spec set passes before 1.3 ships. No documented gaps.
ConvertFrom-Markdown accepts a markdown string (positional and by pipeline) and returns a MarkdownDocument.
ConvertTo-Markdown accepts any node in the hierarchy (positional and by pipeline) and returns the markdown string for that node and its descendants — a whole document or a single subtree.
Round-tripping is semantically stable: text → objects → text → objects produces an equivalent object hierarchy. Byte-for-byte preservation of the original text is explicitly not promised.
Conformance is measured against the example set published in commonmark-spec, not hand-picked cases.
Nothing in the design forces a breaking change in 1.4 or 1.5: MarkdownDocument is the returned type from the start, FrontMatter is a reserved property rather than a child node, and a -Dialect parameter can be added later without altering existing behavior.
The existing Set-Markdown* DSL keeps working unchanged.
Out of scope
Any dialect beyond CommonMark, including GFM tables, task list items, strikethrough, and extended autolinks — #30, targeting 1.5.
Frontmatter parsing and emission — #21, targeting 1.4, blocked on PSModule/YAML.
Rendering to formats other than markdown inside this module. Other formats are reached by piping the hierarchy to a general-purpose serializer such as ConvertTo-Yaml or ConvertTo-Json. A dedicated in-module renderer for another format stays possible but is not planned.
Structural validation (#22), normalization (#24), and file I/O (#23).
Reading a serialized hierarchy — YAML, JSON, CLIXML — back into typed nodes. Tracked in #31.
GitHub platform constructs, including alerts (#7).
Prior art
A community prototype was contributed in #14 implementing ConvertFrom-MarkdownMarkdown and ConvertTo-MarkdownDSL using PSCustomObject nodes with Type, Level, Title, Content, and Parent properties. It demonstrates the round-trip concept and ships tests, but it models only the handful of constructs the DSL emits rather than the specification. The naming, architecture, and scope below differ deliberately.
Reference implementations of the same problem in other languages. They are consulted as a coverage checklist — which constructs exist, and what information each has to carry — not as a structure to reproduce:
cmark — the reference C implementation. Its two-phase parsing algorithm is the model followed here; its object model is not.
mdast — the JavaScript syntax tree specification used by remark. Useful as a completeness check, and as a worked example of the tree-manipulation ergonomics this model is aiming at.
Markdig — a .NET CommonMark parser, notable for separating parsing from rendering so multiple output formats share one object model.
Technical decisions
A purpose-built object model, not a port:cmark and mdast are used as a coverage checklist — they prove which constructs exist and what information each one has to carry — not as a structure to reproduce. Where their conventions conflict with what is pleasant to use from a PowerShell prompt, PowerShell wins. Three places where this model deliberately departs from them:
Not modelled. It is a parsing concept; carrying it into the class hierarchy adds a layer users have to reason about for no benefit at the prompt.
Node identity
Both use tagged unions or enum-typed node structs
Real PowerShell classes plus a Type string, so both -is [MarkdownHeading] and Where-Object Type -EQ 'Heading' work.
The goal is an object hierarchy that is good to manipulate — parse a document, query it, edit it in place, and write it back — not a faithful transliteration of a C or JavaScript AST.
CommonMark is the whole of v1.3: The object hierarchy models the CommonMark grammar exactly — no extension constructs, no dialect-specific properties, no conditional parsing. A single, provably conformant base is worth more than a partial base plus partial extensions, and it gives dialect support (#30) a stable foundation to build on rather than a moving target.
Canonical sources:commonmark-spec is the normative reference for the grammar and the source of the conformance fixtures. cmark is the reference for how to parse — its two-phase algorithm and delimiter-stack approach are proven and worth copying rather than reinventing. Copying the parsing algorithm is separate from copying the object model; the algorithm is borrowed, the model is not.
The hierarchy is the interchange format: This module owns exactly two conversions — markdown text to objects, and objects to markdown text. Every other output format is obtained by handing the hierarchy to a general-purpose serializer: ConvertTo-Yaml from PSModule/YAML, ConvertTo-Json, Export-Clixml, or anything else that walks a PowerShell object graph. Read a document, reshape it, and emit YAML — without this module needing a YAML renderer.
That only works if the object graph is safe for a generic serializer to walk, which turns several design choices into hard requirements:
Requirement
Consequence
No cycles
No Parent back-reference, and no cross-links between nodes. A cycle makes ConvertTo-Json and ConvertTo-Yaml fail outright.
Every node appears exactly once
No secondary collection holding references to nodes that are already in Children — that would duplicate whole subtrees in the serialized output. Link reference definitions are therefore looked up by a method rather than stored in a second property.
Plain, public, typed properties only
No script properties, no hidden state, nothing a generic serializer silently drops. What you see on the object is what gets serialized.
Enums, not integers
Enum values serialize as their names, so YAML and JSON output is readable rather than a wall of magic numbers.
Type on every node
The serialized form is self-describing. Reading that form back into the hierarchy is a separate capability, tracked in #31, and is not part of 1.3.
Node classes therefore hold content, structure, and stylistic detail — and no rendering logic. The markdown renderer is a private component that walks the tree; ConvertTo-Markdown is a thin wrapper over it, and MarkdownNode.ToString() delegates to it rather than implementing it. An in-module renderer for another format stays possible but is not planned — the serializer route covers it.
Note
ConvertTo-Json defaults to -Depth 2, which silently truncates any real markdown tree. The documentation and examples use an explicit depth.
Rendered markdown is specification-valid: The renderer does not merely produce text that this module can read back. Its output is valid CommonMark — correct escaping of characters that would otherwise start a construct, fences long enough to contain their content, list indentation that keeps continuation lines inside the item, and blank-line separation where the specification requires it. Conformance is asserted by re-parsing the rendered output and comparing trees.
PowerShell classes, not PSCustomObject: Classes give a real type system — -is checks, typed properties, parameter type constraints, IntelliSense, and methods. The #14 prototype's PSCustomObject approach cannot express the block/inline distinction or carry behavior such as ToString().
Naming — Markdown prefix on every class: PowerShell classes have no namespaces and are global once the module is imported, so unprefixed names such as Document, Text, or Table would collide with other modules. Every public class is prefixed Markdown, matching the convention in PSModule/GitHub (GitHubNode, GitHubLicense). Node names otherwise follow the construct names used in the CommonMark specification itself, so MarkdownThematicBreak and MarkdownLinkReferenceDefinition are findable by anyone reading the spec alongside the code.
Traversal — the design constraint that shapes the hierarchy
The object hierarchy is optimized for being walked and edited by hand in a shell, not for maximal type strictness. Three decisions follow from that.
Uniform Children on every node: Every node exposes [MarkdownNode[]] $Children, empty for leaves. Blocks and inlines live in the same collection type, so a single recursive walk covers the entire tree with no "does this node type have children" branching and no separate Inlines collection to remember. This is how both cmark and mdast model it. The cost is that the type system permits invalid nesting; the parser and renderer enforce validity instead. That trade is worth it — a stricter type system here mostly makes hand-constructing and transforming trees painful. MarkdownSection is the one node with a second node-valued member, Heading, and Descendants() absorbs that: it yields a section's heading before its children, so the special case lives in the model rather than in every caller.
A three-level class hierarchy, not five:
Class
Base
Purpose
MarkdownNode
—
Abstract base for every node. Carries Type, Children, and the traversal and rendering members.
MarkdownBlock
MarkdownNode
Abstract marker for block-level constructs.
MarkdownInline
MarkdownNode
Abstract marker for inline constructs.
CommonMark's prose distinguishes container blocks from leaf blocks, but that is a parsing concept, not a modelling one, so it is deliberately not reflected in the class hierarchy. The block/inline split is kept because filtering on it is genuinely useful ($_ -is [MarkdownBlock]).
Type as a plain string: Every node exposes Type — Heading, Paragraph, Text, and so on, without the Markdown prefix. It makes Where-Object Type -EQ 'Heading' work without class names in scope, and makes ConvertTo-Json output self-describing.
Members on MarkdownNode:
Member
Returns
Children
Direct child nodes, in document order.
Descendants()
Every node beneath this one, depth-first, in document order.
Descendants([string] $type)
The same, filtered to one node type.
GetText()
The concatenated text content of the subtree, with markup stripped.
Sections()
The nested sections in Children.
Blocks()
The blocks in Children that are not sections.
GetSection([string[]] $path)
The section reached by matching heading text at each step.
ToString()
The subtree rendered back to markdown, by delegating to the renderer.
No Parent back-reference: Nodes reference their children only. A Parent property — as used in the #14 prototype — creates cycles that break ConvertTo-Json, Format-List, and cloning, and it makes moving a subtree between documents error-prone. Parent context needed during parsing is held on the parser's own stack. Consumers that need positional context use Descendants(), which returns document order.
A formatting view for the console: A Format.ps1xml view in src/formats/ renders a document as an indented tree, so $doc at the prompt shows the structure rather than a wall of property expansions. Discoverability is part of being simple to work with.
The object hierarchy
The complete schema. Every class, every property, and the specification section it derives from. Properties marked style exist only so the renderer can reproduce the source form; they carry no semantic content and a consumer that does not care about markdown output can ignore them.
Type hierarchy
Three levels. MarkdownBlock and MarkdownInline add nothing of their own — they exist so $_ -is [MarkdownBlock] is a usable filter.
MarkdownFrontMatter and MarkdownSourceSpan are not nodes. They hang off nodes as properties and never appear in Children. MarkdownHeadingis a node, but it reaches the tree only as a section's Heading — a heading that introduces nothing is not something a document can express.
What contains what
Inheritance says which classes exist. This says how they nest — which is what matters when traversing or building a tree. The recursion points are sections inside sections, blocks inside blocks, and inlines inside inlines. The first two are the same mechanism: a section is a block that contains blocks.
flowchart TD
Doc(["MarkdownDocument"]) --> BL{{"block level"}}
BL --> SE["MarkdownSection"]
BL --> BQ["MarkdownBlockQuote"]
BL --> LS["MarkdownList"]
BL --> PA["MarkdownParagraph"]
BL --> LFB["MarkdownThematicBreak<br>MarkdownIndentedCodeBlock<br>MarkdownFencedCodeBlock<br>MarkdownHtmlBlock<br>MarkdownLinkReferenceDefinition"]
SE --> HD["MarkdownHeading"]
SE --> BL
BQ --> BL
LS --> LI["MarkdownListItem"]
LI --> BL
PA --> IL{{"inline level"}}
HD --> IL
IL --> EM["MarkdownEmphasis<br>MarkdownStrongEmphasis"]
IL --> LK["MarkdownLink<br>MarkdownImage"]
IL --> LFI["MarkdownText<br>MarkdownCodeSpan<br>MarkdownAutolink<br>MarkdownRawHtml<br>MarkdownHardLineBreak<br>MarkdownSoftLineBreak"]
EM --> IL
LK --> IL
Loading
The type system does not enforce these rules — Children is [MarkdownNode[]] everywhere, for the traversal and serialization reasons given above. The parser produces only valid nesting, and the renderer throws on nesting it cannot express.
A worked example
This document:
# Setup
Install with `Install-PSResource`.
- Step one
- Step **two**
parses to this hierarchy:
flowchart TD
D["MarkdownDocument"]
SEC["MarkdownSection"]
H["MarkdownHeading<br>Level = 1"]
HT["MarkdownText<br>Setup"]
P["MarkdownParagraph"]
PT1["MarkdownText<br>Install with"]
PC["MarkdownCodeSpan<br>Install-PSResource"]
PT2["MarkdownText<br>."]
L["MarkdownList<br>Kind = Bullet<br>IsTight = true"]
LI1["MarkdownListItem"]
LI2["MarkdownListItem"]
P1["MarkdownParagraph"]
P2["MarkdownParagraph"]
T1["MarkdownText<br>Step one"]
T2["MarkdownText<br>Step"]
S["MarkdownStrongEmphasis<br>Marker = Asterisk"]
T3["MarkdownText<br>two"]
D --> SEC
SEC -->|Heading| H
SEC --> P
SEC --> L
H --> HT
P --> PT1
P --> PC
P --> PT2
L --> LI1
L --> LI2
LI1 --> P1
LI2 --> P2
P1 --> T1
P2 --> T2
P2 --> S
S --> T3
Loading
Three things this makes concrete. The paragraph and the list are content of the section, not siblings of the heading — the document holds one child, and everything under # Setup hangs off it. List items contain blocks, not text — so the content of a list item is a paragraph, even for a one-line item. And emphasis contains inlines rather than a string, which is why **two** is a MarkdownStrongEmphasis wrapping a MarkdownText rather than a node with a Text property.
Shared members
MarkdownNode is the abstract base of every node. MarkdownBlock and MarkdownInline derive from it and add nothing — they exist purely so $_ -is [MarkdownBlock] is a usable filter.
Member
Type
Notes
Type
[string]
The node name without the Markdown prefix — Heading, Paragraph, Text. Read-only.
Children
[MarkdownNode[]]
Direct children in document order. Empty for leaves, never $null.
Source
[MarkdownSourceSpan]
Where the node came from in the source text. $null for nodes built by hand.
Descendants()
[MarkdownNode[]]
Every node beneath this one, depth-first, document order. A section's Heading is yielded before its children.
Descendants([string] $type)
[MarkdownNode[]]
The same, filtered to one Type.
GetText()
[string]
Concatenated text content of the subtree, markup stripped.
ToString()
[string]
The subtree rendered as markdown, by delegating to the renderer.
Sections()
[MarkdownSection[]]
The nested sections in Children. A method, not a property — a filtered view under a second name would duplicate those nodes in serialized output.
Blocks()
[MarkdownBlock[]]
The blocks in Children that are not sections. Same reasoning.
GetSection([string[]] $path)
[MarkdownSection]
The section reached by matching heading text at each step, ordinal and case-insensitive. Returns nothing when the path matches nothing.
MarkdownSourceSpan — not a MarkdownNode. Populated by the parser, $null on hand-constructed nodes, and ignored when comparing trees for round-trip equivalence.
Property
Type
Notes
StartLine
[int]
1-based.
StartColumn
[int]
1-based.
EndLine
[int]
1-based, inclusive.
EndColumn
[int]
1-based, inclusive.
Every node class also exposes a parameterless constructor and one overload covering its common case, so documents can be built without parsing:
Block-level nodes: the content before the first heading, then the top-level sections.
GetLinkReferenceDefinitions()
[MarkdownLinkReferenceDefinition[]]
A method, not a property — the definitions are already nodes in the tree, and storing a second reference to them would duplicate them in serialized output.
MarkdownFrontMatter — deliberately not a MarkdownNode. It is not markdown, it never appears in Children, and nothing that walks the tree encounters it.
Property
Type
Notes
Format
[MarkdownFrontMatterFormat]
Yaml initially.
Raw
[string]
Verbatim text between the delimiters, so an untouched document round-trips losslessly.
Data
[object]
The deserialized value.
Blocks
MarkdownSection : MarkdownBlock — not a CommonMark construct. A section is the grouping the specification's block sequence implies: a heading and everything up to the next heading of the same or a lower level. Specified in #32.
Property
Type
Notes
Heading
[MarkdownHeading]
The heading that opens the section. A property, not a child — a section has exactly one, and this makes that structural rather than a convention about Children[0].
Children
[MarkdownNode[]]
The section's own blocks, then its nested sections, in document order. Empty collection for a leaf section.
Nesting depth is not the heading level. Heading.Level stays the only source of truth for rendering, so a document that skips a level nests the deeper section directly under the shallower one and re-renders it unchanged.
MarkdownSoftLineBreak : MarkdownInline — §6.8 — no properties beyond the shared members.
Enums
Enum
Values
MarkdownHeadingStyle
Atx, AtxClosed, Setext
MarkdownThematicBreakMarker
Hyphen, Asterisk, Underscore
MarkdownFenceCharacter
Backtick, Tilde
MarkdownListKind
Bullet, Ordered
MarkdownListMarker
Hyphen, Asterisk, Plus, Period, Parenthesis
MarkdownEmphasisMarker
Asterisk, Underscore
MarkdownLinkReferenceKind
Inline, Full, Collapsed, Shortcut
MarkdownTitleDelimiter
DoubleQuote, SingleQuote, Parenthesis
MarkdownAutolinkKind
Uri, Email
MarkdownLineBreakMarker
Backslash, Spaces
MarkdownFrontMatterFormat
Yaml
Enums rather than validated strings, so invalid states are unrepresentable, tab completion works on assignment, and serialized output carries readable names.
Not markdown. A property on the document, never a child node.
Remaining decisions
Frontmatter — modelled in 1.3, implemented in 1.4:MarkdownDocument gets a [MarkdownFrontMatter] $FrontMatter property, and MarkdownFrontMatter carries Format (a MarkdownFrontMatterFormat enum, initially Yaml), Raw (the text between the delimiters), and Data (the deserialized value). This issue defines the types and leaves FrontMatter as $null; parsing and emission arrive in #21 once PSModule/YAML ships ConvertFrom-Yaml and ConvertTo-Yaml. Reserving the property now is what keeps 1.4 a minor bump rather than a major one.
Note
This supersedes the [hashtable] $Metadata decision originally recorded in #21. A dedicated type keeps the format explicit and preserves the raw text for lossless round-tripping, which a bare hashtable cannot do. Issue #21 has been updated to match.
Round-trip fidelity — semantic, not byte-exact: Guaranteeing byte-identical output would require storing every whitespace and indentation detail on every node. Instead each node stores the stylistic choices a reader would notice — heading style, fence character and length, bullet character, ordered-list delimiter, emphasis marker, link reference kind, backtick count — so re-rendering produces the same document in the same style. Insignificant whitespace is normalized. The contract is that re-parsing the rendered text yields an equivalent hierarchy, and that rendering is idempotent from the second pass onward.
Conformance measured against the specification's own examples:commonmark-spec publishes its examples in machine-readable form (spec.json). Since this module does not render HTML, conformance is asserted as: every example parses without error, and every example round-trips idempotently through ConvertFrom-Markdown and ConvertTo-Markdown. Examples that cannot yet be satisfied are tracked explicitly as known gaps rather than silently skipped.
Parsing strategy — two passes, following cmark: CommonMark is defined as a two-phase parse (Appendix: A parsing strategy), and cmark implements it directly: block structure first, then inline content within the resulting leaf blocks, with emphasis resolved by the delimiter-stack algorithm. The implementation follows the same split, which keeps each pass tractable and lets block support and inline support land as separate deliverables.
Function surface, and room for -Dialect:ConvertFrom-Markdown -InputObject [string] (position 0, ValueFromPipeline) returns [MarkdownDocument] — always, including for documents with no metadata, so the return type never changes across versions. ConvertTo-Markdown -InputObject [MarkdownNode] (position 0, ValueFromPipeline) returns [string], accepting any node so subtrees can be rendered on their own. No -Dialect parameter ships in 1.3; #30 adds it in 1.5 as an optional parameter defaulting to CommonMark, which is additive and non-breaking. Neither function touches the file system — that is the caller's job with Get-Content -Raw and Set-Content, and later #23, consistent with how ConvertFrom-Json behaves.
Important
PowerShell 6.1+ ships a built-in ConvertFrom-Markdown in Microsoft.PowerShell.Utility that converts markdown to HTML or VT100-encoded output. Importing this module shadows it. This is intentional: the built-in produces rendered output, this one produces a structured object hierarchy. The built-in stays reachable as Microsoft.PowerShell.Utility\ConvertFrom-Markdown.
Extension seam for dialects: The parser is written so that block starts and inline delimiters are looked up from a table rather than hard-coded into a switch. A dialect then contributes entries to those tables instead of forking the parser. Nothing dialect-specific is implemented here — only the seam that makes #30 additive.
File placement: Classes go in src/classes/public/ (nodes are user-facing and appear in type constraints), grouped in subfolders mirroring the hierarchy — Blocks/, Inlines/, Enums/ — following the layout used by PSModule/GitHub. Parser and renderer internals go in src/functions/private/. The two public functions go in src/functions/public/. Formatting views go in src/formats/. Base classes must be defined before derived classes in the built module; the loading order is verified against the build framework as part of the first deliverable.
Relationship to the existing DSL: The Set-Markdown* functions and the object hierarchy are complementary and independent. The DSL stays the imperative way to compose markdown; the object hierarchy is the way to parse, inspect, and transform it. The DSL's Details output is raw HTML, so it round-trips as MarkdownHtmlBlock — correct per CommonMark, since <details> is not markdown. The DSL's Table output is a GFM construct and therefore round-trips as paragraphs of text until #30 lands; that is expected, not a defect.
Release shape: The object hierarchy is a new feature on an existing module, so 1.3 is a minor bump. Frontmatter (1.4) and dialects (1.5) are each a further minor bump. No major bump is needed anywhere in the plan, because the root object shape is settled in 1.3 and everything after it is additive.
Decomposition: This is far larger than one reviewable pull request, so this issue becomes the parent and Section 3 lists the child Tasks. Each child is one deliverable with its own PR. The children are created once the design above is agreed — no implementation starts before then.
Decisions taken during design review
Source positions — one property, not four: Every node carries a [MarkdownSourceSpan] $Source recording where it came from. A single nested property keeps the noise to one line in serialized output and one thing to ignore, rather than four integers on every node. It is $null for nodes built by hand, and it is excluded when comparing trees for round-trip equivalence — two trees are equivalent if their content and structure match, regardless of where they came from. Including this in 1.3 is what makes the diagnostics in #22 possible at all; adding it later would change the shape of every node.
Construction from scratch is a first-class scenario: Every node class exposes a parameterless constructor plus one overload covering its common case — [MarkdownHeading]::new(2, 'Title'), [MarkdownParagraph]::new('text'), [MarkdownFencedCodeBlock]::new('powershell', $code). Parsing is one way to obtain a hierarchy, not the only way. Whether the Set-Markdown* DSL should eventually be reimplemented on top of the hierarchy is a separate question, deliberately not answered here.
No property-level validation: Properties are plain and settable. $heading.Level = 7 is accepted by the object; it is caught by Test-Markdown (#22) and by the renderer, which throws on states it cannot express. Validating in property setters requires backing fields and explicit accessors, which directly conflicts with the "plain, public, typed properties only" requirement that makes generic serialization work. Validation belongs at the boundaries, not on every assignment.
Sections are the primary structure, not a view over a flat block sequence: A heading and the content it introduces is the unit documents are read and edited in, so the model holds them together. Keeping a flat block sequence and offering a section view alongside it was rejected because it means two representations of one document that have to be kept in step, and because a mutation made through the view has to be written back. Grouping happens once, at parse time. The cost is that heading level is no longer readable from nesting depth — which is why Heading.Level stays authoritative — and one pass over the block sequence per container. This is settled in 1.3 for the same reason the root shape is: it changes the shape of MarkdownDocument. Specified in #32 and in docs/markdown-object-model/.
Line endings — the parser normalizes, the renderer emits LF: The specification treats a line ending as any of LF, CR, or CRLF (§2.1), so the parser accepts all three and the distinction never reaches the object hierarchy. The renderer emits LF. The caller chooses what lands on disk via Set-Content, which already applies platform conventions. This is consistent with the round-trip contract: equivalence is asserted on the tree, not on bytes.
Implementation language — pure PowerShell, with a measured budget: The parser and renderer are written in PowerShell, keeping the module dependency-free, debuggable, and consistent with the rest of the ecosystem. Character-level parsing in PowerShell is slow enough to be a real risk, so 1.3 states a budget rather than assuming: a 1,000-line document parses in under two seconds, and the conformance suite completes inside the normal CI test job. Missing the budget opens an optimization issue — .NET methods instead of PowerShell operators, as PSModule/YAML#30 is doing, or a compiled core via Add-Type. Crucially this is not a shape decision: the object model is unaffected by how the parser is implemented, so the language can change later without a breaking release.
Conformance bar — all examples green, no documented gaps: 1.3 does not ship until every example in the commonmark-spec set passes. "Specification-compliant" in the title has to mean something, and partial conformance is precisely where every previous PowerShell markdown parser has stopped. It also protects #30: dialect support layered on a partially-conformant base inherits every gap. Child Tasks may land while the suite is still red — the suite runs in a known-failing mode until step 9 — but the milestone does not close until it is green.
Implementation plan
Child Tasks, in dependency order. Each is one pull request.
0. Specification
Add docs/markdown-object-model/ with index.md, spec.md, and design.md — the durable contract, so this issue can stop restating it — #32
1. Node type foundation
Create src/classes/public/ with MarkdownNode, MarkdownBlock, and MarkdownInline
Define MarkdownSourceSpan with StartLine, StartColumn, EndLine, EndColumn
Implement the shared members on MarkdownNode: Type, Children, Source, Descendants(), Descendants([string]), Sections(), Blocks(), GetSection([string[]]), GetText(), and ToString() delegating to the renderer
Give every node class a parameterless constructor and one overload for its common case
Define MarkdownDocument with FrontMatter, Children, and GetLinkReferenceDefinitions()
Define MarkdownFrontMatter and MarkdownFrontMatterFormat — types only, never populated in 1.3
Assert in tests that ConvertFrom-Markdown always returns a MarkdownDocument and that FrontMatter is always $null, locking in the shape 1.4 depends on
Verify class load ordering works with the build framework when base classes and derived classes live in different files
Define the supporting enums: MarkdownEmphasisMarker, MarkdownLinkReferenceKind, MarkdownTitleDelimiter, MarkdownAutolinkKind, MarkdownLineBreakMarker
4. Block parser
Implement the block-structure pass in src/functions/private/, following the cmark algorithm
Normalize LF, CR, and CRLF line endings on input, per §2.1
Record a MarkdownSourceSpan on every node produced
Drive block starts from a lookup table rather than a hard-coded switch, so #30 can extend it
Handle container nesting, lazy continuation, list tightness, and link reference definition collection
Group the child block sequence of every block container into sections — a heading closes every open section at its level or deeper, blocks before the first heading stay at container level, and skipped levels nest without inventing a section — #32
Produce a MarkdownDocument with leaf-block content held as raw text pending the inline pass
5. Inline parser
Implement the inline pass over the leaf blocks produced by the block parser
Implement the delimiter-stack algorithm for emphasis and strong emphasis
Resolve links, images, autolinks, code spans, raw HTML, line breaks, backslash escapes, and entity references
Drive inline delimiters from a lookup table, for the same extensibility reason as the block pass
6. ConvertFrom-Markdown
Create src/functions/public/ConvertFrom-Markdown.ps1 wiring the two parser passes together
Accept -InputObject [string] at position 0 with ValueFromPipeline, returning [MarkdownDocument]
Add comment-based help with examples
7. Markdown renderer and ConvertTo-Markdown
Implement the markdown renderer as a private component in src/functions/private/ that walks the tree — node classes stay free of rendering logic
Cover every node type, honoring the stylistic properties captured at parse time
Render a section as its heading followed by its children, at the heading's own level, so output is byte-identical to the ungrouped block sequence
Emit specification-valid markdown: escape characters that would otherwise start a construct, size fences to their content, indent list continuation lines correctly, and separate blocks where the specification requires it
Emit LF line endings, leaving platform conventions to the caller's Set-Content
Throw a clear error on states that cannot be rendered, rather than emitting invalid markdown
Create src/functions/public/ConvertTo-Markdown.ps1 as a thin wrapper over the renderer
Wire MarkdownNode.ToString() to the renderer so any subtree renders on its own
Accept -InputObject [MarkdownNode] at position 0 with ValueFromPipeline, returning [string]
Add comment-based help with examples
8. Console formatting
Add a Format.ps1xml view in src/formats/ that renders a document as an indented tree
9. Specification conformance suite
Add the commonmark-spec example set as test data under tests/
Assert every example parses without error
Assert every example round-trips idempotently through ConvertFrom-Markdown and ConvertTo-Markdown, comparing trees while ignoring Source
Assert sectioning against irregular documents: skipped levels, a document starting below h1, a level that rises again, and headings inside a block quote and a list item
Assert the object graph is acyclic and free of duplicated node references — ConvertTo-Json -Depth 100 and ConvertTo-Yaml succeed on every parsed example
Assert the performance budget: a 1,000-line document parses in under two seconds
Gate the 1.3 milestone on the whole suite passing — no documented gaps
10. Documentation
Document the full object hierarchy in README.md — every class, every property, and its specification section — with the section tree as the shape a reader meets first, linking to docs/markdown-object-model/ rather than restating it
Document the traversal members and the serialization guarantees, including the ConvertTo-Json -Depth caveat
Add examples under examples/ covering parse, query, transform, render, and hand-off to another serializer
Document how the object hierarchy relates to the existing Set-Markdown* DSL, and which DSL constructs are not CommonMark
Each child pull request documents the slice of the schema it adds, so the hierarchy is reviewable as a specification rather than only as code.
The Markdown module today only writes markdown. The
Set-Markdown*DSL functions emit strings, and there is no object representation of a markdown document that can be inspected, queried, or transformed. Automation that needs to read an existing document — extract a section, rewrite a link, validate structure, or merge generated content into a hand-written file — has to fall back to regular expressions against raw text.Every capability the module could offer on top of markdown depends on one thing existing first: a complete, typed object hierarchy that models what a markdown document is. Validation (#22), normalization (#24), file I/O (#23), frontmatter (#21), and dialect support (#30) all build on it. This issue delivers that object hierarchy and the two functions that convert between it and markdown text, targeting v1.3.
Request
Desired capability
A full object hierarchy for markdown, rooted at a document object, in which every construct the markdown specification defines has a corresponding typed object. It is an abstract syntax tree in the ordinary sense — parse a document into objects, query them, manipulate them, and convert the result back out — designed for PowerShell rather than ported from an existing implementation. Two functions expose it, following the established PowerShell conversion verb pair:
ConvertFrom-MarkdownConvertFrom-Json(JSON text → objects)ConvertTo-MarkdownConvertTo-Json(objects → JSON text)Together they enable round-tripping: read a document into objects, inspect or transform it programmatically, and write it back out as markdown.
Scope — CommonMark only
The base grammar is CommonMark, and nothing else. The commonmark-spec repository is the canonical reference: its
spec.txtdefines the grammar and supplies the example set used to prove conformance. cmark, the reference C implementation, is the model for both the parsing strategy and the node type names.Everything beyond CommonMark is an extension and ships in a later minor release:
ConvertFrom-Markdown,ConvertTo-MarkdownEach of these is a minor bump, not a major one. That is a design constraint on 1.3, not a happy accident — see the next section.
Why the later versions are additive, not breaking
Adding frontmatter in 1.4 would be a breaking change if it forced the root of the hierarchy to change shape — for example if 1.3 returned a bare collection of blocks and 1.4 had to wrap it in a document, or if frontmatter arrived as a new first child that shifted every index in
Children.The design avoids that by settling the root in 1.3:
MarkdownDocumentis the top-level object from 1.3 onward.ConvertFrom-Markdownalways returns one, even for a document with no metadata and a single paragraph. Callers bind to a stable type.FrontMatterexists as a property on it from 1.3 onward, typed and always$null. 1.4 populates it. A property that changes from always-$nullto sometimes-populated breaks nobody.Childrenwould shift indices and force every existing traversal to skip a non-markdown node. As a property it is invisible to anything that walks the tree.Dialectfollows the same pattern in 1.5 — a new property with a backward-compatible default, plus a new optional parameter, plus new node types. All additive.The rule this encodes: anything that changes the shape of
MarkdownDocumentmust be decided in 1.3. Anything that only adds node types or populates reserved properties can wait.Document shape
The top-level object is the document. It has two parts: an optional metadata part (frontmatter) and the markdown part (the block content). The markdown part is a tree of sections — a heading together with everything that belongs to it — because that is the unit documents are read and edited in. The section model is specified in #32 and in
docs/markdown-object-model/.Every node — block or inline — has a
Childrencollection, so one recursive walk covers the whole tree. A section additionally carries the heading that opens it as a property, andDescendants()yields that heading before the section's children.Usage sketch:
Acceptance criteria
MarkdownDocumentis the top-level object and exposes both a frontmatter part and a markdown content part.Childrencollection — empty for leaves — so a single recursive walk traverses the entire tree without type-specific branching in caller code.MarkdownFrontMattertype exists in the hierarchy but is never populated or emitted in this issue.Descendants(),GetText(), andToString().ConvertTo-Yaml,ConvertTo-Json, andExport-Clixmlproduce complete output from a parsed document without special handling.ConvertTo-Markdownis valid per the specification — correct escaping, sufficient fence lengths, correct list indentation — not merely text this module can read back.ConvertFrom-Markdownaccepts a markdown string (positional and by pipeline) and returns aMarkdownDocument.ConvertTo-Markdownaccepts any node in the hierarchy (positional and by pipeline) and returns the markdown string for that node and its descendants — a whole document or a single subtree.text → objects → text → objectsproduces an equivalent object hierarchy. Byte-for-byte preservation of the original text is explicitly not promised.MarkdownDocumentis the returned type from the start,FrontMatteris a reserved property rather than a child node, and a-Dialectparameter can be added later without altering existing behavior.Set-Markdown*DSL keeps working unchanged.Out of scope
ConvertTo-YamlorConvertTo-Json. A dedicated in-module renderer for another format stays possible but is not planned.Prior art
A community prototype was contributed in #14 implementing
ConvertFrom-MarkdownMarkdownandConvertTo-MarkdownDSLusingPSCustomObjectnodes withType,Level,Title,Content, andParentproperties. It demonstrates the round-trip concept and ships tests, but it models only the handful of constructs the DSL emits rather than the specification. The naming, architecture, and scope below differ deliberately.Reference implementations of the same problem in other languages. They are consulted as a coverage checklist — which constructs exist, and what information each has to carry — not as a structure to reproduce:
Technical decisions
A purpose-built object model, not a port: cmark and mdast are used as a coverage checklist — they prove which constructs exist and what information each one has to carry — not as a structure to reproduce. Where their conventions conflict with what is pleasant to use from a PowerShell prompt, PowerShell wins. Three places where this model deliberately departs from them:
MarkdownDocument. Nothing that walks the tree has to skip a non-markdown node, and no index inChildrenshifts.Typestring, so both-is [MarkdownHeading]andWhere-Object Type -EQ 'Heading'work.The goal is an object hierarchy that is good to manipulate — parse a document, query it, edit it in place, and write it back — not a faithful transliteration of a C or JavaScript AST.
CommonMark is the whole of v1.3: The object hierarchy models the CommonMark grammar exactly — no extension constructs, no dialect-specific properties, no conditional parsing. A single, provably conformant base is worth more than a partial base plus partial extensions, and it gives dialect support (#30) a stable foundation to build on rather than a moving target.
Canonical sources: commonmark-spec is the normative reference for the grammar and the source of the conformance fixtures. cmark is the reference for how to parse — its two-phase algorithm and delimiter-stack approach are proven and worth copying rather than reinventing. Copying the parsing algorithm is separate from copying the object model; the algorithm is borrowed, the model is not.
The hierarchy is the interchange format: This module owns exactly two conversions — markdown text to objects, and objects to markdown text. Every other output format is obtained by handing the hierarchy to a general-purpose serializer:
ConvertTo-Yamlfrom PSModule/YAML,ConvertTo-Json,Export-Clixml, or anything else that walks a PowerShell object graph. Read a document, reshape it, and emit YAML — without this module needing a YAML renderer.That only works if the object graph is safe for a generic serializer to walk, which turns several design choices into hard requirements:
Parentback-reference, and no cross-links between nodes. A cycle makesConvertTo-JsonandConvertTo-Yamlfail outright.Children— that would duplicate whole subtrees in the serialized output. Link reference definitions are therefore looked up by a method rather than stored in a second property.Typeon every nodeNode classes therefore hold content, structure, and stylistic detail — and no rendering logic. The markdown renderer is a private component that walks the tree;
ConvertTo-Markdownis a thin wrapper over it, andMarkdownNode.ToString()delegates to it rather than implementing it. An in-module renderer for another format stays possible but is not planned — the serializer route covers it.Note
ConvertTo-Jsondefaults to-Depth 2, which silently truncates any real markdown tree. The documentation and examples use an explicit depth.Rendered markdown is specification-valid: The renderer does not merely produce text that this module can read back. Its output is valid CommonMark — correct escaping of characters that would otherwise start a construct, fences long enough to contain their content, list indentation that keeps continuation lines inside the item, and blank-line separation where the specification requires it. Conformance is asserted by re-parsing the rendered output and comparing trees.
PowerShell classes, not
PSCustomObject: Classes give a real type system —-ischecks, typed properties, parameter type constraints, IntelliSense, and methods. The #14 prototype'sPSCustomObjectapproach cannot express the block/inline distinction or carry behavior such asToString().Naming —
Markdownprefix on every class: PowerShell classes have no namespaces and are global once the module is imported, so unprefixed names such asDocument,Text, orTablewould collide with other modules. Every public class is prefixedMarkdown, matching the convention in PSModule/GitHub (GitHubNode,GitHubLicense). Node names otherwise follow the construct names used in the CommonMark specification itself, soMarkdownThematicBreakandMarkdownLinkReferenceDefinitionare findable by anyone reading the spec alongside the code.Traversal — the design constraint that shapes the hierarchy
The object hierarchy is optimized for being walked and edited by hand in a shell, not for maximal type strictness. Three decisions follow from that.
Uniform
Childrenon every node: Every node exposes[MarkdownNode[]] $Children, empty for leaves. Blocks and inlines live in the same collection type, so a single recursive walk covers the entire tree with no "does this node type have children" branching and no separateInlinescollection to remember. This is how both cmark and mdast model it. The cost is that the type system permits invalid nesting; the parser and renderer enforce validity instead. That trade is worth it — a stricter type system here mostly makes hand-constructing and transforming trees painful.MarkdownSectionis the one node with a second node-valued member,Heading, andDescendants()absorbs that: it yields a section's heading before its children, so the special case lives in the model rather than in every caller.A three-level class hierarchy, not five:
MarkdownNodeType,Children, and the traversal and rendering members.MarkdownBlockMarkdownNodeMarkdownInlineMarkdownNodeCommonMark's prose distinguishes container blocks from leaf blocks, but that is a parsing concept, not a modelling one, so it is deliberately not reflected in the class hierarchy. The block/inline split is kept because filtering on it is genuinely useful (
$_ -is [MarkdownBlock]).Typeas a plain string: Every node exposesType—Heading,Paragraph,Text, and so on, without theMarkdownprefix. It makesWhere-Object Type -EQ 'Heading'work without class names in scope, and makesConvertTo-Jsonoutput self-describing.Members on
MarkdownNode:ChildrenDescendants()Descendants([string] $type)GetText()Sections()Children.Blocks()Childrenthat are not sections.GetSection([string[]] $path)ToString()No
Parentback-reference: Nodes reference their children only. AParentproperty — as used in the #14 prototype — creates cycles that breakConvertTo-Json,Format-List, and cloning, and it makes moving a subtree between documents error-prone. Parent context needed during parsing is held on the parser's own stack. Consumers that need positional context useDescendants(), which returns document order.A formatting view for the console: A
Format.ps1xmlview insrc/formats/renders a document as an indented tree, so$docat the prompt shows the structure rather than a wall of property expansions. Discoverability is part of being simple to work with.The object hierarchy
The complete schema. Every class, every property, and the specification section it derives from. Properties marked style exist only so the renderer can reproduce the source form; they carry no semantic content and a consumer that does not care about markdown output can ignore them.
Type hierarchy
Three levels.
MarkdownBlockandMarkdownInlineadd nothing of their own — they exist so$_ -is [MarkdownBlock]is a usable filter.classDiagram direction TB class MarkdownNode { <<abstract>> +String Type +MarkdownNode[] Children +MarkdownSourceSpan Source +Descendants() MarkdownNode[] +GetText() String +ToString() String } class MarkdownBlock { <<abstract>> } class MarkdownInline { <<abstract>> } class MarkdownFrontMatter { +MarkdownFrontMatterFormat Format +String Raw +Object Data } class MarkdownSourceSpan { +Int StartLine +Int StartColumn +Int EndLine +Int EndColumn } MarkdownNode <|-- MarkdownBlock MarkdownNode <|-- MarkdownInline MarkdownNode --> MarkdownSourceSpan : Source MarkdownBlock <|-- MarkdownDocument MarkdownBlock <|-- MarkdownSection MarkdownBlock <|-- MarkdownParagraph MarkdownBlock <|-- MarkdownHeading MarkdownBlock <|-- MarkdownThematicBreak MarkdownBlock <|-- MarkdownIndentedCodeBlock MarkdownBlock <|-- MarkdownFencedCodeBlock MarkdownBlock <|-- MarkdownHtmlBlock MarkdownBlock <|-- MarkdownLinkReferenceDefinition MarkdownBlock <|-- MarkdownBlockQuote MarkdownBlock <|-- MarkdownList MarkdownBlock <|-- MarkdownListItem MarkdownInline <|-- MarkdownText MarkdownInline <|-- MarkdownCodeSpan MarkdownInline <|-- MarkdownEmphasis MarkdownInline <|-- MarkdownStrongEmphasis MarkdownInline <|-- MarkdownLink MarkdownInline <|-- MarkdownImage MarkdownInline <|-- MarkdownAutolink MarkdownInline <|-- MarkdownRawHtml MarkdownInline <|-- MarkdownHardLineBreak MarkdownInline <|-- MarkdownSoftLineBreak MarkdownDocument --> MarkdownFrontMatter : FrontMatter MarkdownSection --> MarkdownHeading : HeadingMarkdownFrontMatterandMarkdownSourceSpanare not nodes. They hang off nodes as properties and never appear inChildren.MarkdownHeadingis a node, but it reaches the tree only as a section'sHeading— a heading that introduces nothing is not something a document can express.What contains what
Inheritance says which classes exist. This says how they nest — which is what matters when traversing or building a tree. The recursion points are sections inside sections, blocks inside blocks, and inlines inside inlines. The first two are the same mechanism: a section is a block that contains blocks.
flowchart TD Doc(["MarkdownDocument"]) --> BL{{"block level"}} BL --> SE["MarkdownSection"] BL --> BQ["MarkdownBlockQuote"] BL --> LS["MarkdownList"] BL --> PA["MarkdownParagraph"] BL --> LFB["MarkdownThematicBreak<br>MarkdownIndentedCodeBlock<br>MarkdownFencedCodeBlock<br>MarkdownHtmlBlock<br>MarkdownLinkReferenceDefinition"] SE --> HD["MarkdownHeading"] SE --> BL BQ --> BL LS --> LI["MarkdownListItem"] LI --> BL PA --> IL{{"inline level"}} HD --> IL IL --> EM["MarkdownEmphasis<br>MarkdownStrongEmphasis"] IL --> LK["MarkdownLink<br>MarkdownImage"] IL --> LFI["MarkdownText<br>MarkdownCodeSpan<br>MarkdownAutolink<br>MarkdownRawHtml<br>MarkdownHardLineBreak<br>MarkdownSoftLineBreak"] EM --> IL LK --> ILThe type system does not enforce these rules —
Childrenis[MarkdownNode[]]everywhere, for the traversal and serialization reasons given above. The parser produces only valid nesting, and the renderer throws on nesting it cannot express.A worked example
This document:
parses to this hierarchy:
flowchart TD D["MarkdownDocument"] SEC["MarkdownSection"] H["MarkdownHeading<br>Level = 1"] HT["MarkdownText<br>Setup"] P["MarkdownParagraph"] PT1["MarkdownText<br>Install with"] PC["MarkdownCodeSpan<br>Install-PSResource"] PT2["MarkdownText<br>."] L["MarkdownList<br>Kind = Bullet<br>IsTight = true"] LI1["MarkdownListItem"] LI2["MarkdownListItem"] P1["MarkdownParagraph"] P2["MarkdownParagraph"] T1["MarkdownText<br>Step one"] T2["MarkdownText<br>Step"] S["MarkdownStrongEmphasis<br>Marker = Asterisk"] T3["MarkdownText<br>two"] D --> SEC SEC -->|Heading| H SEC --> P SEC --> L H --> HT P --> PT1 P --> PC P --> PT2 L --> LI1 L --> LI2 LI1 --> P1 LI2 --> P2 P1 --> T1 P2 --> T2 P2 --> S S --> T3Three things this makes concrete. The paragraph and the list are content of the section, not siblings of the heading — the document holds one child, and everything under
# Setuphangs off it. List items contain blocks, not text — so the content of a list item is a paragraph, even for a one-line item. And emphasis contains inlines rather than a string, which is why**two**is aMarkdownStrongEmphasiswrapping aMarkdownTextrather than a node with aTextproperty.Shared members
MarkdownNodeis the abstract base of every node.MarkdownBlockandMarkdownInlinederive from it and add nothing — they exist purely so$_ -is [MarkdownBlock]is a usable filter.Type[string]Markdownprefix —Heading,Paragraph,Text. Read-only.Children[MarkdownNode[]]$null.Source[MarkdownSourceSpan]$nullfor nodes built by hand.Descendants()[MarkdownNode[]]Headingis yielded before its children.Descendants([string] $type)[MarkdownNode[]]Type.GetText()[string]ToString()[string]Sections()[MarkdownSection[]]Children. A method, not a property — a filtered view under a second name would duplicate those nodes in serialized output.Blocks()[MarkdownBlock[]]Childrenthat are not sections. Same reasoning.GetSection([string[]] $path)[MarkdownSection]MarkdownSourceSpan— not aMarkdownNode. Populated by the parser,$nullon hand-constructed nodes, and ignored when comparing trees for round-trip equivalence.StartLine[int]StartColumn[int]EndLine[int]EndColumn[int]Every node class also exposes a parameterless constructor and one overload covering its common case, so documents can be built without parsing:
Document
MarkdownDocument : MarkdownBlock— the root, and the return type ofConvertFrom-Markdown.FrontMatter[MarkdownFrontMatter]$nullin 1.3. Populated in 1.4 by #21.Children[MarkdownNode[]]GetLinkReferenceDefinitions()[MarkdownLinkReferenceDefinition[]]MarkdownFrontMatter— deliberately not aMarkdownNode. It is not markdown, it never appears inChildren, and nothing that walks the tree encounters it.Format[MarkdownFrontMatterFormat]Yamlinitially.Raw[string]Data[object]Blocks
MarkdownSection : MarkdownBlock— not a CommonMark construct. A section is the grouping the specification's block sequence implies: a heading and everything up to the next heading of the same or a lower level. Specified in #32.Heading[MarkdownHeading]Children[0].Children[MarkdownNode[]]Nesting depth is not the heading level.
Heading.Levelstays the only source of truth for rendering, so a document that skips a level nests the deeper section directly under the shallower one and re-renders it unchanged.MarkdownParagraph : MarkdownBlock— §4.8Children[MarkdownNode[]]MarkdownHeading : MarkdownBlock— §4.2, §4.3. Reached as a section'sHeading, never as a member ofChildren.Level[int]Style[MarkdownHeadingStyle]Atx,AtxClosed(## foo ##), orSetext.Children[MarkdownNode[]]MarkdownThematicBreak : MarkdownBlock— §4.1Marker[MarkdownThematicBreakMarker]Hyphen,Asterisk, orUnderscore.MarkerCount[int]MarkdownIndentedCodeBlock : MarkdownBlock— §4.4Literal[string]MarkdownFencedCodeBlock : MarkdownBlock— §4.5InfoString[string]Language[string]InfoString.FenceCharacter[MarkdownFenceCharacter]BacktickorTilde.FenceLength[int]Literal[string]MarkdownHtmlBlock : MarkdownBlock— §4.6Literal[string]Kind[int]MarkdownLinkReferenceDefinition : MarkdownBlock— §4.7Label[string]NormalizedLabel[string]Destination[string]Title[string]MarkdownBlockQuote : MarkdownBlock— §5.1Children[MarkdownNode[]]MarkdownList : MarkdownBlock— §5.3Kind[MarkdownListKind]BulletorOrdered.Start[int]Marker[MarkdownListMarker]Hyphen,Asterisk,Plusfor bullet lists;Period,Parenthesisfor ordered.IsTight[bool]Children[MarkdownNode[]]MarkdownListItemnodes.MarkdownListItem : MarkdownBlock— §5.2Children[MarkdownNode[]]Inlines
MarkdownText : MarkdownInline— §6.6Literal[string]GetText()returns.Raw[string]&re-renders as&rather than being re-escaped from scratch.MarkdownCodeSpan : MarkdownInline— §6.1Literal[string]BacktickCount[int]MarkdownEmphasis : MarkdownInlineandMarkdownStrongEmphasis : MarkdownInline— §6.2Marker[MarkdownEmphasisMarker]AsteriskorUnderscore.Children[MarkdownNode[]]MarkdownLink : MarkdownInline— §6.3Destination[string]Title[string]$nullwhen absent.TitleDelimiter[MarkdownTitleDelimiter]DoubleQuote,SingleQuote, orParenthesis.DestinationInAngleBrackets[bool]<...>form.Label[string]$nullfor inline links.ReferenceKind[MarkdownLinkReferenceKind]Inline,Full,Collapsed, orShortcut.Children[MarkdownNode[]]MarkdownImage : MarkdownInline— §6.4 — identical toMarkdownLink, withChildrenholding the alt text.MarkdownAutolink : MarkdownInline— §6.5Destination[string]Kind[MarkdownAutolinkKind]UriorEmail.MarkdownRawHtml : MarkdownInline— §6.6Literal[string]MarkdownHardLineBreak : MarkdownInline— §6.7Marker[MarkdownLineBreakMarker]BackslashorSpaces.MarkdownSoftLineBreak : MarkdownInline— §6.8 — no properties beyond the shared members.Enums
MarkdownHeadingStyleAtx,AtxClosed,SetextMarkdownThematicBreakMarkerHyphen,Asterisk,UnderscoreMarkdownFenceCharacterBacktick,TildeMarkdownListKindBullet,OrderedMarkdownListMarkerHyphen,Asterisk,Plus,Period,ParenthesisMarkdownEmphasisMarkerAsterisk,UnderscoreMarkdownLinkReferenceKindInline,Full,Collapsed,ShortcutMarkdownTitleDelimiterDoubleQuote,SingleQuote,ParenthesisMarkdownAutolinkKindUri,EmailMarkdownLineBreakMarkerBackslash,SpacesMarkdownFrontMatterFormatYamlEnums rather than validated strings, so invalid states are unrepresentable, tab completion works on assignment, and serialized output carries readable names.
Constructs that are deliberately not nodes
MarkdownText.Literal, with the source form kept inRaw.Remaining decisions
Frontmatter — modelled in 1.3, implemented in 1.4:
MarkdownDocumentgets a[MarkdownFrontMatter] $FrontMatterproperty, andMarkdownFrontMattercarriesFormat(aMarkdownFrontMatterFormatenum, initiallyYaml),Raw(the text between the delimiters), andData(the deserialized value). This issue defines the types and leavesFrontMatteras$null; parsing and emission arrive in #21 once PSModule/YAML shipsConvertFrom-YamlandConvertTo-Yaml. Reserving the property now is what keeps 1.4 a minor bump rather than a major one.Note
This supersedes the
[hashtable] $Metadatadecision originally recorded in #21. A dedicated type keeps the format explicit and preserves the raw text for lossless round-tripping, which a bare hashtable cannot do. Issue #21 has been updated to match.Round-trip fidelity — semantic, not byte-exact: Guaranteeing byte-identical output would require storing every whitespace and indentation detail on every node. Instead each node stores the stylistic choices a reader would notice — heading style, fence character and length, bullet character, ordered-list delimiter, emphasis marker, link reference kind, backtick count — so re-rendering produces the same document in the same style. Insignificant whitespace is normalized. The contract is that re-parsing the rendered text yields an equivalent hierarchy, and that rendering is idempotent from the second pass onward.
Conformance measured against the specification's own examples: commonmark-spec publishes its examples in machine-readable form (spec.json). Since this module does not render HTML, conformance is asserted as: every example parses without error, and every example round-trips idempotently through
ConvertFrom-MarkdownandConvertTo-Markdown. Examples that cannot yet be satisfied are tracked explicitly as known gaps rather than silently skipped.Parsing strategy — two passes, following cmark: CommonMark is defined as a two-phase parse (Appendix: A parsing strategy), and cmark implements it directly: block structure first, then inline content within the resulting leaf blocks, with emphasis resolved by the delimiter-stack algorithm. The implementation follows the same split, which keeps each pass tractable and lets block support and inline support land as separate deliverables.
Function surface, and room for
-Dialect:ConvertFrom-Markdown -InputObject [string](position 0,ValueFromPipeline) returns[MarkdownDocument]— always, including for documents with no metadata, so the return type never changes across versions.ConvertTo-Markdown -InputObject [MarkdownNode](position 0,ValueFromPipeline) returns[string], accepting any node so subtrees can be rendered on their own. No-Dialectparameter ships in 1.3; #30 adds it in 1.5 as an optional parameter defaulting toCommonMark, which is additive and non-breaking. Neither function touches the file system — that is the caller's job withGet-Content -RawandSet-Content, and later #23, consistent with howConvertFrom-Jsonbehaves.Important
PowerShell 6.1+ ships a built-in
ConvertFrom-MarkdowninMicrosoft.PowerShell.Utilitythat converts markdown to HTML or VT100-encoded output. Importing this module shadows it. This is intentional: the built-in produces rendered output, this one produces a structured object hierarchy. The built-in stays reachable asMicrosoft.PowerShell.Utility\ConvertFrom-Markdown.Extension seam for dialects: The parser is written so that block starts and inline delimiters are looked up from a table rather than hard-coded into a
switch. A dialect then contributes entries to those tables instead of forking the parser. Nothing dialect-specific is implemented here — only the seam that makes #30 additive.File placement: Classes go in
src/classes/public/(nodes are user-facing and appear in type constraints), grouped in subfolders mirroring the hierarchy —Blocks/,Inlines/,Enums/— following the layout used by PSModule/GitHub. Parser and renderer internals go insrc/functions/private/. The two public functions go insrc/functions/public/. Formatting views go insrc/formats/. Base classes must be defined before derived classes in the built module; the loading order is verified against the build framework as part of the first deliverable.Relationship to the existing DSL: The
Set-Markdown*functions and the object hierarchy are complementary and independent. The DSL stays the imperative way to compose markdown; the object hierarchy is the way to parse, inspect, and transform it. The DSL'sDetailsoutput is raw HTML, so it round-trips asMarkdownHtmlBlock— correct per CommonMark, since<details>is not markdown. The DSL'sTableoutput is a GFM construct and therefore round-trips as paragraphs of text until #30 lands; that is expected, not a defect.Release shape: The object hierarchy is a new feature on an existing module, so 1.3 is a minor bump. Frontmatter (1.4) and dialects (1.5) are each a further minor bump. No major bump is needed anywhere in the plan, because the root object shape is settled in 1.3 and everything after it is additive.
Decomposition: This is far larger than one reviewable pull request, so this issue becomes the parent and Section 3 lists the child Tasks. Each child is one deliverable with its own PR. The children are created once the design above is agreed — no implementation starts before then.
Decisions taken during design review
Source positions — one property, not four: Every node carries a
[MarkdownSourceSpan] $Sourcerecording where it came from. A single nested property keeps the noise to one line in serialized output and one thing to ignore, rather than four integers on every node. It is$nullfor nodes built by hand, and it is excluded when comparing trees for round-trip equivalence — two trees are equivalent if their content and structure match, regardless of where they came from. Including this in 1.3 is what makes the diagnostics in #22 possible at all; adding it later would change the shape of every node.Construction from scratch is a first-class scenario: Every node class exposes a parameterless constructor plus one overload covering its common case —
[MarkdownHeading]::new(2, 'Title'),[MarkdownParagraph]::new('text'),[MarkdownFencedCodeBlock]::new('powershell', $code). Parsing is one way to obtain a hierarchy, not the only way. Whether theSet-Markdown*DSL should eventually be reimplemented on top of the hierarchy is a separate question, deliberately not answered here.No property-level validation: Properties are plain and settable.
$heading.Level = 7is accepted by the object; it is caught byTest-Markdown(#22) and by the renderer, which throws on states it cannot express. Validating in property setters requires backing fields and explicit accessors, which directly conflicts with the "plain, public, typed properties only" requirement that makes generic serialization work. Validation belongs at the boundaries, not on every assignment.Sections are the primary structure, not a view over a flat block sequence: A heading and the content it introduces is the unit documents are read and edited in, so the model holds them together. Keeping a flat block sequence and offering a section view alongside it was rejected because it means two representations of one document that have to be kept in step, and because a mutation made through the view has to be written back. Grouping happens once, at parse time. The cost is that heading level is no longer readable from nesting depth — which is why
Heading.Levelstays authoritative — and one pass over the block sequence per container. This is settled in 1.3 for the same reason the root shape is: it changes the shape ofMarkdownDocument. Specified in #32 and indocs/markdown-object-model/.Line endings — the parser normalizes, the renderer emits LF: The specification treats a line ending as any of LF, CR, or CRLF (§2.1), so the parser accepts all three and the distinction never reaches the object hierarchy. The renderer emits LF. The caller chooses what lands on disk via
Set-Content, which already applies platform conventions. This is consistent with the round-trip contract: equivalence is asserted on the tree, not on bytes.Implementation language — pure PowerShell, with a measured budget: The parser and renderer are written in PowerShell, keeping the module dependency-free, debuggable, and consistent with the rest of the ecosystem. Character-level parsing in PowerShell is slow enough to be a real risk, so 1.3 states a budget rather than assuming: a 1,000-line document parses in under two seconds, and the conformance suite completes inside the normal CI test job. Missing the budget opens an optimization issue — .NET methods instead of PowerShell operators, as PSModule/YAML#30 is doing, or a compiled core via
Add-Type. Crucially this is not a shape decision: the object model is unaffected by how the parser is implemented, so the language can change later without a breaking release.Conformance bar — all examples green, no documented gaps: 1.3 does not ship until every example in the commonmark-spec set passes. "Specification-compliant" in the title has to mean something, and partial conformance is precisely where every previous PowerShell markdown parser has stopped. It also protects #30: dialect support layered on a partially-conformant base inherits every gap. Child Tasks may land while the suite is still red — the suite runs in a known-failing mode until step 9 — but the milestone does not close until it is green.
Implementation plan
Child Tasks, in dependency order. Each is one pull request.
0. Specification
docs/markdown-object-model/withindex.md,spec.md, anddesign.md— the durable contract, so this issue can stop restating it — #321. Node type foundation
src/classes/public/withMarkdownNode,MarkdownBlock, andMarkdownInlineMarkdownSourceSpanwithStartLine,StartColumn,EndLine,EndColumnMarkdownNode:Type,Children,Source,Descendants(),Descendants([string]),Sections(),Blocks(),GetSection([string[]]),GetText(), andToString()delegating to the rendererMarkdownDocumentwithFrontMatter,Children, andGetLinkReferenceDefinitions()MarkdownFrontMatterandMarkdownFrontMatterFormat— types only, never populated in 1.3ConvertFrom-Markdownalways returns aMarkdownDocumentand thatFrontMatteris always$null, locking in the shape 1.4 depends on2. CommonMark block nodes
MarkdownParagraph,MarkdownHeading,MarkdownThematicBreak,MarkdownIndentedCodeBlock,MarkdownFencedCodeBlock,MarkdownHtmlBlock,MarkdownLinkReferenceDefinitionMarkdownSection,MarkdownBlockQuote,MarkdownList,MarkdownListItemMarkdownHeadingStyle,MarkdownThematicBreakMarker,MarkdownFenceCharacter,MarkdownListKind,MarkdownListMarker3. CommonMark inline nodes
MarkdownText,MarkdownCodeSpan,MarkdownEmphasis,MarkdownStrongEmphasis,MarkdownLink,MarkdownImage,MarkdownAutolink,MarkdownRawHtml,MarkdownHardLineBreak,MarkdownSoftLineBreakMarkdownEmphasisMarker,MarkdownLinkReferenceKind,MarkdownTitleDelimiter,MarkdownAutolinkKind,MarkdownLineBreakMarker4. Block parser
src/functions/private/, following the cmark algorithmMarkdownSourceSpanon every node producedswitch, so #30 can extend itMarkdownDocumentwith leaf-block content held as raw text pending the inline pass5. Inline parser
6.
ConvertFrom-Markdownsrc/functions/public/ConvertFrom-Markdown.ps1wiring the two parser passes together-InputObject [string]at position 0 withValueFromPipeline, returning[MarkdownDocument]7. Markdown renderer and
ConvertTo-Markdownsrc/functions/private/that walks the tree — node classes stay free of rendering logicSet-Contentsrc/functions/public/ConvertTo-Markdown.ps1as a thin wrapper over the rendererMarkdownNode.ToString()to the renderer so any subtree renders on its own-InputObject [MarkdownNode]at position 0 withValueFromPipeline, returning[string]8. Console formatting
Format.ps1xmlview insrc/formats/that renders a document as an indented tree9. Specification conformance suite
tests/ConvertFrom-MarkdownandConvertTo-Markdown, comparing trees while ignoringSourceh1, a level that rises again, and headings inside a block quote and a list itemConvertTo-Json -Depth 100andConvertTo-Yamlsucceed on every parsed example10. Documentation
README.md— every class, every property, and its specification section — with the section tree as the shape a reader meets first, linking todocs/markdown-object-model/rather than restating itConvertTo-Json -Depthcaveatexamples/covering parse, query, transform, render, and hand-off to another serializerSet-Markdown*DSL, and which DSL constructs are not CommonMarkEach child pull request documents the slice of the schema it adds, so the hierarchy is reviewable as a specification rather than only as code.