Skip to content

📖 [Docs]: Markdown object model specified as a section tree - #33

Merged
Marius Storhaug (MariusStorhaug) merged 5 commits into
mainfrom
docs-32-markdown-object-model-spec
Aug 2, 2026
Merged

📖 [Docs]: Markdown object model specified as a section tree#33
Marius Storhaug (MariusStorhaug) merged 5 commits into
mainfrom
docs-32-markdown-object-model-spec

Conversation

@MariusStorhaug

@MariusStorhaug Marius Storhaug (MariusStorhaug) commented Aug 2, 2026

Copy link
Copy Markdown
Member

The Markdown module now has a written, normative contract for the object model a Markdown document parses into — and that model is organised the way a document actually reads: a document holds a tree of sections, and a section owns its heading, its own content, and the sections nested inside it, recursively. Nothing in the module behaves differently yet. This is documentation of the intended state, published before the code exists so that anyone who will depend on the model can read it, disagree with it, and shape it while that is still free.

New: The Markdown object model is documented as a capability

docs/markdown-object-model/ holds the capability in three documents side by side — index.md introduces it and shows the shape at a glance, spec.md states why the model exists and what it must do, and design.md explains how it is built. The spec is normative and testable: thirteen functional requirements and three non-functional ones, each individually addressable, acceptance criteria written as scenarios, and CommonMark conformance measured against the specification's own published example set rather than against hand-picked samples.

New: Sections are the unit the model is built around

The spec settles the questions that decide what working with a parsed document feels like. A section owns the content that follows its heading up to the next heading of the same or a lower level. A section with no subsections is the same type as one that has them, holding an empty collection — there is no separate leaf type to branch on. The document is the same kind of container as a section, minus a heading and plus the metadata part, so content before the first heading belongs to the document and the same code walks both. A heading inside a block quote or a list item sections that container and leaves the document's outline alone. A skipped heading level nests without inventing a section that is not in the document, and every heading re-renders at its original level.

$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'

New: What the model will not do is stated as plainly as what it will

Byte-exact round-tripping is a non-goal — the model preserves the stylistic choices a reader would notice and normalises the rest. Rendering to formats other than Markdown is a non-goal, because the model is plain data that any general-purpose serializer can already take somewhere else. Replacing the composition DSL is a non-goal: Set-Markdown* stays the way Markdown is written from nothing, and the object model is how existing Markdown is read and changed. The existing DSL keeps working unchanged, as a requirement rather than a promise.


