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

## [Unreleased]

## [0.1.15-alpha] — 2026-10-11

Search now works in static exports, the last piece of chrome that needed a running Blazor app.

### Changed

- **Search runs in the browser.** The app serves its search index at `/_shelldocs/search.json` (no `Program.cs` change), `shelldocs build` copies it into the export, and `shelldocs.js` fetches it on first use and ranks it with the same scoring as before: title, section, description and body, with body snippets, scoped to the current version. Search is the same on GitHub Pages or any static host as on a running app, including under `--base-href`. Results are links, so they open in a new tab like any other.
- **Search buttons are `[data-search-open]` buttons** handled by `shelldocs.js`; add the attribute to any element to open search from it. `shelldocsSearch.open()` still works from script, and there's a matching `shelldocsSearch.close()`.
- `SearchState` is no longer used and is kept only so existing code compiles. `shelldocsSearch.init` is gone.
- `shelldocs build` warns when the site's `ShellDocs.Components` is too old to serve an index, since search would not work in that export.

## [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.
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.14-alpha</Version>
<Version>0.1.15-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds

- **Markdown-first authoring.** YAML frontmatter, Shiki-highlighted code fences, live `razor:preview` examples, inline component tags mid-prose. Set `o.RenderPageTitle = true` to render the frontmatter `title` and `description` as the page header instead of writing `# Title`.
- **File-based navigation.** Drop a `.md` in `content/docs/` and it's a page. Sidebar, breadcrumb, prev/next and TOC come from the folder tree; `meta.json` controls order, dividers, subsections and hidden pages.
- **`Cmd+K` search** across titles, headings and page text, with body snippets. The index is built in memory at startup, so there's no external service; search needs a running Blazor app (it isn't available in the static export).
- **`Cmd+K` search** across titles, headings and page text, with body snippets. The index is built at startup and searched in the browser, so there's no external service and it works in the static export too.
- **Blazor-native.** Your components render as real Razor components, not iframes.
- **Composable.** Bring your own component library (ShellUI, MudBlazor, Radzen, hand-rolled) and register it in one line:
```csharp
o.RegisterComponentsFromAssembly<MyLib.Button>();
```
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 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`.
- **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, the theme toggle and search work there through `shelldocs.js`. Only 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: 7 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ All answers are computed at render time and emitted as plain `<a href>`, so prer

`SearchIndex.FromGraph` runs once at startup and keeps everything in memory: one entry per visible page (title, description, section, plain-text body) and one per h2/h3 heading (headings are read from the source if the page hasn't rendered yet).

`SearchDialog` (Cmd/Ctrl+K, or the header / sidebar buttons) filters the entries to the current version and scores them in .NET: whole-query and per-token matches against title (strongest), section, description and body; every token must match somewhere. Body-only matches get a ~150-character snippet. It's an interactive Blazor component, so search needs a running Blazor app; a static `shelldocs build` site has no search.
Searching happens in the browser, so it's the same on a static host. `DocsSearchIndexMiddleware` (added by an `IStartupFilter`, like the redirects) serves the index at `/_shelldocs/search.json`: `{ versions: [{ id, root, latest }], entries: [{ u, t, d, s, k, b, v }] }`, where `k` is `p` (page) or `h` (heading) and `v` is the id of the version that owns the entry. `shelldocs build` copies that file into the export.

`SearchDialog` renders a hidden template. On Cmd/Ctrl+K or a `[data-search-open]` button, `shelldocs.js` clones it onto `<body>` (outside the DOM Blazor owns, so a circuit starting doesn't close it), fetches the index once, keeps the current version's entries plus those outside every version, and scores them: whole-query and per-token matches against title (strongest), section, description and body; every token must match somewhere. Body-only matches get a ~150-character snippet. Results are links, cloned from the template's `[data-search-row]` rows. `shelldocsSearch.open()` / `.close()` do the same from script.

---

Expand All @@ -211,10 +213,9 @@ Chrome that must work without a Blazor runtime follows one contract:
| 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` |
| Search | `[data-search-dialog]` template, `[data-search-open]` | Cmd/Ctrl+K or a button opens a clone of the template; ranks `/_shelldocs/search.json`; arrows, Enter, Escape |
| 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.

**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 @@ -246,8 +247,9 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens.
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. 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.
6. Copies the app's search index to `_shelldocs/search.json`.
7. Merges the published `wwwroot/` into the output without overwriting prerendered HTML.
8. 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
5 changes: 3 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Goal: everything from DESIGN.md's primitives table shipped. Real docs sites beco
### ✅ `feat/search-primitives` — shipped (differently)
Ships to `ShellDocs.Core` + `ShellDocs.Components`.

