From e8ba07fec8f69eefe95897e6f628680362a06ee1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 17:24:04 +0200 Subject: [PATCH 1/5] Add the markdown object model capability index Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/index.md | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/markdown-object-model/index.md diff --git a/docs/markdown-object-model/index.md b/docs/markdown-object-model/index.md new file mode 100644 index 0000000..05e5227 --- /dev/null +++ b/docs/markdown-object-model/index.md @@ -0,0 +1,36 @@ +--- +title: Markdown object model +description: The capability that turns a markdown document into a typed, section-oriented object model and back again. +--- + +# Markdown object model + +Markdown text goes in, a typed object model comes out, and markdown comes back. The model is organised the way a document reads — a tree of sections, each owning its heading, its own content, and the sections nested inside it — so documentation automation manipulates structure instead of matching patterns in text. + +| Document | Answers | +| --- | --- | +| [spec.md](spec.md) | Why the model exists and what it must do | +| [design.md](design.md) | How it is built — the node types, the sectioning pass, and rendering | + +## At a glance + +```text +Document +├── FrontMatter the metadata part +├── (blocks) content before the first heading +└── Section + ├── Heading + ├── (blocks) content before the first subheading + └── Section recursive, empty for a leaf section +``` + +```powershell +$doc = Get-Content -Raw 'README.md' | ConvertFrom-Markdown + +$doc.GetSection('Usage').Descendants('Link') | Select-Object Destination, Title +$doc.GetSection('Usage', 'Parameters').Children = $generated.Children + +$doc | ConvertTo-Markdown | Set-Content 'README.md' +``` + +The composition DSL is a separate, complementary surface: `Set-Markdown*` is how markdown is written from nothing, the object model is how existing markdown is read and changed. From 9aa22c5f3f1b55a5a73999935f28bd9c26d85137 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 17:24:14 +0200 Subject: [PATCH 2/5] Add the markdown object model specification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/spec.md | 189 +++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/markdown-object-model/spec.md diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md new file mode 100644 index 0000000..6970f1b --- /dev/null +++ b/docs/markdown-object-model/spec.md @@ -0,0 +1,189 @@ +--- +title: Markdown object model +description: The typed object model a markdown document parses into — a tree of sections that owns its content and renders back to specification-valid markdown. +--- + +# Markdown object model + +A markdown document is available as a typed object model that can be inspected, queried, transformed, and rendered back to markdown. The model is organised the way a document reads: a document holds a tree of sections, and a section owns its heading, its own content, and the sections nested inside it. + +## Why + +Markdown is edited by section. Automation extracts a named section from a README, replaces a generated section while leaving hand-written ones untouched, lifts a section and everything under it into another document, or asserts that every required section exists. Without an object model, each of these jobs is done with regular expressions against raw text — fragile against nesting, fenced code, and inline markup. + +A model that mirrors the specification's own block sequence does not solve this either. In that shape a heading is a leaf sitting next to the content it introduces, so a caller has to re-derive the outline — find the heading, scan forward to the next heading of the same or a lower level, slice — at every call site. The outline rules of markdown belong in the model, stated once. + +## Outcomes and impact + +- **Outcome:** Markdown is read and rewritten as structured data, by section, with no text-level pattern matching and no outline arithmetic in caller code. +- **DORA:** Lead time for changes improves for documentation-generating automation, which today re-implements markdown parsing per repository. Change-failure rate improves as generated-content updates stop corrupting hand-written sections. +- **Domain signal:** The share of documentation automation across the ecosystem that manipulates markdown structurally rather than by string replacement. + +## Users and jobs + +| User | Job | +| --- | --- | +| Module and workflow authors | Generate part of a document and merge it into a hand-written file without disturbing the rest | +| Documentation tooling | Read a document's outline, extract a section, and check that required sections are present | +| Contributors | Rewrite links, adjust heading levels, or move a section across documents in bulk | +| Agents | Take a document apart, change one part of it, and put it back without reformatting the whole file | + +## Scope + +**In scope** + +- Parsing a markdown string into the object model. +- Sections as the organising structure of the model, nested to any depth. +- Every block and inline construct defined by [CommonMark](https://spec.commonmark.org/0.31.2/). +- Rendering any node of the model back to markdown, whole document or single subtree. +- Constructing a document from scratch, without parsing. +- Addressing a section by its heading, including a path through nested headings. + +**Out of scope** + +- Markdown dialects beyond CommonMark, including tables, task list items, and strikethrough. +- Parsing and emitting frontmatter content. The model reserves a place for it; interpreting it is separate work. +- Heading anchors and slugs, which are a platform convention rather than a markdown construct. +- Reading, writing, and locating files. The caller supplies text and decides where output lands. +- Structural validation, normalisation, and formatting policy. + +## Non-goals + +- **Byte-exact round-tripping.** Preserving every space and indentation detail would push insignificant whitespace into every node. The model preserves the stylistic choices a reader would notice and normalises the rest. +- **Rendering to formats other than markdown.** The model is plain data, so any general-purpose serializer reaches other formats without the module owning a renderer for each. +- **Replacing the composition DSL.** The `Set-Markdown*` functions stay the way markdown is composed imperatively. The object model is how existing markdown is read and transformed. + +## Functional requirements + +### FR1 — A markdown string parses into a typed object model { #fr1 } + +Parsing MUST accept any text valid under [CommonMark](https://spec.commonmark.org/0.31.2/) and MUST produce a typed object for every block and inline construct the specification defines. Parsing MUST NOT fail on structurally unusual but valid input. + +### FR2 — A section owns its heading and everything beneath it { #fr2 } + +A section MUST expose the heading that introduces it, the content that follows that heading, and the sections nested inside it. Content that follows a heading, up to the next heading of the same or a lower level, MUST belong to that section. + +### FR3 — A section without nested sections is the same kind of thing { #fr3 } + +A section that has no nested sections MUST be the same type as one that does, holding an empty collection. There MUST NOT be a distinct type for leaf sections. + +### FR4 — A document is a section container without a heading { #fr4 } + +The document MUST be the same kind of container as a section, differing only in that it has no heading and carries the document's metadata part. Content appearing before the first heading MUST belong to the document. + +### FR5 — Sectioning applies wherever blocks appear { #fr5 } + +Any construct that contains a sequence of blocks — the document, a section, a block quote, a list item — MUST group its own blocks into sections by the same rule. A heading inside a container MUST section that container and MUST NOT affect its ancestors. + +### FR6 — Heading level survives nesting { #fr6 } + +A heading's level MUST be preserved independently of how deeply its section is nested. A document that skips a level MUST nest the deeper section directly under the shallower one, MUST NOT introduce a section that is not present in the document, and MUST re-render each heading at its original level. A document that starts below the first level, or whose heading levels rise again later, MUST parse without error. + +### FR7 — A section is addressable by its heading { #fr7 } + +A section MUST be reachable by its heading text and by a path of heading texts through nested sections, without the caller indexing into a collection or computing heading levels. + +### FR8 — The whole model is traversable in one walk { #fr8 } + +A single recursive traversal MUST reach every node in the model, without the caller branching on node type. Traversal MUST yield a section's heading before the section's content. + +### FR9 — A document can be built without parsing { #fr9 } + +Every node MUST be constructible directly, so a document can be assembled in memory and rendered without any markdown text existing first. + +### FR10 — Any node renders to specification-valid markdown { #fr10 } + +Rendering MUST accept any node and MUST return markdown for that node and everything below it, so a whole document and a single section are rendered the same way. Output MUST be valid under [CommonMark](https://spec.commonmark.org/0.31.2/) — correctly escaped, with sufficient fence lengths and correct list indentation — not merely text this module can read back. Rendering a section MUST produce the same text as rendering its heading followed by its content in document order. + +### FR11 — Round-tripping is semantically stable { #fr11 } + +Text parsed into the model, rendered, and parsed again MUST produce an equivalent model. Rendering MUST be idempotent from the second pass onward. Two models are equivalent when their content and structure match, regardless of where they were parsed from. + +### FR12 — Every parsed node records where it came from { #fr12 } + +A node produced by parsing MUST record its position in the source text, so tooling can report diagnostics against line numbers. A node built directly MUST report no position, and position MUST be ignored when models are compared for equivalence. + +### FR13 — The composition DSL is unaffected { #fr13 } + +The existing `Set-Markdown*` functions MUST keep working unchanged. + +## Non-functional requirements + +### NFR1 — Conformance is measured against the specification's own examples { #nfr1 } + +Every example published by [commonmark-spec](https://github.com/commonmark/commonmark-spec) MUST parse without error and MUST round-trip idempotently. Examples that cannot be satisfied MUST be recorded as known gaps rather than skipped silently. + +### NFR2 — Parsing is fast enough to use in a pipeline { #nfr2 } + +A 1,000-line document MUST parse in under two seconds, and the conformance suite MUST complete within the repository's normal test job. + +### NFR3 — The model serializes with a general-purpose serializer { #nfr3 } + +The object graph MUST be acyclic and MUST contain no node reachable by more than one path, so that converting a parsed document to JSON, YAML, or CLIXML produces complete output with no duplicated nodes and no special handling. + +## Acceptance criteria + +```gherkin +Feature: Sections own their content + + Scenario: Content follows the heading it belongs to + Given a document with a level 1 heading followed by a paragraph + When the document is parsed + Then the paragraph is content of the section introduced by that heading + + Scenario: A subsection nests inside its parent + Given a document with a level 1 heading followed by a level 2 heading + When the document is parsed + Then the level 2 section is nested inside the level 1 section + And the level 1 section reports one nested section + + Scenario: A section without subsections holds an empty collection + Given a document with a single heading and a paragraph + When the document is parsed + Then that section holds no nested sections + And it is the same type as a section that has them + + Scenario: Content before the first heading belongs to the document + Given a document that opens with a paragraph before any heading + When the document is parsed + Then that paragraph is content of the document + And it is not content of any section + + Scenario: A skipped level nests without inventing a section + Given a document with a level 1 heading followed by a level 3 heading + When the document is parsed + Then the level 3 section is nested directly inside the level 1 section + And no section exists for level 2 + And rendering the document emits the second heading at level 3 + + Scenario: A heading inside a block quote sections the block quote + Given a block quote containing a heading followed by a paragraph + When the document is parsed + Then the section is content of the block quote + And the document reports no section for that heading + + Scenario: A section renders on its own + Given a parsed document containing a section with nested sections + When that section is rendered + Then the result is its heading followed by its content and nested sections + And the result parses back to an equivalent section + + Scenario: Rendering is stable + Given any example from the CommonMark specification example set + When it is parsed, rendered, and parsed again + Then the two models are equivalent + And rendering the second model produces identical text +``` + +## Constraints and assumptions + +- **Constraint:** The model is the module's public surface. Its shape is settled before it first ships, because changing it afterwards is a breaking change for every consumer. +- **Constraint:** Nodes carry content, structure, and source style only. Rendering behaviour lives outside them, so the model stays plain data that any serializer can handle ([NFR3](#nfr3)). +- **Constraint:** The section tree is a grouping of the specification's block sequence, never a departure from it. Rendered output is identical to the text the ungrouped block sequence would produce ([FR10](#fr10)). +- **Assumption:** Sections are the unit callers work in. The model optimises for reaching a section and treating it as a whole, at the cost of a grouping pass at parse time. +- **Assumption:** Documents whose heading levels are irregular are common enough that they are handled by the model rather than rejected ([FR6](#fr6)). + +## Dependencies + +- [CommonMark 0.31.2](https://spec.commonmark.org/0.31.2/) — the construct inventory and the parsing rules the model is derived from. +- [commonmark-spec](https://github.com/commonmark/commonmark-spec) — the machine-readable example set conformance is measured against ([NFR1](#nfr1)). From 5ed9ae9f8b659e8da3c9f7f5d88a5a97e6caa0c1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 17:24:19 +0200 Subject: [PATCH 3/5] Add the markdown object model design Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 157 +++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/markdown-object-model/design.md diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md new file mode 100644 index 0000000..ae2a15d --- /dev/null +++ b/docs/markdown-object-model/design.md @@ -0,0 +1,157 @@ +--- +title: Markdown object model — Design +description: How the section tree is built, stored, traversed, and rendered, and why sections are the primary structure rather than a view over a flat block sequence. +--- + +# Markdown object model — Design + +The model is a tree of nodes. Block parsing produces the specification's block sequence, a grouping pass turns runs of blocks introduced by headings into section nodes, and inline parsing fills the leaf blocks. Rendering reverses the grouping, so the section tree changes how a document is *held*, never what it *emits*. + +## Specification + +[spec.md](spec.md). + +## Approach + +Every construct is a node deriving from `MarkdownNode`. `MarkdownBlock` and `MarkdownInline` add nothing of their own and exist so `$_ -is [MarkdownBlock]` is a usable filter. + +`MarkdownSection` is a block node. It carries the heading that introduces it as a property, and everything that belongs to it — its own blocks, then its nested sections — in the same `Children` collection every other node uses. `MarkdownDocument` is the same container with no heading and a frontmatter property. + +```text +MarkdownDocument +├── FrontMatter : MarkdownFrontMatter the metadata part, not a child node +└── Children + ├── MarkdownParagraph content before the first heading + └── MarkdownSection + ├── Heading : MarkdownHeading the heading that opens the section + └── Children + ├── MarkdownParagraph content before the first subheading + ├── MarkdownFencedCodeBlock + └── MarkdownSection recursive, empty for a leaf section + ├── Heading : MarkdownHeading + └── Children +``` + +Two recursion points remain from the ungrouped model — blocks inside blocks, inlines inside inlines — and sections add a third that reuses the first: a section is a block that contains blocks. + +```mermaid +flowchart TD + Doc(["MarkdownDocument"]) --> BL{{"block level"}} + + BL --> SE["MarkdownSection"] + BL --> BQ["MarkdownBlockQuote"] + BL --> LS["MarkdownList"] + BL --> PA["MarkdownParagraph"] + BL --> LFB["MarkdownThematicBreak
MarkdownIndentedCodeBlock
MarkdownFencedCodeBlock
MarkdownHtmlBlock
MarkdownLinkReferenceDefinition"] + + SE --> HD["MarkdownHeading"] + SE --> BL + BQ --> BL + LS --> LI["MarkdownListItem"] + LI --> BL + + PA --> IL{{"inline level"}} + HD --> IL +``` + +A heading reaches the tree only as a section's `Heading`. Once sections exist, a bare heading in `Children` would mean a heading that introduces nothing, which no document can express. + +## Alternatives considered + +| Option | Trade-offs | Verdict | +| --- | --- | --- | +| Flat block sequence, headings as siblings | Mirrors the specification exactly and needs no grouping pass. Every caller re-derives the outline by scanning forward for the next heading of the same or a lower level, and the level arithmetic is wrong at the edges more often than it is right. | Rejected — pushes the hardest part of the model onto every consumer | +| Section tree as a derived view over a flat model | Keeps the specification shape as the source of truth. Two representations of one document have to be kept in step, and a mutation through the view has to be written back, which is where this design breaks down. | Rejected — two sources of truth | +| `Blocks[]` and `Sections[]` as separate collections | Reads well and matches how the shape is drawn on a whiteboard. Traversal needs both collections, document order between the two is implicit rather than stored, and a filtered view of one collection under a second name puts the same node on two paths, which duplicates it in serialized output. | Rejected — breaks single-walk traversal and clean serialization | +| Section tree as the primary structure, `Children` as the only storage | Grouping happens once, at parse time, in one place. Document order is preserved by the collection itself. Costs one pass over the block sequence, and heading level is no longer readable from nesting depth. | **Chosen** | + +## Architecture + +### Node members + +| Member | On | Purpose | +| --- | --- | --- | +| `[string] Type` | `MarkdownNode` | The construct name, stable across serialization | +| `[MarkdownNode[]] Children` | `MarkdownNode` | The only storage for contained nodes, in document order | +| `[MarkdownSourceSpan] Source` | `MarkdownNode` | Where the node was parsed from; `$null` for nodes built directly | +| `Descendants()` | `MarkdownNode` | Depth-first walk of the whole subtree | +| `Descendants([string] $type)` | `MarkdownNode` | The same walk, filtered by construct name | +| `Sections()` | `MarkdownNode` | The nested sections in `Children` | +| `Blocks()` | `MarkdownNode` | The blocks in `Children` that are not sections | +| `GetSection([string[]] $path)` | `MarkdownNode` | The section reached by matching heading text at each step | +| `GetText()` | `MarkdownNode` | The plain text of the subtree, markup removed | +| `ToString()` | `MarkdownNode` | The markdown for the subtree | +| `[MarkdownHeading] Heading` | `MarkdownSection` | The heading that opens the section | +| `[MarkdownFrontMatter] FrontMatter` | `MarkdownDocument` | The metadata part | + +`Sections()` and `Blocks()` are methods rather than properties. A property returning a filtered view of `Children` would put the same node under two names on one object, and `ConvertTo-Json`, `ConvertTo-Yaml`, and `Export-Clixml` would emit it twice — the duplication [NFR3](spec.md#nfr3) rules out. Methods are also how `Descendants()` already works, so the surface stays consistent. + +`Descendants()` yields a section's `Heading` before its `Children`. This is the one place traversal knows about a node type, and it lives inside the model so that no caller has to. + +### Sectioning + +The grouping pass runs after block parsing, over the child block sequence of each block container, before inline parsing. It is the only place the outline rules of markdown are expressed. + +```text +sectionize(blocks): + roots = [] # blocks and sections at container level + open = [] # open sections, heading levels strictly increasing + + for block in blocks: + if block is a heading: + while open is not empty and open.last.Heading.Level >= block.Level: + remove open.last + section = new Section(Heading = block) + if open is empty: roots.add(section) else: open.last.Children.add(section) + open.add(section) + else: + if open is empty: roots.add(block) else: open.last.Children.add(block) + + return roots +``` + +The consequences are the behaviour [FR6](spec.md#fr6) requires, and they follow from the algorithm rather than from special cases: + +- Blocks before the first heading stay at container level, which is why the document holds content of its own. +- A heading closes every open section at its level or deeper, so a level rising again is ordinary rather than an error. +- A skipped level nests the deeper section directly under the shallower one. Nesting depth is therefore not the heading level, and `MarkdownHeading.Level` remains the only source of truth for rendering. +- A document that starts below level 1 needs no special handling: `open` is empty, so its first section is a root. + +Running per container is what makes a heading inside a block quote or a list item section that container and nothing above it ([FR5](spec.md#fr5)). + +### Rendering + +A section emits its heading, then its children in order. Because `Children` holds blocks and nested sections in document order, and because heading level is read from the heading rather than from depth, the text produced for a document is identical to the text the ungrouped block sequence would produce. Conformance and the round-trip contract are therefore measured on exactly the same output as before sections existed. + +### Addressing a section + +`GetSection()` takes a path as a string array and matches each element against the plain text of the heading at that level — `$doc.GetSection('Usage', 'Parameters')`. An array rather than a delimited string, because heading text may contain any character a delimiter could use. Matching is ordinal and case-insensitive, and the first match at each level wins; a path that matches nothing returns nothing rather than throwing, so it composes in a pipeline. + +## Data and contracts + +The model is the module's public contract, so its nodes are plain objects: public, typed, settable properties and no backing fields. That is what lets any general-purpose serializer take a parsed document and produce complete output, and what keeps the graph acyclic — no node holds a reference to its parent. + +Validation is not performed in property setters. A node accepts a state it cannot render; the renderer throws on what it cannot express, and structural checking is a separate concern. Validating on assignment would require accessors and backing fields, which conflicts directly with plain serializable properties. + +## Security + +- Parsing accepts untrusted text. The grouping pass is linear in the number of blocks and holds one stack bounded by the number of open heading levels, so a hostile document cannot drive it into pathological time or unbounded memory. +- Nesting depth is bounded before recursion, so deeply nested input fails with a clear error rather than exhausting the stack. +- The model never resolves or fetches a link destination. Destinations are carried as text, and what is done with them is the caller's decision. + +## Testing strategy + +| Level | What it covers | +| --- | --- | +| Unit | The grouping pass in isolation: nesting, skipped levels, a level rising again, a document starting below level 1, content before the first heading, and headings inside a block quote and a list item | +| Unit | `Sections()`, `Blocks()`, `Descendants()`, and `GetSection()` against a document with sections three levels deep | +| Contract | Rendered output matches the ungrouped block sequence byte for byte, over the whole [commonmark-spec](https://github.com/commonmark/commonmark-spec) example set | +| Contract | Parse, render, parse again produces an equivalent model, and rendering the second model produces identical text | +| Contract | Converting a parsed document to JSON, YAML, and CLIXML completes with no duplicated node and no cycle | +| Performance | A 1,000-line document parses within the budget in [NFR2](spec.md#nfr2) | + +## Rollout and operability + +The model ships as one release, before which nothing depends on its shape. It is delivered in slices — node types, block parsing, the grouping pass, inline parsing, rendering — and the conformance suite runs in a known-failing mode until the parsing slices are complete. The release does not go out while the suite is red. + +The composition DSL is untouched throughout. It writes markdown; the model reads and transforms it. Whether the DSL is eventually reimplemented on top of the model is a separate question, deliberately left open. From ea5e8d72aff0cadba616900d6cb77ebe6073d83c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 17:26:53 +0200 Subject: [PATCH 4/5] Make the capability docs pass the repository linters Drop the front matter title so the H1 is the only document title, tighten the requirement anchors to the form markdownlint recognises, and capitalise Markdown in prose. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 7 ++-- docs/markdown-object-model/index.md | 7 ++-- docs/markdown-object-model/spec.md | 57 ++++++++++++++-------------- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index ae2a15d..92a0d7d 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -1,5 +1,4 @@ --- -title: Markdown object model — Design description: How the section tree is built, stored, traversed, and rendered, and why sections are the primary structure rather than a view over a flat block sequence. --- @@ -80,7 +79,7 @@ A heading reaches the tree only as a section's `Heading`. Once sections exist, a | `Blocks()` | `MarkdownNode` | The blocks in `Children` that are not sections | | `GetSection([string[]] $path)` | `MarkdownNode` | The section reached by matching heading text at each step | | `GetText()` | `MarkdownNode` | The plain text of the subtree, markup removed | -| `ToString()` | `MarkdownNode` | The markdown for the subtree | +| `ToString()` | `MarkdownNode` | The Markdown for the subtree | | `[MarkdownHeading] Heading` | `MarkdownSection` | The heading that opens the section | | `[MarkdownFrontMatter] FrontMatter` | `MarkdownDocument` | The metadata part | @@ -90,7 +89,7 @@ A heading reaches the tree only as a section's `Heading`. Once sections exist, a ### Sectioning -The grouping pass runs after block parsing, over the child block sequence of each block container, before inline parsing. It is the only place the outline rules of markdown are expressed. +The grouping pass runs after block parsing, over the child block sequence of each block container, before inline parsing. It is the only place the outline rules of Markdown are expressed. ```text sectionize(blocks): @@ -154,4 +153,4 @@ Validation is not performed in property setters. A node accepts a state it canno The model ships as one release, before which nothing depends on its shape. It is delivered in slices — node types, block parsing, the grouping pass, inline parsing, rendering — and the conformance suite runs in a known-failing mode until the parsing slices are complete. The release does not go out while the suite is red. -The composition DSL is untouched throughout. It writes markdown; the model reads and transforms it. Whether the DSL is eventually reimplemented on top of the model is a separate question, deliberately left open. +The composition DSL is untouched throughout. It writes Markdown; the model reads and transforms it. Whether the DSL is eventually reimplemented on top of the model is a separate question, deliberately left open. diff --git a/docs/markdown-object-model/index.md b/docs/markdown-object-model/index.md index 05e5227..5c34248 100644 --- a/docs/markdown-object-model/index.md +++ b/docs/markdown-object-model/index.md @@ -1,11 +1,10 @@ --- -title: Markdown object model -description: The capability that turns a markdown document into a typed, section-oriented object model and back again. +description: The capability that turns a Markdown document into a typed, section-oriented object model and back again. --- # Markdown object model -Markdown text goes in, a typed object model comes out, and markdown comes back. The model is organised the way a document reads — a tree of sections, each owning its heading, its own content, and the sections nested inside it — so documentation automation manipulates structure instead of matching patterns in text. +Markdown text goes in, a typed object model comes out, and Markdown comes back. The model is organised the way a document reads — a tree of sections, each owning its heading, its own content, and the sections nested inside it — so documentation automation manipulates structure instead of matching patterns in text. | Document | Answers | | --- | --- | @@ -33,4 +32,4 @@ $doc.GetSection('Usage', 'Parameters').Children = $generated.Children $doc | ConvertTo-Markdown | Set-Content 'README.md' ``` -The composition DSL is a separate, complementary surface: `Set-Markdown*` is how markdown is written from nothing, the object model is how existing markdown is read and changed. +The composition DSL is a separate, complementary surface: `Set-Markdown*` is how Markdown is written from nothing, the object model is how existing Markdown is read and changed. diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index 6970f1b..e8ea2fb 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -1,23 +1,22 @@ --- -title: Markdown object model -description: The typed object model a markdown document parses into — a tree of sections that owns its content and renders back to specification-valid markdown. +description: The typed object model a Markdown document parses into — a tree of sections that owns its content and renders back to specification-valid Markdown. --- # Markdown object model -A markdown document is available as a typed object model that can be inspected, queried, transformed, and rendered back to markdown. The model is organised the way a document reads: a document holds a tree of sections, and a section owns its heading, its own content, and the sections nested inside it. +A Markdown document is available as a typed object model that can be inspected, queried, transformed, and rendered back to Markdown. The model is organised the way a document reads: a document holds a tree of sections, and a section owns its heading, its own content, and the sections nested inside it. ## Why Markdown is edited by section. Automation extracts a named section from a README, replaces a generated section while leaving hand-written ones untouched, lifts a section and everything under it into another document, or asserts that every required section exists. Without an object model, each of these jobs is done with regular expressions against raw text — fragile against nesting, fenced code, and inline markup. -A model that mirrors the specification's own block sequence does not solve this either. In that shape a heading is a leaf sitting next to the content it introduces, so a caller has to re-derive the outline — find the heading, scan forward to the next heading of the same or a lower level, slice — at every call site. The outline rules of markdown belong in the model, stated once. +A model that mirrors the specification's own block sequence does not solve this either. In that shape a heading is a leaf sitting next to the content it introduces, so a caller has to re-derive the outline — find the heading, scan forward to the next heading of the same or a lower level, slice — at every call site. The outline rules of Markdown belong in the model, stated once. ## Outcomes and impact - **Outcome:** Markdown is read and rewritten as structured data, by section, with no text-level pattern matching and no outline arithmetic in caller code. -- **DORA:** Lead time for changes improves for documentation-generating automation, which today re-implements markdown parsing per repository. Change-failure rate improves as generated-content updates stop corrupting hand-written sections. -- **Domain signal:** The share of documentation automation across the ecosystem that manipulates markdown structurally rather than by string replacement. +- **DORA:** Lead time for changes improves for documentation-generating automation, which today re-implements Markdown parsing per repository. Change-failure rate improves as generated-content updates stop corrupting hand-written sections. +- **Domain signal:** The share of documentation automation across the ecosystem that manipulates Markdown structurally rather than by string replacement. ## Users and jobs @@ -32,10 +31,10 @@ A model that mirrors the specification's own block sequence does not solve this **In scope** -- Parsing a markdown string into the object model. +- Parsing a Markdown string into the object model. - Sections as the organising structure of the model, nested to any depth. - Every block and inline construct defined by [CommonMark](https://spec.commonmark.org/0.31.2/). -- Rendering any node of the model back to markdown, whole document or single subtree. +- Rendering any node of the model back to Markdown, whole document or single subtree. - Constructing a document from scratch, without parsing. - Addressing a section by its heading, including a path through nested headings. @@ -43,81 +42,81 @@ A model that mirrors the specification's own block sequence does not solve this - Markdown dialects beyond CommonMark, including tables, task list items, and strikethrough. - Parsing and emitting frontmatter content. The model reserves a place for it; interpreting it is separate work. -- Heading anchors and slugs, which are a platform convention rather than a markdown construct. +- Heading anchors and slugs, which are a platform convention rather than a Markdown construct. - Reading, writing, and locating files. The caller supplies text and decides where output lands. - Structural validation, normalisation, and formatting policy. ## Non-goals - **Byte-exact round-tripping.** Preserving every space and indentation detail would push insignificant whitespace into every node. The model preserves the stylistic choices a reader would notice and normalises the rest. -- **Rendering to formats other than markdown.** The model is plain data, so any general-purpose serializer reaches other formats without the module owning a renderer for each. -- **Replacing the composition DSL.** The `Set-Markdown*` functions stay the way markdown is composed imperatively. The object model is how existing markdown is read and transformed. +- **Rendering to formats other than Markdown.** The model is plain data, so any general-purpose serializer reaches other formats without the module owning a renderer for each. +- **Replacing the composition DSL.** The `Set-Markdown*` functions stay the way Markdown is composed imperatively. The object model is how existing Markdown is read and transformed. ## Functional requirements -### FR1 — A markdown string parses into a typed object model { #fr1 } +### FR1 — A Markdown string parses into a typed object model {#fr1} Parsing MUST accept any text valid under [CommonMark](https://spec.commonmark.org/0.31.2/) and MUST produce a typed object for every block and inline construct the specification defines. Parsing MUST NOT fail on structurally unusual but valid input. -### FR2 — A section owns its heading and everything beneath it { #fr2 } +### FR2 — A section owns its heading and everything beneath it {#fr2} A section MUST expose the heading that introduces it, the content that follows that heading, and the sections nested inside it. Content that follows a heading, up to the next heading of the same or a lower level, MUST belong to that section. -### FR3 — A section without nested sections is the same kind of thing { #fr3 } +### FR3 — A section without nested sections is the same kind of thing {#fr3} A section that has no nested sections MUST be the same type as one that does, holding an empty collection. There MUST NOT be a distinct type for leaf sections. -### FR4 — A document is a section container without a heading { #fr4 } +### FR4 — A document is a section container without a heading {#fr4} The document MUST be the same kind of container as a section, differing only in that it has no heading and carries the document's metadata part. Content appearing before the first heading MUST belong to the document. -### FR5 — Sectioning applies wherever blocks appear { #fr5 } +### FR5 — Sectioning applies wherever blocks appear {#fr5} Any construct that contains a sequence of blocks — the document, a section, a block quote, a list item — MUST group its own blocks into sections by the same rule. A heading inside a container MUST section that container and MUST NOT affect its ancestors. -### FR6 — Heading level survives nesting { #fr6 } +### FR6 — Heading level survives nesting {#fr6} A heading's level MUST be preserved independently of how deeply its section is nested. A document that skips a level MUST nest the deeper section directly under the shallower one, MUST NOT introduce a section that is not present in the document, and MUST re-render each heading at its original level. A document that starts below the first level, or whose heading levels rise again later, MUST parse without error. -### FR7 — A section is addressable by its heading { #fr7 } +### FR7 — A section is addressable by its heading {#fr7} A section MUST be reachable by its heading text and by a path of heading texts through nested sections, without the caller indexing into a collection or computing heading levels. -### FR8 — The whole model is traversable in one walk { #fr8 } +### FR8 — The whole model is traversable in one walk {#fr8} A single recursive traversal MUST reach every node in the model, without the caller branching on node type. Traversal MUST yield a section's heading before the section's content. -### FR9 — A document can be built without parsing { #fr9 } +### FR9 — A document can be built without parsing {#fr9} -Every node MUST be constructible directly, so a document can be assembled in memory and rendered without any markdown text existing first. +Every node MUST be constructible directly, so a document can be assembled in memory and rendered without any Markdown text existing first. -### FR10 — Any node renders to specification-valid markdown { #fr10 } +### FR10 — Any node renders to specification-valid Markdown {#fr10} -Rendering MUST accept any node and MUST return markdown for that node and everything below it, so a whole document and a single section are rendered the same way. Output MUST be valid under [CommonMark](https://spec.commonmark.org/0.31.2/) — correctly escaped, with sufficient fence lengths and correct list indentation — not merely text this module can read back. Rendering a section MUST produce the same text as rendering its heading followed by its content in document order. +Rendering MUST accept any node and MUST return Markdown for that node and everything below it, so a whole document and a single section are rendered the same way. Output MUST be valid under [CommonMark](https://spec.commonmark.org/0.31.2/) — correctly escaped, with sufficient fence lengths and correct list indentation — not merely text this module can read back. Rendering a section MUST produce the same text as rendering its heading followed by its content in document order. -### FR11 — Round-tripping is semantically stable { #fr11 } +### FR11 — Round-tripping is semantically stable {#fr11} Text parsed into the model, rendered, and parsed again MUST produce an equivalent model. Rendering MUST be idempotent from the second pass onward. Two models are equivalent when their content and structure match, regardless of where they were parsed from. -### FR12 — Every parsed node records where it came from { #fr12 } +### FR12 — Every parsed node records where it came from {#fr12} A node produced by parsing MUST record its position in the source text, so tooling can report diagnostics against line numbers. A node built directly MUST report no position, and position MUST be ignored when models are compared for equivalence. -### FR13 — The composition DSL is unaffected { #fr13 } +### FR13 — The composition DSL is unaffected {#fr13} The existing `Set-Markdown*` functions MUST keep working unchanged. ## Non-functional requirements -### NFR1 — Conformance is measured against the specification's own examples { #nfr1 } +### NFR1 — Conformance is measured against the specification's own examples {#nfr1} Every example published by [commonmark-spec](https://github.com/commonmark/commonmark-spec) MUST parse without error and MUST round-trip idempotently. Examples that cannot be satisfied MUST be recorded as known gaps rather than skipped silently. -### NFR2 — Parsing is fast enough to use in a pipeline { #nfr2 } +### NFR2 — Parsing is fast enough to use in a pipeline {#nfr2} A 1,000-line document MUST parse in under two seconds, and the conformance suite MUST complete within the repository's normal test job. -### NFR3 — The model serializes with a general-purpose serializer { #nfr3 } +### NFR3 — The model serializes with a general-purpose serializer {#nfr3} The object graph MUST be acyclic and MUST contain no node reachable by more than one path, so that converting a parsed document to JSON, YAML, or CLIXML produces complete output with no duplicated nodes and no special handling. From 88ec1efd7913ca64021aac09e2395fc65735afef Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 17:35:01 +0200 Subject: [PATCH 5/5] Allow a front matter title alongside the body heading markdownlint's default MD025 pattern counts a front matter title as a top-level heading, so a capability document could not carry both. Configure front_matter_title as empty and restore the title the mkdocs navigation reads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/linters/.markdown-lint.yml | 2 ++ docs/markdown-object-model/design.md | 1 + docs/markdown-object-model/index.md | 1 + docs/markdown-object-model/spec.md | 1 + 4 files changed, 5 insertions(+) diff --git a/.github/linters/.markdown-lint.yml b/.github/linters/.markdown-lint.yml index 57db57e..952664e 100644 --- a/.github/linters/.markdown-lint.yml +++ b/.github/linters/.markdown-lint.yml @@ -14,6 +14,8 @@ MD007: MD013: line_length: 808 # Line length MD024: false # no-duplicate-heading, INPUTS and OUTPUTS _can_ be the same item +MD025: + front_matter_title: "" # Allow a body H1 alongside the front matter title MD026: punctuation: '.,;:!。,;:' # List of not allowed MD029: false # Ordered list item prefix diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index 92a0d7d..1abadf6 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -1,4 +1,5 @@ --- +title: Markdown object model — Design description: How the section tree is built, stored, traversed, and rendered, and why sections are the primary structure rather than a view over a flat block sequence. --- diff --git a/docs/markdown-object-model/index.md b/docs/markdown-object-model/index.md index 5c34248..9a5b136 100644 --- a/docs/markdown-object-model/index.md +++ b/docs/markdown-object-model/index.md @@ -1,4 +1,5 @@ --- +title: Markdown object model description: The capability that turns a Markdown document into a typed, section-oriented object model and back again. --- diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index e8ea2fb..5c479ae 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -1,4 +1,5 @@ --- +title: Markdown object model description: The typed object model a Markdown document parses into — a tree of sections that owns its content and renders back to specification-valid Markdown. ---