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/CHANGELOG.md b/CHANGELOG.md index baf239b..d90b8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +## [0.1.8-alpha] — 2026-10-02 + +Versioned docs, plus `razor:preview` fixes that let real component-library docs (ShellUI) preview what they actually write. Every new chrome interaction follows the 0.1.7 static-host pattern: server-rendered initial state, data attributes, and delegated handlers in `shelldocs.js`, with no `@onclick` state. + +### Added + +- **Docs versions.** `ShellDocsOptions.AddVersion(string id, string label, string rootUrl, string? description = null, bool latest = false)` stores `DocsVersion(Id, Label, RootUrl, Description, IsLatest)` records in `ShellDocsOptions.Versions`. The current version is the one whose `RootUrl` is a segment-aware prefix of the path, otherwise the latest, otherwise the first. +- **`DocsVersionResolver`** (registered singleton): current version, scoped sidebar nodes, version/package hrefs, search filtering, prev/next scope, breadcrumb filtering. +- **``** chrome: rendered under `` in `DocsSidebar` (so it's in the mobile drawer too), and in `DocsHeader` for the `TopNav` layout (desktop; the sidebar copy covers mobile there). Hidden with fewer than 2 versions. Current label in the trigger, a "latest" badge, and a check on the selected version. Options are plain ``: same page in the target version → current package root in the target version → the target version's first page. +- **`{version}` token in `AddPackage` root URLs**, resolved to the current version's `Id`. Selected-package matching (longest prefix, now segment-aware) uses resolved URLs. Switching packages keeps the version; versioned package roots without an index page link to their first page. +- **``** content primitive plus `ShellDocsOptions.DemoSourceRoot`. It renders the registered component inside the preview frame, and the source tab shows `{DemoSourceRoot}/**/X.razor` (cached, highlighted as razor). A missing component or file renders the red error panel. +- `UrlPath` helpers (`Normalize`, `IsUnder`, `RelativeTo`, `Combine`) and `NavigationGraph.GetPrevNext(node, inScope)`, `FindFolder(url)`, `FirstPageUnder(url)` in `ShellDocs.Core`. +- `PreviewSlot.Nodes` + `PreviewNode` / `PreviewTextNode` / `PreviewElementNode` / `PreviewComponentNode` in `ShellDocs.Markdown`. +- `PreviewFrame` content mode (`Content` / `Code` / `Title` / `Error` / `ErrorTitle`), `DocsHeader.ShowVersionSelector`. + +### Changed + +- **Version-scoped chrome.** Inside a version, `DocsSidebar` renders only that version folder's children, prev/next never crosses a version boundary, search shows the current version plus anything outside every version root, and the breadcrumb omits the version folder. Without versions configured, behavior is unchanged. +- `PackageSelector` and `VersionSelector` re-render on navigation themselves. Parameterless components weren't re-rendered by their parent, so the package selection could go stale after enhanced navigation. +- `shelldocs.js` dropdown delegation now covers `.pkg` and `.ver`. Picking an option or pressing Escape closes the menu. +- Inline component tags and preview tags keep full attribute names. `@bind-Value="v"` is no longer misread as a `Value` parameter. +- Parameter-name matching is case-insensitive, like Blazor's. +- **Icons come from [ShellIcons.Blazor](https://www.nuget.org/packages/ShellIcons.Blazor) 0.1.0-alpha** (new dependency of `ShellDocs.Components`) instead of inline `` markup. `SidebarIcons` maps titles to icon components. Brand marks (GitHub, X) and consumer-supplied raw icons (`DocsPackage.IconPath`, `NavMenuItem.IconSvg`, `Card.IconSvg`) are unchanged. Scoped CSS that styled icon ``s now uses `::deep`, so if you restyle those classes in your own scoped CSS, do the same. + +### Fixed + +- **`razor:preview` with several top-level siblings rendered only the first component.** All top-level content now renders in order inside the one frame: components, HTML wrappers like `
…
`, and text. Wrappers are real render-tree elements, so their children stay inside them under interactive re-renders. The "unknown component" error panel still appears when no registered component is found. `@code { }`, `@* *@` and `@using`-style lines are skipped for rendering but kept in the source view. +- **Enum attributes in Razor form.** `ButtonVariant.Destructive`, `@ButtonVariant.Destructive`, `@true`, `@42`, `@(…)`, and `[Flags]` values as `A | B` all coerce now. +- **Attributes that can't be coerced no longer crash the page.** `EventCallback`/`Action`/`Func` params (`OnClick="HandleClick"`), `@bind-*`, `@ref`, `@onclick`, parameters of unsupported types, values `Coerce` can't parse, unknown attributes on components without a catch-all, and child content on components without a `RenderFragment ChildContent` are skipped with a logged warning. The component still renders. `ComponentPreview` gets the same protection. +- Search results lagged one keystroke behind the input. The query now recalculates via `@bind:after`. +- `PreviewFrame` / `ComponentPreview` copy buttons showed the copy and check icons side by side. The check now appears only after a successful copy. +- `aria-selected` on selector options renders `"true"`/`"false"` instead of a bare or missing attribute. +- `DocsSidebar` now actually implements `IDisposable`, so its `LocationChanged` handler is released. + +### Notes + +- Version folder names with dots (`v0.2.1`) keep their dots in every URL, slug and prerender output path. A route declared as `/docs/{*Path:nonfile}` won't match a URL whose last segment contains a dot (`/docs/v0.2.1`). Pages under it are fine, and the selectors only link to a bare version root when it has an `index.md`. +- To ship `` sources, copy the `.razor` files with a `None` item. The Razor SDK drops `.razor` from publish even with `Content Update` metadata: + `` + ## [0.1.7-alpha] — 2026-09-06 Fixes the interactivity gap the `0.1.5-alpha` static-prerender pipeline opened up. Four chrome interactions (sidebar section expand/collapse, package selector dropdown, TableOfContents scroll-spy, PreviewFrame source view) were written as ordinary interactive Razor components with `@onclick` handlers that mutate `[Parameter] bool` state and re-render via `StateHasChanged()`. Prerendered HTML captured only the initial state; on a static host with no Blazor runtime, every one of those interactions was dead on the deployed site. diff --git a/Directory.Build.props b/Directory.Build.props index 1448996..01d6dc3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,14 +11,13 @@ $(NoWarn);CS1591 - true - 0.1.7-alpha + 0.1.8-alpha ShellUI ShellUI Copyright © 2026 ShellUI @@ -34,12 +33,10 @@ true - false - diff --git a/Directory.Packages.props b/Directory.Packages.props index b1efe97..38cc3ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,9 @@ + + + @@ -16,7 +19,7 @@ - diff --git a/README.md b/README.md index 208c79e..dff7932 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,46 @@ That's a working docs site. See [shelldocs.dev/docs/getting-started/quick-start] ``` - **Static site output.** `shelldocs build` produces static HTML ready for GitHub Pages, Vercel, Netlify, Cloudflare, anywhere. Base-href rewrite + SPA 404 fallback included. +## Versioned docs + +Put each version in its own content folder with the same sub-structure (`content/docs/v0.3/...`, `content/docs/v0.2.1/...`; dots in folder names are fine) and register them: + +```csharp +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"); + +// "{version}" in a package root resolves to the current version's Id. +o.AddPackage("shellui.cli", "ShellUI.CLI", "Command line", "/docs/{version}/cli", icon); +``` + +The current version is the one whose `RootUrl` prefixes the path (segment-aware), otherwise the `latest` one. With 2+ versions a `` renders under the package selector (and in the mobile drawer; in the `TopNav` layout it sits in the header). Switching versions keeps the same page when it exists, else the current package's root, else the version's first page. Switching packages keeps the version. Inside a version the sidebar shows only that version's tree, prev/next never crosses into another version, search shows the current version plus pages outside every version, and the version folder is left out of the breadcrumb. Everything is server-rendered `
`s plus `shelldocs.js` delegation, so it works on static hosts. + +> Routes declared as `/docs/{*Path:nonfile}` don't match a URL whose **last** segment has a dot (`/docs/v0.2.1`). Pages below it (`/docs/v0.2.1/introduction`) are fine, and the selectors never link to a bare version root unless it has an `index.md`. If you add one, drop `:nonfile` from the route. + +## Component previews + +- **`razor:preview` fences render everything** — several sibling components, HTML wrappers (`
…
`) and text, in order, inside one frame. The source tab shows the whole fence. +- **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. +- **Stateful demos from real files.** For demos that need `@code` (dialogs, bound selects, toasts, charts with data), write a `.razor` component, register it, and drop `` into markdown. The source tab shows `{DemoSourceRoot}/**/ButtonClickDemo.razor`: + + ```csharp + o.RegisterComponentsFromAssembly("MyDocs.Demos"); + o.DemoSourceRoot = Path.Combine(builder.Environment.ContentRootPath, "Demos"); + ``` + + The Razor SDK drops `.razor` files from build/publish output, so ship them explicitly. Use a `None` item, because `Content Update` doesn't survive publish: + + ```xml + + ``` + ## Package family | Package | Purpose | |---|---| | [`ShellDocs.CLI`](src/ShellDocs.CLI) | Global tool. `shelldocs init`, `add`, `dev`, `build` | -| [`ShellDocs.Components`](src/ShellDocs.Components) | RCL. Chrome (layout, sidebar, header, search) + content primitives (Callout, Card, Steps, CodeGroup, FileTree, TypeTable, ComponentPreview) | +| [`ShellDocs.Components`](src/ShellDocs.Components) | RCL. Chrome (layout, sidebar, header, search, version/package selectors) + content primitives (Callout, Card, Steps, CodeGroup, FileTree, TypeTable, ComponentPreview, DemoPreview). Icons via [ShellIcons.Blazor](https://www.nuget.org/packages/ShellIcons.Blazor) | | [`ShellDocs.Markdown`](src/ShellDocs.Markdown) | Markdig pipeline. Frontmatter parser, `razor:preview` fence extractor, inline Razor tag extractor, per-property type coercion | | [`ShellDocs.Core`](src/ShellDocs.Core) | Navigation graph, search index, plain-text extraction. No UI | | [`ShellDocs.Tokens`](src/ShellDocs.Tokens) | Design-system CSS variables. shadcn-compatible names for interop with ShellUI and Tailwind-shaped design systems | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3538774..4a53944 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -260,6 +260,25 @@ Cache result — rebuild only when `dev` mode detects a change. `NavigationGraph.GetBreadcrumb(node)`: - Walk up `Parent` chain; O(depth) +`NavigationGraph.GetPrevNext(node, inScope)` / `FindFolder(url)` / `FirstPageUnder(url)`: +- Scoped prev/next (skips out-of-scope neighbours), folder-section lookup by URL, first page under a URL prefix. + +### Versions + +`ShellDocsOptions.Versions` (`AddVersion(id, label, rootUrl, description, latest)`) maps each docs version to a content folder URL. `DocsVersionResolver` (singleton, stateless — the current path is always passed in) answers every version question the chrome asks: + +| Question | Answer | +|---|---| +| Current version | Version whose `RootUrl` is a segment-aware prefix of the path (`UrlPath.IsUnder`), longest wins → else `latest` → else `Versions[0]` | +| Sidebar nodes | Inside a version: that version folder node's `Children` (`FindFolder`). Otherwise `Root.Children` | +| Version switch href | Same relative page in the target version → current package's root in the target version → target version's first page | +| Package href | `{version}` token replaced with the current version's `Id`; a versioned root that isn't a page lands on its first page | +| Prev/next | `GetPrevNext(node, n => InSameScope(n.Url, node.Url))` | +| Search | Entries in the current version + entries outside every version root | +| Breadcrumb | Version folder node removed | + +All answers are computed at render time and emitted as plain `
`, so prerendered/static pages behave identically; dropdown open/close is `shelldocs.js` delegation on `.pkg`/`.ver`. + ### `meta.json` schema ```json @@ -338,7 +357,11 @@ Standard Blazor primitive — takes `Type` + `IReadOnlyDictionary`** renders registered component `X` inside `PreviewFrame` and reads `{DemoSourceRoot}/**/X.razor` (shallowest match, cached and re-read when the file's write time changes) for the source tab. --- 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/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/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/PreviewTests.cs b/tests/ShellDocs.Tests/PreviewTests.cs new file mode 100644 index 0000000..eb05130 --- /dev/null +++ b/tests/ShellDocs.Tests/PreviewTests.cs @@ -0,0 +1,433 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.Logging; +using ShellDocs.Components; +using ShellDocs.Components.Content; +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +public enum ButtonVariant { Default, Destructive, Outline } + +[Flags] +public enum TextStyle { None = 0, Bold = 1, Italic = 2, Underline = 4 } + +public class Button : ComponentBase +{ + [Parameter] public ButtonVariant Variant { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public int Size { get; set; } + [Parameter] public double? Ratio { get; set; } + [Parameter] public TextStyle Style { get; set; } + [Parameter] public string? Label { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public EventCallback OnCount { get; set; } + [Parameter] public Action? OnAction { get; set; } + [Parameter] public Func? Transform { get; set; } + [Parameter] public List? Items { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.OpenElement(0, "button"); + b.AddAttribute(1, "data-variant", Variant.ToString()); + b.AddAttribute(2, "data-style", Style.ToString()); + b.AddAttribute(3, "data-size", Size); + b.AddAttribute(4, "disabled", Disabled); + b.AddContent(5, ChildContent); + b.CloseElement(); + } +} + +// Has a catch-all, so unknown attributes pass straight through. +public class Badge : ComponentBase +{ + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? Attrs { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder b) + { + b.OpenElement(0, "span"); + b.AddMultipleAttributes(1, Attrs); + b.AddContent(2, ChildContent); + b.CloseElement(); + } +} + +public class PreviewParsingTests +{ + private static MarkdownRenderer Renderer() => new(new TypeRegistry().Register\n\n"); + + var comps = slot.Nodes!.OfType().ToList(); + Assert.Equal(3, comps.Count); + Assert.Equal(new[] { "One", "Two", "Three" }, comps.Select(c => c.ChildContentRaw)); + Assert.Equal("Outline", comps[1].Parameters["Variant"]); + // First component still drives the legacy single-component fields. + Assert.Equal(typeof(Button), slot.ComponentType); + Assert.Equal("One", slot.ChildContentRaw); + } + + [Fact] + public void HtmlWrapperAndText_ArePreserved() + { + var slot = Preview("
\n \n \n
\n

Some & text

"); + + var wrapper = Assert.IsType(slot.Nodes!.First(n => n is PreviewElementNode)); + Assert.Equal("div", wrapper.TagName); + Assert.Equal("flex gap-2", wrapper.Attributes.Single(a => a.Key == "class").Value); + Assert.Equal(2, wrapper.Children.OfType().Count()); + + var p = slot.Nodes!.OfType().Single(n => n.TagName == "p"); + Assert.Equal("Some & text", Assert.IsType(Assert.Single(p.Children)).Text); + } + + [Fact] + public void SourceView_IsWholeFence() + { + var fence = "\n"; + Assert.Equal(fence, Preview(fence).Code); + } + + [Fact] + public void RazorAttributes_KeepFullNames_AndLambdasDoNotBreakTheTag() + { + var slot = Preview(""); + var comp = Assert.IsType(Assert.Single(slot.Nodes!)); + Assert.Equal("v", comp.Parameters["@bind-Value"]); + Assert.Equal("btn", comp.Parameters["@ref"]); + Assert.Equal("@(() => Log(\"x > y\"))", comp.Parameters["OnClick"]); + Assert.Equal("ButtonVariant.Destructive", comp.Parameters["Variant"]); + Assert.False(comp.Parameters.ContainsKey("Value")); + Assert.Equal("Go", comp.ChildContentRaw); + } + + [Fact] + public void RazorOnlyConstructs_AreSkippedForRendering_ButKeptInSource() + { + var fence = "@using Foo.Bar\n@* note *@\n\n@code {\n int count = 0; void F() { if (true) { } }\n}"; + var slot = Preview(fence); + Assert.Single(slot.Nodes!.OfType()); + Assert.DoesNotContain(slot.Nodes!.OfType(), t => t.Text.Contains("count") || t.Text.Contains("@using") || t.Text.Contains("note")); + Assert.Contains("@code", slot.Code); + } + + [Fact] + public void ElementDirectiveAttributes_AreDropped() + { + var slot = Preview(""); + var div = Assert.IsType(Assert.Single(slot.Nodes!)); + Assert.DoesNotContain(div.Attributes, a => a.Key.StartsWith('@')); + Assert.Null(div.Attributes.Single(a => a.Key == "hidden").Value); + } + + [Fact] + public void OnlyUnknownComponents_KeepsErrorPanel() + { + var renderer = Renderer(); + var doc = renderer.Render("```razor:preview\n
x
\n```"); + var slot = Assert.IsType(Assert.Single(doc.Slots)); + Assert.Null(slot.ComponentType); + Assert.Contains("Unknown component ", slot.Error); + Assert.Equal("1", slot.Parameters["Foo"]); + Assert.Equal("x", slot.ChildContentRaw); + Assert.Contains(renderer.LastWarnings, w => w.Contains("Missing")); + } + + [Fact] + public void KnownAndUnknownSiblings_RenderKnown_WarnUnknown() + { + var renderer = Renderer(); + var doc = renderer.Render("```razor:preview\n\n\n```"); + var slot = Assert.IsType(Assert.Single(doc.Slots)); + Assert.Null(slot.Error); + Assert.Contains(slot.Nodes!, n => n is PreviewElementNode { TagName: "Missing" }); + Assert.Contains(renderer.LastWarnings, w => w.Contains("Missing")); + } + + [Fact] + public void FenceWithoutComponentTags_RendersAsPlainCode() + { + var renderer = Renderer(); + var doc = renderer.Render("```razor:preview\n
plain
\n```"); + Assert.Empty(doc.Slots); + Assert.Contains("language-razor:preview", doc.Html); + Assert.Contains(renderer.LastWarnings, w => w.Contains("must contain a component tag")); + } + + [Fact] + public void InlineComponentTag_BindAttribute_IsNotMisreadAsParameter() + { + var doc = Renderer().Render("\n@code { int count; void Inc() => count++; }"); + } + + public void Dispose() + { + try { Directory.Delete(_demoRoot, recursive: true); } catch { } + } + + private ComponentRenderHarness Harness() => new(o => + { + o.ContentRoot = Path.Combine(_demoRoot, "no-content"); + o.DemoSourceRoot = _demoRoot; + o.RegisterComponent\n\n\n\n```")); + + var one = html.IndexOf(">One", StringComparison.Ordinal); + var two = html.IndexOf(">Two", StringComparison.Ordinal); + var three = html.IndexOf(">Three", StringComparison.Ordinal); + Assert.True(one > 0 && two > one && three > two, html); + Assert.Contains("data-variant=\"Destructive\"", html); + Assert.Contains("data-variant=\"Outline\"", html); + + // The wrapper is a real element enclosing the first two buttons. + var wrapStart = html.IndexOf("
", StringComparison.Ordinal); + var wrapEnd = html.IndexOf("
", two, StringComparison.Ordinal); + Assert.True(wrapStart > 0 && wrapStart < one && wrapEnd < three, html); + } + + [Fact] + public async Task UncoercibleAttributes_DoNotCrash_ComponentStillRenders() + { + var harness = Harness(); + var html = await harness.RenderAsync(Md( + "```razor:preview\n\n```")); + + Assert.Contains(">Safe", html); + Assert.Contains("