Skip to content

Model the markdown document as a nested section tree #32

Description

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

  • Add docs/markdown-object-model/spec.md with the section model, its requirements, and the sectioning algorithm
  • Add docs/markdown-object-model/index.md describing the capability

Model

  • Add MarkdownSection to src/classes/public/Blocks/ with Level, Title, Style, and a Children collection
  • Add Sections() and Blocks() filter methods to MarkdownNode
  • Extend Descendants() and add GetTitleText() for reading a section's title as plain text

Parser and renderer

  • Add the section-grouping pass over the block sequence of every block container
  • Render a section as its reconstructed heading line followed by its children, preserving the original level and style

Tests

  • Nested sections to three levels, and a leaf section holding an empty collection
  • Content before the first heading, and before the first subheading
  • Skipped heading level, document starting below h1, and a level that rises again
  • Headings inside a block quote and inside a list item
  • Rendered output matches the flat hierarchy byte for byte across the commonmark-spec example set
  • Serialization produces no duplicated node references

Documentation

  • Update the README with the section tree and a section-addressing example
  • Restructure #8 around the section model

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions