Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **`<VersionSelector />`** chrome: rendered under `<PackageSelector />` 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 `<a href>`: 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.
- **`<DemoPreview Component="X" Title="…" />`** 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 `<svg>` 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 `<svg>`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 `<div class="flex gap-2">…</div>`, 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 `<DemoPreview>` sources, copy the `.razor` files with a `None` item. The Razor SDK drops `.razor` from publish even with `Content Update` metadata:
`<None Include="Demos/**/*.razor" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />`

## [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.
Expand Down
5 changes: 1 addition & 4 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@
<NoWarn>$(NoWarn);CS1591</NoWarn> <!-- suppress "missing XML comment" until we backfill -->
</PropertyGroup>

<!-- Central package version pinning via Directory.Packages.props -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.7-alpha</Version>
<Version>0.1.8-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand All @@ -34,12 +33,10 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
</PropertyGroup>

<!-- Default: nothing is packable unless the project opts in -->
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>

<!-- Include README in packages that opt in -->
<ItemGroup Condition="'$(IsPackable)' == 'true'">
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" />
</ItemGroup>
Expand Down
5 changes: 4 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />

<!-- Icons -->
<PackageVersion Include="ShellIcons.Blazor" Version="0.1.0-alpha" />

<!-- Markdown pipeline -->
<PackageVersion Include="Markdig" Version="0.38.0" />
<PackageVersion Include="YamlDotNet" Version="16.2.1" />
Expand All @@ -16,7 +19,7 @@
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
<PackageVersion Include="Spectre.Console" Version="0.49.1" />

<!-- ShellUI (base primitives — added in feat/components-shell once ShellUI is published to NuGet)
<!-- Once ShellUI is on NuGet:
<PackageVersion Include="ShellUI.Components" Version="0.4.0" />
-->

Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<VersionSelector />` 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 `<a href>`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 (`<div class="flex gap-2">…</div>`) 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 `<DemoPreview Component="ButtonClickDemo" Title="Optional" />` into markdown. The source tab shows `{DemoSourceRoot}/**/ButtonClickDemo.razor`:

```csharp
o.RegisterComponentsFromAssembly<App>("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
<None Include="Demos/**/*.razor" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
```

## 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 |
Expand Down
25 changes: 24 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href>`, so prerendered/static pages behave identically; dropdown open/close is `shelldocs.js` delegation on `.pkg`/`.ver`.

### `meta.json` schema

```json
Expand Down Expand Up @@ -338,7 +357,11 @@ Standard Blazor primitive — takes `Type` + `IReadOnlyDictionary<string, object

Then the plain HTML with `data-slot` placeholders is emitted alongside; the DOM ends up interleaved. Details in the [markdown pipeline notes](#markdown-pipeline-internals).

**Parameter serialization:** frontmatter values are strings from YAML. `TypeRegistry` inspects each component's `[Parameter]` properties to know the target type and coerces (int, bool, enum, string). Complex types (records, DTOs) need JSON literals in the markdown.
**Parameter serialization:** attribute values are strings. `SlotRenderer.BuildParameters` matches them to `[Parameter]` properties (case-insensitively) and coerces string, bool, char, numeric primitives and enums, accepting Razor forms: a leading `@`, `@( … )`, `Type.Member` enum values, `A | B` flags, numeric suffixes, `@null`. Anything else is skipped with a logged warning instead of throwing: directive attributes (`@onclick`, `@bind-*`, `@ref`), delegate/`EventCallback` params, unsupported types, bad values, and unknown attributes on components without a `CaptureUnmatchedValues` catch-all.

**`razor:preview` fences** are parsed by `PreviewParser` (a hand-rolled, quote- and `@( … )`-aware tag scanner) into `PreviewSlot.Nodes`: component, element and text nodes in source order. `SlotRenderer.RenderNodes` emits them as real render-tree elements and components, so wrappers keep their children under interactive re-renders too. Razor-only constructs (`@code { }`, `@* *@`, `@using` lines) are skipped for rendering but stay in the source view.

**`<DemoPreview Component="X" />`** 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.

---

Expand Down
4 changes: 1 addition & 3 deletions examples/ShellDocs.Preview/Components/App.razor
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@
<Routes @rendermode="RenderMode.InteractiveServer" />
<script src="_content/ShellDocs.Components/shelldocs.js"></script>
<script type="module">
/* Shiki singleton highlighter — VSCode-quality syntax colouring via
WASM. Loads the fixed subset of languages we support; dual-theme
(github-light + github-dark) resolves through CSS vars. */
/* Shiki (WASM) with github-light/dark themes resolved through CSS vars. */
import { createHighlighter } from 'https://esm.sh/shiki@1.24.0';
window.__shiki = await createHighlighter({
themes: ['github-light', 'github-dark'],
Expand Down
3 changes: 2 additions & 1 deletion examples/ShellDocs.Preview/Components/Pages/DocsPage.razor
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@using ShellIcons.Icons
@page "/docs/{*Path:nonfile}"
@layout DocsLayout
@inject NavigationGraph Graph
Expand All @@ -14,7 +15,7 @@ else
<div class="doc-not-found">
<h1>Page not found</h1>
<p>The page <code>@Path</code> doesn't exist yet.</p>
<p><a href="/docs/introduction"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:0.35rem"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>Back to introduction</a></p>
<p><a href="/docs/introduction"><ArrowLeft Size="14" style="vertical-align:middle;margin-right:0.35rem" />Back to introduction</a></p>
</div>
}

Expand Down
Loading
Loading