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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht

## [Unreleased]

## [0.1.14-alpha] — 2026-10-10

Static sites get the last piece of interactive chrome apart from search, the desktop sidebar collapse, and unknown pages finally answer 404, with a matching `404.html` in static exports.

### Added

- **`<DocsNotFound />`**: the docs page's not-found view, now used by the `init` template. It answers HTTP 404, so search engines skip unknown pages. The status is set as the headers go out, because .NET 10 sends an empty body for a page that is already 404 unless the app has a `NotFoundPage` or status-code re-execution. `Path`, `HomeHref` and `HomeLabel` override the message and the link back (which defaults to where `/docs` leads). Existing sites replace their `else` branch in `DocsPage.razor` with `<DocsNotFound />`.
- **`shelldocs build` writes `404.html`** from the site's own not-found view, for static hosts that serve it for unknown URLs (GitHub Pages, Netlify, Cloudflare Pages). `--spa-fallback` still replaces it with `index.html`.

### Changed

- **Desktop sidebar collapse works without a Blazor runtime.** The collapse and expand buttons are `[data-sidebar-collapse-toggle]` buttons handled by `shelldocs.js`, which flips `[data-sidebar-collapsed]` on the page shell and remembers the choice in `localStorage` across pages. The page shell no longer gets a `docs-sidebar-collapsed` class; style `[data-sidebar-collapsed="true"]` instead. `SidebarCollapseState` is still registered but no longer used.
- **`shelldocs build` fails on a page that renders as not found**, whether it answered 404 or rendered `<DocsNotFound>` with another status.

## [0.1.13-alpha] — 2026-10-10

Code variants for cross-platform libraries: one preview with code tabs per platform (MAUI and Avalonia XAML, say), a page switch that picks the platform site-wide, captions, and live iframe previews. Also fixes one-line component bodies being wrapped in `<p>`.
Expand Down Expand Up @@ -400,7 +414,8 @@ Published to NuGet:
- `<TypeTable>` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4)
- No `<DocsBreadcrumb>` opt-out — currently hides when the trail has ≤ 1 node, otherwise always renders

