From 839bd5e082617a99b2561c90d2f54d5162c50981 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 10 Jul 2026 18:58:49 +0200 Subject: [PATCH 1/3] refactor: update CLI entry point and enhance Markdown pipeline configuration. Remove outdated comments and add new extensions for improved Markdown processing. --- src/ShellDocs.CLI/Program.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ShellDocs.CLI/Program.cs b/src/ShellDocs.CLI/Program.cs index 9883404..2a17c3d 100644 --- a/src/ShellDocs.CLI/Program.cs +++ b/src/ShellDocs.CLI/Program.cs @@ -3,11 +3,10 @@ namespace ShellDocs.CLI; -/* CLI entry point. Real command handlers land in feat/cli-init and feat/cli-dev-build. +/* CLI entry point. This scaffolding wires up the command tree so `shelldocs --help` produces the right shape. */ internal class Program { - // ANSI Shadow figlet — same font ShellUI uses so the family reads as one thing. private const string Logo = @" ███████╗██╗ ██╗███████╗██╗ ██╗ ██████╗ ██████╗ ██████╗███████╗ ██╔════╝██║ ██║██╔════╝██║ ██║ ██╔══██╗██╔═══██╗██╔════╝██╔════╝ From 8317107bb5c225291bec3ae1ac52bf9e7cd7951a Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 10 Jul 2026 18:59:25 +0200 Subject: [PATCH 2/3] feat: introduce HeadingExtractor, SlotExtractor, and MarkdownRenderer 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. --- src/ShellDocs.Markdown/HeadingExtractor.cs | 71 ++++++ .../MarkdownPipelineFactory.cs | 15 +- src/ShellDocs.Markdown/MarkdownRenderer.cs | 37 ++++ src/ShellDocs.Markdown/RenderedDocument.cs | 24 +++ src/ShellDocs.Markdown/SlotExtractor.cs | 204 ++++++++++++++++++ src/ShellDocs.Markdown/TypeRegistry.cs | 27 +++ 6 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 src/ShellDocs.Markdown/HeadingExtractor.cs create mode 100644 src/ShellDocs.Markdown/MarkdownRenderer.cs create mode 100644 src/ShellDocs.Markdown/RenderedDocument.cs create mode 100644 src/ShellDocs.Markdown/SlotExtractor.cs create mode 100644 src/ShellDocs.Markdown/TypeRegistry.cs diff --git a/src/ShellDocs.Markdown/HeadingExtractor.cs b/src/ShellDocs.Markdown/HeadingExtractor.cs new file mode 100644 index 0000000..f9b2f9c --- /dev/null +++ b/src/ShellDocs.Markdown/HeadingExtractor.cs @@ -0,0 +1,71 @@ +using System.Text; +using System.Text.RegularExpressions; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using ShellDocs.Core; + +namespace ShellDocs.Markdown; + +internal static class HeadingExtractor +{ + private static readonly Regex NonAlphaNumericRun = new("[^a-z0-9]+", RegexOptions.Compiled); + + public static IReadOnlyList Extract(MarkdownDocument document) + { + var used = new Dictionary(StringComparer.Ordinal); + var headings = new List(); + + foreach (var block in document.Descendants()) + { + var text = ExtractText(block.Inline).Trim(); + if (text.Length == 0) continue; + + var id = Slugify(text, used); + headings.Add(new Heading(block.Level, text, id)); + } + + return headings; + } + + private static string ExtractText(ContainerInline? inline) + { + if (inline is null) return ""; + var sb = new StringBuilder(); + Walk(inline, sb); + return sb.ToString(); + } + + private static void Walk(Inline inline, StringBuilder sb) + { + switch (inline) + { + case LiteralInline lit: + sb.Append(lit.Content.ToString()); + break; + case CodeInline code: + sb.Append(code.Content); + break; + case LineBreakInline: + sb.Append(' '); + break; + case ContainerInline container: + foreach (var child in container) Walk(child, sb); + break; + } + } + + private static string Slugify(string text, Dictionary used) + { + var lower = text.ToLowerInvariant(); + var slug = NonAlphaNumericRun.Replace(lower, "-").Trim('-'); + if (slug.Length == 0) slug = "section"; + + if (used.TryGetValue(slug, out var count)) + { + used[slug] = count + 1; + return $"{slug}-{count + 1}"; + } + used[slug] = 1; + return slug; + } +} diff --git a/src/ShellDocs.Markdown/MarkdownPipelineFactory.cs b/src/ShellDocs.Markdown/MarkdownPipelineFactory.cs index 997ee0b..c38ff41 100644 --- a/src/ShellDocs.Markdown/MarkdownPipelineFactory.cs +++ b/src/ShellDocs.Markdown/MarkdownPipelineFactory.cs @@ -2,14 +2,19 @@ namespace ShellDocs.Markdown; -/// Configures a Markdig pipeline with ShellDocs' custom extensions: -/// YAML frontmatter, razor:preview fenced blocks, inline Razor component tags. public static class MarkdownPipelineFactory { - // Real implementation lands in feat/markdown-pipeline. This stub gets us compiling. public static MarkdownPipeline Create() => new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .UseYamlFrontMatter() + .UsePipeTables() + .UseGridTables() + .UseAutoIdentifiers() + .UseAutoLinks() + .UseTaskLists() + .UseEmphasisExtras() + .UseFootnotes() + .UseDiagrams() + .UseMediaLinks() + .UseSoftlineBreakAsHardlineBreak() .Build(); } diff --git a/src/ShellDocs.Markdown/MarkdownRenderer.cs b/src/ShellDocs.Markdown/MarkdownRenderer.cs new file mode 100644 index 0000000..7a1dced --- /dev/null +++ b/src/ShellDocs.Markdown/MarkdownRenderer.cs @@ -0,0 +1,37 @@ +using Markdig; +using ShellDocs.Core; + +namespace ShellDocs.Markdown; + +public class MarkdownRenderer +{ + private readonly TypeRegistry _registry; + private readonly MarkdownPipeline _pipeline; + + public MarkdownRenderer() : this(new TypeRegistry(), MarkdownPipelineFactory.Create()) { } + public MarkdownRenderer(TypeRegistry registry) : this(registry, MarkdownPipelineFactory.Create()) { } + public MarkdownRenderer(TypeRegistry registry, MarkdownPipeline pipeline) + { + _registry = registry; + _pipeline = pipeline; + } + + public IReadOnlyList LastWarnings { get; private set; } = Array.Empty(); + + public RenderedDocument Render(string markdown) + { + var parsed = FrontmatterParser.Parse(markdown ?? ""); + + var extractor = new SlotExtractor(_registry); + var (processed, slots, warnings) = extractor.Process(parsed.Body); + LastWarnings = warnings; + + var doc = Markdig.Markdown.Parse(processed, _pipeline); + var headings = HeadingExtractor.Extract(doc); + var html = doc.ToHtml(_pipeline); + + return new RenderedDocument(html, slots, parsed, headings); + } + + public RenderedDocument RenderFile(string path) => Render(File.ReadAllText(path)); +} diff --git a/src/ShellDocs.Markdown/RenderedDocument.cs b/src/ShellDocs.Markdown/RenderedDocument.cs new file mode 100644 index 0000000..cbec426 --- /dev/null +++ b/src/ShellDocs.Markdown/RenderedDocument.cs @@ -0,0 +1,24 @@ +using ShellDocs.Core; + +namespace ShellDocs.Markdown; + +public record RenderedDocument( + string Html, + IReadOnlyList Slots, + ParsedDocument Source, + IReadOnlyList Headings); + +public abstract record Slot(string Id); + +public record ComponentSlot( + string Id, + Type ComponentType, + IReadOnlyDictionary Parameters, + string? ChildContentRaw) : Slot(Id); + +public record PreviewSlot( + string Id, + Type ComponentType, + IReadOnlyDictionary Parameters, + string Code, + string Language) : Slot(Id); diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs new file mode 100644 index 0000000..47b10a4 --- /dev/null +++ b/src/ShellDocs.Markdown/SlotExtractor.cs @@ -0,0 +1,204 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace ShellDocs.Markdown; + +internal class SlotExtractor +{ + private readonly TypeRegistry _registry; + + private static readonly Regex FenceBlock = new( + @"^(?[ \t]*)```(?[^\n\r]*)\r?\n(?[\s\S]*?)\r?\n\1```(?=\r?\n|$)", + RegexOptions.Multiline | RegexOptions.Compiled); + + private static readonly Regex OpeningTag = new( + @"<(?[A-Z][A-Za-z0-9]*)(?\s[^>]*?)?\s*(?/)?>", + RegexOptions.Compiled); + + private static readonly Regex ClosingTag = new( + @"[A-Z][A-Za-z0-9]*)\s*>", + RegexOptions.Compiled); + + private static readonly Regex Attribute = new( + @"(?[A-Za-z][A-Za-z0-9]*)\s*=\s*""(?[^""]*)""", + RegexOptions.Compiled); + + public SlotExtractor(TypeRegistry registry) + { + _registry = registry; + } + + public (string Processed, IReadOnlyList Slots, IReadOnlyList Warnings) Process(string markdown) + { + var slots = new List(); + var warnings = new List(); + + // Mask code fences so component-tag scanning skips them. + // razor:preview fences get replaced with a placeholder slot marker in-line; + // other fences get a mask token that's restored verbatim at the end. + var maskedFences = new Dictionary(); + var processed = FenceBlock.Replace(markdown, m => + { + var indent = m.Groups["indent"].Value; + var lang = m.Groups["lang"].Value.Trim(); + var body = m.Groups["body"].Value; + + if (lang == "razor:preview" || lang.StartsWith("razor:preview ")) + { + var preview = TryBuildPreviewSlot(body, warnings); + if (preview is not null) + { + slots.Add(preview); + return indent + PlaceholderHtml("preview", preview.Id); + } + } + + var maskId = NewMaskId(); + maskedFences[maskId] = m.Value; + return maskId; + }); + + processed = ReplaceComponentTags(processed, slots, warnings); + + foreach (var (id, original) in maskedFences) + { + processed = processed.Replace(id, original); + } + + // Sort slots by the position of their placeholder in the processed text + // so the returned list reflects document order. + var ordered = slots.OrderBy(s => processed.IndexOf(s.Id, StringComparison.Ordinal)).ToList(); + return (processed, ordered, warnings); + } + + private string ReplaceComponentTags(string text, List slots, List warnings) + { + var result = new StringBuilder(text.Length); + var cursor = 0; + + while (cursor < text.Length) + { + var open = OpeningTag.Match(text, cursor); + if (!open.Success) + { + result.Append(text, cursor, text.Length - cursor); + break; + } + + var name = open.Groups["name"].Value; + var isSelfClosing = open.Groups["self"].Success; + var registered = _registry.Resolve(name); + + if (registered is null) + { + warnings.Add($"Unknown component <{name}> — passed through as raw markup."); + result.Append(text, cursor, open.Index + open.Length - cursor); + cursor = open.Index + open.Length; + continue; + } + + result.Append(text, cursor, open.Index - cursor); + + var attrs = ParseAttributes(open.Groups["attrs"].Value); + string? childRaw = null; + int endIndex; + + if (isSelfClosing) + { + endIndex = open.Index + open.Length; + } + else + { + var (closeStart, closeEnd) = FindMatchingClose(text, name, open.Index + open.Length); + if (closeStart < 0) + { + warnings.Add($"Unclosed <{name}> — passed through as raw markup."); + result.Append(text, open.Index, open.Length); + cursor = open.Index + open.Length; + continue; + } + childRaw = text.Substring(open.Index + open.Length, closeStart - (open.Index + open.Length)).Trim(); + endIndex = closeEnd; + } + + var slot = new ComponentSlot(NewSlotId(), registered, attrs, childRaw); + slots.Add(slot); + result.Append(PlaceholderHtml("component", slot.Id)); + cursor = endIndex; + } + + return result.ToString(); + } + + private PreviewSlot? TryBuildPreviewSlot(string code, List warnings) + { + var open = OpeningTag.Match(code); + if (!open.Success) + { + warnings.Add("razor:preview fence must start with a component tag."); + return null; + } + + var name = open.Groups["name"].Value; + var type = _registry.Resolve(name); + if (type is null) + { + warnings.Add($"razor:preview references unknown component <{name}>."); + return null; + } + + var attrs = ParseAttributes(open.Groups["attrs"].Value); + return new PreviewSlot(NewSlotId(), type, attrs, code, "razor"); + } + + private static (int Start, int End) FindMatchingClose(string text, string name, int fromIndex) + { + var depth = 1; + var searchFrom = fromIndex; + while (depth > 0) + { + var open = FindNextTag(OpeningTag, text, name, searchFrom); + var close = FindNextTag(ClosingTag, text, name, searchFrom); + + if (close is null) return (-1, -1); + + if (open is not null && open.Index < close.Index) + { + if (!open.Groups["self"].Success) depth++; + searchFrom = open.Index + open.Length; + } + else + { + depth--; + if (depth == 0) return (close.Index, close.Index + close.Length); + searchFrom = close.Index + close.Length; + } + } + return (-1, -1); + } + + private static Match? FindNextTag(Regex regex, string text, string name, int fromIndex) + { + foreach (Match m in regex.Matches(text, fromIndex)) + { + if (m.Groups["name"].Value == name) return m; + } + return null; + } + + private static IReadOnlyDictionary ParseAttributes(string attrsText) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (Match m in Attribute.Matches(attrsText ?? "")) + { + result[m.Groups["name"].Value] = m.Groups["value"].Value; + } + return result; + } + + private static string PlaceholderHtml(string kind, string id) => + $"
"; + + private static string NewSlotId() => "s" + Guid.NewGuid().ToString("N")[..12]; + private static string NewMaskId() => "SHELLDOCS_MASK_" + Guid.NewGuid().ToString("N")[..12]; +} diff --git a/src/ShellDocs.Markdown/TypeRegistry.cs b/src/ShellDocs.Markdown/TypeRegistry.cs new file mode 100644 index 0000000..e6ba85e --- /dev/null +++ b/src/ShellDocs.Markdown/TypeRegistry.cs @@ -0,0 +1,27 @@ +namespace ShellDocs.Markdown; + +public class TypeRegistry +{ + private readonly Dictionary _byName = new(StringComparer.Ordinal); + + public TypeRegistry Register() where T : class => Register(typeof(T)); + + public TypeRegistry Register(Type type) + { + _byName[type.Name] = type; + return this; + } + + public TypeRegistry Register(string tagName, Type type) + { + _byName[tagName] = type; + return this; + } + + public Type? Resolve(string tagName) + => _byName.TryGetValue(tagName, out var t) ? t : null; + + public bool IsRegistered(string tagName) => _byName.ContainsKey(tagName); + + public IReadOnlyDictionary All => _byName; +} From 0760f6695b12bb92d5c7c5bb5fec82e021543899 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Fri, 10 Jul 2026 18:59:37 +0200 Subject: [PATCH 3/3] test: add unit tests for HeadingExtractor, MarkdownRenderer, and TypeRegistry to validate Markdown processing and component registration functionality. --- .../ShellDocs.Tests/HeadingExtractorTests.cs | 54 +++++++ .../ShellDocs.Tests/MarkdownRendererTests.cs | 151 ++++++++++++++++++ tests/ShellDocs.Tests/TypeRegistryTests.cs | 42 +++++ 3 files changed, 247 insertions(+) create mode 100644 tests/ShellDocs.Tests/HeadingExtractorTests.cs create mode 100644 tests/ShellDocs.Tests/MarkdownRendererTests.cs create mode 100644 tests/ShellDocs.Tests/TypeRegistryTests.cs diff --git a/tests/ShellDocs.Tests/HeadingExtractorTests.cs b/tests/ShellDocs.Tests/HeadingExtractorTests.cs new file mode 100644 index 0000000..4b26563 --- /dev/null +++ b/tests/ShellDocs.Tests/HeadingExtractorTests.cs @@ -0,0 +1,54 @@ +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +public class HeadingExtractorTests +{ + private static IReadOnlyList Extract(string md) => + new MarkdownRenderer().Render(md).Headings; + + [Fact] + public void Extract_ReadsLevelAndText() + { + var headings = Extract("# One\n## Two\n### Three"); + Assert.Equal(3, headings.Count); + Assert.Equal(1, headings[0].Level); + Assert.Equal("One", headings[0].Text); + Assert.Equal(2, headings[1].Level); + Assert.Equal(3, headings[2].Level); + } + + [Fact] + public void Extract_SlugifiesText() + { + var headings = Extract("## Getting Started\n\n## API Reference\n\n### Prop `Value`"); + Assert.Equal("getting-started", headings[0].Id); + Assert.Equal("api-reference", headings[1].Id); + Assert.Equal("prop-value", headings[2].Id); + } + + [Fact] + public void Extract_DisambiguatesDuplicateSlugs() + { + var headings = Extract("## Setup\n\n## Setup\n\n## Setup"); + Assert.Equal("setup", headings[0].Id); + Assert.Equal("setup-2", headings[1].Id); + Assert.Equal("setup-3", headings[2].Id); + } + + [Fact] + public void Extract_HandlesInlineFormattingInHeading() + { + var headings = Extract("## Use **`Button`** with care"); + Assert.Equal("Use Button with care", headings[0].Text); + Assert.Equal("use-button-with-care", headings[0].Id); + } + + [Fact] + public void Extract_EmptyDocument_ReturnsEmpty() + { + var headings = Extract("Just a paragraph."); + Assert.Empty(headings); + } +} diff --git a/tests/ShellDocs.Tests/MarkdownRendererTests.cs b/tests/ShellDocs.Tests/MarkdownRendererTests.cs new file mode 100644 index 0000000..12762d9 --- /dev/null +++ b/tests/ShellDocs.Tests/MarkdownRendererTests.cs @@ -0,0 +1,151 @@ +using ShellDocs.Core; +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +public class MarkdownRendererTests +{ + public class Callout { } + public class Button { } + + private static MarkdownRenderer WithComponents() + { + var reg = new TypeRegistry() + .Register() + .Register\n```\n\nMore."; + var renderer = WithComponents(); + var doc = renderer.Render(md); + + var preview = Assert.IsType(Assert.Single(doc.Slots)); + Assert.Equal(typeof(Button), preview.ComponentType); + Assert.Equal("Default", preview.Parameters["Variant"]); + Assert.Contains(" + + Some paragraph. + + ```razor:preview + Preview body + ``` + +