diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c827e..2bee416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Directory.Build.props b/Directory.Build.props index da075e8..29e69b7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ - 0.1.14-alpha + 0.1.15-alpha ShellUI ShellUI Copyright © 2026 ShellUI diff --git a/README.md b/README.md index b9e3410..7d83229 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ 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 @@ -57,7 +57,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 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`. +- **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, the theme toggle and search work there through `shelldocs.js`. Only 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 25e770c..5bb69b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -189,7 +189,9 @@ All answers are computed at render time and emitted as plain ``, 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 `` (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. --- @@ -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 `` | | 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 | ``, `[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.** `` 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. @@ -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 `//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. 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. +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 `` 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 3c25fe2..7aba29e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 ``. 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 ``. 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 @@ -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.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.
@@ -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, `` 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`.
+- `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`
diff --git a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs
index 71dc1ec..5e8bc14 100644
--- a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs
+++ b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs
@@ -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
@@ -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
diff --git a/src/ShellDocs.Components/Chrome/DocsHeader.razor b/src/ShellDocs.Components/Chrome/DocsHeader.razor
index aa7e4ce..4ad1000 100644
--- a/src/ShellDocs.Components/Chrome/DocsHeader.razor
+++ b/src/ShellDocs.Components/Chrome/DocsHeader.razor
@@ -1,6 +1,5 @@
 @using ShellIcons.Icons
 @inject ShellDocsOptions Options
-@inject IJSRuntime JS
 
 
@@ -67,7 +66,7 @@ } @if (ShowSearch) { - -
- -@code { - private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask(); -} diff --git a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor index aaab0ca..c4e9292 100644 --- a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor +++ b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor @@ -1,6 +1,5 @@ @using ShellIcons.Icons @inject ShellDocsOptions Options -@inject IJSRuntime JS
@@ -13,7 +12,7 @@
- - } - } -
+@* A template: shelldocs.js clones it onto to open search, ranks + /_shelldocs/search.json in the browser and clones a [data-search-row] per result, + so search works without a Blazor runtime. *@ +
diff --git a/src/ShellDocs.Components/Chrome/SearchDialog.razor.css b/src/ShellDocs.Components/Chrome/SearchDialog.razor.css index e086fc5..6bf2435 100644 --- a/src/ShellDocs.Components/Chrome/SearchDialog.razor.css +++ b/src/ShellDocs.Components/Chrome/SearchDialog.razor.css @@ -11,6 +11,7 @@ padding-top: 8vh; animation: search-fade-in 140ms ease; } +.search-backdrop[hidden] { display: none; } @keyframes search-fade-in { from { opacity: 0; } to { opacity: 1; } @@ -87,6 +88,8 @@ border-radius: calc(var(--radius) - 3px); color: var(--foreground); text-align: left; + text-decoration: none; + box-sizing: border-box; cursor: pointer; font-family: inherit; font-size: 0.875rem; diff --git a/src/ShellDocs.Components/Chrome/SearchState.cs b/src/ShellDocs.Components/Chrome/SearchState.cs index 53962bd..44a54bc 100644 --- a/src/ShellDocs.Components/Chrome/SearchState.cs +++ b/src/ShellDocs.Components/Chrome/SearchState.cs @@ -1,5 +1,6 @@ namespace ShellDocs.Components.Chrome; +// No longer used: search is driven by shelldocs.js (shelldocsSearch.open()). Kept so existing code compiles. public class SearchState { public bool IsOpen { get; private set; } diff --git a/src/ShellDocs.Components/DocsSearchIndexMiddleware.cs b/src/ShellDocs.Components/DocsSearchIndexMiddleware.cs new file mode 100644 index 0000000..b147c13 --- /dev/null +++ b/src/ShellDocs.Components/DocsSearchIndexMiddleware.cs @@ -0,0 +1,112 @@ +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; +using ShellDocs.Core; + +namespace ShellDocs.Components; + +/* The search index as shelldocs.js reads it: { versions: [{ id, root, latest }], + entries: [{ u, t, d, s, k, b, v }] }. k is "p" (page) or "h" (heading); v is the + id of the version that owns the entry, absent for entries outside every version. */ +internal sealed class DocsSearchIndexFile +{ + private readonly Lazy<(byte[] Bytes, string ETag)> _file; + + public DocsSearchIndexFile(SearchIndex index, DocsVersionResolver versions) + { + _file = new(() => + { + var bytes = Build(index, versions); + return (bytes, "\"" + Convert.ToHexString(SHA256.HashData(bytes), 0, 8) + "\""); + }); + } + + public byte[] Bytes => _file.Value.Bytes; + public string ETag => _file.Value.ETag; + + internal static byte[] Build(SearchIndex index, DocsVersionResolver versions) + { + using var stream = new MemoryStream(); + using (var json = new Utf8JsonWriter(stream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) + { + json.WriteStartObject(); + json.WriteStartArray("versions"); + foreach (var v in versions.Versions) + { + json.WriteStartObject(); + json.WriteString("id", v.Id); + json.WriteString("root", UrlPath.Normalize(v.RootUrl)); + if (v.IsLatest) json.WriteBoolean("latest", true); + json.WriteEndObject(); + } + json.WriteEndArray(); + + json.WriteStartArray("entries"); + foreach (var e in index.Entries) + { + json.WriteStartObject(); + json.WriteString("u", e.Url); + json.WriteString("t", e.Title); + if (!string.IsNullOrEmpty(e.Description)) json.WriteString("d", e.Description); + if (!string.IsNullOrEmpty(e.Section)) json.WriteString("s", e.Section); + json.WriteString("k", e.Kind == SearchEntryKind.Page ? "p" : "h"); + if (!string.IsNullOrEmpty(e.Body)) json.WriteString("b", e.Body); + if (versions.FindContaining(e.Url) is { } owner) json.WriteString("v", owner.Id); + json.WriteEndObject(); + } + json.WriteEndArray(); + json.WriteEndObject(); + } + return stream.ToArray(); + } +} + +// Serves the index to shelldocs.js, and to `shelldocs build`, which copies it into the export. +internal sealed class DocsSearchIndexMiddleware +{ + public const string IndexPath = "/_shelldocs/search.json"; + + private readonly RequestDelegate _next; + private readonly DocsSearchIndexFile _file; + + public DocsSearchIndexMiddleware(RequestDelegate next, DocsSearchIndexFile file) + { + _next = next; + _file = file; + } + + public Task InvokeAsync(HttpContext context) + { + var request = context.Request; + if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) + return _next(context); + if (!string.Equals(request.Path.Value, IndexPath, StringComparison.OrdinalIgnoreCase)) + return _next(context); + + var response = context.Response; + response.Headers[HeaderNames.ETag] = _file.ETag; + response.Headers[HeaderNames.CacheControl] = "no-cache"; + if (request.Headers[HeaderNames.IfNoneMatch] == _file.ETag) + { + response.StatusCode = StatusCodes.Status304NotModified; + return Task.CompletedTask; + } + + response.ContentType = "application/json; charset=utf-8"; + response.ContentLength = _file.Bytes.Length; + return HttpMethods.IsHead(request.Method) ? Task.CompletedTask : response.Body.WriteAsync(_file.Bytes).AsTask(); + } +} + +internal sealed class DocsSearchStartupFilter : IStartupFilter +{ + public Action Configure(Action next) => app => + { + app.UseMiddleware(); + next(app); + }; +} diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor b/src/ShellDocs.Components/Layouts/DocsLayout.razor index 37ec33b..2d4d776 100644 --- a/src/ShellDocs.Components/Layouts/DocsLayout.razor +++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor @@ -1,6 +1,5 @@ @inherits LayoutComponentBase @inject ShellDocsOptions Options -@inject IJSRuntime JS @using Microsoft.AspNetCore.Components.Sections @* [data-sidebar-collapsed] is flipped by shelldocs.js ([data-sidebar-collapse-toggle]), @@ -22,7 +21,7 @@ - @@ -59,5 +58,4 @@ _ => "topnav" }; - private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask(); } diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs index e2073a0..02dbc31 100644 --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs @@ -48,6 +48,8 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services, services.AddSingleton(); if (options.EnableRedirects) services.AddTransient(); + services.AddSingleton(); + services.AddTransient(); return services; } diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js index bbf976c..9409544 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs.js +++ b/src/ShellDocs.Components/wwwroot/shelldocs.js @@ -22,33 +22,249 @@ window.shelldocsOnEnhancedLoad = (function () { }; })(); -// Cmd/Ctrl+K opens via the DotNetObjectReference it registers on first render. +/* Search. renders a hidden template; opening clones it onto , + outside the DOM Blazor owns, and ranks /_shelldocs/search.json in the browser, so + it works the same on a static host. Cmd/Ctrl+K and [data-search-open] open it. */ window.shelldocsSearch = (function () { - var dotnet = null; - function isModK(e) { - return (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey); + var index = null; + var loading = null; + var failed = false; + var live = null; + var results = []; + var highlighted = 0; + var returnFocus = null; + + function load() { + if (!loading) { + failed = false; + loading = fetch(new URL('_shelldocs/search.json', document.baseURI)) + .then(function (r) { + if (!r.ok) throw new Error('search index: ' + r.status); + return r.json(); + }) + .then(function (data) { + (data.entries || []).forEach(function (e) { + e.lt = (e.t || '').toLowerCase(); + e.ld = (e.d || '').toLowerCase(); + e.ls = (e.s || '').toLowerCase(); + e.lb = (e.b || '').toLowerCase(); + }); + index = { versions: data.versions || [], entries: data.entries || [] }; + }) + .catch(function () { failed = true; loading = null; }); + } + return loading; } - function onKeydown(e) { - if (!isModK(e)) return; - e.preventDefault(); - openInternal(); + + // Same rules as UrlPath.Normalize / IsUnder. + function normalize(path) { + var s = (path || '').split(/[?#]/)[0]; + if (s.charAt(0) !== '/') s = '/' + s; + while (s.length > 1 && s.charAt(s.length - 1) === '/') s = s.slice(0, -1); + return s.toLowerCase(); } - function openInternal() { - if (dotnet) { dotnet.invokeMethodAsync('OpenFromJs'); } + + function isUnder(path, root) { + var r = normalize(root); + if (r === '/') return true; + return path.indexOf(r) === 0 && (path.length === r.length || path.charAt(r.length) === '/'); } - return { - init: function (dotnetRef) { - dotnet = dotnetRef; - document.addEventListener('keydown', onKeydown); - return { - dispose: function () { - document.removeEventListener('keydown', onKeydown); - dotnet = null; - } - }; - }, - open: openInternal - }; + + function currentVersion() { + var base = new URL(document.baseURI).pathname; + var path = location.pathname; + path = normalize(path.indexOf(base) === 0 ? path.slice(base.length) : path); + var best = null, latest = null; + index.versions.forEach(function (v) { + if (v.latest && !latest) latest = v; + if (isUnder(path, v.root) && (!best || v.root.length > best.root.length)) best = v; + }); + return best || latest || index.versions[0] || null; + } + + // The current version's entries, plus those outside every version. + function scoped() { + var current = currentVersion(); + return index.entries.filter(function (e) { return !e.v || (current && e.v === current.id); }); + } + + function evaluate(e, q, tokens) { + var score = 0, bodyHit = false; + if (e.lt.indexOf(q) >= 0) score += 100; + if (e.lt.indexOf(q) === 0) score += 50; + if (e.ls.indexOf(q) >= 0) score += 20; + if (e.ld.indexOf(q) >= 0) score += 10; + if (e.lb.indexOf(q) >= 0) { score += 6; bodyHit = true; } + + for (var i = 0; i < tokens.length; i++) { + var t = tokens[i]; + if (e.lt.indexOf(t) >= 0) score += 8; + else if (e.ls.indexOf(t) >= 0) score += 4; + else if (e.ld.indexOf(t) >= 0) score += 2; + else if (e.lb.indexOf(t) >= 0) { score += 1; bodyHit = true; } + else return null; + } + + if (e.k === 'p') score += 2; + return { entry: e, score: score, snippet: bodyHit ? snippet(e, tokens) : null }; + } + + // ~150 characters around the first token match in the body. + function snippet(e, tokens) { + var hit = -1; + tokens.forEach(function (t) { + var at = e.lb.indexOf(t); + if (at >= 0 && (hit < 0 || at < hit)) hit = at; + }); + if (hit < 0) return null; + var start = Math.max(0, hit - 75); + var end = Math.min(e.b.length, hit + 75); + var text = e.b.slice(start, end).trim(); + if (start > 0) text = '… ' + text; + if (end < e.b.length) text += ' …'; + return text; + } + + function rank(query) { + var entries = scoped(); + var q = query.trim().toLowerCase(); + if (!q) return entries.slice(0, 20).map(function (e) { return { entry: e, score: 0, snippet: null }; }); + var tokens = q.split(' ').filter(Boolean); + var scored = []; + entries.forEach(function (e) { + var s = evaluate(e, q, tokens); + if (s) scored.push(s); + }); + scored.sort(function (a, b) { return b.score - a.score; }); + return scored.slice(0, 30); + } + + function part(selector) { return live.querySelector(selector); } + + function row(kind) { return part('[data-search-rows] [data-search-row="' + kind + '"]').cloneNode(true); } + + function fill(el, selector, text) { + var target = el.querySelector(selector); + if (text) target.textContent = text; else target.remove(); + } + + function message(host, text) { + var el = row('empty'); + el.textContent = text; + host.appendChild(el); + } + + function render() { + var host = part('[data-search-results]'); + var query = part('[data-search-input]').value; + host.textContent = ''; + if (!index) { + message(host, failed ? 'Search is unavailable.' : 'Loading…'); + return; + } + if (results.length === 0) { + if (query.trim()) message(host, 'No results for “' + query + '”.'); + return; + } + results.forEach(function (r, i) { + var el = row(r.entry.k === 'h' ? 'h' : 'p'); + el.id = 'shelldocs-search-option-' + i; + el.href = new URL(r.entry.u.replace(/^\//, ''), document.baseURI).href; + fill(el, '.search-result-title', r.entry.t); + fill(el, '.search-result-section', r.entry.s); + fill(el, '.search-result-snippet', r.snippet); + host.appendChild(el); + }); + highlight(highlighted, false); + } + + function highlight(i, scroll) { + var rows = part('[data-search-results]').querySelectorAll('.search-result'); + if (rows.length === 0) return; + highlighted = (i + rows.length) % rows.length; + Array.prototype.forEach.call(rows, function (el, n) { + el.classList.toggle('highlighted', n === highlighted); + el.setAttribute('aria-selected', n === highlighted ? 'true' : 'false'); + }); + part('[data-search-input]').setAttribute('aria-activedescendant', rows[highlighted].id); + if (scroll) rows[highlighted].scrollIntoView({ block: 'nearest' }); + } + + function recalc() { + if (!live) return; + results = index ? rank(part('[data-search-input]').value) : []; + highlighted = 0; + render(); + } + + function onDialogKeydown(e) { + if (e.key === 'Escape') { e.preventDefault(); close(); } + else if (e.key === 'ArrowDown') { e.preventDefault(); highlight(highlighted + 1, true); } + else if (e.key === 'ArrowUp') { e.preventDefault(); highlight(highlighted - 1, true); } + else if (e.key === 'Tab') { e.preventDefault(); part('[data-search-input]').focus(); } + else if (e.key === 'Enter') { + e.preventDefault(); + var rows = part('[data-search-results]').querySelectorAll('.search-result'); + if (rows[highlighted]) rows[highlighted].click(); + } + } + + function onDialogClick(e) { + if (e.target === live) { close(); return; } + // Closing in this tick would detach the link before the browser follows it. + if (e.target.closest('.search-result')) setTimeout(close, 0); + } + + function onDialogMouseOver(e) { + var el = e.target.closest && e.target.closest('.search-result'); + if (!el) return; + var rows = part('[data-search-results]').querySelectorAll('.search-result'); + var at = Array.prototype.indexOf.call(rows, el); + if (at >= 0 && at !== highlighted) highlight(at, false); + } + + function open() { + if (live && !live.isConnected) live = null; + if (live) { part('[data-search-input]').focus(); return; } + var template = document.querySelector('[data-search-dialog]'); + if (!template) return; + + returnFocus = document.activeElement; + live = template.cloneNode(true); + live.removeAttribute('data-search-dialog'); + live.removeAttribute('hidden'); + document.body.appendChild(live); + live.addEventListener('keydown', onDialogKeydown); + live.addEventListener('click', onDialogClick); + live.addEventListener('mouseover', onDialogMouseOver); + part('[data-search-input]').addEventListener('input', recalc); + + var pending = index ? null : load(); + recalc(); + part('[data-search-input]').focus(); + if (pending) pending.then(recalc); + } + + function close() { + if (!live) return; + live.remove(); + live = null; + if (returnFocus && returnFocus.isConnected && returnFocus.focus) returnFocus.focus(); + returnFocus = null; + } + + document.addEventListener('keydown', function (e) { + if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey) && document.querySelector('[data-search-dialog]')) { + e.preventDefault(); + open(); + } + }); + document.addEventListener('click', function (e) { + if (e.target.closest && e.target.closest('[data-search-open]')) open(); + }); + window.shelldocsOnEnhancedLoad(close); + + return { open: open, close: close }; })(); /* Theme. is the source of truth: whoever flips it (a diff --git a/tests/ShellDocs.Tests/RedirectTests.cs b/tests/ShellDocs.Tests/RedirectTests.cs index 7d6a73c..31838bf 100644 --- a/tests/ShellDocs.Tests/RedirectTests.cs +++ b/tests/ShellDocs.Tests/RedirectTests.cs @@ -169,10 +169,10 @@ public async Task Middleware_Redirects_AndServesTheMap() public void AddShellDocs_RegistersTheStartupFilter_UnlessDisabled() { var on = new ServiceCollection().AddShellDocs(o => o.ContentRoot = _root); - Assert.Contains(on, d => d.ServiceType == typeof(IStartupFilter)); + Assert.Contains(on, d => d.ImplementationType?.Name == "DocsRedirectStartupFilter"); var off = new ServiceCollection().AddShellDocs(o => { o.ContentRoot = _root; o.EnableRedirects = false; }); - Assert.DoesNotContain(off, d => d.ServiceType == typeof(IStartupFilter)); + Assert.DoesNotContain(off, d => d.ImplementationType?.Name == "DocsRedirectStartupFilter"); } [Fact] diff --git a/tests/ShellDocs.Tests/StaticSearchTests.cs b/tests/ShellDocs.Tests/StaticSearchTests.cs new file mode 100644 index 0000000..5b87cea --- /dev/null +++ b/tests/ShellDocs.Tests/StaticSearchTests.cs @@ -0,0 +1,227 @@ +using System.Net; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using ShellDocs.Components; +using ShellDocs.Components.Chrome; +using ShellDocs.Components.Layouts; +using ShellDocs.Core; +using Xunit; + +namespace ShellDocs.Tests; + +// Search runs in shelldocs.js against /_shelldocs/search.json, so it needs no Blazor runtime. +public class StaticSearchTests : IDisposable +{ + private readonly string _root; + + public StaticSearchTests() + { + _root = Path.Combine(Path.GetTempPath(), "shelldocs-search-" + Guid.NewGuid().ToString("N")); + Write("docs/introduction.md", "---\ntitle: Introduction\ndescription: Start here.\n---\n# Introduction\n\nWelcome to the docs.\n\n## Install\n\nRun the tool.\n"); + Write("docs/v2/button.md", "---\ntitle: Button\n---\n# Button\n\nA café button.\n"); + Write("docs/v1/button.md", "---\ntitle: Button\n---\n# Button\n\nThe old button.\n"); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void Write(string relative, string content) + { + var path = Path.Combine(_root, relative.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + } + + private ComponentRenderHarness Harness(DocsLayoutVariant layout = DocsLayoutVariant.Sidebar, bool versions = true) => new(o => + { + o.ContentRoot = _root; + o.LayoutVariant = layout; + if (!versions) return; + o.AddVersion("v2", "v2.0", "/docs/v2", latest: true); + o.AddVersion("v1", "v1.0", "/docs/v1"); + }, "http://localhost/docs/introduction"); + + [Fact] + public async Task SearchDialog_IsAHiddenTemplate_WithNoBlazorHandlers() + { + var html = await Harness().RenderAsync(); + + Assert.Matches("
]*data-search-dialog[^>]*hidden", html); + Assert.Contains("data-search-input", html); + Assert.Contains("data-search-results", html); + Assert.Matches("]*data-search-row=\"p\"", html); + Assert.Matches("]*data-search-row=\"h\"", html); + Assert.Matches("
]*data-search-row=\"empty\"", html); + Assert.DoesNotContain("blazor:on", html); + } + + [Fact] + public async Task SearchButtons_OpenSearchThroughShelldocsJs() + { + var sidebarLayout = await Harness().RenderAsync(); + var topNavLayout = await Harness(DocsLayoutVariant.TopNav).RenderAsync(); + + foreach (var (html, css) in new[] + { + (sidebarLayout, "docs-collapsed-tool\"[^>]*aria-label=\"Search\""), + (sidebarLayout, "docs-sidebar-search"), + (sidebarLayout, "docs-mobile-tool\"[^>]*aria-label=\"Search\""), + (topNavLayout, "docs-header-search"), + }) + { + var button = Regex.Match(html, "]*" + css + "[^>]*>").Value; + Assert.Contains("data-search-open", button); + Assert.DoesNotContain("blazor:onclick", button); + } + } + + [Fact] + public void IndexFile_ListsEntries_WithTheVersionThatOwnsThem() + { + var services = Harness().Services; + var bytes = DocsSearchIndexFile.Build(services.GetRequiredService(), services.GetRequiredService()); + using var json = JsonDocument.Parse(bytes); + + var versions = json.RootElement.GetProperty("versions").EnumerateArray().ToList(); + Assert.Equal(["v2", "v1"], versions.Select(v => v.GetProperty("id").GetString())); + Assert.Equal("/docs/v2", versions[0].GetProperty("root").GetString()); + Assert.True(versions[0].GetProperty("latest").GetBoolean()); + Assert.False(versions[1].TryGetProperty("latest", out _)); + + var entries = json.RootElement.GetProperty("entries").EnumerateArray().ToList(); + var intro = entries.Single(e => e.GetProperty("u").GetString() == "/docs/introduction"); + Assert.Equal("Introduction", intro.GetProperty("t").GetString()); + Assert.Equal("Start here.", intro.GetProperty("d").GetString()); + Assert.Equal("p", intro.GetProperty("k").GetString()); + Assert.Contains("Welcome to the docs.", intro.GetProperty("b").GetString()); + Assert.False(intro.TryGetProperty("v", out _)); + + var heading = entries.Single(e => e.GetProperty("u").GetString() == "/docs/introduction#install"); + Assert.Equal("h", heading.GetProperty("k").GetString()); + Assert.Equal("Introduction", heading.GetProperty("s").GetString()); + Assert.False(heading.TryGetProperty("b", out _)); + + Assert.Equal("v2", entries.Single(e => e.GetProperty("u").GetString() == "/docs/v2/button").GetProperty("v").GetString()); + Assert.Equal("v1", entries.Single(e => e.GetProperty("u").GetString() == "/docs/v1/button").GetProperty("v").GetString()); + // Written as UTF-8, not \u escapes. + Assert.Contains("café", System.Text.Encoding.UTF8.GetString(bytes)); + } + + [Fact] + public void IndexFile_WithoutVersions_HasNoOwners() + { + var services = Harness(versions: false).Services; + var bytes = DocsSearchIndexFile.Build(services.GetRequiredService(), services.GetRequiredService()); + using var json = JsonDocument.Parse(bytes); + + Assert.Empty(json.RootElement.GetProperty("versions").EnumerateArray()); + Assert.All(json.RootElement.GetProperty("entries").EnumerateArray(), e => Assert.False(e.TryGetProperty("v", out _))); + } + + [Fact] + public async Task Middleware_ServesTheIndex_AndRevalidates() + { + var file = Harness().Services.GetRequiredService(); + var passedOn = 0; + var middleware = new DocsSearchIndexMiddleware(_ => { passedOn++; return Task.CompletedTask; }, file); + + var hit = Context(DocsSearchIndexMiddleware.IndexPath); + hit.Response.Body = new MemoryStream(); + await middleware.InvokeAsync(hit); + Assert.Equal(0, passedOn); + Assert.Equal("application/json; charset=utf-8", hit.Response.ContentType); + Assert.Equal(file.Bytes, ((MemoryStream)hit.Response.Body).ToArray()); + Assert.Equal(file.ETag, hit.Response.Headers.ETag.ToString()); + + var cached = Context(DocsSearchIndexMiddleware.IndexPath); + cached.Request.Headers.IfNoneMatch = file.ETag; + await middleware.InvokeAsync(cached); + Assert.Equal(StatusCodes.Status304NotModified, cached.Response.StatusCode); + + await middleware.InvokeAsync(Context("/docs/introduction")); + var post = Context(DocsSearchIndexMiddleware.IndexPath); + post.Request.Method = "POST"; + await middleware.InvokeAsync(post); + Assert.Equal(2, passedOn); + } + + [Fact] + public void AddShellDocs_PutsTheIndexMiddlewareInThePipeline() + { + var filters = Harness().Services.GetServices(); + Assert.Contains(filters, f => f is DocsSearchStartupFilter); + } + + [Fact] + public void ShelldocsJs_SearchesTheIndexInTheBrowser() + { + var js = File.ReadAllText(FindAsset("shelldocs.js")); + Assert.Contains("'_shelldocs/search.json'", js); + Assert.Contains("[data-search-dialog]", js); + Assert.Contains("[data-search-open]", js); + Assert.DoesNotContain("invokeMethodAsync('OpenFromJs')", js); + } + + [Fact] + public void Build_CopiesTheIndexIntoTheExport() + { + var cli = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "shelldocs") ?? Assembly.Load("shelldocs"); + var write = cli.GetType("ShellDocs.CLI.Commands.PrerenderRunner", throwOnError: true)! + .GetMethod("WriteSearchIndex", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + var output = Path.Combine(_root, "out"); + Directory.CreateDirectory(output); + + using var served = new HttpClient(new StubHandler(HttpStatusCode.OK, "{\"versions\":[],\"entries\":[]}")) { BaseAddress = new Uri("http://localhost") }; + var written = (long)write.Invoke(null, [served, output])!; + var path = Path.Combine(output, "_shelldocs", "search.json"); + Assert.Equal(new FileInfo(path).Length, written); + Assert.Equal("{\"versions\":[],\"entries\":[]}", File.ReadAllText(path)); + + // A site on a ShellDocs.Components without the endpoint. + var older = Path.Combine(_root, "older"); + Directory.CreateDirectory(older); + using var missing = new HttpClient(new StubHandler(HttpStatusCode.NotFound, "")) { BaseAddress = new Uri("http://localhost") }; + Assert.Equal(0L, (long)write.Invoke(null, [missing, older])!); + Assert.False(Directory.Exists(Path.Combine(older, "_shelldocs"))); + } + + private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Assert.Equal("/_shelldocs/search.json", request.RequestUri!.AbsolutePath); + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + => SendAsync(request, cancellationToken).GetAwaiter().GetResult(); + } + + private static DefaultHttpContext Context(string path) + { + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + ctx.Request.Path = path; + return ctx; + } + + private static string FindAsset(string name) + { + var testDir = Path.GetDirectoryName(typeof(StaticSearchTests).Assembly.Location)!; + foreach (var candidate in new[] + { + Path.Combine(testDir, "wwwroot", "_content", "ShellDocs.Components", name), + Path.Combine(testDir, "..", "..", "..", "..", "..", "src", "ShellDocs.Components", "wwwroot", name) + }) + { + var full = Path.GetFullPath(candidate); + if (File.Exists(full)) return full; + } + throw new FileNotFoundException(name); + } +}