Skip to content

Commit 04e1c04

Browse files
authored
Merge pull request #37 from shellui-dev/feat/static-search
feat: search works in static exports (0.1.15-alpha)
2 parents fd5a828 + 35eb769 commit 04e1c04

18 files changed

Lines changed: 691 additions & 287 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht
44

55
## [Unreleased]
66

7+
## [0.1.15-alpha] — 2026-10-11
8+
9+
Search now works in static exports, the last piece of chrome that needed a running Blazor app.
10+
11+
### Changed
12+
13+
- **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.
14+
- **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()`.
15+
- `SearchState` is no longer used and is kept only so existing code compiles. `shelldocsSearch.init` is gone.
16+
- `shelldocs build` warns when the site's `ShellDocs.Components` is too old to serve an index, since search would not work in that export.
17+
718
## [0.1.14-alpha] — 2026-10-10
819

920
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.

‎Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
<!-- Package metadata (applies to any project with IsPackable=true) -->
1919
<PropertyGroup>
20-
<Version>0.1.14-alpha</Version>
20+
<Version>0.1.15-alpha</Version>
2121
<Authors>ShellUI</Authors>
2222
<Company>ShellUI</Company>
2323
<Copyright>Copyright © 2026 ShellUI</Copyright>

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,15 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds
4949

5050
- **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`.
5151
- **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.
52-
- **`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).
52+
- **`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.
5353
- **Blazor-native.** Your components render as real Razor components, not iframes.
5454
- **Composable.** Bring your own component library (ShellUI, MudBlazor, Radzen, hand-rolled) and register it in one line:
5555
```csharp
5656
o.RegisterComponentsFromAssembly<MyLib.Button>();
5757
```
5858
Your components win name collisions with ShellDocs' built-ins, which stay available as `<DocsCard>`, `<DocsCallout>`, `<DocsTabs>`, and so on.
5959
- **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`.
60-
- **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`.
60+
- **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`.
6161

6262
## Versioned docs
6363

‎docs/ARCHITECTURE.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,9 @@ All answers are computed at render time and emitted as plain `<a href>`, so prer
189189

190190
`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).
191191

192-
`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.
192+
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.
193+
194+
`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.
193195

194196
---
195197

@@ -211,10 +213,9 @@ Chrome that must work without a Blazor runtime follows one contract:
211213
| Mobile nav | page shell `[data-mobile-open]` | hamburger toggles; backdrop, Escape, link or navigation closes; page scroll locked on `<html>` |
212214
| 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 |
213215
| TOC | `[data-toc-list][data-toc-ids]` | IntersectionObserver scroll-spy, re-attached on `enhancedload` |
216+
| 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 |
214217
| 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 |
215218

216-
Still Blazor-only: search.
217-
218219
**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.
219220

220221
`enhancedload` is a Blazor event (`Blazor.addEventListener`), not a DOM event; `shelldocsOnEnhancedLoad(fn)` attaches once `blazor.web.js` has run.
@@ -246,8 +247,9 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens.
246247
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.
247248
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>`).
248249
5. Requests `/docs/__shelldocs-not-found__` and, if it renders `<DocsNotFound>` with a 404, writes it as `404.html`.
249-
6. Merges the published `wwwroot/` into the output without overwriting prerendered HTML.
250-
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.
250+
6. Copies the app's search index to `_shelldocs/search.json`.
251+
7. Merges the published `wwwroot/` into the output without overwriting prerendered HTML.
252+
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.
251253

252254
---
253255

‎docs/ROADMAP.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ Goal: everything from DESIGN.md's primitives table shipped. Real docs sites beco
146146
### ✅ `feat/search-primitives` — shipped (differently)
147147
Ships to `ShellDocs.Core` + `ShellDocs.Components`.
148148

149-
*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:
149+
*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:
150150

151151
- `SearchIndexBuilder` in `ShellDocs.Core` — walks nav graph, emits `search-index.json`
152152
- Wired into `shelldocs build` — index emitted alongside published site
@@ -209,7 +209,7 @@ Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `She
209209
**Authoring fix (`ShellDocs.Markdown`)**
210210
- `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.
211211

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

215215
- `feat/build-static-prerender` (0.1.5) — `shelldocs build` prerenders every URL into a static site.
@@ -221,6 +221,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
221221
- `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.
222222
- `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.
223223
- `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`.
224+
- `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.
224225
- `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`.
225226

226227
### `feat/animation-polish`

