Skip to content

Add a specification-compliant markdown object hierarchy with ConvertFrom-Markdown and ConvertTo-Markdown #8

Description

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:

Function Direction Analogy
ConvertFrom-Markdown markdown text → object hierarchy ConvertFrom-Json (JSON text → objects)
ConvertTo-Markdown object hierarchy → markdown text ConvertTo-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.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 This issue
1.4 YAML frontmatter #21
1.5 Dialects, starting with GitHub Flavored Markdown #30
Later GitHub platform constructs — alerts, footnotes, math #7

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 -EQ 2 | 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:

Decision What the references do What this model does, and why
Frontmatter mdast makes it the first child of the root A property on MarkdownDocument. Nothing that walks the tree has to skip a non-markdown node, and no index in Children shifts.
Container vs. leaf blocks cmark surfaces the distinction in its type system 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.

Get-Content -Raw 'README.md' | ConvertFrom-Markdown | ConvertTo-Yaml

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 TypeHeading, 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.

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 : Heading
Loading

MarkdownFrontMatter and MarkdownSourceSpan are not nodes. They hang off nodes as properties and never appear in Children. MarkdownHeading is 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:

$doc = [MarkdownDocument]::new()
$section = [MarkdownSection]::new([MarkdownHeading]::new(1, 'Title'))
$section.Children += [MarkdownParagraph]::new('Some text')
$doc.Children += $section
$doc | ConvertTo-Markdown

Document

MarkdownDocument : MarkdownBlock — the root, and the return type of ConvertFrom-Markdown.

Property Type Notes
FrontMatter [MarkdownFrontMatter] Always $null in 1.3. Populated in 1.4 by #21.
Children [MarkdownNode[]] 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.

MarkdownParagraph : MarkdownBlock§4.8

Property Type Notes
Children [MarkdownNode[]] Inline nodes.

MarkdownHeading : MarkdownBlock§4.2, §4.3. Reached as a section's Heading, never as a member of Children.

Property Type Notes
Level [int] 1–6. Setext headings are limited to 1–2.
Style [MarkdownHeadingStyle] style. Atx, AtxClosed (## foo ##), or Setext.
Children [MarkdownNode[]] Inline nodes.

MarkdownThematicBreak : MarkdownBlock§4.1

Property Type Notes
Marker [MarkdownThematicBreakMarker] style. Hyphen, Asterisk, or Underscore.
MarkerCount [int] style. At least 3.

MarkdownIndentedCodeBlock : MarkdownBlock§4.4

Property Type Notes
Literal [string] Code content with the four-space indent removed.

MarkdownFencedCodeBlock : MarkdownBlock§4.5

Property Type Notes
InfoString [string] The full info string as written.
Language [string] First word of the info string. Convenience, derived from InfoString.
FenceCharacter [MarkdownFenceCharacter] style. Backtick or Tilde.
FenceLength [int] style. At least 3, and long enough to contain the content.
Literal [string] Code content.

MarkdownHtmlBlock : MarkdownBlock§4.6

Property Type Notes
Literal [string] Raw HTML, verbatim.
Kind [int] 1–7, the block type from the specification. Determines the termination condition on re-parse.

MarkdownLinkReferenceDefinition : MarkdownBlock§4.7

Property Type Notes
Label [string] As written.
NormalizedLabel [string] Case-folded and whitespace-collapsed per the matching rules, used for resolution.
Destination [string]
Title [string]

MarkdownBlockQuote : MarkdownBlock§5.1

Property Type Notes
Children [MarkdownNode[]] Block nodes.

MarkdownList : MarkdownBlock§5.3

Property Type Notes
Kind [MarkdownListKind] Bullet or Ordered.
Start [int] Starting number for ordered lists.
Marker [MarkdownListMarker] style. Hyphen, Asterisk, Plus for bullet lists; Period, Parenthesis for ordered.
IsTight [bool] Tight lists render without blank lines between items. Semantic, not stylistic — the specification derives it from the source.
Children [MarkdownNode[]] MarkdownListItem nodes.

MarkdownListItem : MarkdownBlock§5.2

Property Type Notes
Children [MarkdownNode[]] Block nodes.

Inlines

MarkdownText : MarkdownInline§6.6

Property Type Notes
Literal [string] The resolved characters, with backslash escapes and entity references decoded. This is what GetText() returns.
Raw [string] style. The original spelling, so &amp; re-renders as &amp; rather than being re-escaped from scratch.

MarkdownCodeSpan : MarkdownInline§6.1

Property Type Notes
Literal [string] Code content.
BacktickCount [int] style. Must exceed the longest backtick run in the content.

MarkdownEmphasis : MarkdownInline and MarkdownStrongEmphasis : MarkdownInline§6.2

Property Type Notes
Marker [MarkdownEmphasisMarker] style. Asterisk or Underscore.
Children [MarkdownNode[]] Inline nodes.

MarkdownLink : MarkdownInline§6.3

Property Type Notes
Destination [string]
Title [string] $null when absent.
TitleDelimiter [MarkdownTitleDelimiter] style. DoubleQuote, SingleQuote, or Parenthesis.
DestinationInAngleBrackets [bool] style. The <...> form.
Label [string] Reference label, $null for inline links.
ReferenceKind [MarkdownLinkReferenceKind] Inline, Full, Collapsed, or Shortcut.
Children [MarkdownNode[]] The link text, as inline nodes.

MarkdownImage : MarkdownInline§6.4 — identical to MarkdownLink, with Children holding the alt text.

MarkdownAutolink : MarkdownInline§6.5

Property Type Notes
Destination [string]
Kind [MarkdownAutolinkKind] Uri or Email.

MarkdownRawHtml : MarkdownInline§6.6

Property Type Notes
Literal [string] The tag, verbatim.

MarkdownHardLineBreak : MarkdownInline§6.7

Property Type Notes
Marker [MarkdownLineBreakMarker] style. Backslash or Spaces.

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.

Constructs that are deliberately not nodes

Construct Spec Why not
Blank lines §4.9 Separators, not content. They determine block boundaries and list tightness, both of which are captured on the surrounding nodes.
Backslash escapes §2.4 Resolve into MarkdownText.Literal, with the source form kept in Raw.
Entity and numeric references §6.2 Same.
Frontmatter 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

2. CommonMark block nodes

  • Define the leaf blocks: MarkdownParagraph, MarkdownHeading, MarkdownThematicBreak, MarkdownIndentedCodeBlock, MarkdownFencedCodeBlock, MarkdownHtmlBlock, MarkdownLinkReferenceDefinition
  • Define the container blocks: MarkdownSection, MarkdownBlockQuote, MarkdownList, MarkdownListItem
  • Define the supporting enums: MarkdownHeadingStyle, MarkdownThematicBreakMarker, MarkdownFenceCharacter, MarkdownListKind, MarkdownListMarker

3. CommonMark inline nodes

  • Define MarkdownText, MarkdownCodeSpan, MarkdownEmphasis, MarkdownStrongEmphasis, MarkdownLink, MarkdownImage, MarkdownAutolink, MarkdownRawHtml, MarkdownHardLineBreak, MarkdownSoftLineBreak
  • 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.

Metadata

Metadata

Labels

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions