Skip to content

feat(markdown): pipeline — frontmatter, razor:preview, component tags, headings - #3

Merged
Shewart merged 3 commits into
mainfrom
feat/markdown-pipeline
Jul 10, 2026
Merged

feat(markdown): pipeline — frontmatter, razor:preview, component tags, headings#3
Shewart merged 3 commits into
mainfrom
feat/markdown-pipeline

Conversation

@Shewart

@Shewart Shewart commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Ships the render layer that every doc primitive downstream will consume. Takes raw markdown → RenderedDocument { Html, Slots, Source, Headings }. The Slots list is what ShellDocs.Components will feed into <DynamicComponent> in the next branch.

What ships to ShellDocs.Markdown

File Public API
TypeRegistry.cs Fluent tag→Type resolver — Register<T>(), Register(name, type), Resolve(tagName), IsRegistered, All. Chainable.
RenderedDocument.cs record RenderedDocument(Html, Slots, Source, Headings). Two slot kinds: ComponentSlot(Id, Type, Parameters, ChildContentRaw?) and PreviewSlot(Id, Type, Parameters, Code, Language).
MarkdownPipelineFactory.cs Configured Markdig pipeline — pipe/grid tables, auto-identifiers, task lists, emphasis extras, footnotes, media links, softline→hardline.
MarkdownRenderer.cs Public entry — Render(markdown), RenderFile(path). Also exposes LastWarnings for build-time diagnostics.
SlotExtractor.cs (internal) Text-level pre-processor that masks fences, replaces razor:preview fences + component tags with placeholder markers, sorts slots by document order.
HeadingExtractor.cs (internal) Walks Markdig AST for HeadingBlock, emits Heading list with GitHub-style slug IDs, disambiguates duplicates (setup, setup-2, setup-3).

Two markdown extensions consumers get

1. razor:preview fenced blocks — a fenced code block with the info string razor:preview gets pulled out as a PreviewSlot carrying the parsed root tag's Type, its attribute dictionary, and the raw code (for the "Code" tab). The wrapper component in ShellDocs.Components will render this as <DocsTabs> with Preview + Code panes.

2. Inline component tags — both self-closing (<Button Variant="Default" />) and body-carrying (<Callout Type="Info">Body text.</Callout>) work. Only PascalCase tag names are considered components (<Callout> yes, <em> no — standard HTML passes through untouched). Unknown component tags emit a build-time warning and pass through as raw markup, so authors immediately notice typos.

Architectural decision — text preprocessing over AST mutation

First attempt threaded custom extensions through Markdig's pipeline to rewrite FencedCodeBlock and HtmlBlock in-place. Two problems killed it:

  1. HtmlBlock.Lines requires explicit StringLineGroup init. The struct default is unusable; the setter chokes on unparented replacements.
  2. <Callout>Body</Callout> on a single line isn't emitted as HtmlBlock at all. Markdig treats non-whitelist tags as inline HTML inside a paragraph — the AST shape depends on whether tags happen to be on their own line vs. inline. Block-level slot extraction would only fire for a fraction of the ways users actually write component tags.

Rewrote as pure text preprocessing:

  1. Mask all code fences with unique tokens (razor:preview fences → PreviewSlot + placeholder in-line; other fences → mask token restored verbatim at the end)
  2. Scan the remaining text for <Component /> and <Component>Body</Component> with nesting-aware regex (depth tracking on same-name opens/closes)
  3. Restore the fence masks
  4. Sort slots by placeholder position in the final text so Slots list reflects document order

Result: predictable, testable, code fences respected (component tags inside a plain code fence stay as code), and both self-closing and body-carrying tags work uniformly.

Design choices worth calling out

Only PascalCase tag names are treated as components. <em>, <strong>, <div> pass through as HTML. Anything starting with A-Z gets registry-checked. Standard convention from React/Blazor, unambiguous for authors.

Unknown tags don't crash — they warn and passthrough. Rationale: doc authors iterate fast and will typo constantly. A hard crash breaks the write→see loop. LastWarnings on the renderer surfaces these for build-time flagging (shelldocs build will fail loud on any warning in feat/cli-dev-build).

Body content is captured as raw string, not re-parsed markdown. For v1, <Callout>**bold text**</Callout> gives ChildContentRaw = "**bold text**" verbatim. The wrapper in ShellDocs.Components decides whether to re-render that through the pipeline as a nested markdown fragment or emit as markup. Keeps the pipeline linear.

Attribute parsing is strict — only double-quoted strings. <Button Variant="Default" /> yes, <Button Variant={variable} /> no. Blazor's parameter binding coerces the strings to their target types at render time via reflection; the pipeline doesn't need to know types. Interpolated expressions can come in v2 if there's demand.

Nesting is depth-tracked by tag name. <Callout><Callout>...</Callout></Callout> correctly matches the outer close to the outer open. Regex alone can't do this, so FindMatchingClose walks with a depth counter.

Slot IDs are short base16 GUIDs (13 chars — s prefix + 12 hex chars of a Guid.NewGuid().ToString("N")). Not full 32-char GUIDs — the placeholder markers embed these and shorter is easier on the eyes when debugging rendered HTML.

Test coverage — 20 new / 44 total, all green

Test class Tests Coverage
TypeRegistryTests 4 Generic register uses type name, explicit tag name wins, unknown lookup returns null, fluent chaining
HeadingExtractorTests 5 Level + text extraction, GitHub-style slugification, duplicate disambiguation, inline formatting stripped from heading text (** and backticks removed), empty doc
MarkdownRendererTests 11 Standard markdown → HTML, frontmatter round-trip, self-closing inline tag → ComponentSlot, block tag with body captures ChildContentRaw, unknown tag passthrough + warning, razor:preview fence → PreviewSlot, multi-slot document-order stability, unregistered preview tag warns, plain HTML (<em>, <strong>) passthrough, headings populated, empty document returns empty shape
ScaffoldingTests 2 Retained for pipeline + template smoke
NavigationGraphBuilderTests + friends 22 All still green from previous branch

Verified

  • dotnet build shelldocs.slnx → 0 warnings, 0 errors
  • dotnet test shelldocs.slnx → 44/44, ~280ms

What this unblocks

  • feat/components-shellMarkdownContent now has a concrete RenderedDocument shape to walk. Slots feed <DynamicComponent> with Type + Parameters dictionaries. DocsSidebar still uses the nav graph from the previous branch; MarkdownContent fills in the page-body render.
  • feat/toc-primitive (Phase 2) — the Headings list on RenderedDocument is what TableOfContents will render + IntersectionObserver-track.
  • feat/search-primitives (Phase 2) — the indexer walks the nav graph, calls MarkdownRenderer.Render on each .md, and pulls Headings + first-N-chars of Html per heading for the excerpt.

Files

  • 6 new .cs files in src/ShellDocs.Markdown/ (~450 LOC total)
  • 3 new test files in tests/ShellDocs.Tests/

Shewart added 3 commits July 10, 2026 18:58
…uration. Remove outdated comments and add new extensions for improved Markdown processing.
… for enhanced Markdown processing. Implement TypeRegistry for component management and RenderedDocument for structured output. Add support for extracting headings and processing slots in Markdown documents.
…Registry to validate Markdown processing and component registration functionality.
Copilot AI review requested due to automatic review settings July 10, 2026 17:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Shewart
Shewart merged commit a12fc62 into main Jul 10, 2026
1 check passed
@Shewart
Shewart deleted the feat/markdown-pipeline branch July 17, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants