From 735fd3a4389674b0456baa990101e97ca0fc507f Mon Sep 17 00:00:00 2001 From: Shewart Date: Fri, 2 Oct 2026 09:02:55 +0200 Subject: [PATCH 01/10] chore: prune comments down to the non-obvious ones - Drop section banners, step-by-step narration, and comments that restate the code. - Keep and tighten the "why" notes: static-host constraints, Markdig/MSBuild gotchas, security caveats, ordering rules. - Remove stale references (Prism, removed methods, old branch names). --- .github/workflows/release.yml | 8 +- Directory.Build.props | 3 - Directory.Packages.props | 2 +- .../ShellDocs.Preview/Components/App.razor | 4 +- .../Components/Pages/Home.razor | 3 +- examples/ShellDocs.Preview/Program.cs | 5 +- src/ShellDocs.CLI/Commands/AddCommand.cs | 15 +--- src/ShellDocs.CLI/Commands/BuildCommand.cs | 12 +-- src/ShellDocs.CLI/Commands/InitCommand.cs | 86 ++++--------------- src/ShellDocs.CLI/Commands/PrerenderRunner.cs | 5 +- src/ShellDocs.CLI/Program.cs | 2 - .../Chrome/DocsFooter.razor | 3 +- .../Chrome/DocsHeader.razor | 4 +- .../Chrome/DocsHeader.razor.css | 4 +- .../Chrome/DocsMobileBar.razor.css | 1 - .../Chrome/DocsSidebar.razor.css | 11 +-- .../Chrome/DocsSidebarNode.razor | 12 +-- .../Chrome/PackageSelector.razor | 6 +- .../Chrome/PackageSelector.razor.css | 6 +- .../Chrome/SearchDialog.razor | 7 +- .../Chrome/SearchState.cs | 3 - .../Chrome/SidebarCollapseState.cs | 5 +- .../Chrome/SidebarIcons.cs | 3 +- .../Chrome/TableOfContents.razor | 6 +- .../Chrome/TableOfContents.razor.css | 8 +- src/ShellDocs.Components/Chrome/ThemeState.cs | 7 +- .../Content/AutoTypeTable.razor | 7 +- .../Content/CodeGroup.razor | 5 +- .../Content/CodeGroup.razor.css | 3 +- .../Content/CodeGroupSyncState.cs | 5 +- .../Content/ComponentPreview.razor | 6 +- .../Content/PreviewFrame.razor | 8 +- .../Content/SlotRenderer.cs | 33 ++----- .../Content/Steps.razor.css | 6 +- .../Content/TypeTable.razor | 5 +- .../Layouts/DocsLayout.razor | 1 - .../Layouts/DocsLayout.razor.css | 13 +-- .../ServiceCollectionExtensions.cs | 9 +- .../ShellDocsIgnoreAttribute.cs | 6 +- src/ShellDocs.Components/ShellDocsOptions.cs | 26 ++---- .../wwwroot/shelldocs-theme.css | 18 ++-- src/ShellDocs.Components/wwwroot/shelldocs.js | 39 +++------ src/ShellDocs.Core/MarkdownPlainText.cs | 5 +- src/ShellDocs.Core/MetaJson.cs | 13 +-- src/ShellDocs.Core/NavigationGraph.cs | 6 +- src/ShellDocs.Core/NavigationGraphBuilder.cs | 17 ++-- src/ShellDocs.Core/SearchIndex.cs | 10 +-- src/ShellDocs.Markdown/SlotExtractor.cs | 26 ++---- src/ShellDocs.Templates/PageTemplates.cs | 4 +- src/ShellDocs.Templates/ScaffoldTemplates.cs | 15 +--- .../StarterPageTemplate.cs | 2 - src/ShellDocs.Tokens/wwwroot/tokens.css | 11 +-- tests/ShellDocs.Tests/AddCommandTests.cs | 2 - tests/ShellDocs.Tests/AssemblyScanTests.cs | 1 - tests/ShellDocs.Tests/BuildCommandTests.cs | 4 +- tests/ShellDocs.Tests/DesignTokensTests.cs | 8 +- tests/ShellDocs.Tests/DocsPageStateTests.cs | 1 - tests/ShellDocs.Tests/InitCommandTests.cs | 16 +--- .../NavigationGraphBuilderTests.cs | 14 +-- tests/ShellDocs.Tests/SlotRendererTests.cs | 4 +- 60 files changed, 141 insertions(+), 439 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08df380..9179fc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,12 +44,8 @@ jobs: - name: List produced packages run: ls -la nupkgs/ - # ─── Pre-push safety: refuse if the version is already on nuget.org ─── - # NuGet locks version numbers permanently after first publish. Without - # this check, `--skip-duplicate` in the push step would silently no-op - # the upload of a fresh binary when the version already exists — burned - # us once (see CHANGELOG 0.1.0-alpha → 0.1.1-alpha). Fails loud so the - # human bumps Directory.Build.props before re-tagging. + # nuget.org locks a version forever after first publish, and --skip-duplicate + # would silently skip the upload. Fail so the version gets bumped before re-tagging. - name: Verify version is not already published if: github.event_name == 'push' || inputs.dry_run == false run: | diff --git a/Directory.Build.props b/Directory.Build.props index 1448996..d071863 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,6 @@ $(NoWarn);CS1591 - true @@ -34,12 +33,10 @@ true - false - diff --git a/Directory.Packages.props b/Directory.Packages.props index b1efe97..4518df7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,7 +16,7 @@ - diff --git a/examples/ShellDocs.Preview/Components/App.razor b/examples/ShellDocs.Preview/Components/App.razor index 23ae0c1..6379185 100644 --- a/examples/ShellDocs.Preview/Components/App.razor +++ b/examples/ShellDocs.Preview/Components/App.razor @@ -25,9 +25,7 @@ """; - // --- Fallback: SHELLDOCS_SETUP.md for --attach mode where we can't safely patch --- - public static string SetupInstructionsMd(string siteName, string githubRepo) => $$""" # ShellDocs setup diff --git a/src/ShellDocs.Templates/StarterPageTemplate.cs b/src/ShellDocs.Templates/StarterPageTemplate.cs index 71f6018..70f6c1e 100644 --- a/src/ShellDocs.Templates/StarterPageTemplate.cs +++ b/src/ShellDocs.Templates/StarterPageTemplate.cs @@ -1,7 +1,5 @@ namespace ShellDocs.Templates; -/// Starter markdown emitted by `shelldocs new page`. -/// Real templates land alongside the CLI init/new commands. public static class StarterPageTemplate { public static string Content(string title, string description) => $$""" diff --git a/src/ShellDocs.Tokens/wwwroot/tokens.css b/src/ShellDocs.Tokens/wwwroot/tokens.css index e5ec02e..17e6a2f 100644 --- a/src/ShellDocs.Tokens/wwwroot/tokens.css +++ b/src/ShellDocs.Tokens/wwwroot/tokens.css @@ -1,12 +1,5 @@ -/* ShellDocs Design Tokens - ───────────────────────────────────────────────────────────────────── - The palette + scale that ShellDocs itself, and any UI library layered - on top (ShellUI, third-party), consume via CSS variables on :root. - Override any variable in a stylesheet loaded AFTER this file to - customise; the whole system responds through the cascade. - - Docs: docs/TOKENS.md - ───────────────────────────────────────────────────────────────────── */ +/* ShellDocs design tokens. Override any variable in a stylesheet loaded after + this one; see docs/TOKENS.md. */ @import url('https://rsms.me/inter/inter.css'); diff --git a/tests/ShellDocs.Tests/AddCommandTests.cs b/tests/ShellDocs.Tests/AddCommandTests.cs index a00c9b0..c95834e 100644 --- a/tests/ShellDocs.Tests/AddCommandTests.cs +++ b/tests/ShellDocs.Tests/AddCommandTests.cs @@ -3,8 +3,6 @@ namespace ShellDocs.Tests; -/* Integration tests for `shelldocs add`. Uses reflection to reach the internal - AddCommand.Run entry point (matches the InitCommand test pattern). */ public class AddCommandTests : IDisposable { private readonly string _sandbox; diff --git a/tests/ShellDocs.Tests/AssemblyScanTests.cs b/tests/ShellDocs.Tests/AssemblyScanTests.cs index 55150d8..8f8fe0d 100644 --- a/tests/ShellDocs.Tests/AssemblyScanTests.cs +++ b/tests/ShellDocs.Tests/AssemblyScanTests.cs @@ -83,7 +83,6 @@ public void RegisterComponent_TypeOverload_RejectsNonComponentBase() Assert.Throws(() => options.RegisterComponent(typeof(NotAComponent))); } - // ---- test doubles used by the scan (all live in this assembly) ---- public class TestMarker { } public class ScannableAlpha : ComponentBase { } public class ScannableBeta : ComponentBase { } diff --git a/tests/ShellDocs.Tests/BuildCommandTests.cs b/tests/ShellDocs.Tests/BuildCommandTests.cs index 0ac2d7a..f5e2d82 100644 --- a/tests/ShellDocs.Tests/BuildCommandTests.cs +++ b/tests/ShellDocs.Tests/BuildCommandTests.cs @@ -49,9 +49,7 @@ private void WriteRobots(string outputDir, string siteUrl) => private int InjectOgMeta(string outputDir, string siteUrl, NavigationGraph graph) => (int)_injectOg.Invoke(null, new object[] { outputDir, siteUrl, graph })!; - // NavigationNode.Children/Parent have `internal set` — bypass via reflection - // so tests can build a graph without exposing the setters or standing up a - // temp content directory. + // Children/Parent have internal setters; reflection avoids a temp content dir. private static readonly PropertyInfo _childrenProp = typeof(NavigationNode).GetProperty("Children")!; private static readonly PropertyInfo _parentProp = typeof(NavigationNode).GetProperty("Parent")!; private static void LinkChildren(NavigationNode parent, params NavigationNode[] children) diff --git a/tests/ShellDocs.Tests/DesignTokensTests.cs b/tests/ShellDocs.Tests/DesignTokensTests.cs index d647b55..c72a3c9 100644 --- a/tests/ShellDocs.Tests/DesignTokensTests.cs +++ b/tests/ShellDocs.Tests/DesignTokensTests.cs @@ -3,20 +3,14 @@ namespace ShellDocs.Tests; -/* The Tokens RCL ships one static asset: tokens.css. If anyone renames the - file, changes the package id, or accidentally drops the file from the - build, these tests fail loud before it hits a consumer. */ +// Fails loudly if tokens.css is renamed, moved, or dropped from the package. public class DesignTokensTests { private static string LoadTokensCss() { - // MSBuild copies the RCL's static web assets into a predictable location - // under the test project's output. Walk up from the test assembly to find - // the package's wwwroot/tokens.css. var testAssembly = Assembly.GetExecutingAssembly().Location; var testDir = Path.GetDirectoryName(testAssembly)!; - // Traverse the well-known static-web-asset path emitted by the Razor SDK. var candidates = new[] { Path.Combine(testDir, "wwwroot", "_content", "ShellDocs.Tokens", "tokens.css"), diff --git a/tests/ShellDocs.Tests/DocsPageStateTests.cs b/tests/ShellDocs.Tests/DocsPageStateTests.cs index c261f23..81190f0 100644 --- a/tests/ShellDocs.Tests/DocsPageStateTests.cs +++ b/tests/ShellDocs.Tests/DocsPageStateTests.cs @@ -44,7 +44,6 @@ public void Dispose_UnsubscribesFromNavigationManager() var (graph, nav) = MinimalGraphAndNav("http://localhost/"); var state = new DocsPageState(graph, nav); - // Should not throw; sanity that Dispose is idempotent-ish. state.Dispose(); } diff --git a/tests/ShellDocs.Tests/InitCommandTests.cs b/tests/ShellDocs.Tests/InitCommandTests.cs index 9d00271..6febcee 100644 --- a/tests/ShellDocs.Tests/InitCommandTests.cs +++ b/tests/ShellDocs.Tests/InitCommandTests.cs @@ -3,11 +3,8 @@ namespace ShellDocs.Tests; -/* Integration tests for `shelldocs init`. Covers ATTACH mode end-to-end - (fast — no `dotnet new` spawn) and the CREATE-mode patchers (PatchProgramCs, - PatchAppRazor) against synthetic fresh-blazor-template fixtures. The full - CREATE path (dotnet new blazor + patchers) is verified by hand — spawning - dotnet in unit tests is slow and fragile. */ +// ATTACH mode end-to-end plus the CREATE-mode patchers against synthetic +// template fixtures. The full CREATE path spawns `dotnet new` and is verified by hand. public class InitCommandTests : IDisposable { private readonly string _tempDir; @@ -54,8 +51,6 @@ private void WriteBlazorCsproj(string content = null!) => private int InvokeAttach() => (int)_run.Invoke(null, new object?[] { null, _tempDir, true, true, "shadcn" })!; - // ---- ATTACH MODE ---------------------------------------------------- - [Fact] public void Attach_MissingCsproj_ReturnsError() { @@ -113,8 +108,6 @@ public void Attach_PreservesUserModifications_OnRerun() Assert.Equal("# My custom intro\n", File.ReadAllText(mdPath)); } - // ---- CREATE-MODE PATCHERS ------------------------------------------- - /// Emits a synthetic Program.cs identical in shape to `dotnet new blazor` output. private void WriteFreshBlazorProgramCs() => File.WriteAllText(Path.Combine(_tempDir, "Program.cs"), @@ -232,8 +225,6 @@ public void PatchAppRazor_IsIdempotent() Assert.Empty(secondChanges); } - // ---- SOLUTION FINDER ------------------------------------------------ - [Fact] public void FindNearestSolution_ReturnsSlnxInSameDir() { @@ -272,8 +263,7 @@ public void FindNearestSolution_WalksUpToParent() [Fact] public void FindNearestSolution_StopsAtGitRoot() { - // .git in tempDir marks it as repo root; no sln inside means null, - // even if an sln exists in tempDir's parent (which it doesn't here). + // .git marks tempDir as the repo root, so the walk stops there. Directory.CreateDirectory(Path.Combine(_tempDir, ".git")); var sub = Path.Combine(_tempDir, "sub"); Directory.CreateDirectory(sub); diff --git a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs index a813f80..dd576a3 100644 --- a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs +++ b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs @@ -149,11 +149,10 @@ public void Build_HiddenSlug_IsExcludedFromSidebar_ButUrlStillResolves() var graph = NavigationGraphBuilder.Build(_root); var titles = graph.Root.Children.Select(c => c.Title).ToList(); - // Sidebar shows only visible (secret excluded despite being on disk). + // Hidden from the sidebar… Assert.Equal(new[] { "Visible" }, titles); - // But secret's URL still resolves (the whole point of `hidden` vs - // deleting the file: the dropdown/direct link still works). + // …but still routable. var secretNode = graph.ResolveByUrl("/secret"); Assert.NotNull(secretNode); Assert.Equal("Secret", secretNode!.Title); @@ -196,8 +195,7 @@ public void Build_HiddenTakesPrecedenceOverPages() [Fact] public void Build_HiddenSlug_IsAlsoExcludedFromAutoAppend() { - // No `pages` array. Without hidden support, auto-append would surface - // secret alongside visible. With hidden, secret still hidden. + // No `pages` array: auto-append must still respect hidden. WriteMd("visible.md", "Visible"); WriteMd("secret.md", "Secret"); WriteMeta("", """{ "hidden": ["secret"] }"""); @@ -211,9 +209,7 @@ public void Build_HiddenSlug_IsAlsoExcludedFromAutoAppend() [Fact] public void Build_MdFileNotInMetaJson_IsAppendedAfterExplicitOrdering() { - // Meta lists only `alpha`, but `bravo.md` exists on disk. - // Bravo should surface at the end, not silently disappear (the - // `shelldocs add` DX gap fix). + // Pages missing from meta.json are appended, not dropped. WriteMd("alpha.md", "Alpha"); WriteMd("bravo.md", "Bravo"); WriteMeta("", """{ "pages": ["alpha"] }"""); @@ -255,8 +251,6 @@ public void Build_UnreferencedItems_AreAlphabetical() [Fact] public void Build_ExplicitOrderingIsPreserved_ForItemsInMetaJson() { - // Verify the auto-append doesn't break the existing "meta.json controls - // ordering for explicitly-listed items" contract. WriteMd("alpha.md", "Alpha"); WriteMd("bravo.md", "Bravo"); WriteMd("charlie.md", "Charlie"); diff --git a/tests/ShellDocs.Tests/SlotRendererTests.cs b/tests/ShellDocs.Tests/SlotRendererTests.cs index b30c414..f234002 100644 --- a/tests/ShellDocs.Tests/SlotRendererTests.cs +++ b/tests/ShellDocs.Tests/SlotRendererTests.cs @@ -5,9 +5,7 @@ namespace ShellDocs.Tests; -// Named-slot routing behavior: child tags matching a target component's -// [Parameter] RenderFragment prop names should route into that param instead -// of being flattened into ChildContent. +// Child tags named after a RenderFragment parameter route into it, not ChildContent. public class SlotRendererTests { public class Alert : ComponentBase From e4fddb8f53a12d5b1ef9ccf4bfb7a1bcee41a8e3 Mon Sep 17 00:00:00 2001 From: Shewart Date: Fri, 2 Oct 2026 09:02:56 +0200 Subject: [PATCH 02/10] feat(core): segment-aware UrlPath helpers and scoped graph queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UrlPath.Normalize / IsUnder / RelativeTo / Combine — "/docs/v0.30" is not under "/docs/v0.3"; dotted segments stay intact. - NavigationGraph.GetPrevNext(node, inScope) skips out-of-scope neighbours. - NavigationGraph.FindFolder(url) finds the section built from a content folder (hidden folders included). - NavigationGraph.FirstPageUnder(prefix). --- src/ShellDocs.Core/NavigationGraph.cs | 52 ++++++++++++++++++++++----- src/ShellDocs.Core/UrlPath.cs | 44 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/ShellDocs.Core/UrlPath.cs diff --git a/src/ShellDocs.Core/NavigationGraph.cs b/src/ShellDocs.Core/NavigationGraph.cs index 3e02d0a..6762cde 100644 --- a/src/ShellDocs.Core/NavigationGraph.cs +++ b/src/ShellDocs.Core/NavigationGraph.cs @@ -6,6 +6,7 @@ public class NavigationGraph private readonly Dictionary _byUrl; private readonly List _flatPages; + private readonly List _hiddenRoots = new(); public NavigationGraph(NavigationNode root, IEnumerable? hiddenPages = null) { @@ -16,7 +17,11 @@ public NavigationGraph(NavigationNode root, IEnumerable? hiddenP // Hidden pages resolve by URL but stay out of _flatPages, so prev/next skips them. if (hiddenPages is not null) { - foreach (var page in hiddenPages) IndexHidden(page); + foreach (var page in hiddenPages) + { + _hiddenRoots.Add(page); + IndexHidden(page); + } } } @@ -44,6 +49,43 @@ private void IndexHidden(NavigationNode node) return (prev, next); } + // Nearest neighbours `inScope` accepts, skipping the rest (keeps prev/next inside a version). + public (NavigationNode? Prev, NavigationNode? Next) GetPrevNext(NavigationNode node, Func inScope) + { + var i = _flatPages.IndexOf(node); + if (i < 0) return (null, null); + NavigationNode? prev = null, next = null; + for (var j = i - 1; j >= 0; j--) + { + if (inScope(_flatPages[j])) { prev = _flatPages[j]; break; } + } + for (var j = i + 1; j < _flatPages.Count; j++) + { + if (inScope(_flatPages[j])) { next = _flatPages[j]; break; } + } + return (prev, next); + } + + // Section node for the content folder at `folderUrl`, searching hidden folders too. + // Null for graphs built without on-disk paths. + public NavigationNode? FindFolder(string folderUrl) + { + if (string.IsNullOrEmpty(Root.Path)) return null; + var target = UrlPath.Normalize(folderUrl); + foreach (var n in FlattenFrom(Root).Concat(_hiddenRoots.SelectMany(FlattenFrom))) + { + if (n.Kind != NodeKind.Section || n == Root || string.IsNullOrEmpty(n.Path)) continue; + var rel = System.IO.Path.GetRelativePath(Root.Path, n.Path).Replace('\\', '/'); + if (rel.StartsWith("..", StringComparison.Ordinal)) continue; + if (string.Equals(UrlPath.Normalize(rel), target, StringComparison.OrdinalIgnoreCase)) return n; + } + return null; + } + + // First visible page (tree order) whose URL is `urlPrefix` or below it. + public NavigationNode? FirstPageUnder(string urlPrefix) + => _flatPages.FirstOrDefault(p => UrlPath.IsUnder(p.Url, urlPrefix)); + public IReadOnlyList GetBreadcrumb(NavigationNode node) { var chain = new List(); @@ -84,11 +126,5 @@ private void Index(NavigationNode node) foreach (var child in node.Children) Index(child); } - private static string Normalize(string url) - { - var s = url.Trim(); - if (!s.StartsWith('/')) s = "/" + s; - if (s.Length > 1 && s.EndsWith('/')) s = s[..^1]; - return s; - } + private static string Normalize(string url) => UrlPath.Normalize(url); } diff --git a/src/ShellDocs.Core/UrlPath.cs b/src/ShellDocs.Core/UrlPath.cs new file mode 100644 index 0000000..5185c50 --- /dev/null +++ b/src/ShellDocs.Core/UrlPath.cs @@ -0,0 +1,44 @@ +namespace ShellDocs.Core; + +// App-relative URL path helpers. Dots are ordinary characters, so "v0.2.1" stays a folder segment. +public static class UrlPath +{ + // Leading slash, no trailing slash (except "/" itself), query/fragment stripped. + public static string Normalize(string? url) + { + var s = (url ?? "").Trim(); + var cut = s.IndexOfAny(['?', '#']); + if (cut >= 0) s = s[..cut]; + if (!s.StartsWith('/')) s = "/" + s; + while (s.Length > 1 && s.EndsWith('/')) s = s[..^1]; + return s; + } + + // Segment-aware: "/docs/v0.30/x" is not under "/docs/v0.3". Case-insensitive, + // matching NavigationGraph.ResolveByUrl. + public static bool IsUnder(string? path, string? root) + { + var p = Normalize(path); + var r = Normalize(root); + if (r == "/") return true; + if (!p.StartsWith(r, StringComparison.OrdinalIgnoreCase)) return false; + return p.Length == r.Length || p[r.Length] == '/'; + } + + // "/components/button" for a page below root, "" for root itself, null when outside. + public static string? RelativeTo(string? path, string? root) + { + if (!IsUnder(path, root)) return null; + var p = Normalize(path); + var r = Normalize(root); + return r == "/" ? (p == "/" ? "" : p) : p[r.Length..]; + } + + public static string Combine(string root, string? relative) + { + var r = Normalize(root); + if (string.IsNullOrEmpty(relative) || relative == "/") return r; + var rel = relative.StartsWith('/') ? relative : "/" + relative; + return Normalize(r == "/" ? rel : r + rel); + } +} From 1f62a7568dfa89a71a74253e1e90c67c2eea7565 Mon Sep 17 00:00:00 2001 From: Shewart Date: Fri, 2 Oct 2026 09:02:57 +0200 Subject: [PATCH 03/10] feat(versions): AddVersion, DocsVersionResolver, VersionSelector and version-scoped chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShellDocsOptions.AddVersion(id, label, rootUrl, description, latest) + DocsVersion record. - DocsVersionResolver: current version (containing → latest → first), scoped sidebar nodes, version/package hrefs, search filter, prev/next scope. - : under PackageSelector in DocsSidebar (mobile drawer included) and in DocsHeader for TopNav. Plain hrefs: same page → same package → first page of the target version. - "{version}" token in AddPackage root URLs; package matching is segment-aware on resolved URLs. - Sidebar, prev/next, search and breadcrumb stay inside the current version. - shelldocs.js: shared dropdown delegation for .pkg/.ver, Escape and option-click close. - Search recalculates via @bind:after (no longer one keystroke behind); aria-selected renders true/false. --- .../Chrome/DocsHeader.razor | 7 +- .../Chrome/DocsSidebar.razor | 12 +- .../Chrome/PackageSelector.razor | 33 +- .../Chrome/SearchDialog.razor | 10 +- .../Chrome/VersionSelector.razor | 65 ++++ .../Chrome/VersionSelector.razor.css | 146 ++++++++ src/ShellDocs.Components/DocsPageState.cs | 12 + .../DocsVersionResolver.cs | 171 +++++++++ .../Layouts/HomeLayout.razor | 2 +- .../ServiceCollectionExtensions.cs | 1 + src/ShellDocs.Components/ShellDocsOptions.cs | 23 +- src/ShellDocs.Components/wwwroot/shelldocs.js | 66 ++-- .../ShellDocs.Tests/ComponentRenderHarness.cs | 69 ++++ .../VersionChromeRenderTests.cs | 122 +++++++ tests/ShellDocs.Tests/VersioningTests.cs | 336 ++++++++++++++++++ 15 files changed, 1026 insertions(+), 49 deletions(-) create mode 100644 src/ShellDocs.Components/Chrome/VersionSelector.razor create mode 100644 src/ShellDocs.Components/Chrome/VersionSelector.razor.css create mode 100644 src/ShellDocs.Components/DocsVersionResolver.cs create mode 100644 tests/ShellDocs.Tests/ComponentRenderHarness.cs create mode 100644 tests/ShellDocs.Tests/VersionChromeRenderTests.cs create mode 100644 tests/ShellDocs.Tests/VersioningTests.cs diff --git a/src/ShellDocs.Components/Chrome/DocsHeader.razor b/src/ShellDocs.Components/Chrome/DocsHeader.razor index 94bb03d..1b875d8 100644 --- a/src/ShellDocs.Components/Chrome/DocsHeader.razor +++ b/src/ShellDocs.Components/Chrome/DocsHeader.razor @@ -63,6 +63,10 @@
+ @if (ShowVersionSelector) + { + + } @if (ShowSearch) { + + +
+} + +@code { + // "sidebar", "header" (TopNav, desktop only) or "drawer" (mobile-only copy + // used when the header carries the desktop one). + [Parameter] public string Placement { get; set; } = "sidebar"; + + private const string TagIcon = "M12 2H2v10l9.29 9.29a1 1 0 0 0 1.41 0l8.59-8.59a1 1 0 0 0 0-1.41z M7 7h.01"; + + // No parameters change on navigation, so the parent's re-render would skip us. + protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; + + private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + => InvokeAsync(StateHasChanged); + + public void Dispose() => Nav.LocationChanged -= OnLocationChanged; +} diff --git a/src/ShellDocs.Components/Chrome/VersionSelector.razor.css b/src/ShellDocs.Components/Chrome/VersionSelector.razor.css new file mode 100644 index 0000000..4d856af --- /dev/null +++ b/src/ShellDocs.Components/Chrome/VersionSelector.razor.css @@ -0,0 +1,146 @@ +/* Mirrors PackageSelector.razor.css, plus the "latest" badge. */ +.ver { + position: relative; + margin-bottom: 1rem; +} + +.ver-trigger { + display: inline-flex; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 0.55rem 0.75rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + color: var(--foreground); + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 150ms, border-color 150ms; +} +.ver-trigger:hover { background: var(--muted); border-color: var(--border-strong); } +.ver-trigger:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + +.ver-mark { + display: inline-flex; + width: 1.5rem; + height: 1.5rem; + align-items: center; + justify-content: center; + background: var(--muted); + color: var(--foreground); + border-radius: 5px; + flex-shrink: 0; +} +.ver-mark svg { width: 0.85rem; height: 0.85rem; } + +.ver-value { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + letter-spacing: -0.005em; + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ver-chevron { + width: 0.9rem; + height: 0.9rem; + color: var(--muted-foreground); + flex-shrink: 0; + margin-left: 0.25rem; + transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); +} +.ver-chevron.open { transform: rotate(180deg); } + +.ver-menu { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + background: var(--popover); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.18), 0 4px 12px -4px rgb(0 0 0 / 0.06); + padding: 0.35rem; + z-index: 30; + /* Always rendered; shelldocs.js toggles [data-open] on .ver. */ + display: none; + flex-direction: column; + gap: 0.15rem; + max-height: 22rem; + overflow-y: auto; +} +.ver[data-open="true"] .ver-menu { display: flex; } + +a.ver-option { text-decoration: none; } + +.ver-option { + display: flex; + align-items: flex-start; + gap: 0.55rem; + padding: 0.5rem 0.55rem; + border-radius: calc(var(--radius) - 3px); + color: var(--foreground); + cursor: pointer; + width: 100%; + transition: background 150ms; +} +.ver-option:hover { background: var(--muted); } +.ver-option.selected { background: var(--accent); } + +.ver-option-body { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + flex: 1; +} +.ver-option-title { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8125rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: -0.005em; +} +.ver-option-desc { font-size: 0.7rem; color: var(--muted-foreground); line-height: 1.35; } +.ver-option-check { + width: 0.9rem; + height: 0.9rem; + color: var(--foreground); + flex-shrink: 0; + margin-top: 0.15rem; +} + +.ver-badge { + display: inline-flex; + align-items: center; + padding: 0.05rem 0.4rem; + border: 1px solid color-mix(in oklch, var(--success, oklch(0.723 0.219 149.579)) 40%, var(--border)); + background: color-mix(in oklch, var(--success, oklch(0.723 0.219 149.579)) 12%, transparent); + color: color-mix(in oklch, var(--success, oklch(0.723 0.219 149.579)) 75%, var(--foreground)); + border-radius: 999px; + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + line-height: 1.4; +} + +/* Header copy is desktop-only; the drawer copy in the sidebar covers mobile. */ +.ver-header { margin-bottom: 0; } +.ver-header .ver-trigger { width: auto; gap: 0.4rem; padding: 0.3rem 0.55rem; } +.ver-header .ver-mark { display: none; } +.ver-header .ver-menu { left: auto; min-width: 15rem; } +.ver-header { display: none; } +@media (min-width: 1024px) { + .ver-header { display: block; } + .ver-drawer { display: none; } +} diff --git a/src/ShellDocs.Components/DocsPageState.cs b/src/ShellDocs.Components/DocsPageState.cs index 4cc0d48..fb6acb5 100644 --- a/src/ShellDocs.Components/DocsPageState.cs +++ b/src/ShellDocs.Components/DocsPageState.cs @@ -10,11 +10,17 @@ public sealed class DocsPageState : IDisposable { private readonly NavigationGraph _graph; private readonly NavigationManager _nav; + private readonly DocsVersionResolver? _versions; public DocsPageState(NavigationGraph graph, NavigationManager nav) + : this(graph, nav, null) { } + + // With a resolver, prev/next stay in the page's version and the version folder leaves the breadcrumb. + public DocsPageState(NavigationGraph graph, NavigationManager nav, DocsVersionResolver? versions) { _graph = graph; _nav = nav; + _versions = versions; _nav.LocationChanged += OnLocationChanged; } @@ -44,6 +50,12 @@ private void Recompute() Prev = Next = null; Breadcrumbs = Array.Empty(); } + else if (_versions is { HasVersions: true } versions) + { + var current = CurrentNode; + (Prev, Next) = _graph.GetPrevNext(current, n => versions.InSameScope(n.Url, current.Url)); + Breadcrumbs = _graph.GetBreadcrumb(current).Where(n => !versions.IsVersionNode(n)).ToList(); + } else { (Prev, Next) = _graph.GetPrevNext(CurrentNode); diff --git a/src/ShellDocs.Components/DocsVersionResolver.cs b/src/ShellDocs.Components/DocsVersionResolver.cs new file mode 100644 index 0000000..7ebd116 --- /dev/null +++ b/src/ShellDocs.Components/DocsVersionResolver.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Components; +using ShellDocs.Core; + +namespace ShellDocs.Components; + +/* Every version question the chrome asks. Stateless — callers pass the current + path — so the answers are fixed at prerender time and work on static hosts. */ +public sealed class DocsVersionResolver +{ + private readonly ShellDocsOptions _options; + private readonly NavigationGraph _graph; + private readonly ConcurrentDictionary _nodes = new(StringComparer.Ordinal); + + public DocsVersionResolver(ShellDocsOptions options, NavigationGraph graph) + { + _options = options; + _graph = graph; + } + + public IReadOnlyList Versions => _options.Versions; + public bool HasVersions => _options.Versions.Count > 0; + + // Base-relative path, without query or fragment. + public static string PathOf(NavigationManager nav) + => UrlPath.Normalize(nav.ToBaseRelativePath(nav.Uri)); + + // Longest matching RootUrl wins. + public DocsVersion? FindContaining(string? path) + { + DocsVersion? best = null; + foreach (var v in _options.Versions) + { + if (!UrlPath.IsUnder(path, v.RootUrl)) continue; + if (best is null || UrlPath.Normalize(v.RootUrl).Length > UrlPath.Normalize(best.RootUrl).Length) best = v; + } + return best; + } + + // Containing version → latest → first configured → null (no versions). + public DocsVersion? Current(string? path) + => FindContaining(path) + ?? _options.Versions.FirstOrDefault(v => v.IsLatest) + ?? _options.Versions.FirstOrDefault(); + + // Prefers the folder whose on-disk path maps to RootUrl; otherwise the outermost + // section whose pages all live under it (graphs built without paths). + public NavigationNode? GetVersionNode(DocsVersion version) + => _nodes.GetOrAdd(version.Id, _ => _graph.FindFolder(version.RootUrl) ?? FindByPrefix(_graph.Root, version.RootUrl)); + + private static NavigationNode? FindByPrefix(NavigationNode node, string root) + { + foreach (var child in node.Children) + { + if (child.Kind != NodeKind.Section) continue; + var pages = Pages(child).ToList(); + if (pages.Count > 0 && pages.All(p => UrlPath.IsUnder(p.Url, root))) return child; + var nested = FindByPrefix(child, root); + if (nested is not null) return nested; + } + return null; + } + + private static IEnumerable Pages(NavigationNode node) + { + foreach (var child in node.Children) + { + if (child.Kind == NodeKind.Page && !string.IsNullOrEmpty(child.Url)) yield return child; + foreach (var p in Pages(child)) yield return p; + } + } + + public IReadOnlyList GetSidebarNodes(string? path) + { + var version = FindContaining(path); + var node = version is null ? null : GetVersionNode(version); + return node?.Children ?? _graph.Root.Children; + } + + public bool InSameScope(string? urlA, string? urlB) + => FindContaining(urlA)?.Id == FindContaining(urlB)?.Id; + + // Visible: entries in the current version, plus anything outside every version root. + public bool IsVisibleFrom(string? url, string? currentPath) + { + if (!HasVersions) return true; + var owner = FindContaining(url); + return owner is null || owner.Id == Current(currentPath)?.Id; + } + + public IEnumerable FilterSearch(IEnumerable entries, string? currentPath) + => HasVersions ? entries.Where(e => IsVisibleFrom(e.Url, currentPath)) : entries; + + public bool IsVersionNode(NavigationNode node) + { + foreach (var v in _options.Versions) + { + if (ReferenceEquals(GetVersionNode(v), node)) return true; + } + return false; + } + + public string ResolvePackageRoot(DocsPackage package, DocsVersion? version) + => version is not null && package.IsVersioned + ? package.RootUrl.Replace(DocsPackage.VersionToken, version.Id, StringComparison.Ordinal) + : package.RootUrl; + + public string ResolvePackageRoot(DocsPackage package, string? currentPath) + => ResolvePackageRoot(package, Current(currentPath)); + + // Longest resolved RootUrl prefix wins; Packages[0] otherwise. + public DocsPackage? SelectPackage(string? currentPath) + { + if (_options.Packages.Count == 0) return null; + var version = Current(currentPath); + DocsPackage? best = null; + var bestLen = -1; + foreach (var pkg in _options.Packages) + { + var root = UrlPath.Normalize(ResolvePackageRoot(pkg, version)); + if (!UrlPath.IsUnder(currentPath, root) || root.Length <= bestLen) continue; + best = pkg; + bestLen = root.Length; + } + return best ?? _options.Packages[0]; + } + + // Unversioned roots stay verbatim (may be a custom page outside the graph); + // versioned roots that aren't a page link to their first page instead of a 404. + public string GetPackageHref(DocsPackage package, string? currentPath) + => GetPackageHref(package, Current(currentPath)); + + public string GetPackageHref(DocsPackage package, DocsVersion? version) + { + var root = ResolvePackageRoot(package, version); + if (!package.IsVersioned || _graph.ResolveByUrl(root) is not null) return root; + return _graph.FirstPageUnder(root)?.Url ?? root; + } + + // Same relative page in the target version → current package's root there → + // the target version's first page → its RootUrl. + public string GetVersionHref(string? currentPath, DocsVersion target) + { + var from = FindContaining(currentPath); + if (from is not null) + { + var rel = UrlPath.RelativeTo(currentPath, from.RootUrl); + var same = UrlPath.Combine(target.RootUrl, rel); + var node = _graph.ResolveByUrl(same); + if (node is not null) return node.Url; + } + + var pkg = SelectPackage(currentPath); + if (pkg is not null && pkg.IsVersioned) + { + var pkgRoot = ResolvePackageRoot(pkg, target); + if (UrlPath.IsUnder(currentPath, ResolvePackageRoot(pkg, Current(currentPath)))) + { + var hit = _graph.ResolveByUrl(pkgRoot) ?? _graph.FirstPageUnder(pkgRoot); + if (hit is not null) return hit.Url; + } + } + + var first = GetVersionNode(target) is { } vnode ? FirstPage(vnode) : null; + first ??= _graph.FirstPageUnder(target.RootUrl); + return first?.Url ?? UrlPath.Normalize(target.RootUrl); + } + + private static NavigationNode? FirstPage(NavigationNode node) + => Pages(node).FirstOrDefault(); +} diff --git a/src/ShellDocs.Components/Layouts/HomeLayout.razor b/src/ShellDocs.Components/Layouts/HomeLayout.razor index ed9d2c4..7205b58 100644 --- a/src/ShellDocs.Components/Layouts/HomeLayout.razor +++ b/src/ShellDocs.Components/Layouts/HomeLayout.razor @@ -1,7 +1,7 @@ @inherits LayoutComponentBase
- +
@Body
diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs index 823c3f0..530e301 100644 --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs @@ -37,6 +37,7 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services, return NavigationGraphBuilder.Build(options.ContentRoot); }); services.AddSingleton(sp => SearchIndex.FromGraph(sp.GetRequiredService())); + services.AddSingleton(); return services; } diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs index e85f496..00cfa2e 100644 --- a/src/ShellDocs.Components/ShellDocsOptions.cs +++ b/src/ShellDocs.Components/ShellDocsOptions.cs @@ -26,6 +26,9 @@ public class ShellDocsOptions public List PrimaryNav { get; } = new(); // 0 or 1 entries hides the sidebar package selector entirely. public List Packages { get; } = new(); + // Fewer than 2 hides the version selector; any entry scopes sidebar, prev/next, + // search and breadcrumb to the current version. + public List Versions { get; } = new(); public List RegisteredComponents { get; } = new(); // Markdown tag name per type, from RegisterComponent(tagName). Last registration wins. public Dictionary ComponentAliases { get; } = new(); @@ -136,6 +139,17 @@ public ShellDocsOptions AddPackage(string id, string title, string description, return this; } + // `id` replaces the "{version}" token in package RootUrls. + public ShellDocsOptions AddVersion(string id, string label, string rootUrl, string? description = null, bool latest = false) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("id must be non-empty.", nameof(id)); + if (string.IsNullOrWhiteSpace(rootUrl)) + throw new ArgumentException("rootUrl must be non-empty.", nameof(rootUrl)); + Versions.Add(new DocsVersion(id, label, rootUrl, description, latest)); + return this; + } + public ShellDocsOptions SetLogo(string url, string? alt = null) { LogoLight = url; @@ -170,7 +184,14 @@ public record NavLink(string Label, string Href, List? Children = n public record NavMenuItem(string Label, string Href, string? Description = null, string? IconSvg = null); // IconPath is a raw SVG `d` attribute value on a 24×24 viewBox — not a URL. -public record DocsPackage(string Id, string Title, string Description, string RootUrl, string? IconPath = null); +// RootUrl may contain the "{version}" token (see DocsPackage.VersionToken). +public record DocsPackage(string Id, string Title, string Description, string RootUrl, string? IconPath = null) +{ + public const string VersionToken = "{version}"; + public bool IsVersioned => RootUrl.Contains(VersionToken, StringComparison.Ordinal); +} + +public record DocsVersion(string Id, string Label, string RootUrl, string? Description, bool IsLatest); public enum ShellDocsTheme { diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js index ccc5432..92a653e 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs.js +++ b/src/ShellDocs.Components/wwwroot/shelldocs.js @@ -233,31 +233,56 @@ window.shelldocsChrome = (function () { if (shell) shell.setAttribute('data-open', next); } - function onPackageClick(e) { - var trigger = e.target.closest('.pkg-trigger'); - var openPkg = document.querySelector('.pkg[data-open="true"]'); - + // .pkg (PackageSelector) and .ver (VersionSelector): the trigger toggles + // [data-open]; outside click, Escape, or picking an option closes it. + var DROPDOWN = '.pkg, .ver'; + var TRIGGER = '.pkg-trigger, .ver-trigger'; + var MENU = '.pkg-menu, .ver-menu'; + var OPTION = '.pkg-option, .ver-option'; + var CHEVRON = '.pkg-chevron, .ver-chevron'; + + function onDropdownClick(e) { + var trigger = e.target.closest(TRIGGER); if (trigger) { - var pkg = trigger.closest('.pkg'); - if (!pkg) return; - var isOpen = pkg.getAttribute('data-open') === 'true'; - if (openPkg && openPkg !== pkg) closePkg(openPkg); - pkg.setAttribute('data-open', isOpen ? 'false' : 'true'); - trigger.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); - var chevron = pkg.querySelector('.pkg-chevron'); - if (chevron) chevron.classList.toggle('open', !isOpen); + var root = trigger.closest(DROPDOWN); + if (!root) return; + var isOpen = root.getAttribute('data-open') === 'true'; + closeAllDropdowns(root); + setDropdown(root, !isOpen); return; } - if (openPkg && !e.target.closest('.pkg-menu')) closePkg(openPkg); + var option = e.target.closest(OPTION); + if (option) { + var owner = option.closest(DROPDOWN); + if (owner) setDropdown(owner, false); + return; + } + + if (!e.target.closest(MENU)) closeAllDropdowns(null); } - function closePkg(pkg) { - pkg.setAttribute('data-open', 'false'); - var trigger = pkg.querySelector('.pkg-trigger'); - if (trigger) trigger.setAttribute('aria-expanded', 'false'); - var chevron = pkg.querySelector('.pkg-chevron'); - if (chevron) chevron.classList.remove('open'); + function onDropdownKeydown(e) { + if (e.key !== 'Escape') return; + var open = document.querySelector('.pkg[data-open="true"], .ver[data-open="true"]'); + if (!open) return; + closeAllDropdowns(null); + var trigger = open.querySelector(TRIGGER); + if (trigger) trigger.focus(); + } + + function setDropdown(root, open) { + root.setAttribute('data-open', open ? 'true' : 'false'); + var trigger = root.querySelector(TRIGGER); + if (trigger) trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + var chevron = root.querySelector(CHEVRON); + if (chevron) chevron.classList.toggle('open', open); + } + + function closeAllDropdowns(except) { + document.querySelectorAll('.pkg[data-open="true"], .ver[data-open="true"]').forEach(function (d) { + if (d !== except) setDropdown(d, false); + }); } function onPreviewClick(e) { @@ -308,7 +333,8 @@ window.shelldocsChrome = (function () { if (delegatesAttached) return; delegatesAttached = true; document.addEventListener('click', onSidebarClick); - document.addEventListener('click', onPackageClick); + document.addEventListener('click', onDropdownClick); + document.addEventListener('keydown', onDropdownKeydown); document.addEventListener('click', onPreviewClick); } diff --git a/tests/ShellDocs.Tests/ComponentRenderHarness.cs b/tests/ShellDocs.Tests/ComponentRenderHarness.cs new file mode 100644 index 0000000..25f9eaa --- /dev/null +++ b/tests/ShellDocs.Tests/ComponentRenderHarness.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.JSInterop; +using ShellDocs.Components; + +namespace ShellDocs.Tests; + +// HtmlRenderer output is what a prerendered page ships with before any Blazor runtime. +internal sealed class ComponentRenderHarness +{ + public LogSink Logs { get; } = new(); + public IServiceProvider Services { get; } + + public ComponentRenderHarness(Action? configure = null, string uri = "http://localhost/") + { + var services = new ServiceCollection(); + services.AddShellDocs(configure); + services.AddSingleton(Logs); + services.AddSingleton(typeof(ILogger<>), typeof(SinkLogger<>)); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddScoped(_ => new StubNav(uri)); + services.AddSingleton(); + Services = services.BuildServiceProvider(); + } + + public async Task RenderAsync(Dictionary? parameters = null) where T : IComponent + { + await using var scope = Services.CreateAsyncScope(); + await using var renderer = new HtmlRenderer(scope.ServiceProvider, NullLoggerFactory.Instance); + return await renderer.Dispatcher.InvokeAsync(async () => + { + var view = ParameterView.FromDictionary(parameters ?? new Dictionary()); + var output = await renderer.RenderComponentAsync(view); + return output.ToHtmlString(); + }); + } + + public sealed class LogSink + { + private readonly List _messages = new(); + public IReadOnlyList Messages { get { lock (_messages) return _messages.ToList(); } } + public void Add(string m) { lock (_messages) _messages.Add(m); } + } + + private sealed class SinkLogger : ILogger + { + private readonly LogSink _sink; + public SinkLogger(LogSink sink) => _sink = sink; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _sink.Add($"{logLevel}: {formatter(state, exception)}"); + } + + private sealed class StubNav : NavigationManager + { + public StubNav(string uri) => Initialize("http://localhost/", uri); + protected override void NavigateToCore(string uri, bool forceLoad) { } + } + + private sealed class NoopJs : IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) => ValueTask.FromResult(default(TValue)!); + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => ValueTask.FromResult(default(TValue)!); + } +} diff --git a/tests/ShellDocs.Tests/VersionChromeRenderTests.cs b/tests/ShellDocs.Tests/VersionChromeRenderTests.cs new file mode 100644 index 0000000..e7d3fb4 --- /dev/null +++ b/tests/ShellDocs.Tests/VersionChromeRenderTests.cs @@ -0,0 +1,122 @@ +using ShellDocs.Components; +using ShellDocs.Components.Chrome; +using Xunit; + +namespace ShellDocs.Tests; + +public class VersionChromeRenderTests : IDisposable +{ + private readonly string _root; + + public VersionChromeRenderTests() + { + _root = Path.Combine(Path.GetTempPath(), "shelldocs-chrome-" + Guid.NewGuid().ToString("N")); + foreach (var v in new[] { "v0.3", "v0.2.1" }) + { + Write($"docs/{v}/introduction.md", $"Intro {v}"); + Write($"docs/{v}/components/form/button.md", $"Button {v}"); + Write($"docs/{v}/cli/build.md", $"Build {v}"); + } + Write("docs/v0.3/cli/dev.md", "Dev v0.3"); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void Write(string rel, string title) + { + var full = Path.Combine(_root, rel); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, $"---\ntitle: {title}\n---\n# {title}\n"); + } + + private ComponentRenderHarness Harness(string path, Action? extra = null, bool versions = true) => new(o => + { + o.ContentRoot = _root; + if (versions) + { + o.AddVersion("v0.3", "v0.3.0", "/docs/v0.3", "Current stable", latest: true); + o.AddVersion("v0.2.1", "v0.2.1", "/docs/v0.2.1", "Previous release"); + } + extra?.Invoke(o); + }, "http://localhost" + path); + + [Fact] + public async Task VersionSelector_ServerRendersCurrentLabel_BadgeCheck_AndSamePageHrefs() + { + var html = await Harness("/docs/v0.3/components/form/button").RenderAsync(); + + Assert.Contains("class=\"ver ver-sidebar\" data-open=\"false\"", html); + Assert.Contains("", html); + Assert.Contains("href=\"/docs/v0.2.1/components/form/button\"", html); + Assert.Contains("href=\"/docs/v0.3/components/form/button\"", html); + Assert.Contains("ver-badge", html); + Assert.Contains("ver-option-check", html); + Assert.Contains("aria-selected=\"true\"", html); + Assert.DoesNotContain("blazor:onclick", html); + } + + [Fact] + public async Task VersionSelector_MissingPage_FallsBackToTargetVersion() + { + var html = await Harness("/docs/v0.3/cli/dev").RenderAsync(); + Assert.Contains("v0.3.0", html); + Assert.DoesNotContain("/docs/v0.2.1/cli/dev", html); + Assert.Contains("href=\"/docs/v0.2.1/", html); + } + + [Fact] + public async Task VersionSelector_HiddenWithFewerThanTwoVersions() + { + var html = await Harness("/docs/v0.3/introduction", o => o.AddVersion("v0.3", "v0.3.0", "/docs/v0.3"), versions: false) + .RenderAsync(); + Assert.True(string.IsNullOrWhiteSpace(html), html); + } + + [Fact] + public async Task PackageSelector_ResolvesVersionToken_AndKeepsVersion() + { + var html = await Harness("/docs/v0.2.1/cli/build", o => + { + o.AddPackage("core", "Core", "Core docs", "/docs/{version}/introduction"); + o.AddPackage("cli", "CLI", "Command line", "/docs/{version}/cli"); + }).RenderAsync(); + + Assert.Contains("href=\"/docs/v0.2.1/introduction\"", html); + Assert.Contains("href=\"/docs/v0.2.1/cli/build\"", html); // root has no index → first page + Assert.Contains(">CLI", html.Substring(0, html.IndexOf("pkg-menu", StringComparison.Ordinal))); // selected in trigger + Assert.DoesNotContain("{version}", html); + } + + [Fact] + public async Task Sidebar_IsScopedToCurrentVersion_AndCarriesVersionSelector() + { + var html = await Harness("/docs/v0.2.1/components/form/button", o => o.LayoutVariant = DocsLayoutVariant.Sidebar) + .RenderAsync(); + + Assert.Contains("href=\"/docs/v0.2.1/introduction\"", html); + Assert.Contains("href=\"/docs/v0.2.1/components/form/button\"", html); + Assert.DoesNotContain("href=\"/docs/v0.3/introduction\"", html.Substring(html.IndexOf("sidebar-tree", StringComparison.Ordinal))); + Assert.DoesNotContain("V0.2.1", html); // the version folder itself isn't a section + Assert.Contains("class=\"ver ver-sidebar\"", html); + // Active page's ancestor sections are server-rendered open. + Assert.Contains("class=\"sidebar-item active\" href=\"/docs/v0.2.1/components/form/button\"", html); + } + + [Fact] + public async Task TopNav_HeaderCarriesDesktopSelector_SidebarCarriesDrawerCopy() + { + var header = await Harness("/docs/v0.3/introduction", o => o.LayoutVariant = DocsLayoutVariant.TopNav).RenderAsync(); + Assert.Contains("class=\"ver ver-header\"", header); + + var sidebar = await Harness("/docs/v0.3/introduction", o => o.LayoutVariant = DocsLayoutVariant.TopNav).RenderAsync(); + Assert.Contains("class=\"ver ver-drawer\"", sidebar); + + var home = await Harness("/", o => o.LayoutVariant = DocsLayoutVariant.TopNav) + .RenderAsync(new() { ["ShowSearch"] = false, ["ShowVersionSelector"] = false }); + Assert.DoesNotContain("ver-header", home); + } +} diff --git a/tests/ShellDocs.Tests/VersioningTests.cs b/tests/ShellDocs.Tests/VersioningTests.cs new file mode 100644 index 0000000..c434e56 --- /dev/null +++ b/tests/ShellDocs.Tests/VersioningTests.cs @@ -0,0 +1,336 @@ +using Microsoft.AspNetCore.Components; +using ShellDocs.Components; +using ShellDocs.Core; +using Xunit; + +namespace ShellDocs.Tests; + +public class UrlPathTests +{ + [Theory] + [InlineData("docs/v0.2.1/", "/docs/v0.2.1")] + [InlineData("/docs/v0.3/intro?x=1#h", "/docs/v0.3/intro")] + [InlineData("", "/")] + [InlineData("/", "/")] + [InlineData("//", "/")] + public void Normalize(string input, string expected) => Assert.Equal(expected, UrlPath.Normalize(input)); + + [Theory] + [InlineData("/docs/v0.3/intro", "/docs/v0.3", true)] + [InlineData("/docs/v0.3", "/docs/v0.3", true)] + [InlineData("/docs/v0.3/", "/docs/v0.3", true)] + [InlineData("/DOCS/V0.3/intro", "/docs/v0.3", true)] + [InlineData("/docs/v0.30/intro", "/docs/v0.3", false)] + [InlineData("/docs/v0.3-beta", "/docs/v0.3", false)] + [InlineData("/docs/v0.2.1/intro", "/docs/v0.2", false)] + [InlineData("/anything", "/", true)] + public void IsUnder_IsSegmentAware(string path, string root, bool expected) + => Assert.Equal(expected, UrlPath.IsUnder(path, root)); + + [Fact] + public void RelativeTo_And_Combine_RoundTrip() + { + Assert.Equal("/components/form/button", UrlPath.RelativeTo("/docs/v0.3/components/form/button", "/docs/v0.3")); + Assert.Equal("", UrlPath.RelativeTo("/docs/v0.3", "/docs/v0.3")); + Assert.Null(UrlPath.RelativeTo("/docs/v0.2.1/x", "/docs/v0.3")); + Assert.Equal("/docs/v0.2.1/components/form/button", UrlPath.Combine("/docs/v0.2.1", "/components/form/button")); + Assert.Equal("/docs/v0.2.1", UrlPath.Combine("/docs/v0.2.1/", "")); + } +} + +// Mirrors the ShellUI docs layout (content/docs/{v0.3,v0.2.1}). Without meta.json +// the flat page order is v0.2.1/*, v0.3/*, then docs/introduction. +public class VersioningTests : IDisposable +{ + private readonly string _root; + private readonly NavigationGraph _graph; + + public VersioningTests() + { + _root = Path.Combine(Path.GetTempPath(), "shelldocs-ver-" + Guid.NewGuid().ToString("N")); + Write("docs/introduction.md", "Unversioned intro"); + foreach (var v in new[] { "v0.3", "v0.2.1" }) + { + Write($"docs/{v}/introduction.md", $"Intro {v}"); + Write($"docs/{v}/installation/cli.md", $"Install CLI {v}"); + Write($"docs/{v}/components/form/button.md", $"Button {v}"); + Write($"docs/{v}/cli/build.md", $"Build {v}"); + } + Write("docs/v0.3/cli/dev.md", "Dev v0.3"); // only exists in v0.3 + Write("docs/v0.3/whats-new.md", "What's new"); // only exists in v0.3 + Write("docs/v0.3/installation/docker.md", "Docker"); // only exists in v0.3 + _graph = NavigationGraphBuilder.Build(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void Write(string rel, string title) + { + var full = Path.Combine(_root, rel); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, $"---\ntitle: {title}\n---\n# {title}\n\n## Usage\n\nBody for {title}.\n"); + } + + private static ShellDocsOptions Options(Action? extra = null) + { + var o = new ShellDocsOptions(); + o.AddVersion("v0.3", "v0.3.0", "/docs/v0.3", "Current stable", latest: true); + o.AddVersion("v0.2.1", "v0.2.1", "/docs/v0.2.1", "Previous release"); + extra?.Invoke(o); + return o; + } + + private DocsVersionResolver Resolver(Action? extra = null) => new(Options(extra), _graph); + + [Fact] + public void DottedVersionFolders_KeepUrlsIntact() + { + var node = _graph.ResolveByUrl("/docs/v0.2.1/components/form/button"); + Assert.NotNull(node); + Assert.Equal("Button v0.2.1", node!.Title); + Assert.Equal("/docs/v0.2.1/components/form/button", node.Url); + Assert.NotNull(_graph.ResolveByUrl("/docs/v0.2.1/introduction/")); + Assert.Contains("/docs/v0.2.1/cli/build", _graph.AllUrls); + } + + [Fact] + public void FindFolder_ReturnsVersionSection() + { + var folder = _graph.FindFolder("/docs/v0.2.1"); + Assert.NotNull(folder); + Assert.Equal(NodeKind.Section, folder!.Kind); + Assert.Contains(folder.Children, c => c.Url == "/docs/v0.2.1/introduction"); + Assert.Null(_graph.FindFolder("/docs/v9")); + } + + [Fact] + public void FirstPageUnder_IsSegmentAware() + { + Assert.StartsWith("/docs/v0.3/", _graph.FirstPageUnder("/docs/v0.3")!.Url); + Assert.Null(_graph.FirstPageUnder("/docs/v0")); + } + + [Fact] + public void ScopedPrevNext_SkipsOutOfScopePages() + { + var first = _graph.FirstPageUnder("/docs/v0.3")!; + var (prev, next) = _graph.GetPrevNext(first, n => UrlPath.IsUnder(n.Url, "/docs/v0.3")); + Assert.Null(prev); + Assert.NotNull(next); + Assert.True(UrlPath.IsUnder(next!.Url, "/docs/v0.3")); + + // Unscoped, the same page's prev crosses into v0.2.1. + var (unscopedPrev, _) = _graph.GetPrevNext(first); + Assert.True(UrlPath.IsUnder(unscopedPrev!.Url, "/docs/v0.2.1")); + } + + [Fact] + public void AddVersion_StoresRecord() + { + var o = Options(); + Assert.Equal(2, o.Versions.Count); + Assert.Equal(new DocsVersion("v0.3", "v0.3.0", "/docs/v0.3", "Current stable", true), o.Versions[0]); + Assert.False(o.Versions[1].IsLatest); + Assert.Throws(() => new ShellDocsOptions().AddVersion("", "x", "/x")); + Assert.Throws(() => new ShellDocsOptions().AddVersion("x", "x", " ")); + } + + [Fact] + public void Current_PrefersContainingVersion_ThenLatest_ThenFirst() + { + var r = Resolver(); + Assert.Equal("v0.2.1", r.Current("/docs/v0.2.1/introduction")!.Id); + Assert.Equal("v0.3", r.Current("/docs/v0.3")!.Id); + Assert.Equal("v0.3", r.Current("/docs/introduction")!.Id); // outside → latest + Assert.Null(r.FindContaining("/docs/introduction")); + + var noLatest = new ShellDocsOptions(); + noLatest.AddVersion("b", "B", "/docs/b"); + noLatest.AddVersion("a", "A", "/docs/a"); + Assert.Equal("b", new DocsVersionResolver(noLatest, _graph).Current("/elsewhere")!.Id); + + Assert.Null(new DocsVersionResolver(new ShellDocsOptions(), _graph).Current("/docs/v0.3")); + } + + [Fact] + public void Current_LongestRootWins_ForNestedRoots() + { + var o = new ShellDocsOptions(); + o.AddVersion("all", "All", "/docs"); + o.AddVersion("v0.3", "v0.3", "/docs/v0.3"); + Assert.Equal("v0.3", new DocsVersionResolver(o, _graph).Current("/docs/v0.3/introduction")!.Id); + } + + [Fact] + public void SidebarNodes_InsideVersion_AreThatFoldersChildren() + { + var r = Resolver(); + var nodes = r.GetSidebarNodes("/docs/v0.2.1/components/form/button"); + var urls = Flatten(nodes).Select(n => n.Url).Where(u => u != "").ToList(); + Assert.NotEmpty(urls); + Assert.All(urls, u => Assert.StartsWith("/docs/v0.2.1/", u)); + Assert.Contains(nodes, n => n.Kind == NodeKind.Section && n.Title == "Components"); + } + + [Fact] + public void SidebarNodes_OutsideVersions_AreWholeTree() + => Assert.Same(_graph.Root.Children, Resolver().GetSidebarNodes("/docs/introduction")); + + [Fact] + public void SidebarNodes_WithoutVersions_AreWholeTree() + => Assert.Same(_graph.Root.Children, new DocsVersionResolver(new ShellDocsOptions(), _graph).GetSidebarNodes("/docs/v0.3/introduction")); + + private static IEnumerable Flatten(IEnumerable nodes) + => nodes.SelectMany(n => new[] { n }.Concat(Flatten(n.Children))); + + [Fact] + public void VersionHref_KeepsSameRelativePage() + { + var r = Resolver(); + var target = r.Versions.Single(v => v.Id == "v0.2.1"); + Assert.Equal("/docs/v0.2.1/components/form/button", r.GetVersionHref("/docs/v0.3/components/form/button", target)); + } + + [Fact] + public void VersionHref_MissingPage_FallsBackToFirstPageOfTargetVersion() + { + var r = Resolver(); + var target = r.Versions.Single(v => v.Id == "v0.2.1"); + var href = r.GetVersionHref("/docs/v0.3/whats-new", target); + Assert.Equal(_graph.FirstPageUnder("/docs/v0.2.1")!.Url, href); + } + + [Fact] + public void VersionHref_MissingPage_KeepsCurrentPackage() + { + var r = Resolver(o => + { + o.AddPackage("core", "Core", "", "/docs/{version}/introduction"); + o.AddPackage("install", "Install", "", "/docs/{version}/installation"); + }); + var target = r.Versions.Single(v => v.Id == "v0.2.1"); + // docker.md exists only in v0.3; expect the Install package, not the version's first page. + Assert.Equal("/docs/v0.2.1/cli/build", _graph.FirstPageUnder("/docs/v0.2.1")!.Url); + Assert.Equal("/docs/v0.2.1/installation/cli", r.GetVersionHref("/docs/v0.3/installation/docker", target)); + } + + [Fact] + public void VersionHref_FromUnversionedPage_GoesToFirstPage() + { + var r = Resolver(); + var target = r.Versions.Single(v => v.Id == "v0.2.1"); + Assert.StartsWith("/docs/v0.2.1/", r.GetVersionHref("/docs/introduction", target)); + } + + [Fact] + public void PackageRoot_VersionToken_ResolvesToCurrentVersion() + { + var r = Resolver(o => o.AddPackage("cli", "CLI", "", "/docs/{version}/cli")); + var pkg = Options(o => o.AddPackage("cli", "CLI", "", "/docs/{version}/cli")).Packages[0]; + Assert.True(pkg.IsVersioned); + Assert.Equal("/docs/v0.2.1/cli", r.ResolvePackageRoot(pkg, "/docs/v0.2.1/introduction")); + Assert.Equal("/docs/v0.3/cli", r.ResolvePackageRoot(pkg, "/docs/introduction")); + } + + [Fact] + public void PackageHref_VersionedRootWithoutIndex_LandsOnFirstPage() + { + var r = Resolver(o => o.AddPackage("cli", "CLI", "", "/docs/{version}/cli")); + var pkg = r.SelectPackage("/docs/v0.2.1/cli/build")!; + Assert.Equal("/docs/v0.2.1/cli/build", r.GetPackageHref(pkg, "/docs/v0.2.1/introduction")); + } + + [Fact] + public void PackageHref_Unversioned_IsVerbatim() + { + var r = Resolver(o => o.AddPackage("home", "Home", "", "/custom-page")); + Assert.Equal("/custom-page", r.GetPackageHref(r.SelectPackage("/")!, "/docs/v0.3/introduction")); + } + + [Fact] + public void SelectPackage_UsesResolvedLongestSegmentPrefix() + { + var r = Resolver(o => + { + o.AddPackage("docs", "Docs", "", "/docs/{version}"); + o.AddPackage("cli", "CLI", "", "/docs/{version}/cli"); + o.AddPackage("clientlib", "Client", "", "/docs/{version}/client"); + }); + Assert.Equal("cli", r.SelectPackage("/docs/v0.2.1/cli/build")!.Id); + Assert.Equal("docs", r.SelectPackage("/docs/v0.2.1/introduction")!.Id); + Assert.Equal("docs", r.SelectPackage("/docs/v0.3/clix")!.Id); // not segment-prefixed by /cli + Assert.Equal("docs", r.SelectPackage("/elsewhere")!.Id); // falls back to Packages[0] + } + + [Fact] + public void FilterSearch_CurrentVersionPlusUnversioned() + { + var r = Resolver(); + var index = SearchIndex.FromGraph(_graph); + var urls = r.FilterSearch(index.Entries, "/docs/v0.2.1/introduction").Select(e => e.Url).ToList(); + + Assert.Contains("/docs/v0.2.1/introduction", urls); + Assert.Contains("/docs/v0.2.1/introduction#usage", urls); + Assert.Contains("/docs/introduction", urls); + Assert.DoesNotContain(urls, u => u.StartsWith("/docs/v0.3/")); + } + + [Fact] + public void FilterSearch_OutsideVersions_UsesLatest() + { + var urls = Resolver().FilterSearch(SearchIndex.FromGraph(_graph).Entries, "/docs/introduction").Select(e => e.Url).ToList(); + Assert.Contains(urls, u => u.StartsWith("/docs/v0.3/")); + Assert.DoesNotContain(urls, u => u.StartsWith("/docs/v0.2.1/")); + } + + [Fact] + public void FilterSearch_NoVersions_IsUnfiltered() + { + var entries = SearchIndex.FromGraph(_graph).Entries; + Assert.Equal(entries.Count, new DocsVersionResolver(new ShellDocsOptions(), _graph).FilterSearch(entries, "/").Count()); + } + + [Fact] + public void PageState_PrevNext_NeverCrossVersionBoundary() + { + var r = Resolver(); + var first = _graph.FirstPageUnder("/docs/v0.3")!; + var state = new DocsPageState(_graph, new StubNav("http://localhost" + first.Url), r); + state.SetDocument(null); + Assert.Null(state.Prev); + Assert.True(UrlPath.IsUnder(state.Next!.Url, "/docs/v0.3")); + + // Last v0.3 page: unscoped next would be the unversioned /docs/introduction. + var last = _graph.Flatten().Last(n => n.Kind == NodeKind.Page && UrlPath.IsUnder(n.Url, "/docs/v0.3")); + var lastState = new DocsPageState(_graph, new StubNav("http://localhost" + last.Url), r); + lastState.SetDocument(null); + Assert.Null(lastState.Next); + Assert.True(UrlPath.IsUnder(lastState.Prev!.Url, "/docs/v0.3")); + } + + [Fact] + public void PageState_Breadcrumb_OmitsVersionFolder() + { + var state = new DocsPageState(_graph, new StubNav("http://localhost/docs/v0.2.1/components/form/button"), Resolver()); + state.SetDocument(null); + var titles = state.Breadcrumbs.Select(b => b.Title).ToList(); + Assert.Equal(new[] { "Docs", "Components", "Form", "Button v0.2.1" }, titles); + } + + [Fact] + public void PageState_WithoutResolver_KeepsLegacyBehaviour() + { + var state = new DocsPageState(_graph, new StubNav("http://localhost/docs/v0.2.1/components/form/button")); + state.SetDocument(null); + Assert.Contains(state.Breadcrumbs, b => b.Title == "V0.2.1"); + } + + internal sealed class StubNav : NavigationManager + { + public StubNav(string uri) => Initialize("http://localhost/", uri); + protected override void NavigateToCore(string uri, bool forceLoad) { } + } +} From 92f6a6244cce72c8f77d60a34d27b2f24fcd75b9 Mon Sep 17 00:00:00 2001 From: Shewart Date: Fri, 2 Oct 2026 09:02:58 +0200 Subject: [PATCH 04/10] fix(preview): render every fence sibling, accept Razor-form values, skip uncoercible attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - razor:preview fences parse into ordered nodes (PreviewSlot.Nodes): sibling components, HTML wrappers and text all render inside the one frame, as real render-tree elements. The unknown-component error panel stays when nothing resolves. - RazorTagScanner: quote- and @( … )-aware tag reader; full attribute names kept, so @bind-Value is no longer misread as Value. - SlotRenderer.Coerce accepts @-prefixed values, Type.Member enums, [Flags] "A | B", numeric suffixes and @null. - BuildParameters never throws: directive attributes, EventCallback/delegate params, unsupported types, bad values and unknown attributes without a catch-all are skipped with a logged warning. Same for ComponentPreview. - Parameter names match case-insensitively, like Blazor. --- .../Content/ComponentPreview.razor | 25 +- .../Content/MarkdownContent.razor | 3 +- .../Content/PreviewFrame.razor | 28 +- .../Content/SlotRenderer.cs | 210 ++++++++++- .../ShellDocs.Components.csproj | 1 + src/ShellDocs.Components/_Imports.razor | 1 + src/ShellDocs.Markdown/PreviewParser.cs | 200 ++++++++++ src/ShellDocs.Markdown/RazorTagScanner.cs | 177 +++++++++ src/ShellDocs.Markdown/RenderedDocument.cs | 24 +- .../ShellDocs.Markdown.csproj | 4 + src/ShellDocs.Markdown/SlotExtractor.cs | 47 +-- tests/ShellDocs.Tests/PreviewTests.cs | 352 ++++++++++++++++++ tests/ShellDocs.Tests/SlotRendererTests.cs | 16 +- 13 files changed, 1006 insertions(+), 82 deletions(-) create mode 100644 src/ShellDocs.Markdown/PreviewParser.cs create mode 100644 src/ShellDocs.Markdown/RazorTagScanner.cs create mode 100644 tests/ShellDocs.Tests/PreviewTests.cs diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor b/src/ShellDocs.Components/Content/ComponentPreview.razor index e7342b5..5918803 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 ILogger Logger @* Expand/collapse and copy are handled by shelldocs.js (data-preview-*). *@ + +@code { + private int count; + + private void Increment() => count++; + private void Reset() => count = 0; +} diff --git a/examples/ShellDocs.Preview/Program.cs b/examples/ShellDocs.Preview/Program.cs index 4517989..cf7fa7d 100644 --- a/examples/ShellDocs.Preview/Program.cs +++ b/examples/ShellDocs.Preview/Program.cs @@ -37,14 +37,21 @@ "M12 2 4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6z"); o.AddPackage("shelldocs.markdown", "ShellDocs.Markdown", "Markdig extensions, slots, MDX.", "/docs/markdown", "M4 4h16v16H4z M4 9h16 M9 4v16"); - o.AddPackage("shelldocs.core", "ShellDocs.Core", "Navigation graph + content model.", "/docs/core", + // "{version}" resolves to the current version's Id, so switching packages keeps the version. + o.AddPackage("shelldocs.core", "ShellDocs.Core", "Navigation graph + content model.", "/docs/{version}/core", "M12 2 2 7l10 5 10-5-10-5z M2 17l10 5 10-5 M2 12l10 5 10-5"); - o.AddPackage("shelldocs.cli", "ShellDocs.CLI", "Scaffold, build, publish.", "/docs/cli", + o.AddPackage("shelldocs.cli", "ShellDocs.CLI", "Scaffold, build, publish.", "/docs/{version}/cli", "m8 6-6 6 6 6 M16 6l6 6-6 6"); o.AddPackage("shelldocs.components","ShellDocs.Components","Layouts, header, sidebar, TOC.", "/docs/components", "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z"); + 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"); + // Built-in primitives (Callout, Card, Steps, …) are registered by AddShellDocs. + o.RegisterComponentsFromAssembly("ShellDocs.Preview.Ui"); + o.RegisterComponentsFromAssembly("ShellDocs.Preview.Demos"); + o.DemoSourceRoot = Path.Combine(builder.Environment.ContentRootPath, "Demos"); }); var app = builder.Build(); diff --git a/examples/ShellDocs.Preview/ShellDocs.Preview.csproj b/examples/ShellDocs.Preview/ShellDocs.Preview.csproj index 51749a0..f9a1401 100644 --- a/examples/ShellDocs.Preview/ShellDocs.Preview.csproj +++ b/examples/ShellDocs.Preview/ShellDocs.Preview.csproj @@ -13,6 +13,9 @@ + + diff --git a/examples/ShellDocs.Preview/Ui/Button.razor b/examples/ShellDocs.Preview/Ui/Button.razor new file mode 100644 index 0000000..5532fda --- /dev/null +++ b/examples/ShellDocs.Preview/Ui/Button.razor @@ -0,0 +1,15 @@ +@using Microsoft.AspNetCore.Components.Web + +@* Stand-in library button for exercising razor:preview (enums, bools, callbacks). *@ + + +@code { + [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Default; + [Parameter] public ButtonSize Size { get; set; } = ButtonSize.Medium; + [Parameter] public bool Disabled { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/examples/ShellDocs.Preview/Ui/Button.razor.css b/examples/ShellDocs.Preview/Ui/Button.razor.css new file mode 100644 index 0000000..3d7b234 --- /dev/null +++ b/examples/ShellDocs.Preview/Ui/Button.razor.css @@ -0,0 +1,29 @@ +.demo-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 2px); + font-family: inherit; + font-weight: 500; + cursor: pointer; + transition: background 150ms, border-color 150ms, opacity 150ms; +} +.demo-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Markdown-authored child content arrives wrapped in

