Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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="/"'`).
- **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.
- **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.
- **`<TypeTable>` 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.
Expand Down Expand Up @@ -360,7 +380,8 @@ Published to NuGet:
- `<TypeTable>` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4)
- No `<DocsBreadcrumb>` 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
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.11-alpha</Version>
<Version>0.1.12-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pre>` 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.
Expand All @@ -218,6 +218,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
- `feat/versioned-docs` (0.1.8) — `AddVersion`, `<VersionSelector>`, version-scoped chrome, multi-sibling `razor:preview`, Razor-form attribute values, `<DemoPreview>`, ShellIcons.
- `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.
- `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, `<TypeTable>` 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`
Expand Down
48 changes: 10 additions & 38 deletions src/ShellDocs.Components/Content/SlotRenderer.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -230,46 +229,19 @@ RenderFragment Fragment(string raw) => razorChildren

// Removes the first balanced `<TagName>…</TagName>` (or `<TagName />`) 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)}(?<attrs>\s[^>]*?)?\s*(?<self>/)?>");
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*(?<self>/)?>");
var closeRe = new Regex($@"</{closeName}\s*>");
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);
}
Expand Down
2 changes: 1 addition & 1 deletion src/ShellDocs.Components/Content/TypeRow.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
6 changes: 4 additions & 2 deletions src/ShellDocs.Components/Content/TypeTable.razor
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@
[Parameter] public RenderFragment? ChildContent { get; set; }

private readonly List<TypeRowInfo> _rows = new();
private readonly HashSet<TypeRow> _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();
}
Expand Down
2 changes: 2 additions & 0 deletions src/ShellDocs.Markdown/ShellDocs.Markdown.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

<ItemGroup>
<InternalsVisibleTo Include="ShellDocs.Tests" />
<!-- SlotRenderer reads named-slot tags with RazorTagScanner. -->
<InternalsVisibleTo Include="ShellDocs.Components" />
</ItemGroup>

</Project>
105 changes: 43 additions & 62 deletions src/ShellDocs.Markdown/SlotExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
@"^(?<indent>[ \t]*)```(?<lang>[^\n\r]*)\r?\n(?<body>[\s\S]*?)\r?\n\1```(?=\r?\n|$)",
@"^(?<indent>[ \t]*)(?<fence>`{3,}|(?<tilde>~{3,}))(?<lang>(?(tilde)(?!~)[^\r\n]*|[^`\r\n]*))\r?\n" +
@"(?:(?<body>[\s\S]*?)\r?\n)??\k<indent>\k<fence>(?(tilde)~*|`*)[ \t]*(?=\r?\n|$)",
RegexOptions.Multiline | RegexOptions.Compiled);

private static readonly Regex OpeningTag = new(
@"<(?<name>[A-Z][A-Za-z0-9]*)(?<attrs>\s[^>]*?)?\s*(?<self>/)?>",
RegexOptions.Compiled);

private static readonly Regex ClosingTag = new(
@"</(?<name>[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. */
Expand Down Expand Up @@ -75,48 +73,42 @@ private string ReplaceComponentTags(string text, List<Slot> slots, List<string>

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;
}

Expand All @@ -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)
{
Expand Down Expand Up @@ -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<string, string> UnmaskValues(IEnumerable<KeyValuePair<string, string?>> 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<string, string>(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<string, string> ParseAttributes(string attrsText)
=> PreviewParser.ToParameters(RazorTagScanner.ParseAttributes(attrsText));

private static string PlaceholderHtml(string kind, string id) =>
$"<div data-shelldocs-slot=\"{kind}\" data-shelldocs-id=\"{id}\"></div>";

Expand Down
Loading
Loading