Skip to content

Commit b0937db

Browse files
authored
Merge pull request #33 from shellui-dev/fix/fence-scanner-inline-attrs
fix: CommonMark fences and quote-aware component tags (0.1.12-alpha)
2 parents edbbea8 + a3f1d42 commit b0937db

12 files changed

Lines changed: 280 additions & 106 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht
44

55
## [Unreleased]
66

7+
## [0.1.12-alpha] — 2026-10-04
8+
9+
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.
10+
11+
### Added
12+
13+
- **Tilde fences.** `~~~` fenced blocks are recognised like backtick ones: tags inside them stay literal, and `~~~razor:preview` renders a preview.
14+
15+
### Fixed
16+
17+
- **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.
18+
- **A `>` inside a quoted attribute value ended an inline component tag early**, so the component (a `<TypeRow>`, 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="/"'`).
19+
- **The same `>` problem in named-slot tags.** A child tag routed into a `RenderFragment` parameter (`<Icon title="a > b">`) was cut at the first `>`, leaking the rest of the tag into the slot. Named-slot tags use the shared scanner too.
20+
- **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.
21+
- **`<TypeTable>` dropped rows that shared a `Name`** (overloads, or two rows describing one thing). Rows are now tracked by component instead of by name.
22+
23+
### Changed
24+
25+
- 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.
26+
727
## [0.1.11-alpha] — 2026-10-03
828

929
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:
360380
- `<TypeTable>` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4)
361381
- No `<DocsBreadcrumb>` opt-out — currently hides when the trail has ≤ 1 node, otherwise always renders
362382

