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 `

`.** `v0.3.2 stable` rendered a paragraph inside the badge, so the page's paragraph margin and colour overrode it. As in MDX, a body on the same line as its tags now renders inline; a body on its own lines still becomes paragraphs. + ## [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. @@ -380,7 +400,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.12-alpha...HEAD +[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.13-alpha...HEAD +[0.1.13-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.13-alpha [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 diff --git a/Directory.Build.props b/Directory.Build.props index 89de84f..f21553d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ - 0.1.12-alpha + 0.1.13-alpha ShellUI ShellUI Copyright © 2026 ShellUI diff --git a/README.md b/README.md index 78dc291..9b8fd8a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ o.AddRedirect("/docs/v0.3.0", "/docs/v0.3"); // also /docs/v0.3.0/x → /docs/ - **Child content is Razor.** Component bodies inside a fence are parsed like the fence itself, so `

…
` keeps its structure and nothing is 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
+
+        
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 => @
+ + @if (_lazy == "click") + { + + } +
; +} diff --git a/src/ShellDocs.Components/Content/IframePreview.razor.css b/src/ShellDocs.Components/Content/IframePreview.razor.css new file mode 100644 index 0000000..778101b --- /dev/null +++ b/src/ShellDocs.Components/Content/IframePreview.razor.css @@ -0,0 +1,30 @@ +.iframe-preview { + position: relative; + width: 100%; + min-height: 8rem; +} +.iframe-preview-frame { + display: block; + width: 100%; + height: 100%; + border: 0; + background: transparent; +} +/* Lazy="click": the button covers the empty frame until clicked. */ +.iframe-preview-run { + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + padding: 0.45rem 0.9rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + background: var(--background); + color: var(--foreground); + font: inherit; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; +} +.iframe-preview-run:hover { background: var(--muted); } +.iframe-preview-run:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.iframe-preview[data-loaded="true"] .iframe-preview-run { display: none; } diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor b/src/ShellDocs.Components/Content/MarkdownContent.razor index 1354526..86185fd 100644 --- a/src/ShellDocs.Components/Content/MarkdownContent.razor +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor @@ -14,6 +14,7 @@ { @* Ordinal ids keep anchors and saved tab state stable across renders. *@ var previewIndex = 0; + var switchesShown = false; @if (_title is not null) {
@@ -23,6 +24,11 @@

@_description

}
+ 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]; + + } + +
+
+ } + 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) || !t.EndsWith("

", StringComparison.Ordinal)) return html; + var inner = t[3..^4]; + return inner.Contains("

", StringComparison.Ordinal) || inner.Contains("

", 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("

First paragraph.

", html); + Assert.Contains("

Second paragraph.

", html); + } + + [Theory] + [InlineData("

one

\n", "one")] + [InlineData("

a

\n

b

\n", "

a

\n

b

\n")] + [InlineData("
  • x
", "
  • x
")] + public void Unwrap_OnlyStripsASingleParagraph(string html, string expected) + => Assert.Equal(expected, SlotRenderer.Unwrap(html)); +} diff --git a/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs b/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs new file mode 100644 index 0000000..c682765 --- /dev/null +++ b/tests/ShellDocs.Tests/PreviewCodeVariantsTests.cs @@ -0,0 +1,257 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using ShellDocs.Components; +using ShellDocs.Components.Chrome; +using ShellDocs.Components.Content; +using Xunit; + +namespace ShellDocs.Tests; + +public class CodeTabParserTests +{ + [Fact] + public void ReadsLabelLanguageAndCode_FromFences_AndKeepsTheRest() + { + var raw = """ + + + ```xml + "); + Assert.Empty(none); + Assert.Equal("", rest); + } +} + +public class PreviewCodeVariantsTests : IDisposable +{ + private readonly string _demoRoot; + + public PreviewCodeVariantsTests() + { + _demoRoot = Path.Combine(Path.GetTempPath(), "shelldocs-variants-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_demoRoot); + File.WriteAllText(Path.Combine(_demoRoot, "SaveDemo.razor"), ""); + } + + public void Dispose() + { + try { Directory.Delete(_demoRoot, recursive: true); } catch { } + } + + public class SaveDemo : ComponentBase + { + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.OpenElement(0, "span"); + b.AddAttribute(1, "class", "save-demo"); + b.CloseElement(); + } + } + + private ComponentRenderHarness Harness(Action? extra = null) => new(o => + { + o.ContentRoot = Path.Combine(_demoRoot, "no-content"); + o.DemoSourceRoot = _demoRoot; + o.RegisterComponent", html); + Assert.Contains("data-tab-target=\"Avalonia\"", html); + Assert.DoesNotMatch("]*data-variant[^>]*>[^<]*MAUI", html); + } + + [Fact] + public async Task Caption_DefaultsToTheSiteOption_AndAPreviewCanOverrideIt() + { + var harness = Harness(o => o.PreviewCaption = "Rendered with ShellUI for Blazor"); + + var byDefault = await harness.RenderAsync(new() { ["Component"] = "SaveDemo" }); + Assert.Contains(">Rendered with ShellUI for Blazor", byDefault); + + var own = await harness.RenderAsync(new() { ["Component"] = "SaveDemo", ["Caption"] = "Blazor" }); + Assert.Contains(">Blazor", own); + } + + [Fact] + public async Task SyncSwitch_GoesUnderThePageHeader_OnPagesThatUseItsKey() + { + var harness = Harness(o => + { + o.RenderPageTitle = true; + o.AddSyncSwitch("platform", "MAUI", "Avalonia"); + }); + var demo = "\n" + PlatformTabs + "\n\n"; + + var page = await harness.RenderAsync(Md("---\ntitle: Button\ndescription: Saves.\n---\n\nIntro.\n\n" + demo)); + Assert.Matches("\\s*
]*data-tabs data-tabs-switch data-tabs-sync=\"platform\" data-tabs-value=\"MAUI\"", page); + Assert.Matches("data-tab-target=\"Avalonia\"[^>]*>Avalonia", page); + Assert.Single(Regex.Matches(page, "data-tabs-switch")); + Assert.DoesNotContain("blazor:onclick", page); + + // Without a page header it goes right above the first block that uses the key. + var noHeader = await harness.RenderAsync(Md("# Button\n\nIntro.\n\n" + demo)); + Assert.Matches("Intro.

