diff --git a/CHANGELOG.md b/CHANGELOG.md index b6bf045..d9c827e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + +- **``**: 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 ``. +- **`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 `` 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 `

`. @@ -400,7 +414,8 @@ Published to NuGet: - `` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4) - No `` 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 diff --git a/Directory.Build.props b/Directory.Build.props index f21553d..da075e8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ - 0.1.13-alpha + 0.1.14-alpha ShellUI ShellUI Copyright © 2026 ShellUI diff --git a/README.md b/README.md index 9b8fd8a..5010d7d 100644 --- a/README.md +++ b/README.md @@ -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 ``, ``, ``, and so on. - **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`. -- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `` (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections, the mobile menu, selectors, tabs, preview toolbars, the TOC and code copy work there through `shelldocs.js`. 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`), 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 (`` in the docs page) and `build` writes the same view as `404.html`. ## Versioned docs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4fc4f7a..25e770c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `` | +| 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 | ``, `[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.** `` 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. @@ -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 `//index.html`. +3. `PrerenderRunner` starts the published app on a free port, requests each URL and writes `//index.html`. A URL that answers 404, or renders ``, 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 ``). -5. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. -6. Optionally rewrites `` 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 `` with a 404, writes it as `404.html`. +6. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. +7. Optionally rewrites `` 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. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 45fdc6c..3c25fe2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 `

` 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.
@@ -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, `` 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, ``, XAML highlighting; one-line component bodies stay inline.
+- `fix/not-found-and-sidebar-collapse` (0.1.14) — `` 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`
diff --git a/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor b/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor
index 88dc406..2fbcde8 100644
--- a/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor
+++ b/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor
@@ -1,4 +1,3 @@
-@using ShellIcons.Icons
 @page "/docs/{*Path:nonfile}"
 @layout DocsLayout
 @inject NavigationGraph Graph
@@ -12,19 +11,9 @@
 }
 else
 {
-    
-

Page not found

-

The page @Path doesn't exist yet.

-

Back to introduction

-
+ } - - @code { [Parameter] public string? Path { get; set; } diff --git a/examples/ShellDocs.Preview/content/docs/installation.md b/examples/ShellDocs.Preview/content/docs/installation.md index f25c3e7..ffcf76d 100644 --- a/examples/ShellDocs.Preview/content/docs/installation.md +++ b/examples/ShellDocs.Preview/content/docs/installation.md @@ -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 `` 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. diff --git a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs index fcb1076..71dc1ec 100644 --- a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs +++ b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs @@ -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 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); @@ -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 @@ -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 . + 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 diff --git a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor index 1f9bb75..aaab0ca 100644 --- a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor +++ b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor @@ -1,7 +1,6 @@ @using ShellIcons.Icons @inject ShellDocsOptions Options @inject IJSRuntime JS -@inject SidebarCollapseState Collapse
@@ -9,7 +8,7 @@ @(!string.IsNullOrEmpty(Options.SiteName) ? Options.SiteName : "Docs") -
diff --git a/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs b/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs index 2b042a1..40b4823 100644 --- a/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs +++ b/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs @@ -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; } diff --git a/src/ShellDocs.Components/Content/DocsNotFound.razor b/src/ShellDocs.Components/Content/DocsNotFound.razor new file mode 100644 index 0000000..478c12a --- /dev/null +++ b/src/ShellDocs.Components/Content/DocsNotFound.razor @@ -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. *@ +
+

Page not found

+

The page @_path doesn't exist.

+

+ @HomeLabel +

+
+ +@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 + ?? "/"; + } +} diff --git a/src/ShellDocs.Components/Content/DocsNotFound.razor.css b/src/ShellDocs.Components/Content/DocsNotFound.razor.css new file mode 100644 index 0000000..dd63f45 --- /dev/null +++ b/src/ShellDocs.Components/Content/DocsNotFound.razor.css @@ -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; } diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor b/src/ShellDocs.Components/Layouts/DocsLayout.razor index 949c168..37ec33b 100644 --- a/src/ShellDocs.Components/Layouts/DocsLayout.razor +++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor @@ -1,11 +1,11 @@ @inherits LayoutComponentBase -@inject ShellDocs.Components.Chrome.SidebarCollapseState Collapse @inject ShellDocsOptions Options @inject IJSRuntime JS -@implements IDisposable @using Microsoft.AspNetCore.Components.Sections -
+@* [data-sidebar-collapsed] is flipped by shelldocs.js ([data-sidebar-collapse-toggle]), + so collapsing works without a Blazor runtime. *@ +
@if (Options.LayoutVariant == DocsLayoutVariant.TopNav) { @@ -15,10 +15,11 @@ } - @if (Options.LayoutVariant == DocsLayoutVariant.Sidebar && Collapse.IsCollapsed) + @if (Options.LayoutVariant == DocsLayoutVariant.Sidebar) { + @* Shown by CSS while the sidebar is collapsed. *@
-