Technical details
  • Documentation and one linter setting. No classes, parser, renderer, or tests are added, and no shipped file changes.
  • Implementation plan progress — completes both Specification tasks in the implementation plan of Model the markdown document as a nested section tree #32 (docs/markdown-object-model/spec.md and docs/markdown-object-model/index.md). design.md is added alongside them because the Documentation Model pairs a spec with a design in the same capability folder. The Model, Parser and renderer, Tests, and Documentation tasks in that plan remain open.
  • .github/linters/.markdown-lint.yml gains an MD025 entry with front_matter_title: "". markdownlint's default pattern counts a front matter title as a top-level heading, so a document could not carry both the title mkdocs navigation reads and the H1 GitHub renders. The setting is tightly coupled to these documents — the capability folder cannot exist without it — so it lands here rather than separately. The identical gap in the inherited baseline is tracked as Allow a body heading alongside the front matter title in the markdown linter config Template-PSModule#38.
  • The three documents were authored against Model the markdown document as a nested section tree #32 and changed only where the repository's own configuration in .github/linters/ required it: the requirement anchors were tightened from { #fr1 } to {#fr1}, which is the form MD051 recognises and Python-Markdown's attr_list still accepts, and markdown was capitalised in prose to match the Markdown terminology term in .textlintrc and the existing README. The Specification template these documents follow still shows the spaced form, tracked as Use unspaced requirement anchors in the specification template MSXOrg/docs#141, so these files are ahead of the standard rather than departing from it. Verified locally with markdownlint-cli2 and textlint run against those exact config files; both are clean.
  • A repository-root docs/ folder is not published to GitHub Pages today: the site is assembled from generated function help, src/en-US about topics, README.md, and the icon, and Structure-Site/src/main.ps1 copies nothing else. These pages are therefore read in the repository, and the {#fr1} anchors stay inert until mkdocs renders them. Tracked as Publish hand-authored documentation from a repository's docs folder Process-PSModule#437.
  • This repository was missing the NoRelease label entirely, even though .github/release.yml already excludes NoRelease from generated release notes — so that exclusion could never fire, and every documentation and maintenance pull request has been landing under "Other Changes". A sweep of the organization found 58 repositories referencing NoRelease in release.yml and this one as the only one without the label. The label now exists and is applied here alongside documentation, which makes both correct. The change also touches no important file pattern (^src/, ^README\.md$), so no release is produced either way.
Changed surface Standards checked Framework docs checked Result
docs/** (Markdown) .github/linters/.markdown-lint.yml, .github/linters/.textlintrc Documentation Model, Spec-Driven Development Aligned
.github/linters/** markdownlint rule configuration Repository Standard, Template-PSModule baseline Fixed in this PR — inherited baseline tracked as PSModule/Template-PSModule#38
Relevant issues (or links)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

No Significant Changes Detected

This PR does not contain changes to files that would trigger a new release:

Path Description
src/** Module source code
README.md Documentation

Build, test, and publish stages will be skipped for this PR.

If you believe this is incorrect, please verify that your changes are in the correct locations.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Super-linter summary

Language Validation result
CHECKOV Pass ✅
GITHUB_ACTIONS Pass ✅
GITLEAKS Pass ✅
GIT_MERGE_CONFLICT_MARKERS Pass ✅
MARKDOWN Pass ✅
NATURAL_LANGUAGE Pass ✅
POWERSHELL Pass ✅
PRE_COMMIT Pass ✅
SPELL_CODESPELL Pass ✅
TRIVY Pass ✅
YAML Pass ✅

All files and directories linted successfully

For more information, see the GitHub Actions workflow run

Powered by Super-linter

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-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

No Significant Changes Detected

This PR does not contain changes to files that would trigger a new release:

Path Description
src/** Module source code
README.md Documentation

Build, test, and publish stages will be skipped for this PR.

If you believe this is incorrect, please verify that your changes are in the correct locations.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Super-linter summary

Language Validation result
CHECKOV Pass ✅
GITHUB_ACTIONS Pass ✅
GITLEAKS Pass ✅
GIT_MERGE_CONFLICT_MARKERS Pass ✅
MARKDOWN Pass ✅
NATURAL_LANGUAGE Pass ✅
POWERSHELL Pass ✅
PRE_COMMIT Pass ✅
SPELL_CODESPELL Pass ✅
TRIVY Pass ✅
YAML Pass ✅

All files and directories linted successfully

For more information, see the GitHub Actions workflow run

Powered by Super-linter

@MariusStorhaug Marius Storhaug (MariusStorhaug) added the NoRelease Excluded from release notes; no version bump label Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

No Significant Changes Detected

This PR does not contain changes to files that would trigger a new release:

Path Description
src/** Module source code
README.md Documentation

Build, test, and publish stages will be skipped for this PR.

If you believe this is incorrect, please verify that your changes are in the correct locations.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Super-linter summary

Language Validation result
CHECKOV Pass ✅
GITHUB_ACTIONS Pass ✅
GITLEAKS Pass ✅
GIT_MERGE_CONFLICT_MARKERS Pass ✅
MARKDOWN Pass ✅
NATURAL_LANGUAGE Pass ✅
POWERSHELL Pass ✅
PRE_COMMIT Pass ✅
SPELL_CODESPELL Pass ✅
TRIVY Pass ✅
YAML Pass ✅

All files and directories linted successfully

For more information, see the GitHub Actions workflow run

Powered by Super-linter

@MariusStorhaug
Marius Storhaug (MariusStorhaug) marked this pull request as ready for review August 2, 2026 16:09
@MariusStorhaug
Marius Storhaug (MariusStorhaug) merged commit 3377a6c into main Aug 2, 2026
43 checks passed
@MariusStorhaug
Marius Storhaug (MariusStorhaug) deleted the docs-32-markdown-object-model-spec branch August 2, 2026 16:10
Marius Storhaug (MariusStorhaug) added a commit to PSModule/Template-PSModule that referenced this pull request Aug 9, 2026
…and a body heading (#39)

Markdown pages in repositories generated from this template can now
carry both a front matter `title:` and a body `# Heading` without the
linter objecting. MkDocs keeps using the front matter title for
navigation and the page title, and the body heading keeps rendering on
github.com, where front matter is shown as a table rather than a title.

## Fixed: A front matter title is no longer counted as a second
top-level heading

The markdown linter shipped with the template applied markdownlint's
default `front_matter_title` pattern, so a front matter `title:` was
treated as the document's top-level heading. Any page that also opened
with `# Heading` was reported as `MD025/single-title/single-h1 Multiple
top-level headings in the same document`, and the author was pushed
toward dropping one of the two — losing either correct site navigation
or a readable page on GitHub.

Nothing needs to change in your repository. Repositories generated from
the template inherit the fix, and existing repositories pick it up the
next time they take the template's linter configuration.

MD025 still does its job: a page with two body `# Heading` lines is
reported exactly as before.

---
<details>
<summary>Technical details</summary>

`.github/linters/.markdown-lint.yml` gains one block in the "Rules by
id" section, in numeric order between `MD024` and `MD026`:

```yaml
MD025:
  front_matter_title: '' # Allow a body H1 alongside the front matter title
```

- **Empty pattern rather than `MD025: false`.** `frontMatterHasTitle()`
in markdownlint treats a defined-but-falsy pattern as "ignore front
matter" while leaving the rest of the rule active, so a genuinely
duplicated body `# Heading` is still reported. Disabling the rule
outright would stop catching that too, which is a real defect rather
than a formatting preference.
- **Converges with MSXOrg/docs**, which carries `front_matter_title: ""`
with the same comment in its copy of this file. Single quotes are used
here to match the one existing quoted value in this file (`punctuation:
'.,;:!。,;:'`); `''` and `""` are the same empty YAML scalar, and
markdownlint sees an identical value either way.
- **No other rule was touched.** The wider drift from MSXOrg/docs —
`MD013` line length (808 vs 3000), `MD041`, `MD051`, `MD060` — is
visible but deliberately out of scope.
- `PSModule/Markdown` carries a byte-identical copy of this file and hit
this exact problem in PSModule/Markdown#33, where `title:` had to be
stripped from three documentation files to get a green lint run. Fixing
the template is what stops that repeating in every module repository.

**Verification.** Throwaway fixtures outside the repository, ephemeral
`npx`, nothing added to the repo. Run against both
`markdownlint-cli@0.45.0` (the CLI super-linter drives) and
`markdownlint-cli2@0.23.2` (markdownlint v0.41.1); the two agree on
every case.

| Case | Config | Result |
| --- | --- | --- |
| Front matter `title:` + body `# Heading` | before | `MD025` reported
at the body heading — reproduces the bug |
| Front matter `title:` + body `# Heading` | after | Clean, exit 0 |
| Two body `# Heading` lines | after | `MD025` still reported at the
second heading |
| The repository's own 8 Markdown files | after | Clean, exit 0 |

Implementation plan progress: both plan sections in
#38 — configuration and verification — are
complete. Nothing deferred.

| Changed surface | Standards checked | Framework docs checked | Result
|
| --- | --- | --- | --- |
| `.github/linters/**` (linter configuration) | MSXOrg Coding Standards
| Repository Defaults | Aligned |

Issue convergence sweep: the scope was every open issue in
`PSModule/Template-PSModule`. #38 is the only one, and it is the closing
issue for this pull request.

</details>

<details>
<summary>Relevant issues (or links)</summary>

- Fixes #38

</details>

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Marius Storhaug (MariusStorhaug) added a commit to MSXOrg/docs that referenced this pull request Aug 9, 2026
#160 moved the specification skeletons out to
Spec-Driven-Development-Templates.md and added a worked requirement
example to Spec-Driven-Development.md. Both were written with the
spaced brace form, which was still what the standard taught at the
time and which nothing in CI would have caught, since MD051 was
disabled.

These six anchors sit inside fenced code blocks, so neither markdownlint
nor Test-DocumentationLink.ps1 examines them and no check was failing.
They matter because they are the text an author copies: the skeleton is
the vector that carried the broken form into PSModule/Markdown#33 in
the first place. A template that disagrees with the rule the same
standard now enforces is the defect this branch exists to remove.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Marius Storhaug (MariusStorhaug) added a commit to MSXOrg/docs that referenced this pull request Aug 9, 2026
…own linter (#144)

Requirement anchors in specifications now use the one form that both the
documentation site and the Markdown linter understand, so a
specification written by copying the template out of [Spec-Driven
Development
Templates](https://msxorg.github.io/docs/Ways-of-Working/Spec-Driven-Development-Templates/)
lints clean with no hand-editing afterwards — in this repository and in
any repository that inherits the standard without inheriting this one's
linter configuration.

## Fixed: References to requirements no longer report as broken links

The Requirements section tells authors to give each requirement an
explicit anchor and to reference it as `[FR1](#fr1)`. Written with
spaces inside the braces, that anchor is invisible to
[markdownlint](https://github.com/DavidAnson/markdownlint) rule
[MD051](https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md):
the heading keeps its slugified anchor instead, and same-page references
to the identifier resolve to nothing. Following the page therefore
produced a document that failed the linter the ecosystem runs in CI.

Anchors are now written without the inner spaces:

```markdown
### FR1 — <what the capability does, behavioral, testable, no technology> {#fr1}
```

Nothing else about the form changes. The anchor is still the identifier
alone, still append-only, and still referenced as `[FR1](#fr1)` on the
same page and `[FR1](spec.md#fr1)` across pages. Both brace forms render
identically under [Python-Markdown's
`attr_list`](https://python-markdown.github.io/extensions/attr_list/),
which this site enables, so the published pages are byte-for-byte the
same as before.

The specification that still used the spaced form has been converted,
along with every skeleton in the templates page, so what an author
copies matches the rule the standard now enforces.

## Changed: A reference to a heading that does not exist now fails the
build

MD051 was switched off in this repository precisely because it could not
read the spaced anchors, with a script covering the gap instead. With
the anchors converted, the rule is on again and a link to a heading that
does not exist fails the lint job like any other error. Contributors get
that caught in CI rather than discovering it as a dead link on the
published site.

## Changed: The Markdown standard now documents the anchor form and the
rule that enforces it

The anchor syntax previously appeared only in Spec-Driven Development,
framed as a convention for specifications — which is how the broken form
came to be copied into pages and repositories that had nothing to do
with specifications. It is now written down in the [Markdown
standard](https://msxorg.github.io/docs/Coding-Standards/Markdown/) as a
rule for any heading on any page, together with the limitation worth
knowing: the linter checks same-file fragments only, so a clean lint run
does not prove that a cross-file `spec.md#fr1` link resolves. Custom
heading anchors are not defined by original Markdown, CommonMark, or
GFM; this site gets `{#id}` from its enabled Python-Markdown `attr_list`
extension, so the braces render literally in GitHub's file view but
become the heading ID on the published site.

---
<details>
<summary>Technical details</summary>

Six commits, each independently reviewable:

1. **`31d41cd` — the anchors.** Mechanical conversion of `{ #id }` to
`{#id}`, 16 occurrences over 2 files — the Spec-Driven-Development prose
that introduces the form (2, on one line) and `deployment/spec.md` (14,
FR1–FR10 and NFR1–NFR4). No rewording, no restructuring, no reflowing.
2. **`7b21fee` — the linter disable that the spaced form caused.**
`MD051: false` removed from `.github/linters/.markdown-lint.yml`. Gated,
not assumed: the line was removed first and `markdownlint-cli2` v0.23.2
(markdownlint v0.41.1) run with that real configuration over every
tracked Markdown file, reporting `0 issues`. Clean, so the removal
stands. Had it surfaced unrelated findings, the disable would have been
restored with a corrected comment and the findings tracked separately
rather than fixed in passing.
3. **`a2c9a0d` — the checker's own help.** The three `{ #id }` examples
in the comment-based help and parsing comment of
`.github/scripts/Test-DocumentationLink.ps1` now show `{#id}`. Examples
only; the matching expression is untouched. The colon-prefixed `{: #id
... }` in the same comment is left as it was — the expression genuinely
tolerates that variant, so converting it would have made the help wrong.
4. **`f5fa802` — the standard that should have carried the rule.**
`src/docs/Coding-Standards/Markdown.md` states that the shared
configuration is the source of truth and then enumerates the rules in
two tables; MD051 was in neither. It had been disabled in the config and
never recorded, so the page was already out of step before this branch.
Commit 2 made that omission live — the rule now fails the build for
every page, not just specifications — so the page gains an MD051 row in
**Enforced rules**, the anchor syntax as one bullet in **Style beyond
the linter**, and the same-file limitation stated once. Verified MD051
is not present in **Relaxed on purpose**; it was not, so nothing was
removed.
5. **`1a7c25c` — the anchors that arrived while this branch waited.**
[#160](#160) moved the
skeletons out to `Spec-Driven-Development-Templates.md` and added a
worked requirement example, both written in the spaced form — which was
still what the standard taught, and which nothing would have caught
while MD051 was disabled. Six anchors converted: 5 in the new templates
page, 1 in the new example. All six sit inside fenced code blocks, so
neither markdownlint nor `Test-DocumentationLink.ps1` examines them and
no check was failing; they matter because a skeleton is the text an
author copies, and that is the vector that carried the broken form into
PSModule/Markdown#33.

6. **`9a66332` — whose syntax this actually is.** The page opens by
saying documentation is authored in GitHub Flavored Markdown, and commit
4 then made `{#id}` an enforced rule — but GFM defines no attribute
syntax, and neither does CommonMark nor Gruber's original. The construct
belongs to individual flavors: PHP Markdown Extra and Python-Markdown,
kramdown's inline attribute lists, Pandoc. This site gets it from
`attr_list`. Verified against the renderers rather than their
documentation — GitHub's `POST /markdown` with `mode=gfm` returns
`<h3>FR1 {#fr1}</h3>`, and the same literal output for `{ #fr1 }` and
`{: #fr1 }`, while Python-Markdown 3.10.2 with `attr_list` turns all
three into `<h3 id="fr1">`. The rule now says so where it is stated, so
an author who sees braces in a repository file view knows that is
expected rather than a mistake, and knows `{#id}` wins as an
intersection of implementations rather than by any specification.
**Rebased twice while waiting for review**, each time onto current
`main` with no content from the incoming work reverted:

- [#158](#158) retired the
`process-psmodule` pages in favour of that capability's canonical site,
deleting a file this branch had edited. A modify/delete conflict,
resolved by keeping the deletion — the 11 anchors converted there went
with the page.
- [#160](#160) rewrote
Spec-Driven-Development and replaced its inline templates with a link to
the new templates page. A content conflict, resolved by taking `main`'s
prose in full; the template conversions this branch used to carry now
apply to the new page instead, in commit 5.

**How much MD051 was actually catching.** MD051 validates *same-file*
fragments only, and skips fenced code blocks entirely. Across the spaced
anchors on this branch, exactly 2 references were being reported —
`[FR8](#fr8)` and `[FR9](#fr9)`, both in `deployment/spec.md`.
Cross-file references of the `spec.md#fr1` form were never checked by
MD051 at all and depend on `Test-DocumentationLink.ps1`, which is why
that script remains the broader of the two checks and stays in CI. This
is the measurement that makes removing the disable safe: there were only
ever two findings to clear, and they are cleared. The reach of the
defect was in what the templates *taught* downstream repositories, not
in the volume of errors produced here.

**Why `{#id}` and not `{: #id }`.** `attr_list` accepts `{#fr1}`, `{
#fr1 }` and `{: #fr1 }`; markdownlint understands only the unspaced one,
so it is the single form that satisfies the renderer and CI at once.
`Test-DocumentationLink.ps1` matches with
`\{\s*:?\s*#([-\w]+)[^}]*\}\s*$`, where `\s*` permits zero spaces, so
the converted anchors resolve under the existing checker with no change
to it.

**Verification** after the second rebase, using only tooling the
repository already provides:

- `Test-DocumentationLink.ps1` — `All documentation links resolve (119
file(s) scanned)`, exit 0.
- `Update-DocumentationIndex.ps1 -Check` — exit 0, no diff.
- `markdownlint-cli2` with `.github/linters/.markdown-lint.yml`, MD051
now enabled, across all 128 tracked Markdown files — `0 issues`.
- `Invoke-PesterSuite.ps1` — 54 passed, 0 failed, across 4 suites.

**Implementation plan progress** — all five steps of #141 are
delivered here: the documentation conversions, the linter-configuration
removal with its gate, and the comment-based help examples. The plan's
`process-psmodule/spec.md` step is satisfied by that file's removal in
#158. Commits 4 and 5 are beyond the issue's plan and close
drift the plan itself would otherwise have left behind. Nothing is
deferred to a follow-up.

**Issue convergence sweep** — scope was every open issue in
`MSXOrg/docs`. #143 (naming the downstream artifacts a
standard governs, so changing it has a known blast radius) is the
closest match, and commit 4 is an instance of exactly that concern
rather than a resolution of it — the general mechanism it asks for is
not delivered here, so it takes no closing keyword. #142
(cross-repository links the Markdown standard tells authors to write)
and #105 (co-locating Gherkin acceptance tests with the
FR/NFR they verify) concern different surfaces. No additional issue is
fully satisfied.

| Changed surface | Standards checked | Framework docs checked | Result
|
| --- | --- | --- | --- |
| `src/docs/**` (Markdown) | Markdown | Documentation Model, Spec-Driven
Development | Aligned |
| `.github/linters/**` (linter configuration) | Markdown | Repository
Standard | Fixed in this PR — the configuration and the standard
documenting it now agree |
| `.github/scripts/**` (PowerShell) | PowerShell — Scripts,
Documentation | Repository Standard | Aligned |

</details>

<details>
<summary>Relevant issues (or links)</summary>

- Resolves #141

### Related work

- References #143
- References #158
- References #160
- References PSModule/Markdown#33

</details>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Documentation changes NoRelease Excluded from release notes; no version bump

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant