diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f23da7..b6bf045 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.13-alpha] — 2026-10-10
+
+Code variants for cross-platform libraries: one preview with code tabs per platform (MAUI and Avalonia XAML, say), a page switch that picks the platform site-wide, captions, and live iframe previews. Also fixes one-line component bodies being wrapped in `
`.
+
+### Added
+
+- **Code variants on previews.** ``, `` and `` take `` children with a code fence inside; the Code panel shows them as tabs instead of the Razor source, for libraries documented in several languages (MAUI and Avalonia XAML, say). The copy button copies the tab that's showing. `SyncKey` works as on ``: blocks with the same key switch together across pages and the choice is saved. A block without a tab for the current choice shows "Not available on {label} yet" and keeps its height. From `.razor`, pass `CodeTabs` (`PreviewCodeTab` records). Without code tabs nothing changes. `razor:preview` fences can't take code tabs: their body is Razor, so a fence can't nest in one.
+- **Page switch.** `o.AddSyncSwitch("platform", "MAUI", "Avalonia")` adds underlined tabs, like shadcn/ui's library switch, to every page whose blocks use that key: under the page header, or right above the first such block. `` places one by hand.
+- **Preview caption.** `Caption="…"` puts a short muted note in the preview toolbar; `ShellDocsOptions.PreviewCaption` sets a site-wide default.
+- **``** shows another page (a live Avalonia app compiled to WebAssembly, for instance) in the Preview panel with the same toolbar and code tabs. `shelldocs.js` loads it when scrolled into view (`Lazy="visible"`, the default), on a **Run live preview** button (`click`) or at once (`none`), appends `?theme=light|dark`, and posts `{ type: "shelldocs-theme", theme }` to the page's origin when the theme changes. The Code tab is hidden when there's no code.
+- XAML highlighting: `xaml` and `axaml` fences are highlighted as XML (`cs` as C#). The `init` template loads Shiki's `xml` grammar; existing sites add `'xml'` to the `langs` list in `App.razor`.
+
+### Changed
+
+- `SlotRenderer` no longer warns about child content for components that read their raw body (`ChildContentSource`).
+
+### Fixed
+
+- **One-line component bodies in prose were wrapped in `
`.
- **Page typography stays out.** Preview frames are `not-prose`, so prose margins, list padding and link underlines don't reach your components.
- **Generic components work.** Set the type argument as Razor does: ``, ``. Libraries' generic components are registered under their bare name.
+- **Code variants.** Give ``, `` or `` `` children with a fence inside, and the Code panel shows those tabs instead of the Razor (for a library documented in MAUI and Avalonia XAML, say). `SyncKey="platform"` switches every block together and remembers the choice; `o.AddSyncSwitch("platform", "MAUI", "Avalonia")` adds a shadcn-style tab row to those pages; a block missing the chosen tab says "Not available on Avalonia yet". `Caption="Rendered with Blazor"` adds a note to the toolbar.
+- **Live iframe previews.** `` shows another page, such as Avalonia on WebAssembly, loaded lazily with `?theme=` and theme-change messages.
- **Centred, stretched or scrolling.** Examples are centred; `razor:preview stretch` (or `Layout="stretch"` on `` / ``) lets charts, inputs and tables fill the frame, and `scroll` lets an example wider than a phone screen (pagination, toolbars, OTP inputs) scroll inside the frame. A scrolling frame clips popovers, so leave it off examples with dropdowns.
- **Razor-shaped attribute values work:** `Variant="ButtonVariant.Destructive"`, `@ButtonVariant.Destructive`, `@true`, `@42`, `[Flags]` values as `Bold | Italic`.
- **Attributes a static preview can't evaluate are skipped, not fatal:** `OnClick="HandleClick"`, `@onclick`, `@bind-*`, `@ref`, non-primitive parameter types, unparseable values. Each logs a warning and the component still renders.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 28dcc71..4fc4f7a 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -126,12 +126,14 @@ Placeholders plus a slot list keep everything at render time: no generated Razor
**Generic components** register under their bare name (`TypeRegistry.TagNameOf`) as open definitions. `SlotRenderer.Component` closes one per use with `GenericComponents.Close`, reading attributes named after the type parameters (`TItem="int"`, resolved from C# spellings across loaded assemblies) or falling back to `object`; one it can't close renders a `.shelldocs-render-error` element instead. A non-generic component with the same name keeps the tag.
-Component child markup is rendered recursively. Inline tags in prose take markdown bodies (`SlotRenderer.FromMarkup`, dedented first, since Markdig treats 4-space indentation as a code block). Inside a `razor:preview` and in `` the body is Razor: `SlotRenderer.FromRazor` parses it with `PreviewParser` and emits real elements, so wrappers around nested components stay intact. Child tags named after a `RenderFragment` parameter (`` → `Alert.Icon`) are routed into that slot.
+Component child markup is rendered recursively. Inline tags in prose take markdown bodies (`SlotRenderer.FromMarkup`, dedented first, since Markdig treats 4-space indentation as a code block; as in MDX, a body on the tags' own line renders inline, without a wrapping `
`). Inside a `razor:preview` and in `` the body is Razor: `SlotRenderer.FromRazor` parses it with `PreviewParser` and emits real elements, so wrappers around nested components stay intact. Child tags named after a `RenderFragment` parameter (`` → `Alert.Icon`) are routed into that slot.
**Parameter coercion.** Attribute values are strings. `BuildParameters` matches them to `[Parameter]` properties case-insensitively and coerces string, bool, char, numeric primitives and enums, accepting Razor forms: a leading `@`, `@( … )`, `Type.Member` enum values, `A | B` flags, numeric suffixes, `@null`. It never throws. Anything static markup can't set is skipped with a logged warning: directive attributes, delegate/`EventCallback` parameters, unsupported types, unparseable values, unknown attributes on components without a `CaptureUnmatchedValues` catch-all, and child content on components without a plain `RenderFragment ChildContent`.
**`PreviewFrame`** is the frame for every example. It carries `not-prose` (every `.shelldocs-prose` rule skips `.not-prose` subtrees) and a `Layout` of `center`, `stretch` or `scroll` (the example scrolls horizontally inside the frame; opt-in because a scroll box clips popovers). Centring uses `safe center`, so an example wider than the frame starts at its left edge. It has a toolbar with Preview | Code tabs, copy and a ⋯ menu (*Open in new tab* → the example's anchor; *Report a bug* / *Suggest something* → new-issue links built by `PreviewLinks` from `IssueTrackerUrl` or `GitHubRepo`). `ComponentPreview` and `DemoPreview` feed it a ready-made `Content` fragment and `Code` string instead of a `PreviewSlot`. `DemoPreview` reads its source from the first `{DemoSourceRoot}/**/X.razor` (shallowest path wins, cached, re-read when the file changes).
+**Code variants.** `PreviewFrame.CodeTabs` (`PreviewCodeTab(Label, Code, Language)`) replaces the single source with a `[data-tabs data-tabs-missing]` group: a tab bar, one panel per tab stacked in one grid cell (so the height never changes) and a hidden "Not available on {label} yet" note. In markdown, `DemoPreview`, `ComponentPreview` and `IframePreview` get their raw body as `ChildContentSource` and `CodeTabParser` reads `` blocks out of it with `RazorTagScanner`, taking each tab's code and language from its first fence (`SlotExtractor.FenceBlock`); `ComponentPreview` passes the rest on as the target's children. `Caption` (or `ShellDocsOptions.PreviewCaption`) is a muted note in the toolbar. `IframePreview` renders `[data-iframe-preview data-src data-lazy]` with an empty iframe that `shelldocs.js` fills. `MarkdownContent` adds a `` (a panel-less `[data-tabs data-tabs-switch]` group) for each `AddSyncSwitch` key the page's blocks use, under the page header or above the first such block.
+
**`DocsPageState`** (scoped) is fed by `MarkdownContent` and recomputes current node, prev/next and breadcrumbs on navigation, so the layout chrome needs no per-page wiring.
---
@@ -204,7 +206,8 @@ Chrome that must work without a Blazor runtime follows one contract:
| Sidebar sections | `.sidebar-section[data-open]` | toggle on click |
| Package / version selectors, preview ⋯ menu | `.pkg` / `.ver` / `.preview-menu` `[data-open]` | open on trigger; close on outside click, Escape or option |
| Preview tabs | `.preview-frame[data-preview-tab]` | Preview / Code; arrow keys; copy reads the source |
-| Tabs, CodeGroup | `[data-tabs]`, `[data-tab-target]`, `[data-tab-panel]` | switch on click / arrows; `[data-tabs-sync]` groups switch together and persist in `localStorage` |
+| Tabs, CodeGroup, preview code tabs, page switch | `[data-tabs]`, `[data-tab-target]`, `[data-tab-panel]` | switch on click / arrows; `[data-tabs-sync]` groups switch together and persist in `localStorage`; a `[data-tabs-missing]` group without the chosen value shows its `[data-tab-missing]` note; with nothing saved yet, groups follow a `[data-tabs-switch]` for their key |
+| Iframe previews | `[data-iframe-preview][data-src][data-lazy]` | sets the iframe's `src` (plus `?theme=`) when visible (IntersectionObserver), on `[data-iframe-run]` or at once; posts `{ type: "shelldocs-theme", theme }` to loaded frames on theme change |
| Mobile nav | page shell `[data-mobile-open]` | hamburger toggles; backdrop, Escape, link or navigation closes; page scroll locked on `` |
| TOC | `[data-toc-list][data-toc-ids]` | IntersectionObserver scroll-spy, re-attached on `enhancedload` |
| Theme | ``, `[data-theme-toggle]` | inline head script applies the saved / system theme; the toggle flips the class and CSS picks its icon; a `MutationObserver` saves any change (including another library's toggle) and pushes it to `ThemeState`; re-applied after enhanced navigation |
@@ -213,7 +216,7 @@ Still Blazor-only: search, the desktop sidebar-collapse button.
`enhancedload` is a Blazor event (`Blazor.addEventListener`), not a DOM event; `shelldocsOnEnhancedLoad(fn)` attaches once `blazor.web.js` has run.
-**Syntax highlighting.** Shiki (loaded by the app as `window.__shiki`) never replaces Blazor-owned nodes: the source `
` gets `data-shiki="source"` (hidden by CSS) and the highlighted output goes into a JS-owned `[data-shiki-output]` sibling, re-rendered when the source text changes and removed when its source is gone.
+**Syntax highlighting.** Shiki (loaded by the app as `window.__shiki`) never replaces Blazor-owned nodes: the source `
` gets `data-shiki="source"` (hidden by CSS) and the highlighted output goes into a JS-owned `[data-shiki-output]` sibling, re-rendered when the source text changes and removed when its source is gone. `xaml` / `axaml` are highlighted as `xml` and `cs` as `csharp`.
---
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 7342ffe..45fdc6c 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.12-alpha`) — shipped
+### ✅ Hardening releases (`0.1.3`–`0.1.13-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.
@@ -219,6 +219,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
- `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.
+- `feat/preview-code-variants` (0.1.13) — code tabs on previews with sync and a missing-tab note, page switch (`AddSyncSwitch`), captions, ``, XAML highlighting; one-line component bodies stay inline.
- `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/examples/ShellDocs.Preview/Components/App.razor b/examples/ShellDocs.Preview/Components/App.razor
index 3946b08..4497a85 100644
--- a/examples/ShellDocs.Preview/Components/App.razor
+++ b/examples/ShellDocs.Preview/Components/App.razor
@@ -32,7 +32,7 @@
import { createHighlighter } from 'https://esm.sh/shiki@1.24.0';
window.__shiki = await createHighlighter({
themes: ['github-light', 'github-dark'],
- langs: ['razor', 'csharp', 'html', 'json', 'yaml', 'bash', 'typescript', 'javascript', 'markdown']
+ langs: ['razor', 'csharp', 'html', 'xml', 'json', 'yaml', 'bash', 'typescript', 'javascript', 'markdown']
});
if (window.shelldocsHighlight) window.shelldocsHighlight();
diff --git a/examples/ShellDocs.Preview/Program.cs b/examples/ShellDocs.Preview/Program.cs
index cf7fa7d..0322087 100644
--- a/examples/ShellDocs.Preview/Program.cs
+++ b/examples/ShellDocs.Preview/Program.cs
@@ -48,6 +48,9 @@
o.AddVersion("v2.0", "v2.0.0", "/docs/v2.0", "Current stable", latest: true);
o.AddVersion("v1.9.1", "v1.9.1", "/docs/v1.9.1", "Previous release");
+ // Page-level MAUI | Avalonia tabs on pages whose blocks use SyncKey="platform".
+ o.AddSyncSwitch("platform", "MAUI", "Avalonia");
+
// Built-in primitives (Callout, Card, Steps, …) are registered by AddShellDocs.
o.RegisterComponentsFromAssembly("ShellDocs.Preview.Ui");
o.RegisterComponentsFromAssembly("ShellDocs.Preview.Demos");
diff --git a/examples/ShellDocs.Preview/content/docs/components/code-variants.md b/examples/ShellDocs.Preview/content/docs/components/code-variants.md
new file mode 100644
index 0000000..9b4dbe4
--- /dev/null
+++ b/examples/ShellDocs.Preview/content/docs/components/code-variants.md
@@ -0,0 +1,119 @@
+---
+title: Code variants
+description: Show one live preview with code for several platforms, switched site-wide.
+category: Components
+order: 70
+---
+
+# Code variants
+
+A preview can show code for several targets instead of the Razor that rendered it. Each target is a `` with a code fence inside. That's how a cross-platform library documents one example in MAUI and Avalonia XAML, like shadcn/ui's code tabs for Radix and Base UI.
+
+
+
+```xml
+
+```
+
+
+```xml
+
+```
+
+
+
+Written as:
+
+````markdown
+
+
+```xml
+
+```
+
+
+```xml
+
+```
+
+
+````
+
+- Each tab's code is its first fence; the fence's language drives highlighting (`xml`, `csharp`, …). `xaml` and `axaml` are highlighted as XML. Without a fence, the tag's text is the code and `Language="…"` sets the language.
+- `SyncKey` works like it does on [``](/docs/components/code-group): every block with the same key switches together, on this page and later ones, and the choice is remembered.
+- The **Copy** button copies the tab that's showing.
+- Without `` children, the Code panel shows the Razor source as before.
+- `` takes `` children too; they're removed from the component's own child content. `razor:preview` fences can't: their body is Razor, so a code fence can't sit inside one. Use `` there.
+- From a `.razor` file, pass the tabs directly: `CodeTabs="@(new PreviewCodeTab[] { new("MAUI", xaml, "xml") })"`.
+
+## A missing variant
+
+When a block has no tab for the current choice, it says so and keeps its height, instead of jumping to another tab. Choose **Avalonia** above, then look at this block:
+
+
+
+```xml
+
+```
+
+
+
+## Page switch
+
+`AddSyncSwitch` adds a row of tabs to every page whose blocks use that key, like shadcn/ui's library switch. It sits under the page header (frontmatter `title` with `RenderPageTitle`), or right above the first block that uses the key. The **MAUI | Avalonia** tabs above the first example on this page come from it:
+
+```csharp
+builder.Services.AddShellDocs(o =>
+{
+ o.AddSyncSwitch("platform", "MAUI", "Avalonia");
+});
+```
+
+The first option is the default until a reader picks one, so every block starts on the same platform. To place it yourself, write `` (with `Options="MAUI, Avalonia"` if the key has no `AddSyncSwitch`); the automatic one is then left out.
+
+## Caption
+
+`Caption="…"` adds a short muted note to the preview's toolbar, such as which platform rendered it. `ShellDocsOptions.PreviewCaption` sets a default for every preview on the site.
+
+## Live iframe preview
+
+`` shows another page in the Preview panel, with the same toolbar and code tabs. It's meant for live examples built with something other than Blazor, such as Avalonia compiled to WebAssembly.
+
+
+
+```xml
+
+```
+
+
+```xml
+
+```
+
+
+
+With `Lazy="click"`, nothing loads until the reader asks:
+
+
+
+| Attribute | Default | Meaning |
+|---|---|---|
+| `Src` | (required) | Page to show. |
+| `Height` | `320` | Pixels, or any CSS length (`24rem`). |
+| `Lazy` | `visible` | `visible` loads when scrolled into view, `click` shows a **Run live preview** button, `none` loads at once. |
+| `SyncKey`, `Caption`, `Title`, `Id` | | As on ``. |
+
+The page in the iframe gets:
+
+- `?theme=light` or `?theme=dark` on its URL, matching the site;
+- a `{ type: "shelldocs-theme", theme: "light" | "dark" }` message whenever the reader switches theme, sent to the page's own origin.
+
+```js
+window.addEventListener('message', e => {
+ if (e.data?.type === 'shelldocs-theme') applyTheme(e.data.theme);
+});
+```
+
+Each iframe starts its own app, so lazy loading keeps a page to the previews the reader can see.
diff --git a/examples/ShellDocs.Preview/content/docs/components/meta.json b/examples/ShellDocs.Preview/content/docs/components/meta.json
index 15a0817..2fc233c 100644
--- a/examples/ShellDocs.Preview/content/docs/components/meta.json
+++ b/examples/ShellDocs.Preview/content/docs/components/meta.json
@@ -1,4 +1,4 @@
{
"title": "Components",
- "pages": ["callout", "card", "code-block", "code-group", "steps", "tabs", "filetree", "type-table", "component-preview"]
+ "pages": ["callout", "card", "code-block", "code-group", "steps", "tabs", "filetree", "type-table", "component-preview", "code-variants"]
}
diff --git a/examples/ShellDocs.Preview/wwwroot/preview/index.html b/examples/ShellDocs.Preview/wwwroot/preview/index.html
new file mode 100644
index 0000000..d3c08db
--- /dev/null
+++ b/examples/ShellDocs.Preview/wwwroot/preview/index.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ Live preview
+
+
+
+
+
+ Example
+
+
theme: · messages: 0
+
+
+
+
diff --git a/src/ShellDocs.Components/Content/CodeTab.razor b/src/ShellDocs.Components/Content/CodeTab.razor
index f21d576..56f8f89 100644
--- a/src/ShellDocs.Components/Content/CodeTab.razor
+++ b/src/ShellDocs.Components/Content/CodeTab.razor
@@ -2,6 +2,8 @@
@code {
[Parameter, EditorRequired] public string Label { get; set; } = "";
+ // Highlighting language for a preview's code tab when the body has no fence info (xml, csharp, …).
+ [Parameter] public string? Language { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[CascadingParameter] public CodeGroup? Parent { get; set; }
diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor b/src/ShellDocs.Components/Content/ComponentPreview.razor
index c2bd4ef..b30b985 100644
--- a/src/ShellDocs.Components/Content/ComponentPreview.razor
+++ b/src/ShellDocs.Components/Content/ComponentPreview.razor
@@ -12,7 +12,10 @@
ErrorTitle="ComponentPreview error"
Id="@_id"
Name="@Component"
- Layout="@Layout" />
+ Layout="@Layout"
+ CodeTabs="@_codeTabs"
+ SyncKey="@SyncKey"
+ Caption="@Caption" />
@code {
[Parameter, EditorRequired] public string? Component { get; set; }
@@ -21,6 +24,10 @@
[Parameter] public string? ChildContentSource { get; set; }
// "center" (default), "stretch" or "scroll"; see PreviewFrame.
[Parameter] public string? Layout { get; set; }
+ // Code tabs that replace the rebuilt source; in markdown, children instead.
+ [Parameter] public IReadOnlyList? CodeTabs { get; set; }
+ [Parameter] public string? SyncKey { get; set; }
+ [Parameter] public string? Caption { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public IReadOnlyDictionary? ExtraProps { get; set; }
@@ -30,9 +37,15 @@
private string? _id;
private string? _error;
private HashSet _typeParams = new(StringComparer.OrdinalIgnoreCase);
+ private IReadOnlyList? _codeTabs;
+ // ChildContentSource without its blocks: the target's own children.
+ private string? _targetSource;
protected override void OnParametersSet()
{
+ var (parsedTabs, remaining) = CodeTabParser.Extract(ChildContentSource);
+ _codeTabs = CodeTabs is { Count: > 0 } ? CodeTabs : parsedTabs;
+ _targetSource = remaining;
_target = Component is null ? null : Registry.Resolve(Component);
_error = _target is null ? $"Unknown component <{Component}>." : null;
_typeParams = new(StringComparer.OrdinalIgnoreCase);
@@ -95,13 +108,13 @@
}
// The children are a Razor example for the target: parse them as Razor
// (named slots included) rather than reuse the markdown-rendered ChildContent.
- if (!string.IsNullOrWhiteSpace(ChildContentSource))
+ if (!string.IsNullOrWhiteSpace(_targetSource))
{
var empty = new Dictionary();
- foreach (var (k, v) in SlotRenderer.BuildParameters(Renderer, target, empty, ChildContentSource, Logger, razorChildren: true))
+ foreach (var (k, v) in SlotRenderer.BuildParameters(Renderer, target, empty, _targetSource, Logger, razorChildren: true))
dict[k] = v;
}
- else if (ChildContent is not null)
+ else if (ChildContent is not null && string.IsNullOrWhiteSpace(ChildContentSource))
{
dict["ChildContent"] = ChildContent;
}
@@ -119,7 +132,7 @@
sb.Append(' ').Append(k).Append("=\"").Append(v).Append('"');
}
}
- var body = ChildContentSource?.Trim();
+ var body = _targetSource?.Trim();
if (string.IsNullOrEmpty(body))
{
sb.Append(" />");
diff --git a/src/ShellDocs.Components/Content/DemoPreview.razor b/src/ShellDocs.Components/Content/DemoPreview.razor
index 0bf7264..952074d 100644
--- a/src/ShellDocs.Components/Content/DemoPreview.razor
+++ b/src/ShellDocs.Components/Content/DemoPreview.razor
@@ -11,7 +11,10 @@
ErrorTitle="DemoPreview error"
Id="@(Id ?? $"demo-{PreviewLinks.Slug(Component)}")"
Name="@Component"
- Layout="@Layout" />
+ Layout="@Layout"
+ CodeTabs="@_codeTabs"
+ SyncKey="@SyncKey"
+ Caption="@Caption" />
@code {
[Parameter, EditorRequired] public string? Component { get; set; }
@@ -20,16 +23,24 @@
[Parameter] public string? Id { get; set; }
// "center" (default), "stretch" or "scroll"; see PreviewFrame.
[Parameter] public string? Layout { get; set; }
+ // Code tabs that replace the Razor source; in markdown, children instead.
+ [Parameter] public IReadOnlyList? CodeTabs { get; set; }
+ [Parameter] public string? SyncKey { get; set; }
+ [Parameter] public string? Caption { get; set; }
+ // Raw child markup from SlotRenderer; its blocks become code tabs.
+ [Parameter] public string? ChildContentSource { get; set; }
private RenderFragment? _content;
private string? _source;
private string? _error;
+ private IReadOnlyList? _codeTabs;
protected override void OnParametersSet()
{
_content = null;
_source = null;
_error = null;
+ _codeTabs = CodeTabs is { Count: > 0 } ? CodeTabs : CodeTabParser.Extract(ChildContentSource).Tabs;
if (string.IsNullOrWhiteSpace(Component))
{
diff --git a/src/ShellDocs.Components/Content/IframePreview.razor b/src/ShellDocs.Components/Content/IframePreview.razor
new file mode 100644
index 0000000..9a3a46e
--- /dev/null
+++ b/src/ShellDocs.Components/Content/IframePreview.razor
@@ -0,0 +1,63 @@
+@namespace ShellDocs.Components.Content
+
+@* A live example served by another page (e.g. Avalonia compiled to WebAssembly).
+ shelldocs.js sets the iframe's src, so nothing loads until it's wanted: when
+ scrolled into view (Lazy="visible"), on the Run button (Lazy="click") or at once
+ (Lazy="none"). It appends ?theme=light|dark and posts
+ { type: "shelldocs-theme", theme } to the frame when the site theme changes. *@
+
+
+@code {
+ [Parameter, EditorRequired] public string? Src { get; set; }
+ // Pixels ("320") or any CSS length ("24rem").
+ [Parameter] public string Height { get; set; } = "320";
+ // "visible" (default), "click" or "none".
+ [Parameter] public string Lazy { get; set; } = "visible";
+ [Parameter] public string? Title { get; set; }
+ // Anchor id; defaults to iframe-{hash of Src}.
+ [Parameter] public string? Id { get; set; }
+ [Parameter] public IReadOnlyList? CodeTabs { get; set; }
+ [Parameter] public string? SyncKey { get; set; }
+ [Parameter] public string? Caption { get; set; }
+ // Raw child markup from SlotRenderer; its blocks become code tabs.
+ [Parameter] public string? ChildContentSource { get; set; }
+
+ private string? _error;
+ private string? _id;
+ private string _lazy = "visible";
+ private string _height = "320px";
+ private IReadOnlyList? _codeTabs;
+
+ protected override void OnParametersSet()
+ {
+ _error = string.IsNullOrWhiteSpace(Src) ? "IframePreview needs a Src=\"/path/to/page\" attribute." : null;
+ _id = Id ?? $"iframe-{PreviewLinks.ShortHash(Src)}";
+ _lazy = Lazy?.Trim().ToLowerInvariant() switch
+ {
+ "click" => "click",
+ "none" or "false" => "none",
+ _ => "visible",
+ };
+ var height = Height?.Trim();
+ _height = string.IsNullOrEmpty(height) ? "320px" : height.All(char.IsAsciiDigit) ? height + "px" : height;
+ _codeTabs = CodeTabs is { Count: > 0 } ? CodeTabs : CodeTabParser.Extract(ChildContentSource).Tabs;
+ }
+
+ private RenderFragment Frame => @
}
+ switchesShown = true;
+ @foreach (var key in _switchKeys)
+ {
+
+ }
}
@foreach (var part in _parts)
{
@@ -32,6 +38,15 @@
}
else if (part is SlotPart slot)
{
+ @* Without a page header, the switch goes right above the first block that uses it. *@
+ @if (!switchesShown && slot.Slot is ComponentSlot { } first && UsesSwitchKey(first))
+ {
+ switchesShown = true;
+ @foreach (var key in _switchKeys)
+ {
+
+ }
+ }
@if (slot.Slot is ComponentSlot comp)
{
@SlotRenderer.Component(Renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, Logger)
@@ -52,6 +67,8 @@
private IReadOnlyList? _parts;
private string? _title;
private string? _description;
+ // AddSyncSwitch keys this page's blocks use and that it doesn't place itself.
+ private IReadOnlyList _switchKeys = Array.Empty();
protected override void OnParametersSet()
{
@@ -63,9 +80,24 @@
_title = NonEmpty(doc.Source.Frontmatter.GetValue("title"));
_description = _title is null ? null : NonEmpty(doc.Source.Frontmatter.GetValue("description"));
}
+ _switchKeys = doc is null ? Array.Empty() : SwitchKeys(doc);
PageState.SetDocument(doc);
}
+ private IReadOnlyList SwitchKeys(RenderedDocument doc)
+ {
+ if (Options.SyncSwitches.Count == 0) return Array.Empty();
+ var components = doc.Slots.OfType().ToList();
+ var placed = components.Where(c => c.ComponentType == typeof(SyncSwitch)).Select(SyncKeyOf).ToHashSet();
+ var used = components.Where(c => c.ComponentType != typeof(SyncSwitch)).Select(SyncKeyOf).ToHashSet();
+ return Options.SyncSwitches.Select(s => s.SyncKey).Where(k => used.Contains(k) && !placed.Contains(k)).ToList();
+ }
+
+ private bool UsesSwitchKey(ComponentSlot slot) => _switchKeys.Contains(SyncKeyOf(slot) ?? "");
+
+ private static string? SyncKeyOf(ComponentSlot slot)
+ => slot.Parameters.FirstOrDefault(p => string.Equals(p.Key, "SyncKey", StringComparison.OrdinalIgnoreCase)).Value;
+
private static bool StartsWithH1(string body)
{
foreach (var line in body.Split('\n'))
diff --git a/src/ShellDocs.Components/Content/PreviewCodeTab.cs b/src/ShellDocs.Components/Content/PreviewCodeTab.cs
new file mode 100644
index 0000000..f2038ec
--- /dev/null
+++ b/src/ShellDocs.Components/Content/PreviewCodeTab.cs
@@ -0,0 +1,105 @@
+using System.Net;
+using System.Text;
+using ShellDocs.Markdown;
+
+namespace ShellDocs.Components.Content;
+
+// One tab of a preview's Code panel, e.g. the MAUI or Avalonia version of an example.
+public sealed record PreviewCodeTab(string Label, string Code, string Language = "text");
+
+/* Reads blocks out of a preview's raw child
+ markup. A tab's code is its first fenced block (language from the fence info),
+ or the tag's text when there's no fence. */
+internal static class CodeTabParser
+{
+ private const string TagName = "CodeTab";
+
+ public static (IReadOnlyList Tabs, string Remaining) Extract(string? raw)
+ {
+ if (string.IsNullOrEmpty(raw) || !raw.Contains('<' + TagName, StringComparison.Ordinal))
+ return (Array.Empty(), raw ?? "");
+
+ var tabs = new List();
+ var rest = new StringBuilder(raw.Length);
+ var i = 0;
+ while (i < raw.Length)
+ {
+ var lt = raw.IndexOf('<' + TagName, i, StringComparison.Ordinal);
+ if (lt < 0) break;
+ if (!RazorTagScanner.TryRead(raw, lt, out var tag) || tag.IsClose || tag.Name != TagName)
+ {
+ rest.Append(raw, i, lt + 1 - i);
+ i = lt + 1;
+ continue;
+ }
+
+ rest.Append(raw, i, lt - i);
+ var body = "";
+ var end = tag.End;
+ if (!tag.IsSelfClosing)
+ {
+ var (closeStart, closeEnd) = RazorTagScanner.FindMatchingClose(raw, TagName, tag.End, StringComparison.Ordinal);
+ body = closeStart < 0 ? raw[tag.End..] : raw[tag.End..closeStart];
+ end = closeStart < 0 ? raw.Length : closeEnd;
+ }
+
+ var label = Attribute(tag, "Label");
+ if (!string.IsNullOrWhiteSpace(label))
+ tabs.Add(FromBody(label, Attribute(tag, "Language"), body));
+ i = end;
+ }
+ rest.Append(raw, i, raw.Length - i);
+ return (tabs, rest.ToString());
+ }
+
+ private static PreviewCodeTab FromBody(string label, string? language, string body)
+ {
+ var fence = SlotExtractor.FenceBlock.Match(body);
+ string code;
+ if (fence.Success)
+ {
+ code = StripIndent(fence.Groups["body"].Value, fence.Groups["indent"].Length);
+ var info = fence.Groups["lang"].Value.Trim();
+ language ??= info.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ }
+ else
+ {
+ code = Dedent(body);
+ }
+ return new PreviewCodeTab(label.Trim(), code.Trim('\r', '\n'), NormalizeLanguage(language));
+ }
+
+ private static string? Attribute(RazorTag tag, string name)
+ {
+ foreach (var (key, value) in tag.Attributes)
+ if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
+ return value is null ? null : WebUtility.HtmlDecode(value);
+ return null;
+ }
+
+ private static string NormalizeLanguage(string? language)
+ => string.IsNullOrWhiteSpace(language) ? "text" : language.Trim().ToLowerInvariant();
+
+ private static string StripIndent(string text, int indent)
+ {
+ if (indent == 0) return text;
+ var lines = text.Replace("\r\n", "\n").Split('\n');
+ for (var n = 0; n < lines.Length; n++)
+ {
+ var strip = 0;
+ while (strip < indent && strip < lines[n].Length && lines[n][strip] is ' ' or '\t') strip++;
+ lines[n] = lines[n][strip..];
+ }
+ return string.Join('\n', lines);
+ }
+
+ private static string Dedent(string text)
+ {
+ var lines = text.Replace("\r\n", "\n").Split('\n');
+ var indent = lines.Where(l => !string.IsNullOrWhiteSpace(l))
+ .Select(l => l.Length - l.TrimStart(' ', '\t').Length)
+ .DefaultIfEmpty(0)
+ .Min();
+ return StripIndent(string.Join('\n', lines), indent);
+ }
+}
diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor
index 6674748..22f3560 100644
--- a/src/ShellDocs.Components/Content/PreviewFrame.razor
+++ b/src/ShellDocs.Components/Content/PreviewFrame.razor
@@ -23,17 +23,27 @@
Preview
-
+ @if (_hasCode)
+ {
+
+ }
+ @if (!string.IsNullOrWhiteSpace(_caption))
+ {
+
@_caption
+ }
-
+ @if (_hasCode)
+ {
+
+ }
-
-
@(Code ?? Preview?.Code)
-
+ @if (_hasCode)
+ {
+
+ @if (CodeTabs is { Count: > 0 } tabs)
+ {
+ @* Same [data-tabs] contract as ; data-tabs-missing shows the note
+ below when the synced choice (e.g. from the page switch) isn't one of these tabs. *@
+
+
+ @for (var i = 0; i < tabs.Count; i++)
+ {
+ var tab = tabs[i];
+
+ }
+
+ @* Panels share one grid cell, so switching (or the missing note) never changes the height. *@
+
+ @for (var i = 0; i < tabs.Count; i++)
+ {
+ var tab = tabs[i];
+
+
@tab.Code
+
+ }
+
+ @* String expressions keep the spaces Razor would trim around the span. *@
+ @("Not available on ")@(" yet")
+
+
+
+ }
+ else
+ {
+
@(Code ?? Preview?.Code)
+ }
+
+ }
@code {
@@ -108,9 +156,19 @@
[Parameter] public string? Id { get; set; }
// Used in issue titles; defaults to the previewed component's type name.
[Parameter] public string? Name { get; set; }
+ // Replace the Code panel's single source with tabs (MAUI, Avalonia, …).
+ [Parameter] public IReadOnlyList? CodeTabs { get; set; }
+ // Code tabs with the same key switch together site-wide, like .
+ [Parameter] public string? SyncKey { get; set; }
+ // Short muted note in the toolbar; defaults to ShellDocsOptions.PreviewCaption.
+ [Parameter] public string? Caption { get; set; }
+ // "Open in new tab" target; defaults to this example's anchor on the page.
+ [Parameter] public string? OpenHref { get; set; }
private string _openHref = "";
private string _layout = "center";
+ private string? _caption;
+ private bool _hasCode;
private string? _bugHref;
private string? _suggestHref;
@@ -128,8 +186,10 @@
"scroll" => "scroll",
_ => "center",
};
+ _caption = Caption ?? Options.PreviewCaption;
+ _hasCode = CodeTabs is { Count: > 0 } || !string.IsNullOrEmpty(Code ?? Preview?.Code);
var path = DocsVersionResolver.PathOf(Nav);
- _openHref = string.IsNullOrEmpty(Id) ? path : $"{path}#{Id}";
+ _openHref = OpenHref ?? (string.IsNullOrEmpty(Id) ? path : $"{path}#{Id}");
var tracker = PreviewLinks.IssueTracker(Options);
if (tracker is null)
diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor.css b/src/ShellDocs.Components/Content/PreviewFrame.razor.css
index 30a0710..ae97b42 100644
--- a/src/ShellDocs.Components/Content/PreviewFrame.razor.css
+++ b/src/ShellDocs.Components/Content/PreviewFrame.razor.css
@@ -237,8 +237,68 @@
color: inherit !important;
}
+/* Caption: a short muted note between the tabs and the actions. */
+.preview-caption {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: right;
+ font-size: 0.75rem;
+ color: var(--muted-foreground);
+}
+
+/* Code tabs (MAUI | Avalonia …): a segmented bar that stays visible while the code scrolls. */
+.preview-code-tabs {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ padding: 0.4rem 0.5rem;
+ border-bottom: 1px solid var(--border);
+ background: color-mix(in oklch, var(--card) 55%, var(--background));
+}
+.preview-code-tab {
+ padding: 0.2rem 0.6rem;
+ border: 1px solid transparent;
+ border-radius: calc(var(--radius) - 3px);
+ background: transparent;
+ color: var(--muted-foreground);
+ font-family: inherit;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 150ms, background 150ms, border-color 150ms;
+}
+.preview-code-tab:hover { color: var(--foreground); }
+.preview-code-tab:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
+.preview-code-tab[aria-selected="true"] {
+ color: var(--foreground);
+ background: var(--background);
+ border-color: var(--border);
+}
+
+/* Every variant shares one grid cell, so the block keeps the tallest one's height;
+ hidden ones stay in layout, invisible. */
+.preview-code-stack { display: grid; }
+.preview-code-variant { grid-area: 1 / 1; min-width: 0; }
+.preview-code-variant[hidden] { display: block; visibility: hidden; }
+.preview-code-missing {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem 1rem;
+ font-size: 0.8125rem;
+ color: var(--muted-foreground);
+}
+.preview-code-missing[hidden] { display: flex; }
+
@media (max-width: 480px) {
.preview-tab span { display: none; }
+ .preview-caption { display: none; }
/* Phones: give the example the width; 1.5rem a side leaves little room for an OTP row. */
.preview-render { padding: 1.5rem 0.75rem; min-height: 10rem; }
}
diff --git a/src/ShellDocs.Components/Content/SlotRenderer.cs b/src/ShellDocs.Components/Content/SlotRenderer.cs
index cdc25f1..8830fbd 100644
--- a/src/ShellDocs.Components/Content/SlotRenderer.cs
+++ b/src/ShellDocs.Components/Content/SlotRenderer.cs
@@ -20,6 +20,8 @@ public static RenderFragment FromMarkup(MarkdownRenderer renderer, string raw, I
/* Markdig treats 4+ space indentation as a code block, so children
authored at the outer tag's indent would render as
. */
var doc = renderer.Render(Dedent(raw));
+ // As in MDX, a body on the tags' own line is inline: v1 gets no
.
+ if (!raw.Contains('\n')) doc = doc with { Html = Unwrap(doc.Html) };
var parts = SlotSplitter.Split(doc);
var seq = 0;
foreach (var part in parts)
@@ -35,6 +37,15 @@ authored at the outer tag's indent would render as
. */
}
};
+ // The inside of a single
…
; anything else is returned as is.
+ internal static string Unwrap(string html)
+ {
+ var t = html.Trim();
+ if (!t.StartsWith("
", StringComparison.Ordinal) ? html : inner;
+ }
+
// Child content inside a razor:preview is Razor, not markdown.
public static RenderFragment FromRazor(MarkdownRenderer renderer, string raw, ILogger? logger = null)
{
@@ -216,7 +227,8 @@ RenderFragment Fragment(string raw) => razorChildren
// missing or templated (RenderFragment) one would throw.
if (props.TryGetValue("ChildContent", out var cc) && cc.PropertyType == typeof(RenderFragment))
dict["ChildContent"] = Fragment(remaining);
- else
+ // Components that read their raw body (code tabs) take it as ChildContentSource below.
+ else if (!props.ContainsKey("ChildContentSource"))
Skip(logger, componentType, "ChildContent", "component has no RenderFragment ChildContent parameter");
}
diff --git a/src/ShellDocs.Components/Content/SyncSwitch.razor b/src/ShellDocs.Components/Content/SyncSwitch.razor
new file mode 100644
index 0000000..af7ee73
--- /dev/null
+++ b/src/ShellDocs.Components/Content/SyncSwitch.razor
@@ -0,0 +1,35 @@
+@namespace ShellDocs.Components.Content
+@inject ShellDocsOptions Site
+
+@* A [data-tabs] group with no panels: choosing an option syncs every group with the
+ same key and saves it, through the same shelldocs.js code as .
+ data-tabs-switch lets groups with no saved choice yet follow its default.
+ MarkdownContent adds one under the page header for each AddSyncSwitch key the
+ page uses; write to place it yourself. *@
+@if (_options.Count > 1)
+{
+
+ @for (var i = 0; i < _options.Count; i++)
+ {
+ var option = _options[i];
+
+ }
+
+}
+
+@code {
+ [Parameter, EditorRequired] public string SyncKey { get; set; } = "";
+ // Comma-separated; defaults to the options of the AddSyncSwitch with this key.
+ [Parameter] public string? Options { get; set; }
+
+ private IReadOnlyList _options = Array.Empty();
+
+ protected override void OnParametersSet()
+ {
+ _options = !string.IsNullOrWhiteSpace(Options)
+ ? Options.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ : Site.SyncSwitches.FirstOrDefault(s => s.SyncKey == SyncKey)?.Options ?? Array.Empty();
+ }
+}
diff --git a/src/ShellDocs.Components/Content/SyncSwitch.razor.css b/src/ShellDocs.Components/Content/SyncSwitch.razor.css
new file mode 100644
index 0000000..5dc9dbb
--- /dev/null
+++ b/src/ShellDocs.Components/Content/SyncSwitch.razor.css
@@ -0,0 +1,32 @@
+/* Underlined tabs under the page header, like shadcn/ui's library switch. */
+.sync-switch {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.25rem;
+ margin: 0 0 1.75rem;
+}
+.sync-switch-option {
+ position: relative;
+ padding: 0.35rem 0 0.45rem;
+ border: 0;
+ background: transparent;
+ color: var(--muted-foreground);
+ font: inherit;
+ font-size: 0.9375rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: color 150ms;
+}
+.sync-switch-option:hover { color: var(--foreground); }
+.sync-switch-option[aria-selected="true"] { color: var(--foreground); }
+.sync-switch-option[aria-selected="true"]::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 2px;
+ border-radius: 1px;
+ background: var(--foreground);
+}
+.sync-switch-option:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 2px; }
diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs
index a220e9b..9ddc39c 100644
--- a/src/ShellDocs.Components/ShellDocsOptions.cs
+++ b/src/ShellDocs.Components/ShellDocsOptions.cs
@@ -17,6 +17,11 @@ public class ShellDocsOptions
// (title and body appended as query params). Defaults to GitHubRepo's issues/new;
// with neither set, those items are hidden.
public string? IssueTrackerUrl { get; set; }
+ // Default note in every preview's toolbar, e.g. "Rendered with ShellUI for Blazor".
+ // A preview's own Caption replaces it.
+ public string? PreviewCaption { get; set; }
+ // Page-level tab rows that drive a code-tab SyncKey site-wide (AddSyncSwitch).
+ public List SyncSwitches { get; } = new();
public string? LogoLight { get; set; }
public string? LogoDark { get; set; }
@@ -149,6 +154,19 @@ public ShellDocsOptions AddRedirect(string from, string to, bool permanent = tru
return this;
}
+ // AddSyncSwitch("platform", "MAUI", "Avalonia"): a tab row under the page header, on
+ // pages whose blocks use SyncKey="platform", that picks the tab every ,
+ // and preview code panel with that key shows.
+ public ShellDocsOptions AddSyncSwitch(string syncKey, params string[] options)
+ {
+ if (string.IsNullOrWhiteSpace(syncKey))
+ throw new ArgumentException("syncKey must be non-empty.", nameof(syncKey));
+ if (options is not { Length: >= 2 })
+ throw new ArgumentException("A sync switch needs at least two options.", nameof(options));
+ SyncSwitches.Add(new DocsSyncSwitch(syncKey, options));
+ return this;
+ }
+
public ShellDocsOptions AddNavLink(string label, string href)
{
PrimaryNav.Add(new NavLink(label, href));
@@ -249,6 +267,8 @@ public record DocsVersion(string Id, string Label, string RootUrl, string? Descr
public record DocsRedirectRule(string From, string To, bool Permanent = true);
+public record DocsSyncSwitch(string SyncKey, IReadOnlyList Options);
+
public enum ShellDocsTheme
{
Shadcn,
diff --git a/src/ShellDocs.Components/wwwroot/shelldocs-theme.css b/src/ShellDocs.Components/wwwroot/shelldocs-theme.css
index 8b76a99..6482846 100644
--- a/src/ShellDocs.Components/wwwroot/shelldocs-theme.css
+++ b/src/ShellDocs.Components/wwwroot/shelldocs-theme.css
@@ -169,6 +169,9 @@ body { margin: 0; min-height: 100vh; }
font-feature-settings: 'ss01', 'cv02';
}
+/* fills its preview panel edge to edge. */
+.preview-render:has(> [data-iframe-preview]) { padding: 0; min-height: 0; overflow: hidden; }
+
/* A component in markdown that can't render (e.g. a generic one whose type
argument doesn't resolve) shows this instead of breaking the page. */
.shelldocs-render-error {
diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js
index 2b0259d..07d218c 100644
--- a/src/ShellDocs.Components/wwwroot/shelldocs.js
+++ b/src/ShellDocs.Components/wwwroot/shelldocs.js
@@ -83,6 +83,7 @@ window.shelldocsTheme = (function () {
subscribers.slice().forEach(function (ref) {
ref.invokeMethodAsync('ThemeChanged', dark).catch(function () {});
});
+ if (window.shelldocsIframes) window.shelldocsIframes.postTheme(dark);
}
if (window.MutationObserver) {
@@ -115,6 +116,65 @@ window.shelldocsTheme = (function () {
})();
window.shelldocsApplyTheme = window.shelldocsTheme.apply;
+/* : shelldocs.js sets each [data-iframe-preview] iframe's src, with
+ ?theme=light|dark, when it scrolls into view (data-lazy="visible"), on the Run
+ button ("click") or at once ("none"), and posts theme changes to loaded frames. */
+window.shelldocsIframes = (function () {
+ var observer = null;
+
+ function theme() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }
+
+ function load(host) {
+ if (!host || host.getAttribute('data-loaded') === 'true') return;
+ var frame = host.querySelector('iframe');
+ var src = host.getAttribute('data-src');
+ if (!frame || !src) return;
+ var url = new URL(src, document.baseURI);
+ url.searchParams.set('theme', theme());
+ frame.src = url.href;
+ host.setAttribute('data-loaded', 'true');
+ }
+
+ function watch(host) {
+ if (!window.IntersectionObserver) { load(host); return; }
+ observer = observer || new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (!entry.isIntersecting) return;
+ observer.unobserve(entry.target);
+ load(entry.target);
+ });
+ }, { rootMargin: '200px 0px' });
+ observer.observe(host);
+ }
+
+ function init(scope) {
+ if (!scope || !scope.querySelectorAll) return;
+ var hosts = Array.prototype.slice.call(scope.querySelectorAll('[data-iframe-preview]'));
+ if (scope.matches && scope.matches('[data-iframe-preview]')) hosts.unshift(scope);
+ hosts.forEach(function (host) {
+ if (host.__shelldocsIframe) return;
+ host.__shelldocsIframe = true;
+ var lazy = host.getAttribute('data-lazy');
+ if (lazy === 'none') load(host);
+ else if (lazy !== 'click') watch(host);
+ });
+ }
+
+ function postTheme(dark) {
+ var message = { type: 'shelldocs-theme', theme: dark ? 'dark' : 'light' };
+ document.querySelectorAll('[data-iframe-preview][data-loaded="true"] iframe').forEach(function (frame) {
+ try { frame.contentWindow.postMessage(message, new URL(frame.src).origin); } catch (e) {}
+ });
+ }
+
+ document.addEventListener('click', function (e) {
+ var run = e.target.closest && e.target.closest('[data-iframe-run]');
+ if (run) load(run.closest('[data-iframe-preview]'));
+ });
+
+ return { init: init, load: load, postTheme: postTheme };
+})();
+
/* Shiki highlighting. The source
belongs to Blazor (or to a markup block
Blazor tracks), so it is never replaced or edited: it gets [data-shiki="source"]
(hidden by CSS) and Shiki's output goes into a JS-owned sibling
@@ -122,6 +182,9 @@ window.shelldocsApplyTheme = window.shelldocsTheme.apply;
detached node — stale code after navigation — and could throw on replaceChild.
Each pass re-renders an output whose source text changed and drops orphans. */
+// Fence languages Shiki knows under another name; XAML is highlighted as XML.
+var LANG_ALIASES = { xaml: 'xml', axaml: 'xml', cs: 'csharp', 'c#': 'csharp' };
+
function langOf(codeEl) {
var cls = (codeEl.className || '').split(/\s+/);
for (var i = 0; i < cls.length; i++) {
@@ -139,7 +202,8 @@ function highlightOne(preEl) {
if (!preEl || !window.__shiki || !preEl.parentNode || preEl.hasAttribute('data-shiki-output')) return;
var code = preEl.querySelector('code');
if (!code) return;
- var lang = langOf(code);
+ var declared = langOf(code);
+ var lang = LANG_ALIASES[declared] || declared;
if (!lang) return;
/* Languages Shiki hasn't loaded stay as plain text. */
if (!window.__shiki.getLoadedLanguages().includes(lang)) return;
@@ -451,7 +515,12 @@ window.shelldocsChrome = (function () {
var copy = e.target.closest('[data-preview-copy]');
if (copy) {
var frame = copy.closest('.preview-frame');
- var code = frame && frame.querySelector('[data-preview-panel="code"] code');
+ var panel = frame && frame.querySelector('[data-preview-panel="code"]');
+ // With code tabs, the visible one (none while the "not available" note shows).
+ var source = panel && panel.querySelector('[data-tabs-missing]')
+ ? panel.querySelector('[data-tab-panel]:not([hidden])')
+ : panel;
+ var code = source && source.querySelector('code');
if (!code) return;
// textContent: the code panel may be display:none, where innerText loses layout.
var text = code.textContent;
@@ -494,22 +563,37 @@ window.shelldocsChrome = (function () {
});
}
+ /* A group without the value is left alone, except preview code tabs
+ ([data-tabs-missing]): they show "Not available on {value} yet" instead. */
function selectTabsValue(root, value, focus) {
var buttons = ownTabParts(root, '[data-tab-target]');
- if (!buttons.some(function (b) { return b.getAttribute('data-tab-target') === value; })) return false;
+ var has = buttons.some(function (b) { return b.getAttribute('data-tab-target') === value; });
+ if (!has && !root.hasAttribute('data-tabs-missing')) return false;
root.setAttribute('data-tabs-value', value);
- buttons.forEach(function (b) {
+ buttons.forEach(function (b, i) {
var selected = b.getAttribute('data-tab-target') === value;
b.setAttribute('aria-selected', selected ? 'true' : 'false');
- b.setAttribute('tabindex', selected ? '0' : '-1');
+ b.setAttribute('tabindex', selected || (!has && i === 0) ? '0' : '-1');
if (selected && focus) b.focus();
});
ownTabParts(root, '[data-tab-panel]').forEach(function (panel) {
panel.hidden = panel.getAttribute('data-tab-panel') !== value;
});
+ ownTabParts(root, '[data-tab-missing]').forEach(function (note) {
+ note.hidden = has;
+ var label = note.querySelector('[data-tab-missing-label]');
+ if (label && !has) label.textContent = value;
+ });
return true;
}
+ // The page switch () for a key, if the page has one.
+ function switchFor(sync) {
+ return Array.prototype.find.call(document.querySelectorAll('[data-tabs-switch]'), function (sw) {
+ return sw.getAttribute('data-tabs-sync') === sync;
+ }) || null;
+ }
+
function chooseTab(root, value, focus) {
if (!selectTabsValue(root, value, focus)) return;
var sync = root.getAttribute('data-tabs-sync');
@@ -530,7 +614,12 @@ window.shelldocsChrome = (function () {
roots.forEach(function (root) {
var sync = root.getAttribute('data-tabs-sync');
var saved = null;
- if (sync) { try { saved = localStorage.getItem(TAB_STORE + sync); } catch (e) {} }
+ if (sync) {
+ try { saved = localStorage.getItem(TAB_STORE + sync); } catch (e) {}
+ // Nothing chosen yet: follow the page switch's default, so blocks agree with it.
+ var sw = saved ? null : switchFor(sync);
+ if (sw && sw !== root) saved = sw.getAttribute('data-tabs-value');
+ }
else if (root.id) saved = tabMemory[location.pathname + '#' + root.id];
if (saved && root.getAttribute('data-tabs-value') !== saved) selectTabsValue(root, saved, false);
});
@@ -578,6 +667,7 @@ window.shelldocsChrome = (function () {
if (added[n].nodeType !== 1) continue;
restorePreviewTabs(added[n]);
restoreTabs(added[n]);
+ window.shelldocsIframes.init(added[n]);
restoreMobileNav(added[n]);
}
}
@@ -662,6 +752,7 @@ window.shelldocsChrome = (function () {
function initPage() {
initToc();
restoreTabs(document);
+ window.shelldocsIframes.init(document);
}
if (document.readyState !== 'loading') {
diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs
index 36ce40d..dd6245a 100644
--- a/src/ShellDocs.Markdown/SlotExtractor.cs
+++ b/src/ShellDocs.Markdown/SlotExtractor.cs
@@ -10,7 +10,7 @@ internal class SlotExtractor
/* 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(
+ internal static readonly Regex FenceBlock = new(
@"^(?[ \t]*)(?`{3,}|(?~{3,}))(?(?(tilde)(?!~)[^\r\n]*|[^`\r\n]*))\r?\n" +
@"(?:(?[\s\S]*?)\r?\n)??\k\k(?(tilde)~*|`*)[ \t]*(?=\r?\n|$)",
RegexOptions.Multiline | RegexOptions.Compiled);
diff --git a/src/ShellDocs.Templates/ScaffoldTemplates.cs b/src/ShellDocs.Templates/ScaffoldTemplates.cs
index 4bf6382..8f9c0a5 100644
--- a/src/ShellDocs.Templates/ScaffoldTemplates.cs
+++ b/src/ShellDocs.Templates/ScaffoldTemplates.cs
@@ -209,7 +209,7 @@ public static string ProgramAddShellDocs(string siteName, string githubRepo) =>
import { createHighlighter } from 'https://esm.sh/shiki@1.24.0';
window.__shiki = await createHighlighter({
themes: ['github-light', 'github-dark'],
- langs: ['razor', 'csharp', 'html', 'json', 'yaml', 'bash', 'typescript', 'javascript', 'markdown']
+ langs: ['razor', 'csharp', 'html', 'xml', 'json', 'yaml', 'bash', 'typescript', 'javascript', 'markdown']
});
if (window.shelldocsHighlight) window.shelldocsHighlight();
diff --git a/tests/ShellDocs.Tests/InlineChildContentTests.cs b/tests/ShellDocs.Tests/InlineChildContentTests.cs
new file mode 100644
index 0000000..e0d8cc2
--- /dev/null
+++ b/tests/ShellDocs.Tests/InlineChildContentTests.cs
@@ -0,0 +1,39 @@
+using System.Text.RegularExpressions;
+using ShellDocs.Components.Content;
+using Xunit;
+
+namespace ShellDocs.Tests;
+
+public class InlineChildContentTests
+{
+ private static Task Render(string markdown) => new ComponentRenderHarness(o =>
+ {
+ o.ContentRoot = Path.Combine(Path.GetTempPath(), "shelldocs-no-content-" + Guid.NewGuid().ToString("N"));
+ o.RegisterComponent();
+ }).RenderAsync(new() { ["Markdown"] = markdown });
+
+ [Fact]
+ public async Task BodyOnTheTagsLine_StaysInline()
+ {
+ var html = await Render("Status: v0.3.2 **stable** and more.\n");
+
+ Assert.Matches("]*>v0.3.2 stable", html);
+ Assert.DoesNotMatch("]*>
", html);
+ }
+
+ [Fact]
+ public async Task BodyOnItsOwnLines_KeepsParagraphs()
+ {
+ var html = await Render("\nFirst paragraph.\n\nSecond paragraph.\n\n");
+
+ Assert.Contains("