diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ffc2c..336cfa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +### Added + +- **Preview layout.** `razor:preview stretch` (fence info string), or `Layout="stretch"` on `` / `` / `PreviewFrame`, lets block-level components (charts, inputs, tables) fill the frame instead of shrinking to their content. `center` stays the default. +- **`not-prose` class.** Every `.shelldocs-prose` rule skips `.not-prose` subtrees. Preview frames carry it; add it to any element that should keep page typography out. + +### Changed + +- **`razor:preview` child content is parsed as Razor, not markdown.** Component bodies inside a fence (and `` bodies) go through the same node parser as the fence itself: elements wrap nested components exactly as written and nothing is wrapped in `

`. Inline component tags in prose still take markdown bodies. +- **`ThemeToggle` works without a Blazor runtime.** Clicks are handled by `shelldocs.js` and both icons render, with CSS picking one from ``. + +### Fixed + +- **Prose styles leaked into previews.** Paragraph margins, list padding, heading sizes and link underlines from `.shelldocs-prose` hit live components and beat Tailwind's layered utilities (gaps inside cards and menus, underlined nav links). +- **Nested markup inside a preview component was mangled.** `

…
` closed the `
` before the nested component and wrapped loose text in `

`. +- **Theme state only followed ShellDocs' own toggle.** A component library flipping `` left the stored theme and `ThemeState` stale. `shelldocs.js` now watches the class, saves it under `shelldocs-theme` and pushes it into `ThemeState`. +- **`enhancedload` handlers never ran.** Blazor raises the event through `Blazor.addEventListener`, not as a DOM event, so re-applying the theme, re-attaching the TOC and closing the mobile nav after enhanced navigation (static SSR apps) silently did nothing. + ## [0.1.9-alpha] — 2026-10-02 A Preview | Code toolbar for every example, fixes for chrome that reset or ignored early clicks, consumer components no longer losing to built-ins with the same name, and code spans that stay code. diff --git a/README.md b/README.md index 37c5845..627b93d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds ``` Your components win name collisions with ShellDocs' built-ins, which stay available as ``, ``, ``, and so on. - **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`. -- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections, the mobile menu, selectors, tabs, preview toolbars, the TOC and code copy work there through `shelldocs.js`. Search, the theme-toggle button, desktop sidebar collapse and stateful demos need a running Blazor app. +- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections, the mobile menu, selectors, tabs, preview toolbars, the TOC and code copy work there through `shelldocs.js`. So does the theme toggle. Search, desktop sidebar collapse and stateful demos need a running Blazor app. ## Versioned docs @@ -69,6 +69,9 @@ It's all server-rendered links plus `shelldocs.js`, so it works on static hosts. - **Preview | Code toolbar on every example**, with copy and a ⋯ menu: *Open in new tab*, *Report a bug*, *Suggest something*. The issue links go to `https://github.com/{GitHubRepo}/issues/new`, pre-filled with the example and page URL; point them elsewhere with `o.IssueTrackerUrl`. - **`razor:preview` fences render everything.** Several sibling components, HTML wrappers (`

…
`) and text render in order inside one frame, and the Code tab shows the whole fence. +- **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. +- **Centred or stretched.** Examples are centred; `razor:preview stretch` (or `Layout="stretch"` on `` / ``) lets charts, inputs and tables fill the frame. - **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. - **Inline code stays code.** `` ` @code { - protected override void OnInitialized() => Theme.OnChange += StateHasChanged; + private DotNetObjectReference? _ref; + private IJSObjectReference? _subscription; + // Keeps ThemeState in step with the dark class, whoever flips it. protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || Theme.IsInitialized) return; + if (!firstRender) return; try { - var current = await JS.InvokeAsync("eval", - "document.documentElement.classList.contains('dark') ? 'dark' : 'light'"); - Theme.Init(current == "dark"); + _ref = DotNetObjectReference.Create(this); + _subscription = await JS.InvokeAsync("shelldocsTheme.subscribe", _ref); + var dark = await JS.InvokeAsync("shelldocsTheme.isDark"); + if (Theme.IsInitialized) Theme.Set(dark); + else Theme.Init(dark); } catch { } } - private async Task OnClick() + [JSInvokable] + public void ThemeChanged(bool isDark) => Theme.Set(isDark); + + public async ValueTask DisposeAsync() { - Theme.Toggle(); try { - await JS.InvokeVoidAsync("eval", Theme.IsDark - ? "document.documentElement.classList.add('dark'); try { localStorage.setItem('shelldocs-theme','dark'); } catch{}" - : "document.documentElement.classList.remove('dark'); try { localStorage.setItem('shelldocs-theme','light'); } catch{}"); + if (_subscription is not null) + { + await _subscription.InvokeVoidAsync("dispose"); + await _subscription.DisposeAsync(); + } } catch { } + _ref?.Dispose(); } - - public void Dispose() => Theme.OnChange -= StateHasChanged; } diff --git a/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css b/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css index 63e2dfe..193f9c4 100644 --- a/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css +++ b/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css @@ -15,3 +15,8 @@ .theme-toggle:hover { color: var(--foreground); background: var(--muted); } .theme-toggle:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .theme-toggle ::deep svg { width: 1rem; height: 1rem; } + +/* Both icons render; the dark class picks one, so a theme flipped by any script shows here. */ +.theme-toggle ::deep .theme-icon-dark { display: none; } +:root.dark .theme-toggle ::deep .theme-icon-light { display: none; } +:root.dark .theme-toggle ::deep .theme-icon-dark { display: inline; } diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor b/src/ShellDocs.Components/Content/ComponentPreview.razor index f09d145..cb17061 100644 --- a/src/ShellDocs.Components/Content/ComponentPreview.razor +++ b/src/ShellDocs.Components/Content/ComponentPreview.razor @@ -3,6 +3,7 @@ @using System.Text @using ShellDocs.Markdown @inject TypeRegistry Registry +@inject MarkdownRenderer Renderer @inject ILogger Logger ." : null)" ErrorTitle="ComponentPreview error" Id="@_id" - Name="@Component" /> + Name="@Component" + Layout="@Layout" /> @code { [Parameter, EditorRequired] public string? Component { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; } // Raw child markup from SlotRenderer, used to rebuild the source view. [Parameter] public string? ChildContentSource { get; set; } + // "center" (default) or "stretch"; see PreviewFrame. + [Parameter] public string? Layout { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? ExtraProps { get; set; } @@ -73,7 +77,18 @@ } } } - if (ChildContent is not null) dict["ChildContent"] = ChildContent; + // 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)) + { + var empty = new Dictionary(); + foreach (var (k, v) in SlotRenderer.BuildParameters(Renderer, target, empty, ChildContentSource, Logger, razorChildren: true)) + dict[k] = v; + } + else if (ChildContent is not null) + { + dict["ChildContent"] = ChildContent; + } return dict; } diff --git a/src/ShellDocs.Components/Content/DemoPreview.razor b/src/ShellDocs.Components/Content/DemoPreview.razor index 3b56a08..cad06ad 100644 --- a/src/ShellDocs.Components/Content/DemoPreview.razor +++ b/src/ShellDocs.Components/Content/DemoPreview.razor @@ -10,13 +10,16 @@ Error="@_error" ErrorTitle="DemoPreview error" Id="@(Id ?? $"demo-{PreviewLinks.Slug(Component)}")" - Name="@Component" /> + Name="@Component" + Layout="@Layout" /> @code { [Parameter, EditorRequired] public string? Component { get; set; } [Parameter] public string? Title { get; set; } // Anchor id; defaults to demo-{component-slug}. Set it when a page shows the same demo twice. [Parameter] public string? Id { get; set; } + // "center" (default) or "stretch"; see PreviewFrame. + [Parameter] public string? Layout { get; set; } private RenderFragment? _content; private string? _source; diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor index d80501f..5bf4f12 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -10,7 +10,8 @@ so they work without a Blazor runtime. Both panels are always rendered; CSS shows the one named by [data-preview-tab]. Fed either a PreviewSlot (razor:preview fence) or Content + Code (DemoPreview, ComponentPreview). *@ -

+@* not-prose: page typography stays out of live examples. *@ +
@if (!string.IsNullOrEmpty(Title)) {
@Title
@@ -59,7 +60,7 @@
-
+
@if (ErrorMessage is not null) { + + ``` + """); + + var nav = Regex.Match(html, "", RegexOptions.Singleline).Groups[1].Value; + Assert.DoesNotContain("

", nav); + Assert.Contains("MyApp", nav); + Assert.Matches("

Docs]*>Go
", nav); + } + + [Fact] + public async Task RazorPreview_NestedComponents_AreRazorAllTheWayDown() + { + var html = await Render(""" + ```razor:preview + +
+
+ ``` + """); + + Assert.Matches("
", html); + Assert.DoesNotContain("

", Regex.Match(html, "

", RegexOptions.Singleline).Value); + } + + [Fact] + public async Task InlineComponent_InProse_StillTakesMarkdownChildren() + { + var html = await Render("Some text.\n\n**bold** body\n"); + + Assert.Contains("bold", html); + } + + [Fact] + public async Task ComponentPreview_ChildContent_IsParsedAsRazor() + { + var html = await Render("\n
\n
\n"); + + // HtmlRenderer encodes the newline text nodes around the div as . + Assert.Matches("", html); + } + + [Fact] + public async Task PreviewLayout_DefaultsToCenter_StretchFromFenceOrParameter() + { + var centered = await Render("```razor:preview\n\n```"); + Assert.Contains("data-layout=\"center\"", centered); + + var stretched = await Render("```razor:preview stretch\n\n```"); + Assert.Contains("data-layout=\"stretch\"", stretched); + + var component = await Harness().RenderAsync(new() { ["Component"] = "Button", ["Layout"] = "stretch" }); + Assert.Contains("data-layout=\"stretch\"", component); + } + + [Fact] + public async Task PreviewFrame_OptsOutOfProse() + { + var html = await Render("```razor:preview\n\n```"); + Assert.Contains("class=\"preview-frame not-prose\"", html); + } + + [Fact] + public void ProseRules_AllSkipNotProseSubtrees() + { + var css = ReadThemeCss(); + var selectors = Regex.Matches(css, @"^(\.shelldocs-prose [^{]+)\{", RegexOptions.Multiline) + .SelectMany(m => Regex.Split(m.Groups[1].Value, @",(?![^(]*\))")) + .Select(s => s.Trim()) + .Where(s => s.StartsWith(".shelldocs-prose ") && !s.StartsWith(".shelldocs-prose >") && !s.Contains("pre.shiki")) + .ToList(); + + Assert.NotEmpty(selectors); + Assert.All(selectors, s => Assert.Contains(":where(:not(.not-prose, .not-prose *))", s)); + } + + [Fact] + public async Task ThemeToggle_IsStaticHostFriendly() + { + var html = await Harness().RenderAsync(); + + Assert.Contains("data-theme-toggle", html); + Assert.Contains("theme-icon-light", html); + Assert.Contains("theme-icon-dark", html); + Assert.DoesNotContain("blazor:onclick", html); + } + + private static string ReadThemeCss() + { + var testDir = Path.GetDirectoryName(typeof(PreviewFidelityTests).Assembly.Location)!; + var candidates = new[] + { + Path.Combine(testDir, "wwwroot", "_content", "ShellDocs.Components", "shelldocs-theme.css"), + Path.Combine(testDir, "..", "..", "..", "..", "..", "src", "ShellDocs.Components", "wwwroot", "shelldocs-theme.css") + }; + foreach (var c in candidates) + { + var full = Path.GetFullPath(c); + if (File.Exists(full)) return File.ReadAllText(full); + } + throw new FileNotFoundException("shelldocs-theme.css not found"); + } +} diff --git a/tests/ShellDocs.Tests/PreviewToolbarTests.cs b/tests/ShellDocs.Tests/PreviewToolbarTests.cs index 44d26c2..4848570 100644 --- a/tests/ShellDocs.Tests/PreviewToolbarTests.cs +++ b/tests/ShellDocs.Tests/PreviewToolbarTests.cs @@ -89,7 +89,7 @@ public async Task ComponentPreview_RendersThroughPreviewFrame() ["ExtraProps"] = new Dictionary { ["Variant"] = "Outline" } }); - Assert.Contains("class=\"preview-frame\"", html); + Assert.Contains("class=\"preview-frame not-prose\"", html); Assert.Contains("id=\"example-button-", html); Assert.Contains("data-variant=\"Outline\"", html); Assert.Contains("<Button Variant="Outline" />", html);