*As built:* `SearchIndex.FromGraph` builds the index in memory at startup (no `search-index.json`), and `SearchDialog` is its own Blazor modal with token scoring and body snippets, not ShellUI's `<CommandPalette>`. It needs a running Blazor app. The original plan:
*As built:* `SearchIndex.FromGraph` builds the index in memory at startup (no `search-index.json`), and `SearchDialog` is its own modal with token scoring and body snippets, not ShellUI's `<CommandPalette>`. It first needed a running Blazor app; `feat/static-search` moved the ranking into `shelldocs.js`, with the index served at `/_shelldocs/search.json` and copied into `shelldocs build` exports. The original plan:

- `SearchIndexBuilder` in `ShellDocs.Core` — walks nav graph, emits `search-index.json`
- Wired into `shelldocs build` — index emitted alongside published site
Expand Down 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.14-alpha`) — shipped
### ✅ Hardening releases (`0.1.3`–`0.1.15-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 @@ -221,6 +221,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
- `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`.
- `feat/static-search` (0.1.15) — search runs in `shelldocs.js` against `/_shelldocs/search.json`, which `build` copies into the export, so static sites have search.
- `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
28 changes: 28 additions & 0 deletions src/ShellDocs.CLI/Commands/PrerenderRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ 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[/]");
var indexBytes = WriteSearchIndex(http, outputDir);
if (indexBytes > 0)
AnsiConsole.MarkupLine($"[dim]search:[/] wrote [cyan]{SearchIndexPath.TrimStart('/')}[/] ({Math.Max(1, indexBytes / 1024)} KB)");
else if (indexBytes == 0)
AnsiConsole.MarkupLine(" [yellow]warn:[/] the site serves no search index (its ShellDocs.Components predates static search), so search won't work in this export");
return new Result(failed == 0, rendered, failed);
}
finally
Expand Down Expand Up @@ -157,6 +162,29 @@ private static bool WriteNotFoundPage(HttpClient http, string outputDir)
}
}

internal const string SearchIndexPath = "/_shelldocs/search.json";

// shelldocs.js fetches the index from this path, so the export keeps it there.
// Returns the bytes written, 0 when the app serves no index, -1 on error.
internal static long WriteSearchIndex(HttpClient http, string outputDir)
{
try
{
var response = http.GetAsync(SearchIndexPath).GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode) return 0;
var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
var outPath = Path.Combine(outputDir, "_shelldocs", "search.json");
Directory.CreateDirectory(Path.GetDirectoryName(outPath)!);
File.WriteAllBytes(outPath, bytes);
return bytes.Length;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($" [yellow]warn:[/] couldn't read the search index: {ex.Message}");
return -1;
}
}

private record RedirectEntry(string From, string To);

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

<header class="docs-header">
<div class="docs-header-inner">
Expand Down Expand Up @@ -67,7 +66,7 @@
}
@if (ShowSearch)
{
<button type="button" class="docs-header-search" @onclick="OpenSearch" aria-label="Search">
<button type="button" class="docs-header-search" data-search-open aria-label="Search">
<Search />
<span class="docs-header-search-label">Search</span>
<span class="docs-header-kbd">
Expand Down Expand Up @@ -115,5 +114,4 @@
[Parameter] public bool ShowVersionSelector { get; set; } = true;
// Layouts without a sidebar drawer render the primary nav as a mobile menu.
[Parameter] public bool MobileMenu { get; set; }
private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask();
}
7 changes: 1 addition & 6 deletions src/ShellDocs.Components/Chrome/DocsMobileBar.razor
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
@using ShellIcons.Icons
@inject IJSRuntime JS

<div class="docs-mobile-bar" role="toolbar" aria-label="Mobile toolbar">
<button type="button" class="docs-mobile-tool" data-mobile-nav-toggle aria-expanded="false" aria-label="Toggle navigation">
<Menu Class="mobile-nav-icon-open" />
<X Class="mobile-nav-icon-close" />
</button>

<button type="button" class="docs-mobile-tool" @onclick="OpenSearch" aria-label="Search">
<button type="button" class="docs-mobile-tool" data-search-open aria-label="Search">
<Search />
</button>

<ThemeToggle />
</div>

@code {
private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask();
}
4 changes: 1 addition & 3 deletions src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
@using ShellIcons.Icons
@inject ShellDocsOptions Options
@inject IJSRuntime JS

<div class="docs-sidebar-header">
<div class="docs-sidebar-brand-row">
Expand All @@ -13,7 +12,7 @@
</button>
</div>

<button type="button" class="docs-sidebar-search" @onclick="OpenSearch" aria-label="Search">
<button type="button" class="docs-sidebar-search" data-search-open aria-label="Search">
<Search />
<span class="docs-sidebar-search-label">Search…</span>
<span class="docs-sidebar-search-kbd">
Expand All @@ -25,5 +24,4 @@

@code {
private string ShortcutKey => OperatingSystem.IsMacOS() ? "⌘" : "Ctrl";
private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask();
}
Loading
Loading