diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ca380..0f23da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +## [0.1.12-alpha] — 2026-10-04 + +Parser fixes found while rewriting the ShellDocs docs site on 0.1.11: fences and component tags are now read the way CommonMark and Razor read them, so a page that shows its own markdown source no longer breaks what follows it. + +### Added + +- **Tilde fences.** `~~~` fenced blocks are recognised like backtick ones: tags inside them stay literal, and `~~~razor:preview` renders a preview. + +### Fixed + +- **A fence longer than three backticks that showed a shorter fence broke the rest of the page.** The slot extractor only knew three-backtick fences, so a four-backtick block containing a three-backtick fence was paired with the wrong closing line: the next `razor:preview` rendered as plain code, and component tags inside the example turned into empty placeholders. Fences now follow CommonMark: three or more backticks or tildes, closed by a run of the same character at least as long. An empty fence no longer pairs with a later one, and a closing fence may be longer than the opening one or have trailing spaces. +- **A `>` inside a quoted attribute value ended an inline component tag early**, so the component (a ``, for example) silently disappeared. Inline tags are now read by the same scanner as `razor:preview`, so quoted values can hold `>`, another tag, or text that looks like an attribute (`Description='sets href="/"'`). +- **The same `>` problem in named-slot tags.** A child tag routed into a `RenderFragment` parameter (``) was cut at the first `>`, leaking the rest of the tag into the slot. Named-slot tags use the shared scanner too. +- **Code spans in an attribute value could split the value** when the span contained a quote. Spans are restored per value after the tag is parsed. +- **`` dropped rows that shared a `Name`** (overloads, or two rows describing one thing). Rows are now tracked by component instead of by name. + +### Changed + +- An inline tag with an unterminated quote is passed through as raw markup instead of being read up to the first `>`. A tag can't span a blank line. + ## [0.1.11-alpha] — 2026-10-03 Polish from moving the ShellUI docs onto 0.1.10: the sidebar no longer repeats a folder's index page, and previews and tables behave on phones. @@ -360,7 +380,8 @@ Published to NuGet: - `` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4) - No `` opt-out — currently hides when the trail has ≤ 1 node, otherwise always renders -[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.11-alpha...HEAD +[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.12-alpha...HEAD +[0.1.12-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.12-alpha [0.1.11-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.11-alpha [0.1.10-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.10-alpha [0.1.9-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.9-alpha diff --git a/Directory.Build.props b/Directory.Build.props index 887c6ad..5c9c478 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ - 0.1.11-alpha + 0.1.12-alpha ShellUI ShellUI Copyright © 2026 ShellUI diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 81eed01..7342ffe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -209,7 +209,7 @@ Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `She **Authoring fix (`ShellDocs.Markdown`)** - `SlotExtractor.ReplaceComponentTags` no longer `.Trim()`s the raw child content of inline component tags. The Trim was stripping the first line's indent and defeating `SlotRenderer.Dedent` — Markdig then interpreted the remaining 4-space-indented lines as an indented code block. Symptom was the same "literal `
` around placeholder divs" bug that had already been fixed for `razor:preview` fences; the inline-tag code path was still hitting it.
 
-### ✅ Hardening releases (`0.1.3`–`0.1.11-alpha`) — shipped
+### ✅ Hardening releases (`0.1.3`–`0.1.12-alpha`) — shipped
 Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHANGELOG):
 
 - `feat/build-static-prerender` (0.1.5) — `shelldocs build` prerenders every URL into a static site.
@@ -218,6 +218,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
 - `feat/versioned-docs` (0.1.8) — `AddVersion`, ``, version-scoped chrome, multi-sibling `razor:preview`, Razor-form attribute values, ``, ShellIcons.
 - `feat/preview-toolbar` (0.1.9) — Preview | Code toolbar with ⋯ menu, ``, static-host `` and mobile nav, consumer components winning name collisions, code-span masking, highlighting and hydration fixes.
 - `fix/sidebar-index-pages` (0.1.11) — index pages as sidebar section links, `scroll` preview layout and phone-width previews, scrolling tables.
+- `fix/fence-scanner-inline-attrs` (0.1.12) — CommonMark fences (any length, tildes), inline and named-slot tags read by the Razor tag scanner so `>` in a quoted value is safe, `` rows that share a name.
 - `fix/preview-fidelity`, `feat/generics-redirects-page-title` (0.1.10) — `not-prose` previews, Razor child content, stretch layout, generic components in markdown, redirects (middleware + static pages), `RenderPageTitle`, theme sync and a static-host `ThemeToggle`.
 
 ### `feat/animation-polish`
diff --git a/src/ShellDocs.Components/Content/SlotRenderer.cs b/src/ShellDocs.Components/Content/SlotRenderer.cs
index 0073de0..cdc25f1 100644
--- a/src/ShellDocs.Components/Content/SlotRenderer.cs
+++ b/src/ShellDocs.Components/Content/SlotRenderer.cs
@@ -1,7 +1,6 @@
 using System.Globalization;
 using System.Reflection;
 using System.Text;
-using System.Text.RegularExpressions;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Rendering;
 using Microsoft.Extensions.Logging;
@@ -230,46 +229,19 @@ RenderFragment Fragment(string raw) => razorChildren
 
     // Removes the first balanced `…` (or ``) from
     // `text`, returning its inner content and the remaining text.
-    private static (string? Content, string Remaining) ExtractNamedSlot(string text, string tagName)
+    // Tags are read with RazorTagScanner, so a '>' inside a quoted attribute value doesn't end one.
+    internal static (string? Content, string Remaining) ExtractNamedSlot(string text, string tagName)
     {
-        var open = Regex.Match(text, $@"<{Regex.Escape(tagName)}(?\s[^>]*?)?\s*(?/)?>");
-        if (!open.Success) return (null, text);
-
-        if (open.Groups["self"].Success)
+        for (var lt = text.IndexOf('<'); lt >= 0; lt = text.IndexOf('<', lt + 1))
         {
-            var head = text.Substring(0, open.Index);
-            var tail = text.Substring(open.Index + open.Length);
-            return ("", head + tail);
-        }
+            if (!RazorTagScanner.TryRead(text, lt, out var open) || open.IsClose) continue;
+            if (!string.Equals(open.Name, tagName, StringComparison.Ordinal)) continue;
 
-        var closeName = Regex.Escape(tagName);
-        var scanFrom = open.Index + open.Length;
-        var depth = 1;
-        var openRe = new Regex($@"<{closeName}(\s[^>]*?)?\s*(?/)?>");
-        var closeRe = new Regex($@"");
-        while (depth > 0)
-        {
-            var nextOpen = openRe.Match(text, scanFrom);
-            var nextClose = closeRe.Match(text, scanFrom);
-            if (!nextClose.Success) return (null, text);
-            if (nextOpen.Success && nextOpen.Index < nextClose.Index)
-            {
-                if (!nextOpen.Groups["self"].Success) depth++;
-                scanFrom = nextOpen.Index + nextOpen.Length;
-            }
-            else
-            {
-                depth--;
-                if (depth == 0)
-                {
-                    var innerStart = open.Index + open.Length;
-                    var inner = text.Substring(innerStart, nextClose.Index - innerStart);
-                    var head = text.Substring(0, open.Index);
-                    var tail = text.Substring(nextClose.Index + nextClose.Length);
-                    return (inner, head + tail);
-                }
-                scanFrom = nextClose.Index + nextClose.Length;
-            }
+            if (open.IsSelfClosing) return ("", text[..open.Start] + text[open.End..]);
+
+            var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(text, tagName, open.End, StringComparison.Ordinal);
+            if (closeStart < 0) return (null, text);
+            return (text[open.End..closeStart], text[..open.Start] + text[closeEnd..]);
         }
         return (null, text);
     }
diff --git a/src/ShellDocs.Components/Content/TypeRow.razor b/src/ShellDocs.Components/Content/TypeRow.razor
index 5487905..5a92075 100644
--- a/src/ShellDocs.Components/Content/TypeRow.razor
+++ b/src/ShellDocs.Components/Content/TypeRow.razor
@@ -11,6 +11,6 @@
 
     protected override void OnInitialized()
     {
-        Parent?.Register(new TypeRowInfo(Name, Type, Default, Description, Required));
+        Parent?.Register(this, new TypeRowInfo(Name, Type, Default, Description, Required));
     }
 }
diff --git a/src/ShellDocs.Components/Content/TypeTable.razor b/src/ShellDocs.Components/Content/TypeTable.razor
index 36f5afc..25cedf1 100644
--- a/src/ShellDocs.Components/Content/TypeTable.razor
+++ b/src/ShellDocs.Components/Content/TypeTable.razor
@@ -66,10 +66,12 @@
     [Parameter] public RenderFragment? ChildContent { get; set; }
 
     private readonly List _rows = new();
+    private readonly HashSet _registered = new();
 
-    internal void Register(TypeRowInfo info)
+    // Keyed by the row component, not its Name: rows may share a name (overloads).
+    internal void Register(TypeRow row, TypeRowInfo info)
     {
-        if (_rows.Any(r => r.Name == info.Name)) return;
+        if (!_registered.Add(row)) return;
         _rows.Add(info);
         StateHasChanged();
     }
diff --git a/src/ShellDocs.Markdown/ShellDocs.Markdown.csproj b/src/ShellDocs.Markdown/ShellDocs.Markdown.csproj
index 5636253..29cbebd 100644
--- a/src/ShellDocs.Markdown/ShellDocs.Markdown.csproj
+++ b/src/ShellDocs.Markdown/ShellDocs.Markdown.csproj
@@ -14,6 +14,8 @@
 
   
     
+    
+    
   
 
 
diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs
index dc1d20a..36ce40d 100644
--- a/src/ShellDocs.Markdown/SlotExtractor.cs
+++ b/src/ShellDocs.Markdown/SlotExtractor.cs
@@ -7,17 +7,15 @@ internal class SlotExtractor
 {
     private readonly TypeRegistry _registry;
 
+    /* CommonMark fenced block: a run of three or more backticks or tildes, closed by
+       a run of the same character at least as long. A longer fence can show a
+       shorter one in its body, so the two must not be paired with each other. */
     private static readonly Regex FenceBlock = new(
-        @"^(?[ \t]*)```(?[^\n\r]*)\r?\n(?[\s\S]*?)\r?\n\1```(?=\r?\n|$)",
+        @"^(?[ \t]*)(?`{3,}|(?~{3,}))(?(?(tilde)(?!~)[^\r\n]*|[^`\r\n]*))\r?\n" +
+        @"(?:(?[\s\S]*?)\r?\n)??\k\k(?(tilde)~*|`*)[ \t]*(?=\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 BlankLine = new(@"\n[ \t]*\r?\n", RegexOptions.Compiled);
 
     /* CommonMark code span: a backtick run, content, and a closing run of the same
        length. May wrap lines but not cross a blank line. */
@@ -75,48 +73,42 @@ private string ReplaceComponentTags(string text, List slots, List
 
         while (cursor < text.Length)
         {
-            var open = OpeningTag.Match(text, cursor);
-            if (!open.Success)
+            if (!TryFindOpeningTag(text, cursor, out var open))
             {
                 result.Append(text, cursor, text.Length - cursor);
                 break;
             }
 
-            var name = open.Groups["name"].Value;
-            var isSelfClosing = open.Groups["self"].Success;
+            var name = open.Name;
             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;
+                result.Append(text, cursor, open.End - cursor);
+                cursor = open.End;
                 continue;
             }
 
-            result.Append(text, cursor, open.Index - cursor);
+            result.Append(text, cursor, open.Start - cursor);
 
-            var attrs = ParseAttributes(Unmask(open.Groups["attrs"].Value));
+            var attrs = UnmaskValues(open.Attributes);
             string? childRaw = null;
-            int endIndex;
+            var endIndex = open.End;
 
-            if (isSelfClosing)
+            if (!open.IsSelfClosing)
             {
-                endIndex = open.Index + open.Length;
-            }
-            else
-            {
-                var (closeStart, closeEnd) = FindMatchingClose(text, name, open.Index + open.Length);
+                var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(text, name, open.End, StringComparison.Ordinal);
                 if (closeStart < 0)
                 {
                     warnings.Add($"Unclosed <{name}> — passed through as raw markup.");
-                    result.Append(text, open.Index, open.Length);
-                    cursor = open.Index + open.Length;
+                    result.Append(text, open.Start, open.End - open.Start);
+                    cursor = open.End;
                     continue;
                 }
                 /* No Trim(): SlotRenderer.Dedent needs the first line's indent,
                    or Markdig reads the remaining lines as an indented code block. */
-                childRaw = Unmask(text.Substring(open.Index + open.Length, closeStart - (open.Index + open.Length)));
+                childRaw = Unmask(text[open.End..closeStart]);
                 endIndex = closeEnd;
             }
 
@@ -129,6 +121,24 @@ or Markdig reads the remaining lines as an indented code block. */
         return result.ToString();
     }
 
+    /* Next component-shaped opening tag at or after `from`: an uppercase
+       alphanumeric name, read by the same scanner razor:preview uses so quoted
+       values may contain '>' or text that looks like another attribute. */
+    private static bool TryFindOpeningTag(string text, int from, out RazorTag tag)
+    {
+        for (var lt = text.IndexOf('<', from); lt >= 0; lt = text.IndexOf('<', lt + 1))
+        {
+            if (lt + 1 >= text.Length || text[lt + 1] is < 'A' or > 'Z') continue;
+            if (!RazorTagScanner.TryRead(text, lt, out tag) || tag.IsClose) continue;
+            if (!tag.Name.All(char.IsAsciiLetterOrDigit)) continue;
+            // An unterminated quote would otherwise run on to the next quote on the page.
+            if (BlankLine.Match(text, tag.Start, tag.End - tag.Start).Success) continue;
+            return true;
+        }
+        tag = default;
+        return false;
+    }
+
     // `razor:preview stretch` lets block-level examples fill the frame; `scroll` lets wide ones scroll.
     private static string? PreviewLayout(string lang)
     {
@@ -162,45 +172,16 @@ or Markdig reads the remaining lines as an indented code block. */
         return null;
     }
 
-    private static (int Start, int End) FindMatchingClose(string text, string name, int fromIndex)
+    /* Attributes keep their full names (`@bind-Value`, not `Value`) so SlotRenderer
+       can skip directive attributes. Code spans are restored per value, after the
+       tag is parsed, so quotes inside a span can't split the value. */
+    private IReadOnlyDictionary UnmaskValues(IEnumerable> attrs)
     {
-        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;
+        var dict = new Dictionary(StringComparer.Ordinal);
+        foreach (var (name, value) in attrs) dict[name] = Unmask(value ?? "");
+        return dict;
     }
 
-    // Full names (`@bind-Value`, not `Value`) so SlotRenderer can skip directive attributes.
-    private static IReadOnlyDictionary ParseAttributes(string attrsText)
-        => PreviewParser.ToParameters(RazorTagScanner.ParseAttributes(attrsText));
-
     private static string PlaceholderHtml(string kind, string id) =>
         $"
"; diff --git a/tests/ShellDocs.Tests/FenceScannerTests.cs b/tests/ShellDocs.Tests/FenceScannerTests.cs new file mode 100644 index 0000000..192a6b4 --- /dev/null +++ b/tests/ShellDocs.Tests/FenceScannerTests.cs @@ -0,0 +1,81 @@ +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +// The slot extractor has to agree with Markdig about where a fenced block starts +// and ends, or everything after a misread fence is masked or scanned wrongly. +public class FenceScannerTests +{ + public class Callout { } + public class Button { } + + private static MarkdownRenderer Renderer() => new(new TypeRegistry().Register().Register