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:

FunctionDirectionAnalogy
ConvertFrom-Markdownmarkdown text → object hierarchyConvertFrom-Json (JSON text → objects)
ConvertTo-Markdownobject hierarchy → markdown textConvertTo-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:

VersionDeliversTracked by
1.3The object hierarchy, CommonMark parsing and rendering, ConvertFrom-Markdown, ConvertTo-MarkdownThis issue
1.4YAML frontmatter#21
1.5Dialects, starting with GitHub Flavored Markdown#30
LaterGitHub 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
├── Level <- the heading level, as written
├── Title <- the heading text, as inline nodes
├── Style <- ATX, closed ATX, or setext
└── 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 Level, Title, and Style rather than as a child node, and Descendants() yields the section's title inlines before its 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('Section') |Where-Object Level -EQ2|ForEach-Object { $_.GetTitleText() }
# 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('Section') |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 carries the level, title, and style of the heading that opens it, together with 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:

DecisionWhat the references doWhat this model does, and why
Frontmattermdast makes it the first child of the rootA property on MarkdownDocument. Nothing that walks the tree has to skip a non-markdown node, and no index in Children shifts.
Container vs. leaf blockscmark surfaces the distinction in its type systemNot 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 identityBoth use tagged unions or enum-typed node structsReal PowerShell classes plus a Type string, so both -is [MarkdownSection] and Where-Object Type -EQ 'Section' 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:

RequirementConsequence
No cyclesNo Parent back-reference, and no cross-links between nodes. A cycle makes ConvertTo-Json and ConvertTo-Yaml fail outright.
Every node appears exactly onceNo 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 onlyNo script properties, no hidden state, nothing a generic serializer silently drops. What you see on the object is what gets serialized.
Enums, not integersEnum values serialize as their names, so YAML and JSON output is readable rather than a wall of magic numbers.
Type on every nodeThe 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, Title, and Descendants() absorbs that: it yields a section's title inlines before its children, so the special case lives in the model rather than in every caller.

A three-level class hierarchy, not five:

ClassBasePurpose
MarkdownNodeAbstract base for every node. Carries Type, Children, and the traversal and rendering members.
MarkdownBlockMarkdownNodeAbstract marker for block-level constructs.
MarkdownInlineMarkdownNodeAbstract 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 TypeSection, Paragraph, Text, and so on, without the Markdown prefix. It makes Where-Object Type -EQ 'Section' work without class names in scope, and makes ConvertTo-Json output self-describing.

Members on MarkdownNode:

MemberReturns
ChildrenDirect 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.
GetTitleText()A section's title as plain text, 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 title 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 — lives in docs/markdown-object-model/design.md: the type hierarchy, what contains what, a worked example, the shared members, the document, the blocks, the inlines, the enums, and the constructs that are deliberately not nodes. The normative requirements it satisfies live in docs/markdown-object-model/spec.md.

Those documents are the source of truth for the shape of the model and stay current as it evolves. This issue does not restate them; it carries the sequencing, the release shape, and the decomposition into child Tasks.

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 — [MarkdownSection]::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. $section.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 — the section carries the heading's level, title, and style rather than containing a heading node. 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 MarkdownSection.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(), GetTitleText(), 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, MarkdownThematicBreak, MarkdownIndentedCodeBlock, MarkdownFencedCodeBlock, MarkdownHtmlBlock, MarkdownLinkReferenceDefinition
  • Define the container blocks: MarkdownSection with Level, Title, and Style, plus 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 line, reconstructed from Level, Title, and Style, followed by its children, 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 object model in README.md, with the section tree as the shape a reader meets first, linking to docs/markdown-object-model/ rather than restating the schema
  • 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