From 407f2fb45c4401c42d69d849a21bb168ba7de890 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:27:29 +0200 Subject: [PATCH 01/10] Update the object model spec for the merged section shape A section now carries its heading level, title as inline nodes, and heading style, instead of containing a separate heading node. FR2, FR4, FR6, FR7, FR8 and FR10 are reworded in place, and the acceptance criteria gain a scenario for inline markup in a title surviving a parse and render cycle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/spec.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index 5c479ae..83330db 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -5,7 +5,7 @@ description: The typed object model a Markdown document parses into — a tree o # 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 carries the heading that opens it, its own content, and the sections nested inside it. ## Why @@ -59,9 +59,9 @@ A model that mirrors the specification's own block sequence does not solve this 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 carries its heading and owns 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. +A section MUST expose the level, the title, and the heading style of the heading that introduces it, together with the content that follows that heading and the sections nested inside it. There MUST NOT be a separate node type for a heading. The title MUST be held as inline nodes, so that markup written inside a heading survives a parse and render cycle, and a section MUST also expose its title as plain text. 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} @@ -69,7 +69,7 @@ A section that has no nested sections MUST be the same type as one that does, ho ### 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. +The document MUST be the same kind of container as a section, differing only in that it carries no heading level, title, or style, and carries the document's metadata part instead. Content appearing before the first heading MUST belong to the document. ### FR5 — Sectioning applies wherever blocks appear {#fr5} @@ -77,15 +77,15 @@ Any construct that contains a sequence of blocks — the document, a section, a ### 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. +A section's level MUST be preserved independently of how deeply that 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 section 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. +A section MUST be reachable by its title text and by a path of title 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. +A single recursive traversal MUST reach every node in the model, without the caller branching on node type. The inline nodes a section holds as its title MUST be reached by that traversal, before the section's content. ### FR9 — A document can be built without parsing {#fr9} @@ -93,7 +93,7 @@ Every node MUST be constructible directly, so a document can be assembled in mem ### 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 its heading line, reconstructed from its level, title, and style, followed by its content in document order. ### FR11 — Round-tripping is semantically stable {#fr11} @@ -143,6 +143,12 @@ Feature: Sections own their content Then that section holds no nested sections And it is the same type as a section that has them + Scenario: A section title keeps its inline markup + Given a document whose heading contains emphasis and a code span + When the document is parsed + Then the section's title holds that emphasis and code span as inline nodes + And rendering the section reproduces the heading with its markup intact + 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 @@ -165,7 +171,7 @@ Feature: Sections own their content 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 + Then the result is its heading line followed by its content and nested sections And the result parses back to an equivalent section Scenario: Rendering is stable From 71f1ded4f3858f6ecf18766e667bd09dc2c3ecb4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:27:54 +0200 Subject: [PATCH 02/10] Add the section nesting and tree depth bounds to the spec FR14 states both bounds the model has always had but never wrote down: a chain of nested sections stops at six, because sections nest only on strictly increasing levels and an ATX heading carries one to six hash characters, while total tree depth is unbounded because sectioning restarts inside every block container. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/spec.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index 83330db..1efc010 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -33,7 +33,7 @@ A model that mirrors the specification's own block sequence does not solve this **In scope** - Parsing a Markdown string into the object model. -- Sections as the organising structure of the model, nested to any depth. +- Sections as the organising structure of the model, nested as deeply as heading levels allow. - 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. @@ -107,6 +107,12 @@ A node produced by parsing MUST record its position in the source text, so tooli The existing `Set-Markdown*` functions MUST keep working unchanged. +### FR14 — Section nesting is bounded at six levels; the tree is not bounded at all {#fr14} + +A chain of sections nested one inside another MUST NOT exceed six. Sections nest only where levels strictly increase, and an ATX heading is an opening sequence of one to six unescaped `#` characters ([§4.2](https://spec.commonmark.org/0.31.2/#atx-headings)), so the longest chain a document can express runs from level one to level six. There is no seventh level. + +Total depth of the model MUST NOT be bounded. Sectioning restarts inside every block container, and a heading is legal inside a block quote and inside a list item, so a block quote nested in a level six section may hold a section of its own at level one. The bound is six levels of sectioning per container, across an unlimited number of containers. + ## Non-functional requirements ### NFR1 — Conformance is measured against the specification's own examples {#nfr1} @@ -168,6 +174,12 @@ Feature: Sections own their content Then the section is content of the block quote And the document reports no section for that heading + Scenario: A container restarts the section depth + Given a level 6 section containing a block quote that opens with a level 1 heading + When the document is parsed + Then the block quote holds a section at level 1 + And no chain of nested sections within a single container exceeds six + Scenario: A section renders on its own Given a parsed document containing a section with nested sections When that section is rendered From f8efd55d5b234a01115c31f4c523b1ccd21cad7a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:28:33 +0200 Subject: [PATCH 03/10] Add the comment node requirements to the spec FR15 to FR20 state what a comment is in the model: exact-block recognition with the trailing-content rule the CommonMark HTML block end condition implies, inner text and a terminated indicator, unterminated comments running to the end of the document, the degenerate forms, preservation of delimiters and inner spacing, and comment-looking text in code excluded. Acceptance criteria gain a matching feature. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/spec.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index 1efc010..395f0e0 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -113,6 +113,32 @@ A chain of sections nested one inside another MUST NOT exceed six. Sections nest Total depth of the model MUST NOT be bounded. Sectioning restarts inside every block container, and a heading is legal inside a block quote and inside a list item, so a block quote nested in a level six section may hold a section of its own at level one. The bound is six levels of sectioning per container, across an unlimited number of containers. +### FR15 — A comment is a node of the model {#fr15} + +A comment MUST be its own kind of node rather than opaque raw HTML, at block level and at inline level alike, and every comment MUST report the same construct name so that one query finds all of them. + +A block is a comment only when the block is exactly a comment. [CommonMark](https://spec.commonmark.org/0.31.2/#html-blocks) ends an HTML block at the first line containing `-->`, and whatever follows the terminator on that line belongs to the same block — in [example 172](https://spec.commonmark.org/0.31.2/#example-172), `*bar*` is one HTML block in which `*bar*` is not emphasized. A block whose comment is followed by other content on the same line MUST therefore stay an HTML block, because promoting it would discard the trailing content. + +### FR16 — A comment exposes its text and whether it was terminated {#fr16} + +A comment MUST expose its inner text without the `` delimiters, so that reading a comment requires no string handling by the caller. It MUST also expose whether the comment was terminated in the source. + +### FR17 — An unterminated comment runs to the end of the document {#fr17} + +Where no line containing `-->` follows, the comment MUST extend to the last line of the document, which is the end condition [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks) defines. The model MUST record the comment as unterminated rather than presenting it as closed. + +### FR18 — The degenerate comment forms are comments {#fr18} + +`` and `` are comments under [§6.6](https://spec.commonmark.org/0.31.2/#raw-html). Both MUST parse as comments, MUST carry no inner text, and MUST render back exactly as written. + +### FR19 — A comment's delimiters and inner spacing are preserved {#fr19} + +A comment MUST re-render in the form it was written, so `` MUST NOT become ``. Preservation of the written form is independent of the inner text a caller reads. + +### FR20 — Comment-looking text inside code is not a comment {#fr20} + +Text resembling a comment inside a code span, a fenced code block, or an indented code block is code content. It MUST NOT be recognised as a comment, and it MUST render back unchanged. + ## Non-functional requirements ### NFR1 — Conformance is measured against the specification's own examples {#nfr1} @@ -193,6 +219,34 @@ Feature: Sections own their content And rendering the second model produces identical text ``` +```gherkin +Feature: Comments are part of the model + + Scenario: A block that is exactly a comment is a comment + Given a document containing a line that holds only a comment + When the document is parsed + Then that block is a comment + And its text is the comment content without the delimiters + + Scenario: A comment with trailing content stays an HTML block + Given a document containing the line "*bar*" + When the document is parsed + Then that block is an HTML block + And the trailing content is retained verbatim + + Scenario: An unterminated comment reaches the end of the document + Given a document containing an opening comment delimiter and no terminator + When the document is parsed + Then the comment extends to the last line of the document + And it reports that it was not terminated + + Scenario: Comment-looking text in code stays code + Given a fenced code block whose content looks like a comment + When the document is parsed + Then the document reports no comment + And the code block content renders back unchanged +``` + ## 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. From 181babd17c42b2716383bec43d860df8d53e9f24 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:28:47 +0200 Subject: [PATCH 04/10] Update the capability overview tree for the merged section shape The at a glance tree still showed a Heading child under Section. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/markdown-object-model/index.md b/docs/markdown-object-model/index.md index 9a5b136..41e9b93 100644 --- a/docs/markdown-object-model/index.md +++ b/docs/markdown-object-model/index.md @@ -5,7 +5,7 @@ description: The capability that turns a Markdown document into a typed, section # 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 carrying the heading that opens it, its own content, and the sections nested inside it — so documentation automation manipulates structure instead of matching patterns in text. | Document | Answers | | --- | --- | @@ -19,7 +19,7 @@ Document ├── FrontMatter the metadata part ├── (blocks) content before the first heading └── Section - ├── Heading + ├── Level / Title / Style the heading, carried on the section ├── (blocks) content before the first subheading └── Section recursive, empty for a leaf section ``` From 4755f52fe738896d44bf7fc2b22a138ef1717ae9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:30:28 +0200 Subject: [PATCH 05/10] Update the object model design for the merged section shape The section carries Level, Title as inline nodes, and Style, so there is no heading node left in the model. Updates the approach prose, the tree and containment diagrams, the node members table, rendering, and section addressing, and records the two rejected shapes in the alternatives table: the earlier Section wrapping a heading node, and the string-title shape prototyped in PSModule/Markdown#18. Traversal now yields a section's title inlines before its children, which keeps FR8 true and keeps heading links reachable from Descendants(). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 37 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index 1abadf6..00b3b42 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -15,7 +15,7 @@ The model is a tree of nodes. Block parsing produces the specification's block s 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. +`MarkdownSection` is a block node. It carries the heading that opens it — `Level`, `Title`, and `Style` — rather than containing a heading node, and holds 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 without a heading and with a frontmatter property. ```text MarkdownDocument @@ -23,15 +23,21 @@ MarkdownDocument └── Children ├── MarkdownParagraph content before the first heading └── MarkdownSection - ├── Heading : MarkdownHeading the heading that opens the section + ├── Level : int the heading level, as written + ├── Title : MarkdownInline[] the heading text, as inline nodes + ├── Style : MarkdownHeadingStyle how the heading was written └── Children ├── MarkdownParagraph content before the first subheading ├── MarkdownFencedCodeBlock └── MarkdownSection recursive, empty for a leaf section - ├── Heading : MarkdownHeading + ├── Level / Title / Style └── Children ``` +`Title` holds inline nodes rather than a string, so `# Release **1.2** notes` keeps its emphasis and re-renders with it. A string would flatten heading markup with nothing left to recover it from. `GetTitleText()` reads a title as plain text for the callers that want one. + +There is no separate heading node. With level, title, and style on the section, nothing in the model could hold one — a heading that introduces nothing is not something a document can express — so the heading's stylistic properties are section properties and `Descendants('Section')` is how headings are found. + 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 @@ -44,26 +50,27 @@ flowchart TD 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 + SE -->|Title| 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 +### The section shape + | 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** | +| `Section { Heading, Children }` — the section wraps a heading node | Keeps a node for a construct CommonMark defines, and gives the heading line a source span of its own. Costs an indirection on the two most common accesses, needs a documented traversal rule for where the heading is yielded, and the node it preserves is one nothing else in the model can hold. | Rejected — the indirection buys a node nothing else references | +| `Header { Level, Title (string), Content }` — as prototyped in [PSModule/Markdown#18](https://github.com/PSModule/Markdown/pull/18) | The simplest containment shape, and proven to work across all three platforms. A string title discards inline markup in a heading, and the loss is unrecoverable once parsing has finished. | Rejected — lossy | +| `Section { Level, Title (inlines), Style, Children }`, `Children` as the only storage | One type, direct access to level and title, and no fidelity loss. Grouping happens once, at parse time, in one place, and 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 @@ -78,15 +85,18 @@ A heading reaches the tree only as a section's `Heading`. Once sections exist, a | `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 | +| `GetSection([string[]] $path)` | `MarkdownNode` | The section reached by matching title 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 | +| `[int] Level` | `MarkdownSection` | The heading level, as written | +| `[MarkdownInline[]] Title` | `MarkdownSection` | The heading text, as inline nodes | +| `[MarkdownHeadingStyle] Style` | `MarkdownSection` | How the heading was written | +| `GetTitleText()` | `MarkdownSection` | The title as plain text, markup removed | | `[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. +`Title` is the one node-valued member outside `Children`, and `Descendants()` absorbs it: the walk yields a section's title inlines before its children. That is the single place traversal knows about a node type, and it lives inside the model so that no caller has to hold it. Without it, a query as ordinary as `$doc.Descendants('Link')` would silently miss every link written inside a heading. ### Sectioning @@ -121,11 +131,11 @@ Running per container is what makes a heading inside a block quote or a list ite ### 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. +A section emits its heading line, reconstructed from `Level`, `Title`, and `Style`, then its children in order. Because `Children` holds blocks and nested sections in document order, and because the level is read from the section rather than from its 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. +`GetSection()` takes a path as a string array and matches each element against the plain text of the section title at that level — `$doc.GetSection('Usage', 'Parameters')`. An array rather than a delimited string, because a title 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 @@ -145,6 +155,7 @@ Validation is not performed in property setters. A node accepts a state it canno | --- | --- | | 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 | +| Unit | A section title holding emphasis, a code span, and a link: the inlines are reachable from `Descendants()`, `GetTitleText()` strips the markup, and rendering restores it | | 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 | From 8dfcf9f9610635496557073ea6d122abf1904dc5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:30:54 +0200 Subject: [PATCH 06/10] State all four section closure rules in the design The pseudocode covered three; the fourth was only implicit in the function returning. All four are now stated as prose beside it, and the pseudocode is updated onto the merged section shape. Adds the note that the pass runs before inline parsing, and the observation that the six-level nesting bound falls out of rule 1 rather than being enforced. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index 00b3b42..c91cafd 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -102,32 +102,46 @@ flowchart TD 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. +Four rules close and attach sections, and together they are the whole of the outline: + +1. A heading of level N closes every open section whose level is greater than or equal to N. +2. The new section attaches to the nearest still-open section with a level below N, or to the container root when none remains. +3. A block that is not a heading attaches to the innermost open section, or to the container root when none is open. +4. Reaching the end of a container closes everything still open in it. + +The first three are the loop; the fourth is the return. + ```text sectionize(blocks): roots = [] # blocks and sections at container level - open = [] # open sections, heading levels strictly increasing + open = [] # open sections, levels strictly increasing for block in blocks: if block is a heading: - while open is not empty and open.last.Heading.Level >= block.Level: + while open is not empty and open.last.Level >= block.Level: # rule 1 remove open.last - section = new Section(Heading = block) - if open is empty: roots.add(section) else: open.last.Children.add(section) + section = new Section(Level = block.Level, + Title = block.Title, + Style = block.Style) + if open is empty: roots.add(section) else: open.last.Children.add(section) # rule 2 open.add(section) else: - if open is empty: roots.add(block) else: open.last.Children.add(block) + if open is empty: roots.add(block) else: open.last.Children.add(block) # rule 3 - return roots + return roots # rule 4: every section still open closes here ``` -The consequences are the behaviour [FR6](spec.md#fr6) requires, and they follow from the algorithm rather than from special cases: +The heading block is consumed rather than kept — its level, title, and style move onto the section, and nothing of it survives as a node. Because the pass runs before inline parsing, `Title` carries the heading's unparsed inline content at this point, and the inline pass fills it in the same walk that fills every other leaf. + +The consequences are the behaviour [FR6](spec.md#fr6) requires, and they follow from the four rules 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 skipped level nests the deeper section directly under the shallower one. Nesting depth is therefore not the heading level, and `MarkdownSection.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. +- `open` never holds more than six sections, because levels in it strictly increase and a heading level is at most six. That is the section nesting bound in [FR14](spec.md#fr14), and it falls out of rule 1 rather than being enforced. -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)). +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)). It is also why total tree depth is unbounded while section nesting is not: every container starts with an empty `open`, so a block quote inside a level 6 section begins the count again. ### Rendering From 4d0c6775d1f618fd33836b5918a8bc8acfd81e2d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:32:45 +0200 Subject: [PATCH 07/10] Correct the CommonMark example cited for the HTML block end condition The comment-plus-trailing-content case is example 177. Example 172 is the style element case. Verified against spec.txt for 0.31.2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index 395f0e0..2b461f8 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -117,7 +117,7 @@ Total depth of the model MUST NOT be bounded. Sectioning restarts inside every b A comment MUST be its own kind of node rather than opaque raw HTML, at block level and at inline level alike, and every comment MUST report the same construct name so that one query finds all of them. -A block is a comment only when the block is exactly a comment. [CommonMark](https://spec.commonmark.org/0.31.2/#html-blocks) ends an HTML block at the first line containing `-->`, and whatever follows the terminator on that line belongs to the same block — in [example 172](https://spec.commonmark.org/0.31.2/#example-172), `*bar*` is one HTML block in which `*bar*` is not emphasized. A block whose comment is followed by other content on the same line MUST therefore stay an HTML block, because promoting it would discard the trailing content. +A block is a comment only when the block is exactly a comment. [CommonMark](https://spec.commonmark.org/0.31.2/#html-blocks) ends an HTML block at the first line containing `-->`, and whatever follows the terminator on that line belongs to the same block — in [example 177](https://spec.commonmark.org/0.31.2/#example-177), `*bar*` is one HTML block in which `*bar*` is not emphasized. A block whose comment is followed by other content on the same line MUST therefore stay an HTML block, because promoting it would discard the trailing content. ### FR16 — A comment exposes its text and whether it was terminated {#fr16} From 88f74994238c2ea9b7e60270c055c492966e9090 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:34:38 +0200 Subject: [PATCH 08/10] Move the node schema from the Epic into the design The complete inventory - every class, property and CommonMark section reference - existed only in the body of PSModule/Markdown#8, so it would have become a closed issue body once that Epic closed. It now lives in the design: type hierarchy, worked example, shared members, document, blocks, inlines, enums, and the constructs that are deliberately not nodes. Adapted to evergreen present tense, with the merged section shape applied as it moves: MarkdownHeading is gone, MarkdownSection carries Level, Title and Style, and headings join the list of constructs that are not nodes. Two CommonMark section numbers are corrected on the way in - textual content is 6.9 and entity references are 2.5. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 377 +++++++++++++++++++++++++-- 1 file changed, 357 insertions(+), 20 deletions(-) diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index c91cafd..f9d9188 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -57,8 +57,70 @@ flowchart TD PA --> IL{{"inline level"}} SE -->|Title| IL + + IL --> EM["MarkdownEmphasis
MarkdownStrongEmphasis"] + IL --> LK["MarkdownLink
MarkdownImage"] + IL --> LFI["MarkdownText
MarkdownCodeSpan
MarkdownAutolink
MarkdownRawHtml
MarkdownHardLineBreak
MarkdownSoftLineBreak"] + + EM --> IL + LK --> IL ``` +The type system does not enforce these rules. `Children` is `[MarkdownNode[]]` on every node, which is what lets one walk cover the whole tree and one serializer handle the result. The parser produces only valid nesting, and the renderer throws on nesting it cannot express. + +### A worked example + +This document: + +```markdown +# Setup + +Install with `Install-PSResource`. + +- Step one +- Step **two** +``` + +parses to this model: + +```mermaid +flowchart TD + D["MarkdownDocument"] + SEC["MarkdownSection
Level = 1
Style = Atx"] + HT["MarkdownText
Setup"] + P["MarkdownParagraph"] + PT1["MarkdownText
Install with"] + PC["MarkdownCodeSpan
Install-PSResource"] + PT2["MarkdownText
."] + L["MarkdownList
Kind = Bullet
IsTight = true"] + LI1["MarkdownListItem"] + LI2["MarkdownListItem"] + P1["MarkdownParagraph"] + P2["MarkdownParagraph"] + T1["MarkdownText
Step one"] + T2["MarkdownText
Step"] + S["MarkdownStrongEmphasis
Marker = Asterisk"] + T3["MarkdownText
two"] + + D --> SEC + SEC -->|Title| HT + SEC --> P + SEC --> L + P --> PT1 + P --> PC + P --> PT2 + L --> LI1 + L --> LI2 + LI1 --> P1 + LI2 --> P2 + P1 --> T1 + P2 --> T2 + P2 --> S + S --> T3 +``` + +Four things this makes concrete. The document holds one child, and everything under `# Setup` hangs off it — the paragraph and the list are content *of the section*, not siblings of a heading. The heading itself is not a node: its level and style are section properties, and its text is the section's `Title`. List items contain *blocks*, so the content of a one-line item is still a paragraph. And emphasis contains inlines rather than a string, which is why `**two**` is a `MarkdownStrongEmphasis` wrapping a `MarkdownText`. + ## Alternatives considered ### The section shape @@ -74,30 +136,305 @@ flowchart TD ## 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 title text at each step | -| `GetText()` | `MarkdownNode` | The plain text of the subtree, markup removed | -| `ToString()` | `MarkdownNode` | The Markdown for the subtree | -| `[int] Level` | `MarkdownSection` | The heading level, as written | -| `[MarkdownInline[]] Title` | `MarkdownSection` | The heading text, as inline nodes | -| `[MarkdownHeadingStyle] Style` | `MarkdownSection` | How the heading was written | -| `GetTitleText()` | `MarkdownSection` | The title as plain text, markup removed | -| `[MarkdownFrontMatter] FrontMatter` | `MarkdownDocument` | The metadata part | +The schema below is the complete node inventory: every class, every property, and the specification section it derives from. Properties marked *style* exist only so the renderer can reproduce the source form; they carry no semantic content, and a consumer that does not render Markdown can ignore them. + +### Type hierarchy + +Three levels, and `Type` as a plain string on each node — `Section`, `Paragraph`, `Text`, without the `Markdown` prefix — so `Where-Object Type -EQ 'Section'` works without class names in scope and `ConvertTo-Json` output is self-describing. + +```mermaid +classDiagram + direction TB + + class MarkdownNode { + <> + +String Type + +MarkdownNode[] Children + +MarkdownSourceSpan Source + +Descendants() MarkdownNode[] + +GetText() String + +ToString() String + } + class MarkdownBlock { + <> + } + class MarkdownInline { + <> + } + class MarkdownFrontMatter { + +MarkdownFrontMatterFormat Format + +String Raw + +Object Data + } + class MarkdownSourceSpan { + +Int StartLine + +Int StartColumn + +Int EndLine + +Int EndColumn + } + + MarkdownNode <|-- MarkdownBlock + MarkdownNode <|-- MarkdownInline + MarkdownNode --> MarkdownSourceSpan : Source + + MarkdownBlock <|-- MarkdownDocument + MarkdownBlock <|-- MarkdownSection + MarkdownBlock <|-- MarkdownParagraph + MarkdownBlock <|-- MarkdownThematicBreak + MarkdownBlock <|-- MarkdownIndentedCodeBlock + MarkdownBlock <|-- MarkdownFencedCodeBlock + MarkdownBlock <|-- MarkdownHtmlBlock + MarkdownBlock <|-- MarkdownLinkReferenceDefinition + MarkdownBlock <|-- MarkdownBlockQuote + MarkdownBlock <|-- MarkdownList + MarkdownBlock <|-- MarkdownListItem + + MarkdownInline <|-- MarkdownText + MarkdownInline <|-- MarkdownCodeSpan + MarkdownInline <|-- MarkdownEmphasis + MarkdownInline <|-- MarkdownStrongEmphasis + MarkdownInline <|-- MarkdownLink + MarkdownInline <|-- MarkdownImage + MarkdownInline <|-- MarkdownAutolink + MarkdownInline <|-- MarkdownRawHtml + MarkdownInline <|-- MarkdownHardLineBreak + MarkdownInline <|-- MarkdownSoftLineBreak + + MarkdownDocument --> MarkdownFrontMatter : FrontMatter + MarkdownSection --> MarkdownInline : Title +``` + +CommonMark's prose separates *container* blocks from *leaf* blocks, but that is a parsing concept rather than a modelling one, so the class hierarchy does not reflect it. The block and inline split is kept because filtering on it is genuinely useful. + +`MarkdownFrontMatter` and `MarkdownSourceSpan` are not nodes. They hang off nodes as properties and never appear in `Children`. + +### Shared members + +`MarkdownNode` is the abstract base of every node. `MarkdownBlock` and `MarkdownInline` derive from it and add nothing. + +| Member | Type | Notes | +| --- | --- | --- | +| `Type` | `[string]` | The node name without the `Markdown` prefix. Read-only. | +| `Children` | `[MarkdownNode[]]` | Direct children in document order. Empty for leaves, never `$null`. | +| `Source` | `[MarkdownSourceSpan]` | Where the node came from in the source text. `$null` for nodes built by hand. | +| `Descendants()` | `[MarkdownNode[]]` | Every node beneath this one, depth-first, document order. A section's title inlines are yielded before its children. | +| `Descendants([string] $type)` | `[MarkdownNode[]]` | The same, filtered to one `Type`. | +| `GetText()` | `[string]` | Concatenated text content of the subtree, markup stripped. | +| `ToString()` | `[string]` | The subtree rendered as Markdown, by delegating to the renderer. | +| `Sections()` | `[MarkdownSection[]]` | The nested sections in `Children`. | +| `Blocks()` | `[MarkdownBlock[]]` | The blocks in `Children` that are not sections. | +| `GetSection([string[]] $path)` | `[MarkdownSection]` | The section reached by matching title text at each step, ordinal and case-insensitive. Returns nothing when the path matches nothing. | `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. `Title` is the one node-valued member outside `Children`, and `Descendants()` absorbs it: the walk yields a section's title inlines before its children. That is the single place traversal knows about a node type, and it lives inside the model so that no caller has to hold it. Without it, a query as ordinary as `$doc.Descendants('Link')` would silently miss every link written inside a heading. +**`MarkdownSourceSpan`** — not a node. Populated by the parser, `$null` on hand-constructed nodes, and ignored when models are compared for round-trip equivalence. + +| Property | Type | Notes | +| --- | --- | --- | +| `StartLine` | `[int]` | 1-based. | +| `StartColumn` | `[int]` | 1-based. | +| `EndLine` | `[int]` | 1-based, inclusive. | +| `EndColumn` | `[int]` | 1-based, inclusive. | + +Every node class exposes a parameterless constructor and one overload covering its common case, so a document can be built without parsing ([FR9](spec.md#fr9)): + +```powershell +$doc = [MarkdownDocument]::new() +$section = [MarkdownSection]::new(1, 'Title') +$section.Children += [MarkdownParagraph]::new('Some text') +$doc.Children += $section +$doc | ConvertTo-Markdown +``` + +The section overload takes a level and a plain string, and wraps the string in a text node, so the common case does not require assembling inlines by hand. + +### Document + +**`MarkdownDocument : MarkdownBlock`** — the root, and the return type of `ConvertFrom-Markdown`. + +| Property | Type | Notes | +| --- | --- | --- | +| `FrontMatter` | `[MarkdownFrontMatter]` | Reserved. The type exists and the property stays `$null` while parsing and emitting frontmatter is out of scope, so populating it later does not change the document's shape. | +| `Children` | `[MarkdownNode[]]` | Block-level nodes: the content before the first heading, then the top-level sections. | +| `GetLinkReferenceDefinitions()` | `[MarkdownLinkReferenceDefinition[]]` | A method, not a property — the definitions are already nodes in the tree, and a second reference to them would duplicate them in serialized output. | + +**`MarkdownFrontMatter`** — deliberately not a node. It is not Markdown, it never appears in `Children`, and nothing that walks the tree encounters it. + +| Property | Type | Notes | +| --- | --- | --- | +| `Format` | `[MarkdownFrontMatterFormat]` | The metadata format. | +| `Raw` | `[string]` | Verbatim text between the delimiters, so an untouched document round-trips losslessly. | +| `Data` | `[object]` | The deserialized value. | + +### Blocks + +**`MarkdownSection : MarkdownBlock`** — not a CommonMark construct. A section is the grouping the specification's block sequence implies: a heading and everything up to the next heading of the same or a lower level ([FR2](spec.md#fr2)). It absorbs the heading itself, so [§4.2](https://spec.commonmark.org/0.31.2/#atx-headings) and [§4.3](https://spec.commonmark.org/0.31.2/#setext-headings) are modelled here and nowhere else. + +| Property | Type | Notes | +| --- | --- | --- | +| `Level` | `[int]` | 1–6. A setext heading is 1 or 2. | +| `Title` | `[MarkdownInline[]]` | The heading text as inline nodes. Reached by `Descendants()` ahead of `Children`. | +| `Style` | `[MarkdownHeadingStyle]` | *style.* `Atx`, `AtxClosed` (`## foo ##`), or `Setext`. | +| `Children` | `[MarkdownNode[]]` | The section's own blocks, then its nested sections, in document order. Empty collection for a leaf section. | +| `GetTitleText()` | `[string]` | The title as plain text, markup removed. | + +Nesting depth is *not* the level. `Level` stays the only source of truth for rendering, so a document that skips a level nests the deeper section directly under the shallower one and re-renders it unchanged. + +**`MarkdownParagraph : MarkdownBlock`** — [§4.8](https://spec.commonmark.org/0.31.2/#paragraphs) + +| Property | Type | Notes | +| --- | --- | --- | +| `Children` | `[MarkdownNode[]]` | Inline nodes. | + +**`MarkdownThematicBreak : MarkdownBlock`** — [§4.1](https://spec.commonmark.org/0.31.2/#thematic-breaks) + +| Property | Type | Notes | +| --- | --- | --- | +| `Marker` | `[MarkdownThematicBreakMarker]` | *style.* `Hyphen`, `Asterisk`, or `Underscore`. | +| `MarkerCount` | `[int]` | *style.* At least 3. | + +**`MarkdownIndentedCodeBlock : MarkdownBlock`** — [§4.4](https://spec.commonmark.org/0.31.2/#indented-code-blocks) + +| Property | Type | Notes | +| --- | --- | --- | +| `Literal` | `[string]` | Code content with the four-space indent removed. | + +**`MarkdownFencedCodeBlock : MarkdownBlock`** — [§4.5](https://spec.commonmark.org/0.31.2/#fenced-code-blocks) + +| Property | Type | Notes | +| --- | --- | --- | +| `InfoString` | `[string]` | The full info string as written. | +| `Language` | `[string]` | First word of the info string. Convenience, derived from `InfoString`. | +| `FenceCharacter` | `[MarkdownFenceCharacter]` | *style.* `Backtick` or `Tilde`. | +| `FenceLength` | `[int]` | *style.* At least 3, and long enough to contain the content. | +| `Literal` | `[string]` | Code content. | + +**`MarkdownHtmlBlock : MarkdownBlock`** — [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks) + +| Property | Type | Notes | +| --- | --- | --- | +| `Literal` | `[string]` | Raw HTML, verbatim. | +| `Kind` | `[int]` | 1–7, the block type from the specification. Determines the termination condition on re-parse. | + +**`MarkdownLinkReferenceDefinition : MarkdownBlock`** — [§4.7](https://spec.commonmark.org/0.31.2/#link-reference-definitions) + +| Property | Type | Notes | +| --- | --- | --- | +| `Label` | `[string]` | As written. | +| `NormalizedLabel` | `[string]` | Case-folded and whitespace-collapsed per the matching rules, used for resolution. | +| `Destination` | `[string]` | | +| `Title` | `[string]` | | + +**`MarkdownBlockQuote : MarkdownBlock`** — [§5.1](https://spec.commonmark.org/0.31.2/#block-quotes) + +| Property | Type | Notes | +| --- | --- | --- | +| `Children` | `[MarkdownNode[]]` | Block nodes. | + +**`MarkdownList : MarkdownBlock`** — [§5.3](https://spec.commonmark.org/0.31.2/#lists) + +| Property | Type | Notes | +| --- | --- | --- | +| `Kind` | `[MarkdownListKind]` | `Bullet` or `Ordered`. | +| `Start` | `[int]` | Starting number for ordered lists. | +| `Marker` | `[MarkdownListMarker]` | *style.* `Hyphen`, `Asterisk`, `Plus` for bullet lists; `Period`, `Parenthesis` for ordered. | +| `IsTight` | `[bool]` | Tight lists render without blank lines between items. Semantic, not stylistic — the specification derives it from the source. | +| `Children` | `[MarkdownNode[]]` | `MarkdownListItem` nodes. | + +**`MarkdownListItem : MarkdownBlock`** — [§5.2](https://spec.commonmark.org/0.31.2/#list-items) + +| Property | Type | Notes | +| --- | --- | --- | +| `Children` | `[MarkdownNode[]]` | Block nodes. | + +### Inlines + +**`MarkdownText : MarkdownInline`** — [§6.9](https://spec.commonmark.org/0.31.2/#textual-content) + +| Property | Type | Notes | +| --- | --- | --- | +| `Literal` | `[string]` | The resolved characters, with [backslash escapes](https://spec.commonmark.org/0.31.2/#backslash-escapes) and [entity references](https://spec.commonmark.org/0.31.2/#entity-and-numeric-character-references) decoded. This is what `GetText()` returns. | +| `Raw` | `[string]` | *style.* The original spelling, so `&` re-renders as `&` rather than being re-escaped from scratch. | + +**`MarkdownCodeSpan : MarkdownInline`** — [§6.1](https://spec.commonmark.org/0.31.2/#code-spans) + +| Property | Type | Notes | +| --- | --- | --- | +| `Literal` | `[string]` | Code content. | +| `BacktickCount` | `[int]` | *style.* Must exceed the longest backtick run in the content. | + +**`MarkdownEmphasis : MarkdownInline`** and **`MarkdownStrongEmphasis : MarkdownInline`** — [§6.2](https://spec.commonmark.org/0.31.2/#emphasis-and-strong-emphasis) + +| Property | Type | Notes | +| --- | --- | --- | +| `Marker` | `[MarkdownEmphasisMarker]` | *style.* `Asterisk` or `Underscore`. | +| `Children` | `[MarkdownNode[]]` | Inline nodes. | + +**`MarkdownLink : MarkdownInline`** — [§6.3](https://spec.commonmark.org/0.31.2/#links) + +| Property | Type | Notes | +| --- | --- | --- | +| `Destination` | `[string]` | | +| `Title` | `[string]` | `$null` when absent. | +| `TitleDelimiter` | `[MarkdownTitleDelimiter]` | *style.* `DoubleQuote`, `SingleQuote`, or `Parenthesis`. | +| `DestinationInAngleBrackets` | `[bool]` | *style.* The `<...>` form. | +| `Label` | `[string]` | Reference label, `$null` for inline links. | +| `ReferenceKind` | `[MarkdownLinkReferenceKind]` | `Inline`, `Full`, `Collapsed`, or `Shortcut`. | +| `Children` | `[MarkdownNode[]]` | The link text, as inline nodes. | + +**`MarkdownImage : MarkdownInline`** — [§6.4](https://spec.commonmark.org/0.31.2/#images) — identical to `MarkdownLink`, with `Children` holding the alt text. + +**`MarkdownAutolink : MarkdownInline`** — [§6.5](https://spec.commonmark.org/0.31.2/#autolinks) + +| Property | Type | Notes | +| --- | --- | --- | +| `Destination` | `[string]` | | +| `Kind` | `[MarkdownAutolinkKind]` | `Uri` or `Email`. | + +**`MarkdownRawHtml : MarkdownInline`** — [§6.6](https://spec.commonmark.org/0.31.2/#raw-html) + +| Property | Type | Notes | +| --- | --- | --- | +| `Literal` | `[string]` | The tag, verbatim. | + +**`MarkdownHardLineBreak : MarkdownInline`** — [§6.7](https://spec.commonmark.org/0.31.2/#hard-line-breaks) + +| Property | Type | Notes | +| --- | --- | --- | +| `Marker` | `[MarkdownLineBreakMarker]` | *style.* `Backslash` or `Spaces`. | + +**`MarkdownSoftLineBreak : MarkdownInline`** — [§6.8](https://spec.commonmark.org/0.31.2/#soft-line-breaks) — no properties beyond the shared members. + +### Enums + +| Enum | Values | +| --- | --- | +| `MarkdownHeadingStyle` | `Atx`, `AtxClosed`, `Setext` | +| `MarkdownThematicBreakMarker` | `Hyphen`, `Asterisk`, `Underscore` | +| `MarkdownFenceCharacter` | `Backtick`, `Tilde` | +| `MarkdownListKind` | `Bullet`, `Ordered` | +| `MarkdownListMarker` | `Hyphen`, `Asterisk`, `Plus`, `Period`, `Parenthesis` | +| `MarkdownEmphasisMarker` | `Asterisk`, `Underscore` | +| `MarkdownLinkReferenceKind` | `Inline`, `Full`, `Collapsed`, `Shortcut` | +| `MarkdownTitleDelimiter` | `DoubleQuote`, `SingleQuote`, `Parenthesis` | +| `MarkdownAutolinkKind` | `Uri`, `Email` | +| `MarkdownLineBreakMarker` | `Backslash`, `Spaces` | +| `MarkdownFrontMatterFormat` | `Yaml` | + +Enums rather than validated strings, so invalid states are unrepresentable, tab completion works on assignment, and serialized output carries readable names. `MarkdownHeadingStyle` is a section property, because the section is what carries the heading. + +### Constructs that are deliberately not nodes + +| Construct | Specification | Why not | +| --- | --- | --- | +| Headings | [§4.2](https://spec.commonmark.org/0.31.2/#atx-headings), [§4.3](https://spec.commonmark.org/0.31.2/#setext-headings) | A heading and the section it opens are one thing. Level, title, and style are section properties, so a separate node would be one nothing in the model could hold. | +| Blank lines | [§4.9](https://spec.commonmark.org/0.31.2/#blank-lines) | Separators, not content. They determine block boundaries and list tightness, both of which are captured on the surrounding nodes. | +| Backslash escapes | [§2.4](https://spec.commonmark.org/0.31.2/#backslash-escapes) | Resolve into `MarkdownText.Literal`, with the source form kept in `Raw`. | +| Entity and numeric references | [§2.5](https://spec.commonmark.org/0.31.2/#entity-and-numeric-character-references) | Same. | +| Frontmatter | — | Not Markdown. A property on the document, never a child node. | + ### 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. @@ -153,7 +490,7 @@ A section emits its heading line, reconstructed from `Level`, `Title`, and `Styl ## 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. +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. A `Parent` property would create cycles that break `ConvertTo-Json`, `Format-List`, and cloning, and it would make moving a subtree between documents error-prone. The parent context a parser needs lives on the parser's own stack, and a consumer that needs positional context uses `Descendants()`, which returns document order. 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. From 46c2bd8569444545f96aa6a87271b3d19fae65c8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:35:35 +0200 Subject: [PATCH 09/10] Record the two-comment-type decision in the design Comments occur at block and inline level, and a PowerShell class has a single base, so one type cannot derive from both MarkdownBlock and MarkdownInline. The model carries MarkdownCommentBlock and MarkdownComment, both reporting a Type of Comment so one query finds every comment. Records the rejected single type outside the block and inline split, and the rejected IsComment flag on the existing HTML nodes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index f9d9188..c602b82 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -48,7 +48,7 @@ flowchart TD BL --> BQ["MarkdownBlockQuote"] BL --> LS["MarkdownList"] BL --> PA["MarkdownParagraph"] - BL --> LFB["MarkdownThematicBreak
MarkdownIndentedCodeBlock
MarkdownFencedCodeBlock
MarkdownHtmlBlock
MarkdownLinkReferenceDefinition"] + BL --> LFB["MarkdownThematicBreak
MarkdownIndentedCodeBlock
MarkdownFencedCodeBlock
MarkdownHtmlBlock
MarkdownCommentBlock
MarkdownLinkReferenceDefinition"] SE --> BL BQ --> BL @@ -60,7 +60,7 @@ flowchart TD IL --> EM["MarkdownEmphasis
MarkdownStrongEmphasis"] IL --> LK["MarkdownLink
MarkdownImage"] - IL --> LFI["MarkdownText
MarkdownCodeSpan
MarkdownAutolink
MarkdownRawHtml
MarkdownHardLineBreak
MarkdownSoftLineBreak"] + IL --> LFI["MarkdownText
MarkdownCodeSpan
MarkdownAutolink
MarkdownRawHtml
MarkdownComment
MarkdownHardLineBreak
MarkdownSoftLineBreak"] EM --> IL LK --> IL @@ -134,6 +134,16 @@ Four things this makes concrete. The document holds one child, and everything un | `Header { Level, Title (string), Content }` — as prototyped in [PSModule/Markdown#18](https://github.com/PSModule/Markdown/pull/18) | The simplest containment shape, and proven to work across all three platforms. A string title discards inline markup in a heading, and the loss is unrecoverable once parsing has finished. | Rejected — lossy | | `Section { Level, Title (inlines), Style, Children }`, `Children` as the only storage | One type, direct access to level and title, and no fidelity loss. Grouping happens once, at parse time, in one place, and 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** | +### Comments as a node type + +A comment is the only way a Markdown document says something to a tool rather than to a reader, and it occurs at block level and at inline level alike. A PowerShell class has a single base, so one type cannot derive from both `MarkdownBlock` and `MarkdownInline`. The model therefore carries two — `MarkdownCommentBlock` and `MarkdownComment` — and both report a `Type` of `Comment`, so `Descendants('Comment')` finds every comment at either level. + +| Option | Trade-offs | Verdict | +| --- | --- | --- | +| One type deriving from `MarkdownNode` directly | One name for one concept. It sits outside the block and inline split the filtering idiom depends on, so `$_ -is [MarkdownBlock]` stops being a complete test of what a container holds. | Rejected — breaks the filtering idiom | +| No new type — an `IsComment` flag on `MarkdownHtmlBlock` and `MarkdownRawHtml` | The smallest surface, and nothing new to learn. The content stays raw text with its delimiters attached, so every caller still strips delimiters and matches text, which is most of the problem left unsolved. | Rejected — solves almost nothing | +| Two types, one block and one inline | Mirrors the `MarkdownHtmlBlock` and `MarkdownRawHtml` pair already in the inventory, so `$_ -is [MarkdownBlock]` keeps working, and a shared `Type` keeps one query sufficient. Costs two class names for one concept. | **Chosen** | + ## Architecture The schema below is the complete node inventory: every class, every property, and the specification section it derives from. Properties marked *style* exist only so the renderer can reproduce the source form; they carry no semantic content, and a consumer that does not render Markdown can ignore them. @@ -184,6 +194,7 @@ classDiagram MarkdownBlock <|-- MarkdownIndentedCodeBlock MarkdownBlock <|-- MarkdownFencedCodeBlock MarkdownBlock <|-- MarkdownHtmlBlock + MarkdownBlock <|-- MarkdownCommentBlock MarkdownBlock <|-- MarkdownLinkReferenceDefinition MarkdownBlock <|-- MarkdownBlockQuote MarkdownBlock <|-- MarkdownList @@ -197,6 +208,7 @@ classDiagram MarkdownInline <|-- MarkdownImage MarkdownInline <|-- MarkdownAutolink MarkdownInline <|-- MarkdownRawHtml + MarkdownInline <|-- MarkdownComment MarkdownInline <|-- MarkdownHardLineBreak MarkdownInline <|-- MarkdownSoftLineBreak @@ -318,6 +330,14 @@ Nesting depth is *not* the level. `Level` stays the only source of truth for ren | `Literal` | `[string]` | Raw HTML, verbatim. | | `Kind` | `[int]` | 1–7, the block type from the specification. Determines the termination condition on re-parse. | +**`MarkdownCommentBlock : MarkdownBlock`** — [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks). A block that is exactly a comment ([FR15](spec.md#fr15)). A block carrying a comment plus trailing content on the same line stays a `MarkdownHtmlBlock`. + +| Property | Type | Notes | +| --- | --- | --- | +| `Text` | `[string]` | The inner text, without the `` delimiters. Empty for the degenerate forms. | +| `Raw` | `[string]` | *style.* The comment exactly as written, so `` does not re-render as ``. | +| `IsTerminated` | `[bool]` | `$false` when the comment ran to the end of the document with no line containing `-->`. | + **`MarkdownLinkReferenceDefinition : MarkdownBlock`** — [§4.7](https://spec.commonmark.org/0.31.2/#link-reference-definitions) | Property | Type | Notes | @@ -399,6 +419,8 @@ Nesting depth is *not* the level. `Level` stays the only source of truth for ren | --- | --- | --- | | `Literal` | `[string]` | The tag, verbatim. | +**`MarkdownComment : MarkdownInline`** — [§6.6](https://spec.commonmark.org/0.31.2/#raw-html). A comment inside inline content, including the degenerate `` and `` forms. Carries `Text`, `Raw`, and `IsTerminated`, exactly as `MarkdownCommentBlock` does, and reports the same `Type` of `Comment`. + **`MarkdownHardLineBreak : MarkdownInline`** — [§6.7](https://spec.commonmark.org/0.31.2/#hard-line-breaks) | Property | Type | Notes | @@ -507,6 +529,7 @@ Validation is not performed in property setters. A node accepts a state it canno | 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 | | Unit | A section title holding emphasis, a code span, and a link: the inlines are reachable from `Descendants()`, `GetTitleText()` strips the markup, and rendering restores it | +| Unit | Comment recognition: a comment as its own block, a comment inside a paragraph and inside a section title, a comment with trailing content on the same line staying an HTML block, an unterminated comment, both degenerate forms, and comment-looking text inside a code span, a fenced block, and an indented block | | 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 | From c555633eea0addba1d7ddce9d7c63a656c832f97 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 13:45:16 +0200 Subject: [PATCH 10/10] Bound an unterminated comment by its container, not by the document CommonMark 4.6 ends an HTML block at the last line of the document OR the last line of the container block holding it, whichever comes first. FR17 stated only the first, which is wrong for a comment inside a block quote or a list item - content after the container would have been swallowed. Adds an acceptance scenario for the container case and updates the IsTerminated note. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/markdown-object-model/design.md | 4 ++-- docs/markdown-object-model/spec.md | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/markdown-object-model/design.md b/docs/markdown-object-model/design.md index c602b82..a8dafa0 100644 --- a/docs/markdown-object-model/design.md +++ b/docs/markdown-object-model/design.md @@ -330,13 +330,13 @@ Nesting depth is *not* the level. `Level` stays the only source of truth for ren | `Literal` | `[string]` | Raw HTML, verbatim. | | `Kind` | `[int]` | 1–7, the block type from the specification. Determines the termination condition on re-parse. | -**`MarkdownCommentBlock : MarkdownBlock`** — [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks). A block that is exactly a comment ([FR15](spec.md#fr15)). A block carrying a comment plus trailing content on the same line stays a `MarkdownHtmlBlock`. +**`MarkdownCommentBlock : MarkdownBlock`** — [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks). A block that is exactly a comment ([FR15](spec.md#fr15)). A block carrying a comment plus trailing content on the same line stays a `MarkdownHtmlBlock`. An unterminated comment ends with the container holding it, or with the document when nothing encloses it ([FR17](spec.md#fr17)). | Property | Type | Notes | | --- | --- | --- | | `Text` | `[string]` | The inner text, without the `` delimiters. Empty for the degenerate forms. | | `Raw` | `[string]` | *style.* The comment exactly as written, so `` does not re-render as ``. | -| `IsTerminated` | `[bool]` | `$false` when the comment ran to the end of the document with no line containing `-->`. | +| `IsTerminated` | `[bool]` | `$false` when no subsequent line contained `-->`, so the comment ended with its container rather than with a terminator. | **`MarkdownLinkReferenceDefinition : MarkdownBlock`** — [§4.7](https://spec.commonmark.org/0.31.2/#link-reference-definitions) diff --git a/docs/markdown-object-model/spec.md b/docs/markdown-object-model/spec.md index 2b461f8..9fc8db0 100644 --- a/docs/markdown-object-model/spec.md +++ b/docs/markdown-object-model/spec.md @@ -123,9 +123,9 @@ A block is a comment only when the block is exactly a comment. [CommonMark](http A comment MUST expose its inner text without the `` delimiters, so that reading a comment requires no string handling by the caller. It MUST also expose whether the comment was terminated in the source. -### FR17 — An unterminated comment runs to the end of the document {#fr17} +### FR17 — An unterminated comment ends with its container {#fr17} -Where no line containing `-->` follows, the comment MUST extend to the last line of the document, which is the end condition [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks) defines. The model MUST record the comment as unterminated rather than presenting it as closed. +Where no subsequent line contains `-->`, the comment MUST extend to the last line of the block container holding it, and to the last line of the document only when no container encloses it. [§4.6](https://spec.commonmark.org/0.31.2/#html-blocks) gives both endings: an HTML block ends at "the last line of the document, or the last line of the container block containing the current HTML block, if no line is encountered that meets the end condition". An unterminated comment inside a block quote or a list item therefore ends with that block quote or list item, and content following the container is unaffected. The model MUST record the comment as unterminated rather than presenting it as closed. ### FR18 — The degenerate comment forms are comments {#fr18} @@ -240,6 +240,14 @@ Feature: Comments are part of the model Then the comment extends to the last line of the document And it reports that it was not terminated + Scenario: An unterminated comment inside a container ends with that container + Given a block quote containing an opening comment delimiter and no terminator + And a paragraph following the block quote + When the document is parsed + Then the comment extends to the last line of the block quote + And it reports that it was not terminated + And the paragraph following the block quote is not part of the comment + Scenario: Comment-looking text in code stays code Given a fenced code block whose content looks like a comment When the document is parsed