[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.13-alpha...HEAD
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.14-alpha...HEAD
[0.1.14-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.14-alpha
[0.1.13-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.13-alpha
[0.1.12-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.12-alpha
[0.1.11-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.11-alpha
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.13-alpha</Version>
<Version>0.1.14-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds
```
Your components win name collisions with ShellDocs' built-ins, which stay available as `<DocsCard>`, `<DocsCallout>`, `<DocsTabs>`, and so on.
- **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`.
- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `<base href>` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections, the mobile menu, selectors, tabs, preview toolbars, the TOC and code copy work there through `shelldocs.js`. So does the theme toggle. Search, desktop sidebar collapse and stateful demos need a running Blazor app.
- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `<base href>` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections and collapse, the mobile menu, selectors, tabs, preview toolbars, the TOC, code copy and the theme toggle work there through `shelldocs.js`. Only search and stateful demos need a running Blazor app. Unknown pages answer 404 (`<DocsNotFound />` in the docs page) and `build` writes the same view as `404.html`.

## Versioned docs

Expand Down
12 changes: 8 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,13 @@ Chrome that must work without a Blazor runtime follows one contract:
| Tabs, CodeGroup, preview code tabs, page switch | `[data-tabs]`, `[data-tab-target]`, `[data-tab-panel]` | switch on click / arrows; `[data-tabs-sync]` groups switch together and persist in `localStorage`; a `[data-tabs-missing]` group without the chosen value shows its `[data-tab-missing]` note; with nothing saved yet, groups follow a `[data-tabs-switch]` for their key |
| Iframe previews | `[data-iframe-preview][data-src][data-lazy]` | sets the iframe's `src` (plus `?theme=`) when visible (IntersectionObserver), on `[data-iframe-run]` or at once; posts `{ type: "shelldocs-theme", theme }` to loaded frames on theme change |
| Mobile nav | page shell `[data-mobile-open]` | hamburger toggles; backdrop, Escape, link or navigation closes; page scroll locked on `<html>` |
| Sidebar collapse | page shell `[data-sidebar-collapsed]`, `[data-sidebar-collapse-toggle]` | toggles collapse; remembered in `localStorage` and restored as soon as `shelldocs.js` runs, so the sidebar doesn't flash open |
| TOC | `[data-toc-list][data-toc-ids]` | IntersectionObserver scroll-spy, re-attached on `enhancedload` |
| Theme | `<html class="dark">`, `[data-theme-toggle]` | inline head script applies the saved / system theme; the toggle flips the class and CSS picks its icon; a `MutationObserver` saves any change (including another library's toggle) and pushes it to `ThemeState`; re-applied after enhanced navigation |

Still Blazor-only: search, the desktop sidebar-collapse button.
Still Blazor-only: search.

**Not found.** `<DocsNotFound />` is the docs page's not-found view. It answers 404 by setting the status in `Response.OnStarting`: .NET 10 sends an empty body for a page whose status is already 404 unless the app has a `NotFoundPage` or status-code re-execution, so the status has to change only as the headers go out. It carries `data-shelldocs-not-found`, which `shelldocs build` checks for.

`enhancedload` is a Blazor event (`Blazor.addEventListener`), not a DOM event; `shelldocsOnEnhancedLoad(fn)` attaches once `blazor.web.js` has run.

Expand Down Expand Up @@ -240,10 +243,11 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens.

1. `dotnet publish -c Release` into `obj/shelldocs-publish`, then copies in any `content/` file publish left out (a csproj that copies `meta.json` but not `.md`).
2. Builds the navigation graph and collects every URL (visible and hidden) plus `/`.
3. `PrerenderRunner` starts the published app on a free port, requests each URL and writes `<output>/<path>/index.html`.
3. `PrerenderRunner` starts the published app on a free port, requests each URL and writes `<output>/<path>/index.html`. A URL that answers 404, or renders `<DocsNotFound>`, fails the build.
4. Reads the app's redirect map and writes a redirect page for each source URL without a prerendered page (meta refresh plus `location.replace`, relative to `<base href>`).
5. Merges the published `wwwroot/` into the output without overwriting prerendered HTML.
6. Optionally rewrites `<base href>` in every HTML file, copies `index.html` to `404.html`, and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta.
5. Requests `/docs/__shelldocs-not-found__` and, if it renders `<DocsNotFound>` with a 404, writes it as `404.html`.
6. Merges the published `wwwroot/` into the output without overwriting prerendered HTML.
7. Optionally rewrites `<base href>` in every HTML file, copies `index.html` to `404.html` (replacing the not-found page), and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta.

---

Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `She
**Authoring fix (`ShellDocs.Markdown`)**
- `SlotExtractor.ReplaceComponentTags` no longer `.Trim()`s the raw child content of inline component tags. The Trim was stripping the first line's indent and defeating `SlotRenderer.Dedent` — Markdig then interpreted the remaining 4-space-indented lines as an indented code block. Symptom was the same "literal `<pre>` around placeholder divs" bug that had already been fixed for `razor:preview` fences; the inline-tag code path was still hitting it.

### ✅ Hardening releases (`0.1.3`–`0.1.13-alpha`) — shipped
### ✅ Hardening releases (`0.1.3`–`0.1.14-alpha`) — shipped
Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHANGELOG):

- `feat/build-static-prerender` (0.1.5) — `shelldocs build` prerenders every URL into a static site.
Expand All @@ -220,6 +220,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
- `fix/sidebar-index-pages` (0.1.11) — index pages as sidebar section links, `scroll` preview layout and phone-width previews, scrolling tables.
- `fix/fence-scanner-inline-attrs` (0.1.12) — CommonMark fences (any length, tildes), inline and named-slot tags read by the Razor tag scanner so `>` in a quoted value is safe, `<TypeTable>` rows that share a name.
- `feat/preview-code-variants` (0.1.13) — code tabs on previews with sync and a missing-tab note, page switch (`AddSyncSwitch`), captions, `<IframePreview>`, XAML highlighting; one-line component bodies stay inline.
- `fix/not-found-and-sidebar-collapse` (0.1.14) — `<DocsNotFound>` answers 404, `build` fails on not-found pages and writes `404.html`, desktop sidebar collapse in `shelldocs.js`.
- `fix/preview-fidelity`, `feat/generics-redirects-page-title` (0.1.10) — `not-prose` previews, Razor child content, stretch layout, generic components in markdown, redirects (middleware + static pages), `RenderPageTitle`, theme sync and a static-host `ThemeToggle`.

### `feat/animation-polish`
Expand Down
13 changes: 1 addition & 12 deletions examples/ShellDocs.Preview/Components/Pages/DocsPage.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
@using ShellIcons.Icons
@page "/docs/{*Path:nonfile}"
@layout DocsLayout
@inject NavigationGraph Graph
Expand All @@ -12,19 +11,9 @@
}
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"><ArrowLeft Size="14" style="vertical-align:middle;margin-right:0.35rem" />Back to introduction</a></p>
</div>
<DocsNotFound />
}

<style>
.doc-not-found { padding: 3rem 0; }
.doc-not-found h1 { font-size: 1.5rem; font-weight: 600; }
.doc-not-found code { background: var(--muted); padding: 0.125rem 0.375rem; border-radius: 4px; font-family: var(--font-mono); font-size: 0.875em; border: 1px solid var(--border); }
</style>

@code {
[Parameter] public string? Path { get; set; }

Expand Down
4 changes: 2 additions & 2 deletions examples/ShellDocs.Preview/content/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ shelldocs build
`shelldocs build` prerenders every page to static HTML in `publish/` (change it with `--output`). Deploy it to GitHub Pages, Cloudflare Pages, Netlify or S3. Useful flags:

- `--base-href /my-repo/` rewrites `<base href>` for sites served from a subpath.
- `--spa-fallback` copies `index.html` to `404.html`.
- `--spa-fallback` copies `index.html` to `404.html`. Without it, `404.html` is the site's own not-found page, which GitHub Pages, Netlify and Cloudflare Pages serve for unknown URLs.
- `--site-url https://docs.example.com` emits `sitemap.xml`, `robots.txt` and `og:` meta tags.

In the static output, navigation, sidebar sections, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle and code copy all work through `shelldocs.js`. Search, the desktop sidebar-collapse button and stateful demo components still need a running Blazor Server app (interactive server rendering).
In the static output, navigation, sidebar sections and collapse, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle and code copy all work through `shelldocs.js`. Search and stateful demo components still need a running Blazor Server app (interactive server rendering).

URLs that aren't pages (folders, version roots, unversioned URLs that moved into a version) become redirect pages in the static output, the same redirects the running app answers.
36 changes: 35 additions & 1 deletion src/ShellDocs.CLI/Commands/PrerenderRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,19 @@ void OnStreamLine(string line)
var response = http.GetAsync(url).GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
{
AnsiConsole.MarkupLine($" [yellow]warn:[/] [cyan]{url}[/] returned [yellow]{(int)response.StatusCode}[/]");
var why = response.StatusCode == HttpStatusCode.NotFound ? "404 (page not found)" : ((int)response.StatusCode).ToString();
AnsiConsole.MarkupLine($" [yellow]warn:[/] [cyan]{url}[/] returned [yellow]{why}[/]");
failed++;
continue;
}
var html = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
// A docs page that rendered <DocsNotFound> where the status couldn't be set.
if (IsNotFoundPage(html))
{
AnsiConsole.MarkupLine($" [yellow]warn:[/] [cyan]{url}[/] rendered [yellow]Page not found[/]");
failed++;
continue;
}
var outPath = UrlToFilePath(url, outputDir);
Directory.CreateDirectory(Path.GetDirectoryName(outPath)!);
File.WriteAllText(outPath, html);
Expand All @@ -115,6 +123,7 @@ void OnStreamLine(string line)

var redirects = WriteRedirects(http, outputDir);
if (redirects > 0) AnsiConsole.MarkupLine($"[dim]redirects:[/] wrote [cyan]{redirects}[/] redirect page(s)");
if (WriteNotFoundPage(http, outputDir)) AnsiConsole.MarkupLine("[dim]not found:[/] wrote [cyan]404.html[/]");
return new Result(failed == 0, rendered, failed);
}
finally
Expand All @@ -123,6 +132,31 @@ void OnStreamLine(string line)
}
}

internal const string NotFoundMarker = "data-shelldocs-not-found";
// A URL no site has, so the docs route renders <DocsNotFound>.
internal const string NotFoundProbe = "/docs/__shelldocs-not-found__";

internal static bool IsNotFoundPage(string html) => html.Contains(NotFoundMarker, StringComparison.Ordinal);

// Static hosts (GitHub Pages, Netlify, Cloudflare Pages) serve 404.html for unknown
// URLs. Written from the site's own not-found view, when its docs route has one.
private static bool WriteNotFoundPage(HttpClient http, string outputDir)
{
try
{
var response = http.GetAsync(NotFoundProbe).GetAwaiter().GetResult();
var html = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (response.StatusCode != HttpStatusCode.NotFound || !IsNotFoundPage(html)) return false;
File.WriteAllText(Path.Combine(outputDir, "404.html"), html);
return true;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($" [yellow]warn:[/] couldn't render the not-found page: {ex.Message}");
return false;
}
}

private record RedirectEntry(string From, string To);

// The app serves its redirect map (DocsRedirectMiddleware); each source URL without
Expand Down
3 changes: 1 addition & 2 deletions src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
@using ShellIcons.Icons
@inject ShellDocsOptions Options
@inject IJSRuntime JS
@inject SidebarCollapseState Collapse

<div class="docs-sidebar-header">
<div class="docs-sidebar-brand-row">
<a class="docs-sidebar-brand" href="/">
<BrandLogo />
<span class="docs-sidebar-brand-name">@(!string.IsNullOrEmpty(Options.SiteName) ? Options.SiteName : "Docs")</span>
</a>
<button type="button" class="docs-sidebar-collapse" @onclick="Collapse.Toggle" aria-label="Collapse sidebar" title="Collapse sidebar">
<button type="button" class="docs-sidebar-collapse" data-sidebar-collapse-toggle aria-label="Collapse sidebar" title="Collapse sidebar">
<PanelLeft StrokeWidth="1.75" />
</button>
</div>
Expand Down
3 changes: 1 addition & 2 deletions src/ShellDocs.Components/Chrome/SidebarCollapseState.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
namespace ShellDocs.Components.Chrome;

// Desktop collapse for the Sidebar layout — persistent, unlike the
// temporary mobile drawer in MobileNavState.
// No longer used: the sidebar collapse is driven by shelldocs.js. Kept so existing code compiles.
public class SidebarCollapseState
{
public bool IsCollapsed { get; private set; }
Expand Down
52 changes: 52 additions & 0 deletions src/ShellDocs.Components/Content/DocsNotFound.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
@namespace ShellDocs.Components.Content
@attribute [ShellDocsIgnore]
@using Microsoft.AspNetCore.Http
@using ShellIcons.Icons
@inject NavigationManager Nav
@inject DocsRedirects Redirects
@inject NavigationGraph Graph

@* The docs page's "not found" view. It sets HTTP 404 while the response hasn't
started (prerender, static SSR), so search engines skip it and `shelldocs build`
counts it as a failure; data-shelldocs-not-found marks it for the build too. *@
<div class="docs-not-found" data-shelldocs-not-found>
<h1>Page not found</h1>
<p>The page <code>@_path</code> doesn't exist.</p>
<p>
<a href="@_home"><ArrowLeft Class="docs-not-found-icon" />@HomeLabel</a>
</p>
</div>

@code {
[CascadingParameter] public HttpContext? HttpContext { get; set; }
// Defaults to the requested URL's path.
[Parameter] public string? Path { get; set; }
// Defaults to where /docs leads (the first docs page, in the latest version).
[Parameter] public string? HomeHref { get; set; }
[Parameter] public string HomeLabel { get; set; } = "Back to the docs";

private string _path = "";
private string _home = "/";

/* Blazor sends an empty body for a page whose status is already 404 unless the app
has a NotFoundPage or status-code re-execution, so the status is set as the
headers go out, after this view has rendered. */
protected override void OnInitialized()
{
if (HttpContext is { Response.HasStarted: false } http)
http.Response.OnStarting(() =>
{
http.Response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
});
}

protected override void OnParametersSet()
{
_path = Path ?? DocsVersionResolver.PathOf(Nav);
_home = HomeHref
?? Redirects.Resolve("/docs")?.Url
?? Graph.FirstPageUnder("/docs")?.Url
?? "/";
}
}
22 changes: 22 additions & 0 deletions src/ShellDocs.Components/Content/DocsNotFound.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.docs-not-found { padding: 3rem 0; }
.docs-not-found h1 { margin: 0 0 0.75rem; font-size: 1.5rem; font-weight: 600; letter-spacing: -0.015em; }
.docs-not-found p { margin: 0.5rem 0; color: var(--muted-foreground); }
.docs-not-found code {
padding: 0.125rem 0.375rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--muted);
color: var(--foreground);
font-family: var(--font-mono);
font-size: 0.875em;
}
.docs-not-found a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--foreground);
font-weight: 500;
text-decoration: none;
}
.docs-not-found a:hover { text-decoration: underline; text-underline-offset: 3px; }
.docs-not-found ::deep .docs-not-found-icon { width: 0.875rem; height: 0.875rem; }
Loading
Loading