‎src/ShellDocs.CLI/Commands/PrerenderRunner.cs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ void OnStreamLine(string line)
124124
var redirects = WriteRedirects(http, outputDir);
125125
if (redirects > 0) AnsiConsole.MarkupLine($"[dim]redirects:[/] wrote [cyan]{redirects}[/] redirect page(s)");
126126
if (WriteNotFoundPage(http, outputDir)) AnsiConsole.MarkupLine("[dim]not found:[/] wrote [cyan]404.html[/]");
127+
var indexBytes = WriteSearchIndex(http, outputDir);
128+
if (indexBytes > 0)
129+
AnsiConsole.MarkupLine($"[dim]search:[/] wrote [cyan]{SearchIndexPath.TrimStart('/')}[/] ({Math.Max(1, indexBytes / 1024)} KB)");
130+
else if (indexBytes == 0)
131+
AnsiConsole.MarkupLine(" [yellow]warn:[/] the site serves no search index (its ShellDocs.Components predates static search), so search won't work in this export");
127132
return new Result(failed == 0, rendered, failed);
128133
}
129134
finally
@@ -157,6 +162,29 @@ private static bool WriteNotFoundPage(HttpClient http, string outputDir)
157162
}
158163
}
159164

165+
internal const string SearchIndexPath = "/_shelldocs/search.json";
166+
167+
// shelldocs.js fetches the index from this path, so the export keeps it there.
168+
// Returns the bytes written, 0 when the app serves no index, -1 on error.
169+
internal static long WriteSearchIndex(HttpClient http, string outputDir)
170+
{
171+
try
172+
{
173+
var response = http.GetAsync(SearchIndexPath).GetAwaiter().GetResult();
174+
if (!response.IsSuccessStatusCode) return 0;
175+
var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
176+
var outPath = Path.Combine(outputDir, "_shelldocs", "search.json");
177+
Directory.CreateDirectory(Path.GetDirectoryName(outPath)!);
178+
File.WriteAllBytes(outPath, bytes);
179+
return bytes.Length;
180+
}
181+
catch (Exception ex)
182+
{
183+
AnsiConsole.MarkupLine($" [yellow]warn:[/] couldn't read the search index: {ex.Message}");
184+
return -1;
185+
}
186+
}
187+
160188
private record RedirectEntry(string From, string To);
161189

162190
// The app serves its redirect map (DocsRedirectMiddleware); each source URL without

‎src/ShellDocs.Components/Chrome/DocsHeader.razor‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
@using ShellIcons.Icons
22
@inject ShellDocsOptions Options
3-
@inject IJSRuntime JS
43

54
<header class="docs-header">
65
<div class="docs-header-inner">
@@ -67,7 +66,7 @@
6766
}
6867
@if (ShowSearch)
6968
{
70-
<button type="button" class="docs-header-search" @onclick="OpenSearch" aria-label="Search">
69+
<button type="button" class="docs-header-search" data-search-open aria-label="Search">
7170
<Search />
7271
<span class="docs-header-search-label">Search</span>
7372
<span class="docs-header-kbd">
@@ -115,5 +114,4 @@
115114
[Parameter] public bool ShowVersionSelector { get; set; } = true;
116115
// Layouts without a sidebar drawer render the primary nav as a mobile menu.
117116
[Parameter] public bool MobileMenu { get; set; }
118-
private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask();
119117
}
Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
@using ShellIcons.Icons
2-
@inject IJSRuntime JS
32

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

10-
<button type="button" class="docs-mobile-tool" @onclick="OpenSearch" aria-label="Search">
9+
<button type="button" class="docs-mobile-tool" data-search-open aria-label="Search">
1110
<Search />
1211
</button>
1312

1413
<ThemeToggle />
1514
</div>
16-
17-
@code {
18-
private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask();
19-
}

‎src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
@using ShellIcons.Icons
22
@inject ShellDocsOptions Options
3-
@inject IJSRuntime JS
43

54
<div class="docs-sidebar-header">
65
<div class="docs-sidebar-brand-row">
@@ -13,7 +12,7 @@
1312
</button>
1413
</div>
1514

16-
<button type="button" class="docs-sidebar-search" @onclick="OpenSearch" aria-label="Search">
15+
<button type="button" class="docs-sidebar-search" data-search-open aria-label="Search">
1716
<Search />
1817
<span class="docs-sidebar-search-label">Search…</span>
1918
<span class="docs-sidebar-search-kbd">
@@ -25,5 +24,4 @@
2524

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

0 commit comments

Comments
 (0)