From 1c3b7e48559c5e67d6e8d8485ab42e7044fc33ba Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 10 Oct 2026 22:49:22 +0200 Subject: [PATCH 1/5] feat(components): serve the search index at /_shelldocs/search.json --- .../DocsSearchIndexMiddleware.cs | 112 +++++++++++++++ .../ServiceCollectionExtensions.cs | 2 + tests/ShellDocs.Tests/RedirectTests.cs | 4 +- tests/ShellDocs.Tests/StaticSearchTests.cs | 134 ++++++++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 src/ShellDocs.Components/DocsSearchIndexMiddleware.cs create mode 100644 tests/ShellDocs.Tests/StaticSearchTests.cs 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/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/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..b8f984b --- /dev/null +++ b/tests/ShellDocs.Tests/StaticSearchTests.cs @@ -0,0 +1,134 @@ +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; + +// The search index is served as JSON at /_shelldocs/search.json. +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 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); + } + + private static DefaultHttpContext Context(string path) + { + var ctx = new DefaultHttpContext(); + ctx.Request.Method = "GET"; + ctx.Request.Path = path; + return ctx; + } + +} From 4f0624151f6775d40b8433f6b9f28b92852415ee Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 10 Oct 2026 22:49:23 +0200 Subject: [PATCH 2/5] feat(search): search in the browser, without a Blazor runtime --- .../Chrome/DocsHeader.razor | 4 +- .../Chrome/DocsMobileBar.razor | 7 +- .../Chrome/DocsSidebarHeader.razor | 4 +- .../Chrome/SearchDialog.razor | 286 +++--------------- .../Chrome/SearchDialog.razor.css | 3 + .../Chrome/SearchState.cs | 1 + .../Layouts/DocsLayout.razor | 4 +- src/ShellDocs.Components/wwwroot/shelldocs.js | 262 ++++++++++++++-- tests/ShellDocs.Tests/StaticSearchTests.cs | 60 +++- 9 files changed, 355 insertions(+), 276 deletions(-) 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/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/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/StaticSearchTests.cs b/tests/ShellDocs.Tests/StaticSearchTests.cs index b8f984b..d38f413 100644 --- a/tests/ShellDocs.Tests/StaticSearchTests.cs +++ b/tests/ShellDocs.Tests/StaticSearchTests.cs @@ -12,7 +12,7 @@ namespace ShellDocs.Tests; -// The search index is served as JSON at /_shelldocs/search.json. +// Search runs in shelldocs.js against /_shelldocs/search.json, so it needs no Blazor runtime. public class StaticSearchTests : IDisposable { private readonly string _root; @@ -46,6 +46,40 @@ private void Write(string relative, string content) 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() { @@ -123,6 +157,16 @@ public void AddShellDocs_PutsTheIndexMiddlewareInThePipeline() 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); + } + private static DefaultHttpContext Context(string path) { var ctx = new DefaultHttpContext(); @@ -131,4 +175,18 @@ private static DefaultHttpContext Context(string 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); + } } From 330159ed560663ed693b346ff045cfd389ed18bb Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 10 Oct 2026 22:49:23 +0200 Subject: [PATCH 3/5] feat(cli): copy the search index into the static export --- src/ShellDocs.CLI/Commands/PrerenderRunner.cs | 28 +++++++++++++++ tests/ShellDocs.Tests/StaticSearchTests.cs | 35 +++++++++++++++++++ 2 files changed, 63 insertions(+) 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/tests/ShellDocs.Tests/StaticSearchTests.cs b/tests/ShellDocs.Tests/StaticSearchTests.cs index d38f413..5b87cea 100644 --- a/tests/ShellDocs.Tests/StaticSearchTests.cs +++ b/tests/ShellDocs.Tests/StaticSearchTests.cs @@ -167,6 +167,41 @@ public void ShelldocsJs_SearchesTheIndexInTheBrowser() 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(); From 55ef654b1605c4535227f9c920c9b852a81e15a0 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 10 Oct 2026 22:49:23 +0200 Subject: [PATCH 4/5] docs: search works in static exports --- CHANGELOG.md | 9 +++++++++ README.md | 4 ++-- docs/ARCHITECTURE.md | 12 +++++++----- docs/ROADMAP.md | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c827e..8357acd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +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/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..b31df18 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 From 35eb769872b979983d0b09acfdf03b2cce32fc08 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 11 Oct 2026 12:48:17 +0200 Subject: [PATCH 5/5] chore: bump to 0.1.15-alpha, changelog --- CHANGELOG.md | 2 ++ Directory.Build.props | 2 +- docs/ROADMAP.md | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8357acd..2bee416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ 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 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/docs/ROADMAP.md b/docs/ROADMAP.md index b31df18..7aba29e 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.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`