. */ +.demo-btn ::deep p { margin: 0; display: inline; color: inherit; font: inherit; } + +.demo-btn-small { font-size: 0.75rem; padding: 0.3rem 0.65rem; } +.demo-btn-medium { font-size: 0.8125rem; padding: 0.45rem 0.9rem; } +.demo-btn-large { font-size: 0.9375rem; padding: 0.6rem 1.15rem; } + +.demo-btn-default { background: var(--primary); color: var(--primary-foreground); } +.demo-btn-default:hover:not(:disabled) { opacity: 0.9; } +.demo-btn-destructive { background: var(--error, oklch(0.637 0.237 25.331)); color: white; } +.demo-btn-destructive:hover:not(:disabled) { opacity: 0.9; } +.demo-btn-outline { background: transparent; border-color: var(--border); color: var(--foreground); } +.demo-btn-outline:hover:not(:disabled) { background: var(--muted); } +.demo-btn-secondary { background: var(--muted); color: var(--foreground); } +.demo-btn-secondary:hover:not(:disabled) { background: var(--accent); } diff --git a/examples/ShellDocs.Preview/Ui/ButtonVariant.cs b/examples/ShellDocs.Preview/Ui/ButtonVariant.cs new file mode 100644 index 0000000..10c423e --- /dev/null +++ b/examples/ShellDocs.Preview/Ui/ButtonVariant.cs @@ -0,0 +1,5 @@ +namespace ShellDocs.Preview.Ui; + +public enum ButtonVariant { Default, Destructive, Outline, Secondary } + +public enum ButtonSize { Small, Medium, Large } diff --git a/examples/ShellDocs.Preview/content/docs/v1.9.1/cli/build.md b/examples/ShellDocs.Preview/content/docs/v1.9.1/cli/build.md new file mode 100644 index 0000000..95f0118 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v1.9.1/cli/build.md @@ -0,0 +1,9 @@ +--- +title: shelldocs build +description: Static prerender (v1.9.1). +--- + +# shelldocs build (v1.9.1) + +The CLI package has no `cli/index.md`, so the package selector lands on the +first page under `/docs/v1.9.1/cli` — this one. diff --git a/examples/ShellDocs.Preview/content/docs/v1.9.1/components/button.md b/examples/ShellDocs.Preview/content/docs/v1.9.1/components/button.md new file mode 100644 index 0000000..12cf72a --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v1.9.1/components/button.md @@ -0,0 +1,11 @@ +--- +title: Button +description: The 1.9.1 button (no outline variant yet). +--- + +# Button (v1.9.1) + +```razor:preview + + +``` diff --git a/examples/ShellDocs.Preview/content/docs/v1.9.1/core/graph.md b/examples/ShellDocs.Preview/content/docs/v1.9.1/core/graph.md new file mode 100644 index 0000000..a5be8f7 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v1.9.1/core/graph.md @@ -0,0 +1,10 @@ +--- +title: Navigation graph +description: Building the graph (v1.9.1). +--- + +# Navigation graph (v1.9.1) + +## Folders become sections + +Version folders keep their dots: `v1.9.1` stays `v1.9.1` in every URL. diff --git a/examples/ShellDocs.Preview/content/docs/v1.9.1/core/index.md b/examples/ShellDocs.Preview/content/docs/v1.9.1/core/index.md new file mode 100644 index 0000000..59fbc96 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v1.9.1/core/index.md @@ -0,0 +1,9 @@ +--- +title: Core overview +description: The navigation graph in v1.9.1. +--- + +# Core overview (v1.9.1) + +`core/index.md` maps to the folder URL `/docs/v1.9.1/core`, which is what the +`ShellDocs.Core` package entry links to via `/docs/{version}/core`. diff --git a/examples/ShellDocs.Preview/content/docs/v1.9.1/introduction.md b/examples/ShellDocs.Preview/content/docs/v1.9.1/introduction.md new file mode 100644 index 0000000..653be13 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v1.9.1/introduction.md @@ -0,0 +1,11 @@ +--- +title: Introduction +description: ShellDocs v1.9.1 — a tiny versioned docs tree. +--- + +# Introduction (v1.9.1) + +This page lives under `content/docs/v1.9.1/`. Its sibling version has the same +relative path, so the version selector switches between them in place. + +Version 1.9.1 is the **legacy** line — no `cli dev` page, no outline buttons. diff --git a/examples/ShellDocs.Preview/content/docs/v1.9.1/meta.json b/examples/ShellDocs.Preview/content/docs/v1.9.1/meta.json new file mode 100644 index 0000000..d319f88 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v1.9.1/meta.json @@ -0,0 +1,4 @@ +{ + "title": "v1.9.1", + "pages": ["introduction", "components", "core", "cli"] +} diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/cli/build.md b/examples/ShellDocs.Preview/content/docs/v2.0/cli/build.md new file mode 100644 index 0000000..991c67f --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/cli/build.md @@ -0,0 +1,9 @@ +--- +title: shelldocs build +description: Static prerender (v2.0.0). +--- + +# shelldocs build (v2.0.0) + +The CLI package has no `cli/index.md`, so the package selector lands on the +first page under `/docs/v2.0/cli` — this one. diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/cli/dev.md b/examples/ShellDocs.Preview/content/docs/v2.0/cli/dev.md new file mode 100644 index 0000000..16b041e --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/cli/dev.md @@ -0,0 +1,9 @@ +--- +title: shelldocs dev +description: Only exists in v2.0.0. +--- + +# shelldocs dev + +This page only exists in **v2.0.0**. Switching to v1.9.1 from here keeps you in +the CLI package and lands on `cli/build`. diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/components/button.md b/examples/ShellDocs.Preview/content/docs/v2.0/components/button.md new file mode 100644 index 0000000..316bd23 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/components/button.md @@ -0,0 +1,47 @@ +--- +title: Button +description: Multi-sibling previews, enum forms and a demo-file preview. +--- + +# Button + +## Variants + +Every top-level sibling in a `razor:preview` fence renders, in order. Enum +values accept the Razor forms `Destructive`, `ButtonVariant.Destructive` and +`@ButtonVariant.Destructive`. + +```razor:preview + + + + +``` + +## Inside a wrapper + +Plain HTML wrappers and text render too: + +```razor:preview +