363-
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.11-alpha...HEAD
383+
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.12-alpha...HEAD
384+
[0.1.12-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.12-alpha
364385
[0.1.11-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.11-alpha
365386
[0.1.10-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.10-alpha
366387
[0.1.9-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.9-alpha

‎Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
<!-- Package metadata (applies to any project with IsPackable=true) -->
1919
<PropertyGroup>
20-
<Version>0.1.11-alpha</Version>
20+
<Version>0.1.12-alpha</Version>
2121
<Authors>ShellUI</Authors>
2222
<Company>ShellUI</Company>
2323
<Copyright>Copyright © 2026 ShellUI</Copyright>

‎docs/ROADMAP.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `She
209209
**Authoring fix (`ShellDocs.Markdown`)**
210210
- `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 `<pre>` around placeholder divs" bug that had already been fixed for `razor:preview` fences; the inline-tag code path was still hitting it.
211211

212-
### ✅ Hardening releases (`0.1.3`–`0.1.11-alpha`) — shipped
212+
### ✅ Hardening releases (`0.1.3`–`0.1.12-alpha`) — shipped
213213
Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHANGELOG):
214214

215215
- `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
218218
- `feat/versioned-docs` (0.1.8) — `AddVersion`, `<VersionSelector>`, version-scoped chrome, multi-sibling `razor:preview`, Razor-form attribute values, `<DemoPreview>`, ShellIcons.
219219
- `feat/preview-toolbar` (0.1.9) — Preview | Code toolbar with ⋯ menu, `<Tabs>`, static-host `<CodeGroup>` and mobile nav, consumer components winning name collisions, code-span masking, highlighting and hydration fixes.
220220
- `fix/sidebar-index-pages` (0.1.11) — index pages as sidebar section links, `scroll` preview layout and phone-width previews, scrolling tables.
221+
- `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, `<TypeTable>` rows that share a name.
221222
- `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`.
222223

223224
### `feat/animation-polish`

‎src/ShellDocs.Components/Content/SlotRenderer.cs‎

Lines changed: 10 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System.Globalization;
22
using System.Reflection;
33
using System.Text;
4-
using System.Text.RegularExpressions;
54
using Microsoft.AspNetCore.Components;
65
using Microsoft.AspNetCore.Components.Rendering;
76
using Microsoft.Extensions.Logging;
@@ -230,46 +229,19 @@ RenderFragment Fragment(string raw) => razorChildren
230229

231230
// Removes the first balanced `<TagName>…</TagName>` (or `<TagName />`) from
232231
// `text`, returning its inner content and the remaining text.
233-
private static (string? Content, string Remaining) ExtractNamedSlot(string text, string tagName)
232+
// Tags are read with RazorTagScanner, so a '>' inside a quoted attribute value doesn't end one.
233+
internal static (string? Content, string Remaining) ExtractNamedSlot(string text, string tagName)
234234
{
235-
var open = Regex.Match(text, $@"<{Regex.Escape(tagName)}(?<attrs>\s[^>]*?)?\s*(?<self>/)?>");
236-
if (!open.Success) return (null, text);
237-
238-
if (open.Groups["self"].Success)
235+
for (var lt = text.IndexOf('<'); lt >= 0; lt = text.IndexOf('<', lt + 1))
239236
{
240-
var head = text.Substring(0, open.Index);
241-
var tail = text.Substring(open.Index + open.Length);
242-
return ("", head + tail);
243-
}
237+
if (!RazorTagScanner.TryRead(text, lt, out var open) || open.IsClose) continue;
238+
if (!string.Equals(open.Name, tagName, StringComparison.Ordinal)) continue;
244239

245-
var closeName = Regex.Escape(tagName);
246-
var scanFrom = open.Index + open.Length;
247-
var depth = 1;
248-
var openRe = new Regex($@"<{closeName}(\s[^>]*?)?\s*(?<self>/)?>");
249-
var closeRe = new Regex($@"</{closeName}\s*>");
250-
while (depth > 0)
251-
{
252-
var nextOpen = openRe.Match(text, scanFrom);
253-
var nextClose = closeRe.Match(text, scanFrom);
254-
if (!nextClose.Success) return (null, text);
255-
if (nextOpen.Success && nextOpen.Index < nextClose.Index)
256-
{
257-
if (!nextOpen.Groups["self"].Success) depth++;
258-
scanFrom = nextOpen.Index + nextOpen.Length;
259-
}
260-
else
261-
{
262-
depth--;
263-
if (depth == 0)
264-
{
265-
var innerStart = open.Index + open.Length;
266-
var inner = text.Substring(innerStart, nextClose.Index - innerStart);
267-
var head = text.Substring(0, open.Index);
268-
var tail = text.Substring(nextClose.Index + nextClose.Length);
269-
return (inner, head + tail);
270-
}
271-
scanFrom = nextClose.Index + nextClose.Length;
272-
}
240+
if (open.IsSelfClosing) return ("", text[..open.Start] + text[open.End..]);
241+
242+
var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(text, tagName, open.End, StringComparison.Ordinal);
243+
if (closeStart < 0) return (null, text);
244+
return (text[open.End..closeStart], text[..open.Start] + text[closeEnd..]);
273245
}
274246
return (null, text);
275247
}

‎src/ShellDocs.Components/Content/TypeRow.razor‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@
1111

1212
protected override void OnInitialized()
1313
{
14-
Parent?.Register(new TypeRowInfo(Name, Type, Default, Description, Required));
14+
Parent?.Register(this, new TypeRowInfo(Name, Type, Default, Description, Required));
1515
}
1616
}

‎src/ShellDocs.Components/Content/TypeTable.razor‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,12 @@
6666
[Parameter] public RenderFragment? ChildContent { get; set; }
6767

6868
private readonly List<TypeRowInfo> _rows = new();
69+
private readonly HashSet<TypeRow> _registered = new();
6970

70-
internal void Register(TypeRowInfo info)
71+
// Keyed by the row component, not its Name: rows may share a name (overloads).
72+
internal void Register(TypeRow row, TypeRowInfo info)
7173
{
72-
if (_rows.Any(r => r.Name == info.Name)) return;
74+
if (!_registered.Add(row)) return;
7375
_rows.Add(info);
7476
StateHasChanged();
7577
}

‎src/ShellDocs.Markdown/ShellDocs.Markdown.csproj‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
<ItemGroup>
1616
<InternalsVisibleTo Include="ShellDocs.Tests" />
17+
<!-- SlotRenderer reads named-slot tags with RazorTagScanner. -->
18+
<InternalsVisibleTo Include="ShellDocs.Components" />
1719
</ItemGroup>
1820

1921
</Project>

‎src/ShellDocs.Markdown/SlotExtractor.cs‎

Lines changed: 43 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,15 @@ internal class SlotExtractor
77
{
88
private readonly TypeRegistry _registry;
99

10+
/* CommonMark fenced block: a run of three or more backticks or tildes, closed by
11+
a run of the same character at least as long. A longer fence can show a
12+
shorter one in its body, so the two must not be paired with each other. */
1013
private static readonly Regex FenceBlock = new(
11-
@"^(?<indent>[ \t]*)```(?<lang>[^\n\r]*)\r?\n(?<body>[\s\S]*?)\r?\n\1```(?=\r?\n|$)",
14+
@"^(?<indent>[ \t]*)(?<fence>`{3,}|(?<tilde>~{3,}))(?<lang>(?(tilde)(?!~)[^\r\n]*|[^`\r\n]*))\r?\n" +
15+
@"(?:(?<body>[\s\S]*?)\r?\n)??\k<indent>\k<fence>(?(tilde)~*|`*)[ \t]*(?=\r?\n|$)",
1216
RegexOptions.Multiline | RegexOptions.Compiled);
1317

14-
private static readonly Regex OpeningTag = new(
15-
@"<(?<name>[A-Z][A-Za-z0-9]*)(?<attrs>\s[^>]*?)?\s*(?<self>/)?>",
16-
RegexOptions.Compiled);
17-
18-
private static readonly Regex ClosingTag = new(
19-
@"</(?<name>[A-Z][A-Za-z0-9]*)\s*>",
20-
RegexOptions.Compiled);
18+
private static readonly Regex BlankLine = new(@"\n[ \t]*\r?\n", RegexOptions.Compiled);
2119

2220
/* CommonMark code span: a backtick run, content, and a closing run of the same
2321
length. May wrap lines but not cross a blank line. */
@@ -75,48 +73,42 @@ private string ReplaceComponentTags(string text, List<Slot> slots, List<string>
7573

7674
while (cursor < text.Length)
7775
{
78-
var open = OpeningTag.Match(text, cursor);
79-
if (!open.Success)
76+
if (!TryFindOpeningTag(text, cursor, out var open))
8077
{
8178
result.Append(text, cursor, text.Length - cursor);
8279
break;
8380
}
8481

85-
var name = open.Groups["name"].Value;
86-
var isSelfClosing = open.Groups["self"].Success;
82+
var name = open.Name;
8783
var registered = _registry.Resolve(name);
8884

8985
if (registered is null)
9086
{
9187
warnings.Add($"Unknown component <{name}> — passed through as raw markup.");
92-
result.Append(text, cursor, open.Index + open.Length - cursor);
93-
cursor = open.Index + open.Length;
88+
result.Append(text, cursor, open.End - cursor);
89+
cursor = open.End;
9490
continue;
9591
}
9692

97-
result.Append(text, cursor, open.Index - cursor);
93+
result.Append(text, cursor, open.Start - cursor);
9894

99-
var attrs = ParseAttributes(Unmask(open.Groups["attrs"].Value));
95+
var attrs = UnmaskValues(open.Attributes);
10096
string? childRaw = null;
101-
int endIndex;
97+
var endIndex = open.End;
10298

103-
if (isSelfClosing)
99+
if (!open.IsSelfClosing)
104100
{
105-
endIndex = open.Index + open.Length;
106-
}
107-
else
108-
{
109-
var (closeStart, closeEnd) = FindMatchingClose(text, name, open.Index + open.Length);
101+
var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(text, name, open.End, StringComparison.Ordinal);
110102
if (closeStart < 0)
111103
{
112104
warnings.Add($"Unclosed <{name}> — passed through as raw markup.");
113-
result.Append(text, open.Index, open.Length);
114-
cursor = open.Index + open.Length;
105+
result.Append(text, open.Start, open.End - open.Start);
106+
cursor = open.End;
115107
continue;
116108
}
117109
/* No Trim(): SlotRenderer.Dedent needs the first line's indent,
118110
or Markdig reads the remaining lines as an indented code block. */
119-
childRaw = Unmask(text.Substring(open.Index + open.Length, closeStart - (open.Index + open.Length)));
111+
childRaw = Unmask(text[open.End..closeStart]);
120112
endIndex = closeEnd;
121113
}
122114

@@ -129,6 +121,24 @@ or Markdig reads the remaining lines as an indented code block. */
129121
return result.ToString();
130122
}
131123

124+
/* Next component-shaped opening tag at or after `from`: an uppercase
125+
alphanumeric name, read by the same scanner razor:preview uses so quoted
126+
values may contain '>' or text that looks like another attribute. */
127+
private static bool TryFindOpeningTag(string text, int from, out RazorTag tag)
128+
{
129+
for (var lt = text.IndexOf('<', from); lt >= 0; lt = text.IndexOf('<', lt + 1))
130+
{
131+
if (lt + 1 >= text.Length || text[lt + 1] is < 'A' or > 'Z') continue;
132+
if (!RazorTagScanner.TryRead(text, lt, out tag) || tag.IsClose) continue;
133+
if (!tag.Name.All(char.IsAsciiLetterOrDigit)) continue;
134+
// An unterminated quote would otherwise run on to the next quote on the page.
135+
if (BlankLine.Match(text, tag.Start, tag.End - tag.Start).Success) continue;
136+
return true;
137+
}
138+
tag = default;
139+
return false;
140+
}
141+
132142
// `razor:preview stretch` lets block-level examples fill the frame; `scroll` lets wide ones scroll.
133143
private static string? PreviewLayout(string lang)
134144
{
@@ -162,45 +172,16 @@ or Markdig reads the remaining lines as an indented code block. */
162172
return null;
163173
}
164174

165-
private static (int Start, int End) FindMatchingClose(string text, string name, int fromIndex)
175+
/* Attributes keep their full names (`@bind-Value`, not `Value`) so SlotRenderer
176+
can skip directive attributes. Code spans are restored per value, after the
177+
tag is parsed, so quotes inside a span can't split the value. */
178+
private IReadOnlyDictionary<string, string> UnmaskValues(IEnumerable<KeyValuePair<string, string?>> attrs)
166179
{
167-
var depth = 1;
168-
var searchFrom = fromIndex;
169-
while (depth > 0)
170-
{
171-
var open = FindNextTag(OpeningTag, text, name, searchFrom);
172-
var close = FindNextTag(ClosingTag, text, name, searchFrom);
173-
174-
if (close is null) return (-1, -1);
175-
176-
if (open is not null && open.Index < close.Index)
177-
{
178-
if (!open.Groups["self"].Success) depth++;
179-
searchFrom = open.Index + open.Length;
180-
}
181-
else
182-
{
183-
depth--;
184-
if (depth == 0) return (close.Index, close.Index + close.Length);
185-
searchFrom = close.Index + close.Length;
186-
}
187-
}
188-
return (-1, -1);
189-
}
190-
191-
private static Match? FindNextTag(Regex regex, string text, string name, int fromIndex)
192-
{
193-
foreach (Match m in regex.Matches(text, fromIndex))
194-
{
195-
if (m.Groups["name"].Value == name) return m;
196-
}
197-
return null;
180+
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
181+
foreach (var (name, value) in attrs) dict[name] = Unmask(value ?? "");
182+
return dict;
198183
}
199184

200-
// Full names (`@bind-Value`, not `Value`) so SlotRenderer can skip directive attributes.
201-
private static IReadOnlyDictionary<string, string> ParseAttributes(string attrsText)
202-
=> PreviewParser.ToParameters(RazorTagScanner.ParseAttributes(attrsText));
203-
204185
private static string PlaceholderHtml(string kind, string id) =>
205186
$"<div data-shelldocs-slot=\"{kind}\" data-shelldocs-id=\"{id}\"></div>";
206187

0 commit comments

Comments
 (0)