Documents are worked on by section. Automation pulls the "Usage" section out of a README, replaces the body of a generated section while leaving hand-written ones untouched, lifts a section and everything under it into another document, or checks that every required section is present. In each of these jobs the unit of work is a heading together with everything that belongs to it.
Request
Current experience
The object hierarchy specified in #8 models a document the way CommonMark defines it: a heading is a leaf block sitting as a sibling next to paragraphs, lists, and code blocks in one flat Children collection. Nothing in the object graph says that a heading owns the content beneath it. Acting on a section means finding the heading, scanning forward for the next heading of the same or a lower level, and slicing the collection — the outline rules of markdown, reimplemented at every call site, and easy to get wrong at the edges.
The DSL has the opposite shape. Heading 1 'Title' { ... } already treats a section as a container with its content inside it, and that is the model the module presents to its users today. A parsed document that comes back flat does not read like the document the same user just wrote.
Desired experience
A section is a first-class object. A document holds a list of sections; a section holds its heading, its own content, and the sections nested inside it — recursively, to any depth. A section with no subsections is the same type with an empty collection, not a different type.
Document
├── FrontMatter the metadata part
├── (blocks) content before the first heading
└── Section a heading and everything under it
├── Level / Title / Style the heading itself, carried on the section
├── (blocks) content before the first subheading
└── Section recursive, empty for a leaf section
The shape a caller works with:
$doc = Get-Content -Raw 'README.md' | ConvertFrom-Markdown
# A section is addressable by its heading, not by index arithmetic over a flat list.
$usage = $doc.GetSection('Usage')
# It carries its subsections with it, so moving or copying it is one assignment.
$other.Children.Add($usage)
# Replacing a generated section leaves every hand-written section alone.
$doc.GetSection('Usage/Parameters').Children = $generated.Children
# The outline falls out of the model rather than being computed from heading levels.
$doc.Descendants('Section') | ForEach-Object { $_.GetTitleText() }
$doc | ConvertTo-Markdown | Set-Content 'README.md'
Acceptance criteria
- A document parses into a tree of sections, where a section carries its own heading level, title, and style, and holds its blocks and its nested sections.
- A section's title preserves inline markup, so emphasis, code spans, and links inside a heading survive a parse and render cycle.
- A section with no nested sections is the same type as one that has them, holding an empty collection.
- The document is structurally the same container as a section, minus a heading and plus frontmatter, so the same code walks both.
- Content that appears before the first heading belongs to the document; content before the first subheading belongs to the section it is under.
- A skipped heading level — an
h1 followed by an h3 — nests without inventing a section that is not in the document, and re-renders at its original level.
- A document that starts at a level other than
h1, or that raises the level again later, parses without error and re-renders unchanged.
- Headings inside a block quote or a list item section that container's own content, not the document.
- Markdown rendered from the section tree is byte-identical to markdown rendered from the flat hierarchy for the same document, so CommonMark conformance is unaffected.
- A section is addressable by its heading text, including a path through nested headings.
- The existing
Set-Markdown* DSL keeps working unchanged.
Out of scope
- Heading anchors and slugs. Slug generation is platform-specific rather than a CommonMark construct, and belongs with the dialect work in #30.
- Every construct below the section level. The block and inline nodes stay exactly as specified in #8.
- Section-aware editing cmdlets. This issue delivers the model; any
Get-MarkdownSection style surface is decided separately.
Technical decisions
Sections replace the flat heading model, and this is not a breaking change: #8 is milestone 1.3 and has not shipped — the latest release is v1.2.5. The section tree therefore lands as part of 1.3 rather than as a change to it. #8 is restructured around this model and stays the epic; this issue owns the section layer.
MarkdownSection is a block node: It derives from MarkdownBlock like every other container, so $_ -is [MarkdownBlock] keeps working and the section tree is not a parallel structure bolted onto the side of the hierarchy.
The section carries the heading rather than containing one: MarkdownSection exposes [int] $Level, [MarkdownInline[]] $Title, and [MarkdownHeadingStyle] $Style alongside Children. There is no separate heading node — a heading and the section it opens are one thing, which is how a document reads: an h1 is a container, not a marker sitting next to one. $section.Level and $section.Title are the two most common accesses and both are direct.
Title holds inline nodes rather than a string, so # Release **1.2** notes keeps its emphasis and re-renders with it. A plain string would silently flatten heading markup and make the loss unrecoverable.
Rejected alternatives:
| Option |
Trade-off |
Verdict |
Section { Heading, Children } — a section wraps a heading node |
Keeps MarkdownHeading as a node for a construct CommonMark defines, and gives the heading line its own source span. Costs an indirection on every access, and needs a documented traversal rule for where the heading is yielded. |
Rejected — the indirection buys a node nothing else references |
Header { Level, Title (string), Content } — as prototyped in #18 |
Simplest, and proven to work. A string title discards inline markup in headings with no way to recover it. |
Rejected — lossy |
Section { Level, Title (inlines), Style, Children } |
One type, direct access, no fidelity loss. MarkdownHeading disappears from the inventory and its style properties move onto the section. |
Chosen |
MarkdownHeading is absorbed, not retained: With the section carrying level, title, and style, a separate heading node would be unreachable — nothing could hold one. The heading's stylistic properties (ATX versus setext, closing sequence) become section properties. Descendants('Section') is therefore how headings are found, and there is no traversal special case for yielding a heading before its children.
Children stays the single storage and the single serialization surface: A section's blocks and its nested sections live in one Children collection in document order — content first, subsections after, which is the only order markdown can produce. Sections() and Blocks() are filter methods over Children, following the existing Descendants() idiom. They are methods rather than properties on purpose: a property returning a filtered view would put the same node under two names, and ConvertTo-Json, ConvertTo-Yaml, and Export-Clixml would emit it twice — the duplicate-reference problem #8 already rules out.
Nesting depth is not the heading level: MarkdownSection.Level remains the source of truth for rendering, and nesting depth is never used to derive it. A document that goes h1 then h3 nests the h3 section directly under the h1 section and re-renders it as an h3. No synthetic section is inserted for the missing level, because a section that is not in the document is not in the tree.
Section nesting is capped at six; the tree is not: Sections nest only when levels strictly increase, and an ATX heading carries an opening sequence of one to six # characters (§4.2), so the longest possible chain of section inside section is h1 through h6. Total tree depth is unbounded, because sectioning restarts inside every block container and headings are legal inside block quotes and list items — a block quote nested in an h6 section may contain its own h1. Six levels of sectioning per container, unlimited containers.
Sectioning is a rule about block sequences, not about the document: Any block container — the document, a section, a block quote, a list item — groups its own child blocks into sections. The complete closure rule set is four rules. A heading of level N closes every open section whose level is greater than or equal to N; the new section attaches to the nearest still-open section of lower level, or to the container root if none remains; any block that is not a heading attaches to the innermost open section, or to the container root if none is open; and reaching the end of a container closes everything still open in it. One algorithm, applied everywhere blocks appear.
Rendering flattens: A section emits its heading line, reconstructed from Level, Title, and Style, followed by its children in order. The output for a given document is identical to what an ungrouped block sequence would emit, so the conformance suite in #8 measures the same thing and the round-trip contract is unchanged.
Relationship to #18: That draft already implements this containment shape — MarkdownHeader holds Level, Title, and Content — and proves it works, with tests passing on all three platforms. It differs in three ways that this issue settles: the title is a string rather than inline nodes, nodes derive from no common base so there is no shared traversal, and Details and Table appear as first-class types although one is raw HTML and the other is not CommonMark. The shape is confirmed by that prototype; the fidelity and type-base decisions are made here.
File placement: MarkdownSection joins the other block classes in src/classes/public/Blocks/, per the layout decided in #8. The section-grouping pass is a parser internal in src/functions/private/.
Specification: The normative model — requirements, acceptance criteria, and the sectioning algorithm — lives in docs/markdown-object-model/spec.md in this repository, following Spec-Driven Development. This issue describes the change; the spec describes the intended state.
Implementation plan
Specification
Model
Parser and renderer
Tests
Documentation
Documents are worked on by section. Automation pulls the "Usage" section out of a README, replaces the body of a generated section while leaving hand-written ones untouched, lifts a section and everything under it into another document, or checks that every required section is present. In each of these jobs the unit of work is a heading together with everything that belongs to it.
Request
Current experience
The object hierarchy specified in #8 models a document the way CommonMark defines it: a heading is a leaf block sitting as a sibling next to paragraphs, lists, and code blocks in one flat
Childrencollection. Nothing in the object graph says that a heading owns the content beneath it. Acting on a section means finding the heading, scanning forward for the next heading of the same or a lower level, and slicing the collection — the outline rules of markdown, reimplemented at every call site, and easy to get wrong at the edges.The DSL has the opposite shape.
Heading 1 'Title' { ... }already treats a section as a container with its content inside it, and that is the model the module presents to its users today. A parsed document that comes back flat does not read like the document the same user just wrote.Desired experience
A section is a first-class object. A document holds a list of sections; a section holds its heading, its own content, and the sections nested inside it — recursively, to any depth. A section with no subsections is the same type with an empty collection, not a different type.
The shape a caller works with:
Acceptance criteria
h1followed by anh3— nests without inventing a section that is not in the document, and re-renders at its original level.h1, or that raises the level again later, parses without error and re-renders unchanged.Set-Markdown*DSL keeps working unchanged.Out of scope
Get-MarkdownSectionstyle surface is decided separately.Technical decisions
Sections replace the flat heading model, and this is not a breaking change: #8 is milestone 1.3 and has not shipped — the latest release is v1.2.5. The section tree therefore lands as part of 1.3 rather than as a change to it. #8 is restructured around this model and stays the epic; this issue owns the section layer.
MarkdownSectionis a block node: It derives fromMarkdownBlocklike every other container, so$_ -is [MarkdownBlock]keeps working and the section tree is not a parallel structure bolted onto the side of the hierarchy.The section carries the heading rather than containing one:
MarkdownSectionexposes[int] $Level,[MarkdownInline[]] $Title, and[MarkdownHeadingStyle] $StylealongsideChildren. There is no separate heading node — a heading and the section it opens are one thing, which is how a document reads: anh1is a container, not a marker sitting next to one.$section.Leveland$section.Titleare the two most common accesses and both are direct.Titleholds inline nodes rather than a string, so# Release **1.2** noteskeeps its emphasis and re-renders with it. A plain string would silently flatten heading markup and make the loss unrecoverable.Rejected alternatives:
Section { Heading, Children }— a section wraps a heading nodeMarkdownHeadingas a node for a construct CommonMark defines, and gives the heading line its own source span. Costs an indirection on every access, and needs a documented traversal rule for where the heading is yielded.Header { Level, Title (string), Content }— as prototyped in #18Section { Level, Title (inlines), Style, Children }MarkdownHeadingdisappears from the inventory and its style properties move onto the section.MarkdownHeadingis absorbed, not retained: With the section carrying level, title, and style, a separate heading node would be unreachable — nothing could hold one. The heading's stylistic properties (ATX versus setext, closing sequence) become section properties.Descendants('Section')is therefore how headings are found, and there is no traversal special case for yielding a heading before its children.Childrenstays the single storage and the single serialization surface: A section's blocks and its nested sections live in oneChildrencollection in document order — content first, subsections after, which is the only order markdown can produce.Sections()andBlocks()are filter methods overChildren, following the existingDescendants()idiom. They are methods rather than properties on purpose: a property returning a filtered view would put the same node under two names, andConvertTo-Json,ConvertTo-Yaml, andExport-Clixmlwould emit it twice — the duplicate-reference problem #8 already rules out.Nesting depth is not the heading level:
MarkdownSection.Levelremains the source of truth for rendering, and nesting depth is never used to derive it. A document that goesh1thenh3nests theh3section directly under theh1section and re-renders it as anh3. No synthetic section is inserted for the missing level, because a section that is not in the document is not in the tree.Section nesting is capped at six; the tree is not: Sections nest only when levels strictly increase, and an ATX heading carries an opening sequence of one to six
#characters (§4.2), so the longest possible chain of section inside section ish1throughh6. Total tree depth is unbounded, because sectioning restarts inside every block container and headings are legal inside block quotes and list items — a block quote nested in anh6section may contain its ownh1. Six levels of sectioning per container, unlimited containers.Sectioning is a rule about block sequences, not about the document: Any block container — the document, a section, a block quote, a list item — groups its own child blocks into sections. The complete closure rule set is four rules. A heading of level N closes every open section whose level is greater than or equal to N; the new section attaches to the nearest still-open section of lower level, or to the container root if none remains; any block that is not a heading attaches to the innermost open section, or to the container root if none is open; and reaching the end of a container closes everything still open in it. One algorithm, applied everywhere blocks appear.
Rendering flattens: A section emits its heading line, reconstructed from
Level,Title, andStyle, followed by its children in order. The output for a given document is identical to what an ungrouped block sequence would emit, so the conformance suite in #8 measures the same thing and the round-trip contract is unchanged.Relationship to #18: That draft already implements this containment shape —
MarkdownHeaderholdsLevel,Title, andContent— and proves it works, with tests passing on all three platforms. It differs in three ways that this issue settles: the title is a string rather than inline nodes, nodes derive from no common base so there is no shared traversal, andDetailsandTableappear as first-class types although one is raw HTML and the other is not CommonMark. The shape is confirmed by that prototype; the fidelity and type-base decisions are made here.File placement:
MarkdownSectionjoins the other block classes insrc/classes/public/Blocks/, per the layout decided in #8. The section-grouping pass is a parser internal insrc/functions/private/.Specification: The normative model — requirements, acceptance criteria, and the sectioning algorithm — lives in
docs/markdown-object-model/spec.mdin this repository, following Spec-Driven Development. This issue describes the change; the spec describes the intended state.Implementation plan
Specification
docs/markdown-object-model/spec.mdwith the section model, its requirements, and the sectioning algorithmdocs/markdown-object-model/index.mddescribing the capabilityModel
MarkdownSectiontosrc/classes/public/Blocks/withLevel,Title,Style, and aChildrencollectionSections()andBlocks()filter methods toMarkdownNodeDescendants()and addGetTitleText()for reading a section's title as plain textParser and renderer
Tests
h1, and a level that rises againDocumentation