\\s*
]*data-tabs-switch", noHeader); + + // Placed by hand: not added a second time. + var manual = await harness.RenderAsync(Md("# Button\n\n\n\n" + demo)); + Assert.Single(Regex.Matches(manual, "data-tabs-switch")); + + var otherKey = await harness.RenderAsync(Md("---\ntitle: Button\n---\n\n\n")); + Assert.DoesNotContain("data-tabs-switch", otherKey); + + var chrome = await harness.RenderAsync(); + Assert.DoesNotContain("data-tabs-switch", chrome); + + var inline = await Harness().RenderAsync(new() { ["SyncKey"] = "pm", ["Options"] = "npm, pnpm" }); + Assert.Contains("data-tab-target=\"pnpm\"", inline); + + Assert.Throws(() => new ShellDocsOptions().AddSyncSwitch("platform", "MAUI")); + } + + [Fact] + public async Task IframePreview_LeavesLoadingToShellDocsJs() + { + var html = await Harness().RenderAsync(new() { ["Src"] = "/preview/?c=button" }); + + Assert.Matches("class=\"iframe-preview\"[^>]*data-iframe-preview data-src=\"/preview/\\?c=button\" data-lazy=\"visible\" style=\"height:320px\"", html); + Assert.DoesNotMatch("]*\\ssrc=", html); + Assert.Contains("data-layout=\"stretch\"", html); + Assert.DoesNotContain("data-preview-tab-target=\"code\"", html); // no code tabs, no Code tab + Assert.DoesNotContain("data-preview-copy", html); + Assert.Contains("href=\"/preview/?c=button\"", html); // Open in new tab + Assert.DoesNotContain("data-iframe-run", html); + } + + [Fact] + public async Task IframePreview_ClickMode_Height_CodeTabs_AndMissingSrc() + { + var harness = Harness(); + var html = await harness.RenderAsync(Md( + "\n" + PlatformTabs + "\n\n")); + + Assert.Contains("data-lazy=\"click\" style=\"height:24rem\"", html); + Assert.Matches("data-iframe-run[^>]*>Run live preview", html); + Assert.Contains("data-preview-tab-target=\"code\"", html); + Assert.Contains("data-tabs-sync=\"platform\"", html); + Assert.Contains("data-tab-target=\"MAUI\"", html); + Assert.DoesNotContain(harness.Logs.Messages, m => m.Contains("skipped attribute")); + + var missing = await harness.RenderAsync(new()); + Assert.Contains("IframePreview needs a Src", missing); + } + + [Fact] + public void ShelldocsJs_ShowsTheMissingNote_AndLoadsIframesLazily() + { + var js = ReadAsset("shelldocs.js"); + + Assert.Contains("root.hasAttribute('data-tabs-missing')", js); + Assert.Contains("[data-tab-missing-label]", js); + Assert.Contains("data-tabs-switch", js); + Assert.Contains("url.searchParams.set('theme', theme())", js); + Assert.Contains("type: 'shelldocs-theme'", js); + Assert.Contains("xaml: 'xml'", js); + Assert.Matches(new Regex("IntersectionObserver"), js); + } + + private static string ReadAsset(string name) + { + var testDir = Path.GetDirectoryName(typeof(PreviewCodeVariantsTests).Assembly.Location)!; + foreach (var candidate in new[] + { + Path.Combine(testDir, "wwwroot", "_content", "ShellDocs.Components", name), + Path.Combine(testDir, "..", "..", "..", "..", "..", "src", "ShellDocs.Components", "wwwroot", name) + }) + { + var full = Path.GetFullPath(candidate); + if (File.Exists(full)) return File.ReadAllText(full); + } + throw new FileNotFoundException(name); + } +}