+ + + +
+Text after the row renders as well. +``` + +## Attributes a static preview can't evaluate + +`OnClick="HandleClick"`, `@onclick`, `@bind-*` and `@ref` are skipped with a +logged warning; the component still renders: + +```razor:preview + +``` + +## Stateful demo + +Demos that need `@code` live in `Demos/*.razor`; the source tab shows the file. + + diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/core/graph.md b/examples/ShellDocs.Preview/content/docs/v2.0/core/graph.md new file mode 100644 index 0000000..c3bc553 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/core/graph.md @@ -0,0 +1,10 @@ +--- +title: Navigation graph +description: Building the graph (v2.0.0). +--- + +# Navigation graph (v2.0.0) + +## Folders become sections + +Version folders keep their dots: `v2.0` stays `v2.0` in every URL. diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/core/index.md b/examples/ShellDocs.Preview/content/docs/v2.0/core/index.md new file mode 100644 index 0000000..e4cffea --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/core/index.md @@ -0,0 +1,9 @@ +--- +title: Core overview +description: The navigation graph in v2.0.0. +--- + +# Core overview (v2.0.0) + +`core/index.md` maps to the folder URL `/docs/v2.0/core`, which is what the +`ShellDocs.Core` package entry links to via `/docs/{version}/core`. diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/introduction.md b/examples/ShellDocs.Preview/content/docs/v2.0/introduction.md new file mode 100644 index 0000000..830db88 --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/introduction.md @@ -0,0 +1,11 @@ +--- +title: Introduction +description: ShellDocs v2.0.0 — a tiny versioned docs tree. +--- + +# Introduction (v2.0.0) + +This page lives under `content/docs/v2.0/`. Its sibling version has the same +relative path, so the version selector switches between them in place. + +Version 2.0 brings the **fresh** navigation model, `cli dev` and ``. diff --git a/examples/ShellDocs.Preview/content/docs/v2.0/meta.json b/examples/ShellDocs.Preview/content/docs/v2.0/meta.json new file mode 100644 index 0000000..5703bfe --- /dev/null +++ b/examples/ShellDocs.Preview/content/docs/v2.0/meta.json @@ -0,0 +1,4 @@ +{ + "title": "v2.0.0", + "pages": ["introduction", "components", "core", "cli"] +} From c8e733fc8e46b1af6376c7dbc463b1927e9ec1d6 Mon Sep 17 00:00:00 2001 From: Shewart Date: Fri, 2 Oct 2026 09:03:02 +0200 Subject: [PATCH 07/10] refactor(icons): render chrome and content icons with ShellIcons.Blazor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ShellIcons.Blazor 0.1.0-alpha (Lucide-derived typed icon components) to ShellDocs.Components. - Replace inline icons across chrome, content primitives, layouts and the example pages with typed icons (ChevronDown, Search, Copy, Check, PanelLeft, Sun/Moon, TriangleAlert, …). - SidebarIcons maps titles to icon component types instead of raw path strings. - Icon s now come from a child component, so scoped CSS targets them through ::deep. - Brand marks (GitHub, X) and consumer-supplied raw icons (DocsPackage.IconPath, NavMenuItem.IconSvg, Card.IconSvg) stay inline; only their defaults use ShellIcons. - Files where Lucide names collide (Heading, Type, TableOfContents) use fully qualified icon tags. --- Directory.Packages.props | 3 + .../Components/Pages/DocsPage.razor | 3 +- .../Components/Pages/Home.razor | 15 ++- .../Components/Pages/Showcase.razor | 5 +- .../Chrome/DocsBreadcrumb.razor | 3 +- .../Chrome/DocsBreadcrumb.razor.css | 2 +- .../Chrome/DocsHeader.razor | 38 +++--- .../Chrome/DocsHeader.razor.css | 10 +- .../Chrome/DocsMobileBar.razor | 24 ++-- .../Chrome/DocsSidebarHeader.razor | 8 +- .../Chrome/DocsSidebarHeader.razor.css | 4 +- .../Chrome/DocsSidebarNode.razor | 23 ++-- .../Chrome/PackageSelector.razor | 31 +++-- .../Chrome/PackageSelector.razor.css | 10 +- .../Chrome/PrevNextNav.razor | 5 +- .../Chrome/PrevNextNav.razor.css | 8 +- .../Chrome/SearchDialog.razor | 13 +- .../Chrome/SearchDialog.razor.css | 6 +- .../Chrome/SidebarIcons.cs | 126 ++++++++++-------- .../Chrome/TableOfContents.razor | 2 +- .../Chrome/TableOfContents.razor.css | 2 +- .../Chrome/ThemeToggle.razor | 5 +- .../Chrome/ThemeToggle.razor.css | 2 +- .../Chrome/VersionSelector.razor | 11 +- .../Chrome/VersionSelector.razor.css | 8 +- .../Content/Callout.razor | 9 +- .../Content/Callout.razor.css | 2 +- .../Content/ComponentPreview.razor | 4 +- .../Content/ComponentPreview.razor.css | 4 +- .../Content/FileTreeItem.razor | 5 +- .../Content/LinkCard.razor | 3 +- .../Content/LinkCard.razor.css | 2 +- .../Content/PreviewFrame.razor | 7 +- .../Content/PreviewFrame.razor.css | 6 +- .../Layouts/DocsLayout.razor | 4 +- .../Layouts/DocsLayout.razor.css | 2 +- .../ShellDocs.Components.csproj | 1 + 37 files changed, 221 insertions(+), 195 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4518df7..38cc3ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,9 @@ + + + diff --git a/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor b/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor index 482595d..88dc406 100644 --- a/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor +++ b/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @page "/docs/{*Path:nonfile}" @layout DocsLayout @inject NavigationGraph Graph @@ -14,7 +15,7 @@ else

Page not found

The page @Path doesn't exist yet.

-

Back to introduction

+

Back to introduction

} diff --git a/examples/ShellDocs.Preview/Components/Pages/Home.razor b/examples/ShellDocs.Preview/Components/Pages/Home.razor index 689138e..7d8d205 100644 --- a/examples/ShellDocs.Preview/Components/Pages/Home.razor +++ b/examples/ShellDocs.Preview/Components/Pages/Home.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @page "/" @layout HomeLayout @@ -35,42 +36,42 @@
- +

Markdown-first

Author in familiar .md files with YAML frontmatter. File-based routing over content/.

- +

Live Razor components

Drop <Button /> mid-markdown. Registered components render as real Blazor — no iframes.

- +

⌘K search

Build-time client-side search index. Zero backend, works offline, sub-millisecond queries.

- +

Themeable

Ships shadcn / fuma / nextra presets. Every token is a CSS variable — override any of them, live.

- +

Static or server

shelldocs build emits Blazor WASM static output for GitHub Pages, Cloudflare Pages, S3. Or run Server mode on Azure, Fly.io, or a container host.

- +

CLI-driven

shelldocs init, new, dev, build. Same feel as shellui, same install-and-go DX.

@@ -198,7 +199,7 @@ Works in Server, WASM, and static output. diff --git a/examples/ShellDocs.Preview/Components/Pages/Showcase.razor b/examples/ShellDocs.Preview/Components/Pages/Showcase.razor index eb90ced..3bf629f 100644 --- a/examples/ShellDocs.Preview/Components/Pages/Showcase.razor +++ b/examples/ShellDocs.Preview/Components/Pages/Showcase.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @page "/showcase" @layout HomeLayout @@ -35,7 +36,7 @@

@s.Description

- +
@@ -47,7 +48,7 @@

Open a PR to get listed here. Include a screenshot, a one-line description, and your live URL.

Submit on GitHub - +
diff --git a/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor b/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor index 9dcb11b..d0bb57f 100644 --- a/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor +++ b/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @inject DocsPageState PageState @implements IDisposable @using ShellDocs.Core @@ -25,7 +26,7 @@ { @node.Title } - + } } diff --git a/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor.css b/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor.css index 9639377..204ecd6 100644 --- a/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor.css +++ b/src/ShellDocs.Components/Chrome/DocsBreadcrumb.razor.css @@ -33,7 +33,7 @@ font-weight: 500; } -.docs-breadcrumb-sep { +::deep .docs-breadcrumb-sep { width: 0.75rem; height: 0.75rem; opacity: 0.6; diff --git a/src/ShellDocs.Components/Chrome/DocsHeader.razor b/src/ShellDocs.Components/Chrome/DocsHeader.razor index 1b875d8..8948a80 100644 --- a/src/ShellDocs.Components/Chrome/DocsHeader.razor +++ b/src/ShellDocs.Components/Chrome/DocsHeader.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @inject ShellDocsOptions Options @inject ShellDocs.Components.Chrome.MobileNavState MobileNav @inject IJSRuntime JS @@ -5,19 +6,14 @@
@@ -32,7 +28,7 @@
@@ -40,7 +36,14 @@ { - @((MarkupString)(child.IconSvg ?? DefaultIcon)) + @if (child.IconSvg is not null) + { + @((MarkupString)child.IconSvg) + } + else + { + + } @child.Label @@ -70,7 +73,7 @@ @if (ShowSearch) { diff --git a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor index 21b7d05..1f9bb75 100644 --- a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor +++ b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @inject ShellDocsOptions Options @inject IJSRuntime JS @inject SidebarCollapseState Collapse @@ -9,15 +10,12 @@ @(!string.IsNullOrEmpty(Options.SiteName) ? Options.SiteName : "Docs")
} else @@ -49,11 +50,11 @@ else if (Node.Kind == NodeKind.Section) else { var isActive = string.Equals(Node.Url.TrimEnd('/'), CurrentPath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); - var itemIcon = Depth <= 1 ? SidebarIcons.GetPath(Node.Title) : null; + var itemIcon = Depth <= 1 ? SidebarIcons.Get(Node.Title) : null; @if (itemIcon is not null) { - + } @Node.Title @@ -108,15 +109,17 @@ else private RenderFragment RenderLabelInner() => builder => { - var iconD = Depth <= 1 ? SidebarIcons.GetPath(Node.Title) : null; - if (iconD is not null) + var icon = Depth <= 1 ? SidebarIcons.Get(Node.Title) : null; + if (icon is not null) { - builder.AddMarkupContent(0, - $""); + builder.OpenComponent(0, icon); + builder.AddComponentParameter(1, "Class", "sidebar-section-icon"); + builder.AddComponentParameter(2, "StrokeWidth", SidebarIcons.StrokeWidth); + builder.CloseComponent(); } - builder.OpenElement(1, "span"); - builder.AddAttribute(2, "class", "sidebar-section-title"); - builder.AddContent(3, Node.Title); + builder.OpenElement(3, "span"); + builder.AddAttribute(4, "class", "sidebar-section-title"); + builder.AddContent(5, Node.Title); builder.CloseElement(); }; } diff --git a/src/ShellDocs.Components/Chrome/PackageSelector.razor b/src/ShellDocs.Components/Chrome/PackageSelector.razor index c28bbfb..40c471a 100644 --- a/src/ShellDocs.Components/Chrome/PackageSelector.razor +++ b/src/ShellDocs.Components/Chrome/PackageSelector.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @inject NavigationManager Nav @inject ShellDocsOptions Options @inject DocsVersionResolver Versions @@ -13,14 +14,17 @@
@@ -29,9 +33,14 @@ var isSel = pkg.Id == selected.Id; - - - + @if (pkg.IconPath is { } optionPath) + { + + } + else + { + + } @pkg.Title @@ -39,7 +48,7 @@ @if (isSel) { - + } } @@ -48,8 +57,6 @@ } @code { - private const string DefaultIcon = "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z M3.27 6.96 12 12.01l8.73-5.05 M12 22.08V12"; - // No parameters change on navigation, so the parent's re-render would skip us. protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; diff --git a/src/ShellDocs.Components/Chrome/PackageSelector.razor.css b/src/ShellDocs.Components/Chrome/PackageSelector.razor.css index 8e86209..804938c 100644 --- a/src/ShellDocs.Components/Chrome/PackageSelector.razor.css +++ b/src/ShellDocs.Components/Chrome/PackageSelector.razor.css @@ -32,7 +32,7 @@ border-radius: 5px; flex-shrink: 0; } -.pkg-mark svg { width: 1rem; height: 1rem; } +.pkg-mark ::deep svg { width: 1rem; height: 1rem; } .pkg-value { flex: 1; @@ -46,7 +46,7 @@ white-space: nowrap; } -.pkg-chevron { +::deep .pkg-chevron { width: 0.9rem; height: 0.9rem; color: var(--muted-foreground); @@ -54,7 +54,7 @@ margin-left: 0.25rem; transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); } -.pkg-chevron.open { transform: rotate(180deg); } +::deep .pkg-chevron.open { transform: rotate(180deg); } .pkg-menu { position: absolute; @@ -108,7 +108,7 @@ a.pkg-option { text-decoration: none; } flex-shrink: 0; margin-top: 1px; } -.pkg-option-icon svg { width: 0.85rem; height: 0.85rem; } +.pkg-option-icon ::deep svg { width: 0.85rem; height: 0.85rem; } .pkg-option-body { display: flex; @@ -119,7 +119,7 @@ a.pkg-option { text-decoration: none; } } .pkg-option-title { font-size: 0.8125rem; font-weight: 600; letter-spacing: -0.005em; } .pkg-option-desc { font-size: 0.7rem; color: var(--muted-foreground); line-height: 1.35; } -.pkg-option-check { +::deep .pkg-option-check { width: 0.9rem; height: 0.9rem; color: var(--foreground); diff --git a/src/ShellDocs.Components/Chrome/PrevNextNav.razor b/src/ShellDocs.Components/Chrome/PrevNextNav.razor index b7838a0..b221956 100644 --- a/src/ShellDocs.Components/Chrome/PrevNextNav.razor +++ b/src/ShellDocs.Components/Chrome/PrevNextNav.razor @@ -1,3 +1,4 @@ +@using ShellIcons.Icons @inject DocsPageState PageState @implements IDisposable @using ShellDocs.Core @@ -14,7 +15,7 @@ {