diff --git a/CHANGELOG.md b/CHANGELOG.md index 336cfa3..5645f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,22 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht - **Preview layout.** `razor:preview stretch` (fence info string), or `Layout="stretch"` on `` / `` / `PreviewFrame`, lets block-level components (charts, inputs, tables) fill the frame instead of shrinking to their content. `center` stays the default. - **`not-prose` class.** Every `.shelldocs-prose` rule skips `.not-prose` subtrees. Preview frames carry it; add it to any element that should keep page typography out. +- **Generic components in markdown.** `RegisterComponentsFromAssembly` now registers generic components under their bare name (``), and `razor:preview`, inline tags and `` close them from a type-parameter attribute, as Razor does: ``. Type arguments take C# spellings (`int?`, `List`, full or simple type names). Without one, `object` is used when the constraints allow it (with a warning); otherwise the component renders an inline error instead of breaking the page. A non-generic component with the same name keeps the tag. `` lists the type parameters. +- **Redirects.** URLs that aren't pages now redirect: content folders and version roots to their first page (`/docs` prefers unversioned pages, then the latest version), and unversioned URLs that exist in the latest version to that page (`/docs/cli/dev` → `/docs/v2.0/cli/dev`). `o.AddRedirect(from, to, permanent)` adds segment-aware prefix rules for moved content; computed redirects answer 302, rules 301. Middleware registered by `AddShellDocs` serves them before routing (so dotted version roots like `/docs/v0.2.1` work), and `shelldocs build` writes them as redirect pages. Opt out with `o.EnableRedirects = false`. +- **`ShellDocsOptions.RenderPageTitle`.** Renders frontmatter `title` as the page heading and `description` as a lead paragraph when the body doesn't start with its own `# Heading`. Off by default. ### Changed - **`razor:preview` child content is parsed as Razor, not markdown.** Component bodies inside a fence (and `` bodies) go through the same node parser as the fence itself: elements wrap nested components exactly as written and nothing is wrapped in `

`. Inline component tags in prose still take markdown bodies. - **`ThemeToggle` works without a Blazor runtime.** Clicks are handled by `shelldocs.js` and both icons render, with CSS picking one from ``. +- **`ShellDocs.Components` references the ASP.NET Core shared framework** (for the redirect middleware) instead of the `Microsoft.AspNetCore.Components.Web` package. It already read content from disk, so it was server-side in practice. ### Fixed - **Prose styles leaked into previews.** Paragraph margins, list padding, heading sizes and link underlines from `.shelldocs-prose` hit live components and beat Tailwind's layered utilities (gaps inside cards and menus, underlined nav links). - **Nested markup inside a preview component was mangled.** `

` closed the `
` before the nested component and wrapped loose text in `

`. - **Theme state only followed ShellDocs' own toggle.** A component library flipping `` left the stored theme and `ThemeState` stale. `shelldocs.js` now watches the class, saves it under `shelldocs-theme` and pushes it into `ThemeState`. +- **`shelldocs build` prerendered "Page not found" pages** when the csproj copied `meta.json` but not the `.md` files (``). Build only mirrored `content/` when it was missing entirely; it now fills in every file publish left out. The example project's csproj is fixed too. - **`enhancedload` handlers never ran.** Blazor raises the event through `Blazor.addEventListener`, not as a DOM event, so re-applying the theme, re-attaching the TOC and closing the mobile nav after enhanced navigation (static SSR apps) silently did nothing. ## [0.1.9-alpha] — 2026-10-02 diff --git a/README.md b/README.md index 627b93d..9a9021a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds ## What you get -- **Markdown-first authoring.** YAML frontmatter, Shiki-highlighted code fences, live `razor:preview` examples, inline component tags mid-prose. +- **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). - **Blazor-native.** Your components render as real Razor components, not iframes. @@ -63,7 +63,15 @@ The current version is the one whose `RootUrl` prefixes the path (segment-aware) It's all server-rendered links plus `shelldocs.js`, so it works on static hosts. -> Routes declared as `/docs/{*Path:nonfile}` don't match a URL whose **last** segment has a dot (`/docs/v0.2.1`). Pages below it (`/docs/v0.2.1/introduction`) are fine, and the selectors never link to a bare version root unless it has an `index.md`. If you add one, drop `:nonfile` from the route. +### Redirects + +URLs that aren't pages redirect instead of 404ing: content folders and version roots go to their first page (`/docs/v0.2.1` → `/docs/v0.2.1/introduction`; `/docs` prefers unversioned pages, then the latest version), and unversioned URLs that exist in the latest version go there (`/docs/cli/dev` → `/docs/v0.3/cli/dev`). Add rules for moved content: + +```csharp +o.AddRedirect("/docs/v0.3.0", "/docs/v0.3"); // also /docs/v0.3.0/x → /docs/v0.3/x (301) +``` + +`AddShellDocs` registers middleware that answers these before routing, so a dotted version root works even though `/docs/{*Path:nonfile}` routes can't match it. `shelldocs build` writes the same redirects as static pages. Turn it off with `o.EnableRedirects = false`. ## Component previews @@ -71,6 +79,7 @@ It's all server-rendered links plus `shelldocs.js`, so it works on static hosts. - **`razor:preview` fences render everything.** Several sibling components, HTML wrappers (`

…
`) and text render in order inside one frame, and the Code tab shows the whole fence. - **Child content is Razor.** Component bodies inside a fence are parsed like the fence itself, so `
…
` keeps its structure and nothing is wrapped in `

`. - **Page typography stays out.** Preview frames are `not-prose`, so prose margins, list padding and link underlines don't reach your components. +- **Generic components work.** Set the type argument as Razor does: ``, ``. Libraries' generic components are registered under their bare name. - **Centred or stretched.** Examples are centred; `razor:preview stretch` (or `Layout="stretch"` on `` / ``) lets charts, inputs and tables fill the frame. - **Razor-shaped attribute values work:** `Variant="ButtonVariant.Destructive"`, `@ButtonVariant.Destructive`, `@true`, `@42`, `[Flags]` values as `Bold | Italic`. - **Attributes a static preview can't evaluate are skipped, not fatal:** `OnClick="HandleClick"`, `@onclick`, `@bind-*`, `@ref`, non-primitive parameter types, unparseable values. Each logs a warning and the component still renders. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a58d07f..8dc27e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,14 +54,14 @@ Turns markdown into HTML plus typed component slots. Depends on `ShellDocs.Core` ### `ShellDocs.Components` -The Razor class library everyone references. Depends on `ShellDocs.Core`, `ShellDocs.Markdown`, `ShellDocs.Tokens`, `ShellIcons.Blazor`. +The Razor class library everyone references. Depends on `ShellDocs.Core`, `ShellDocs.Markdown`, `ShellDocs.Tokens`, `ShellIcons.Blazor` and the ASP.NET Core shared framework (it reads content from disk and runs middleware, so it's server-side). - `AddShellDocs(options)` and `ShellDocsOptions`. - Layouts: `DocsLayout` (`TopNav` / `Sidebar` variants), `HomeLayout`. - Chrome: `DocsHeader`, `DocsSidebar`, `DocsSidebarHeader`, `DocsMobileBar`, `PackageSelector`, `VersionSelector`, `DocsBreadcrumb`, `PrevNextNav`, `TableOfContents`, `SearchDialog`, `ThemeToggle`, `DocsFooter`, `BrandLogo`. - Content primitives (auto-registered for markdown): `Callout`, `Card`, `CardGrid`, `LinkCard`, `Steps`/`Step`, `FileTree`/`FileTreeItem`, `Tabs`/`Tab`, `CodeGroup`/`CodeTab`, `TypeTable`/`TypeRow`, `AutoTypeTable`, `ComponentPreview`, `DemoPreview`. - Render machinery (`[ShellDocsIgnore]`, not reachable from markdown): `MarkdownContent`, `PreviewFrame`. -- Services: `DocsPageState` (scoped), `DocsVersionResolver` (singleton). +- Services: `DocsPageState` (scoped), `DocsVersionResolver` and `DocsRedirects` (singletons), plus the redirect middleware. - `wwwroot/shelldocs.js` and `wwwroot/shelldocs-theme.css`. ### `ShellDocs.Tokens` @@ -124,6 +124,8 @@ Placeholders plus a slot list keep everything at render time: no generated Razor - `ComponentSlot` → `` with parameters from `SlotRenderer.BuildParameters`. - `PreviewSlot` → ``, where `SlotRenderer.RenderNodes` emits elements and components as real render-tree nodes, so wrappers keep their children under interactive re-renders. +**Generic components** register under their bare name (`TypeRegistry.TagNameOf`) as open definitions. `SlotRenderer.Component` closes one per use with `GenericComponents.Close`, reading attributes named after the type parameters (`TItem="int"`, resolved from C# spellings across loaded assemblies) or falling back to `object`; one it can't close renders a `.shelldocs-render-error` element instead. A non-generic component with the same name keeps the tag. + Component child markup is rendered recursively. Inline tags in prose take markdown bodies (`SlotRenderer.FromMarkup`, dedented first, since Markdig treats 4-space indentation as a code block). Inside a `razor:preview` and in `` the body is Razor: `SlotRenderer.FromRazor` parses it with `PreviewParser` and emits real elements, so wrappers around nested components stay intact. Child tags named after a `RenderFragment` parameter (`` → `Alert.Icon`) are routed into that slot. **Parameter coercion.** Attribute values are strings. `BuildParameters` matches them to `[Parameter]` properties case-insensitively and coerces string, bool, char, numeric primitives and enums, accepting Razor forms: a leading `@`, `@( … )`, `Type.Member` enum values, `A | B` flags, numeric suffixes, `@null`. It never throws. Anything static markup can't set is skipped with a logged warning: directive attributes, delegate/`EventCallback` parameters, unsupported types, unparseable values, unknown attributes on components without a `CaptureUnmatchedValues` catch-all, and child content on components without a plain `RenderFragment ChildContent`. @@ -169,6 +171,16 @@ Runtime queries: `ResolveByUrl` is a case-insensitive dictionary lookup on norma All answers are computed at render time and emitted as plain ``, so prerendered pages behave identically. +### Redirects + +`DocsRedirects` (singleton) maps URLs that aren't pages to a target, computed once from the graph and versions: + +- content folders and version roots → their first page, within the folder's version scope (a folder holding version folders → its first unversioned page, else the latest version's); +- unversioned URLs that exist in the latest version → that page; +- `AddRedirect` rules on top: segment-aware prefixes that may chain into the computed redirects. They are the only thing that can redirect an existing page. + +`DocsRedirectMiddleware` runs first in the pipeline (added by an `IStartupFilter`, so no `Program.cs` change) and answers 302 for computed redirects or 301 for permanent rules, keeping `PathBase` and the query string. Running before routing is what makes `/docs/v0.2.1` work. It also serves the full map at `/_shelldocs/redirects.json` for `shelldocs build`. `EnableRedirects = false` leaves it out. + --- ## Search @@ -223,11 +235,12 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens. `shelldocs build`: -1. `dotnet publish -c Release` into `obj/shelldocs-publish` (mirroring `content/` into it if the csproj didn't copy it). +1. `dotnet publish -c Release` into `obj/shelldocs-publish`, then copies in any `content/` file publish left out (a csproj that copies `meta.json` but not `.md`). 2. Builds the navigation graph and collects every URL (visible and hidden) plus `/`. 3. `PrerenderRunner` starts the published app on a free port, requests each URL and writes `//index.html`. -4. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. -5. Optionally rewrites `` in every HTML file, copies `index.html` to `404.html`, and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta. +4. Reads the app's redirect map and writes a redirect page for each source URL without a prerendered page (meta refresh plus `location.replace`, relative to ``). +5. Merges the published `wwwroot/` into the output without overwriting prerendered HTML. +6. Optionally rewrites `` in every HTML file, copies `index.html` to `404.html`, and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta. --- diff --git a/examples/ShellDocs.Preview/ShellDocs.Preview.csproj b/examples/ShellDocs.Preview/ShellDocs.Preview.csproj index f9a1401..0aabee7 100644 --- a/examples/ShellDocs.Preview/ShellDocs.Preview.csproj +++ b/examples/ShellDocs.Preview/ShellDocs.Preview.csproj @@ -12,7 +12,8 @@ - + + diff --git a/examples/ShellDocs.Preview/Ui/ValueBadge.razor b/examples/ShellDocs.Preview/Ui/ValueBadge.razor new file mode 100644 index 0000000..9c68c4a --- /dev/null +++ b/examples/ShellDocs.Preview/Ui/ValueBadge.razor @@ -0,0 +1,13 @@ +@typeparam TValue + +@* Stand-in generic library component for exercising razor:preview type arguments. *@ + + @Label + @Value + @typeof(TValue).Name + + +@code { + [Parameter] public string? Label { get; set; } + [Parameter] public TValue? Value { get; set; } +} diff --git a/examples/ShellDocs.Preview/Ui/ValueBadge.razor.css b/examples/ShellDocs.Preview/Ui/ValueBadge.razor.css new file mode 100644 index 0000000..7b78c07 --- /dev/null +++ b/examples/ShellDocs.Preview/Ui/ValueBadge.razor.css @@ -0,0 +1,20 @@ +.value-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.65rem; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.8125rem; + background: var(--card); +} +.value-badge-label { color: var(--muted-foreground); } +.value-badge-value { font-weight: 600; } +.value-badge-type { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--muted-foreground); + padding: 0.05rem 0.4rem; + border-radius: 999px; + background: var(--muted); +} diff --git a/examples/ShellDocs.Preview/content/docs/installation.md b/examples/ShellDocs.Preview/content/docs/installation.md index f85a974..f25c3e7 100644 --- a/examples/ShellDocs.Preview/content/docs/installation.md +++ b/examples/ShellDocs.Preview/content/docs/installation.md @@ -55,4 +55,6 @@ shelldocs build - `--spa-fallback` copies `index.html` to `404.html`. - `--site-url https://docs.example.com` emits `sitemap.xml`, `robots.txt` and `og:` meta tags. -In the static output, navigation, sidebar sections, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle and code copy all work through `shelldocs.js`. Search, the desktop sidebar-collapse button and stateful demo components still need a running Blazor app (Server or WebAssembly). +In the static output, navigation, sidebar sections, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle and code copy all work through `shelldocs.js`. Search, the desktop sidebar-collapse button and stateful demo components still need a running Blazor Server app (interactive server rendering). + +URLs that aren't pages (folders, version roots, unversioned URLs that moved into a version) become redirect pages in the static output, the same redirects the running app answers. diff --git a/examples/ShellDocs.Preview/content/docs/markdown-syntax.md b/examples/ShellDocs.Preview/content/docs/markdown-syntax.md index e21dcfa..f3cb1b4 100644 --- a/examples/ShellDocs.Preview/content/docs/markdown-syntax.md +++ b/examples/ShellDocs.Preview/content/docs/markdown-syntax.md @@ -21,6 +21,8 @@ order: 10 --- ``` +With `o.RenderPageTitle = true`, ShellDocs renders `title` as the page's heading and `description` as a lead paragraph, so pages don't need a `# Title` line. A page whose body starts with its own `# Heading` keeps it. + ## Standard markdown works Headings, lists, tables, code fences, images, links — all standard: @@ -72,3 +74,17 @@ Examples are centred. Add `stretch` to the info string (`razor:preview stretch`) ``` Preview frames are marked `not-prose`, so the page's typography (paragraph margins, list padding, link underlines) never reaches the components inside. Add the class to any other element that should opt out. + +### Generic components + +Generic components (`@typeparam TValue`) register under their bare name. Set the type argument the way Razor does, as an attribute named after the type parameter. Attribute values are then coerced to the closed type: + +```razor:preview +

+``` + +Type arguments accept C# spellings (`int`, `int?`, `List`, `MyApp.Models.Product`). Without one, ShellDocs uses `object` when the constraints allow it and logs a warning; a type it can't resolve shows an inline error. `` works the same way. diff --git a/src/ShellDocs.CLI/Commands/BuildCommand.cs b/src/ShellDocs.CLI/Commands/BuildCommand.cs index 1a372a8..1030de7 100644 --- a/src/ShellDocs.CLI/Commands/BuildCommand.cs +++ b/src/ShellDocs.CLI/Commands/BuildCommand.cs @@ -39,14 +39,14 @@ public static int Run(string dir, string output, string? baseHref, bool spaFallb var publishExit = RunPublish(csproj, publishStage); if (publishExit != 0) return publishExit; - // Mirror content/ into publish: csprojs using `` - // don't copy .md files, which would prerender every page as "Page not found". + // Fill in content/ files publish left out: csprojs using `` + // copy meta.json but not .md files, which would prerender every page as "Page not found". var publishContent = Path.Combine(publishStage, "content"); - if (!Directory.Exists(publishContent)) - { - AnsiConsole.MarkupLine("[dim]content:[/] publish output has no content/ — mirroring from source"); - CopyDirectoryOverwriting(contentRoot, publishContent); - } + var published = CountFiles(publishContent); + CopyDirectoryMerging(contentRoot, publishContent); + var filledIn = CountFiles(publishContent) - published; + if (filledIn > 0) + AnsiConsole.MarkupLine($"[dim]content:[/] copied [cyan]{filledIn}[/] file(s) from content/ that publish left out"); // Home ("/") is served by Home.razor and isn't part of NavigationGraph. var urls = new List { "/" }; @@ -136,6 +136,9 @@ private static int RunPublish(string csproj, string publishDir) } // Never overwrites — protects prerendered HTML sitting in the output tree. + private static int CountFiles(string dir) + => Directory.Exists(dir) ? Directory.GetFiles(dir, "*", SearchOption.AllDirectories).Length : 0; + internal static void CopyDirectoryMerging(string source, string dest) { Directory.CreateDirectory(dest); @@ -150,19 +153,6 @@ internal static void CopyDirectoryMerging(string source, string dest) } } - internal static void CopyDirectoryOverwriting(string source, string dest) - { - Directory.CreateDirectory(dest); - foreach (var file in Directory.GetFiles(source)) - { - File.Copy(file, Path.Combine(dest, Path.GetFileName(file)), overwrite: true); - } - foreach (var subdir in Directory.GetDirectories(source)) - { - CopyDirectoryOverwriting(subdir, Path.Combine(dest, Path.GetFileName(subdir))); - } - } - // `baseHref` must include leading + trailing slashes ("/repo-name/"). internal static int RewriteBaseHrefInAllHtml(string outputDir, string baseHref) { diff --git a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs index a96fbaa..fcb1076 100644 --- a/src/ShellDocs.CLI/Commands/PrerenderRunner.cs +++ b/src/ShellDocs.CLI/Commands/PrerenderRunner.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Net; +using System.Text.Json; using System.Text.RegularExpressions; using Spectre.Console; @@ -110,6 +112,9 @@ void OnStreamLine(string line) } AnsiConsole.MarkupLine($"[dim]prerender:[/] wrote [green]{rendered}[/] page(s)" + (failed > 0 ? $", [yellow]{failed}[/] failed" : "")); + + var redirects = WriteRedirects(http, outputDir); + if (redirects > 0) AnsiConsole.MarkupLine($"[dim]redirects:[/] wrote [cyan]{redirects}[/] redirect page(s)"); return new Result(failed == 0, rendered, failed); } finally @@ -118,6 +123,61 @@ void OnStreamLine(string line) } } + private record RedirectEntry(string From, string To); + + // The app serves its redirect map (DocsRedirectMiddleware); each source URL without + // a prerendered page gets a page that forwards to the target. An app on an older + // ShellDocs, or with redirects off, answers 404 and nothing is written. + private static int WriteRedirects(HttpClient http, string outputDir) + { + List? entries; + try + { + var response = http.GetAsync("/_shelldocs/redirects.json").GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) return 0; + var json = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + entries = JsonSerializer.Deserialize>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($" [yellow]warn:[/] couldn't read the redirect map: {ex.Message}"); + return 0; + } + + var written = 0; + foreach (var entry in entries ?? new()) + { + var outPath = UrlToFilePath(entry.From, outputDir); + if (File.Exists(outPath)) continue; + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + File.WriteAllText(outPath, RedirectPage(entry.To)); + written++; + } + return written; + } + + // Target is base-relative so `--base-href` (which rewrites ) keeps it right. + internal static string RedirectPage(string to) + { + var relative = to.TrimStart('/'); + var attr = WebUtility.HtmlEncode(relative); + var js = JsonSerializer.Serialize(relative); + return $$""" + + + + + + Redirecting… + + + + + Redirecting… + + """; + } + private static string InferAssemblyName(string csproj) { try diff --git a/src/ShellDocs.Components/Content/AutoTypeTable.razor b/src/ShellDocs.Components/Content/AutoTypeTable.razor index 0f635ea..b3ef9e0 100644 --- a/src/ShellDocs.Components/Content/AutoTypeTable.razor +++ b/src/ShellDocs.Components/Content/AutoTypeTable.razor @@ -73,8 +73,19 @@ else private static List BuildRows(Type target) { var rows = new List(); + + // Generic components list their type parameters first, set as TItem="…". + // Defaults are read from an instance closed over object, when that's allowed. + var instanceType = target; + if (target.IsGenericTypeDefinition) + { + foreach (var tp in target.GetGenericArguments()) + rows.Add(new TypeRowInfo(tp.Name, "type parameter", null, "Generic type argument, set as an attribute.", true)); + try { instanceType = target.MakeGenericType(target.GetGenericArguments().Select(_ => typeof(object)).ToArray()); } + catch (ArgumentException) { instanceType = null; } + } object? instance = null; - try { instance = Activator.CreateInstance(target); } catch { } + try { if (instanceType is not null) instance = Activator.CreateInstance(instanceType); } catch { } foreach (var prop in target.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { @@ -83,7 +94,7 @@ else var name = prop.Name; var type = FormatType(prop.PropertyType); var required = prop.GetCustomAttribute() is not null; - var def = ReadDefault(prop, instance); + var def = ReadDefault(instanceType?.GetProperty(name, BindingFlags.Public | BindingFlags.Instance) ?? prop, instance); var desc = XmlDocIndex.SummaryFor(prop); rows.Add(new TypeRowInfo(name, type, def, desc, required)); } @@ -95,6 +106,7 @@ else { var underlying = Nullable.GetUnderlyingType(t); if (underlying is not null) return FormatType(underlying) + "?"; + if (t.IsGenericParameter) return t.Name; if (t == typeof(string)) return "string"; if (t == typeof(bool)) return "bool"; if (t == typeof(int)) return "int"; diff --git a/src/ShellDocs.Components/Content/ComponentPreview.razor b/src/ShellDocs.Components/Content/ComponentPreview.razor index cb17061..d41e5c3 100644 --- a/src/ShellDocs.Components/Content/ComponentPreview.razor +++ b/src/ShellDocs.Components/Content/ComponentPreview.razor @@ -8,7 +8,7 @@ ." : null)" + Error="@_error" ErrorTitle="ComponentPreview error" Id="@_id" Name="@Component" @@ -28,10 +28,25 @@ private IDictionary? _targetParams; private string? _source; private string? _id; + private string? _error; + private HashSet _typeParams = new(StringComparer.OrdinalIgnoreCase); protected override void OnParametersSet() { _target = Component is null ? null : Registry.Resolve(Component); + _error = _target is null ? $"Unknown component <{Component}>." : null; + _typeParams = new(StringComparer.OrdinalIgnoreCase); + if (_target is { IsGenericTypeDefinition: true } open) + { + // TItem="Product" closes a generic target, as in Razor. + var attrs = (ExtraProps ?? new Dictionary()) + .Where(kv => kv.Value is string) + .ToDictionary(kv => kv.Key, kv => (string)kv.Value); + _target = GenericComponents.Close(open, attrs, out _, out var error, out var notes); + foreach (var note in notes) Logger.LogWarning("ShellDocs: {Note}", note); + if (_target is null) _error = error; + _typeParams = new(open.GetGenericArguments().Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + } _targetParams = _target is null ? null : BuildTargetParams(_target); _source = BuildSource(); // Same component twice on a page gets distinct ids via the source hash. @@ -54,6 +69,7 @@ { foreach (var (k, v) in ExtraProps) { + if (_typeParams.Contains(k)) continue; if (!props.TryGetValue(k, out var prop)) { if (!k.StartsWith('@') && SlotRenderer.HasCatchAll(target)) dict[k] = v; diff --git a/src/ShellDocs.Components/Content/GenericComponents.cs b/src/ShellDocs.Components/Content/GenericComponents.cs new file mode 100644 index 0000000..a532cb7 --- /dev/null +++ b/src/ShellDocs.Components/Content/GenericComponents.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Reflection; +using ShellDocs.Markdown; + +namespace ShellDocs.Components.Content; + +/* Generic components register under their bare name () as open + definitions and are closed per use from attributes named after their type + parameters, as Razor writes them: . A missing + type argument falls back to object when the constraints allow it. */ +internal static class GenericComponents +{ + private static readonly Dictionary Aliases = new(StringComparer.Ordinal) + { + ["bool"] = typeof(bool), ["byte"] = typeof(byte), ["sbyte"] = typeof(sbyte), ["char"] = typeof(char), + ["decimal"] = typeof(decimal), ["double"] = typeof(double), ["float"] = typeof(float), + ["int"] = typeof(int), ["uint"] = typeof(uint), ["long"] = typeof(long), ["ulong"] = typeof(ulong), + ["short"] = typeof(short), ["ushort"] = typeof(ushort), ["nint"] = typeof(nint), ["nuint"] = typeof(nuint), + ["object"] = typeof(object), ["string"] = typeof(string), + }; + + // Closes `type` if it's a generic definition. `remaining` is `attrs` without the + // type-parameter attributes; `error` is set (and the result null) when it can't close. + public static Type? Close( + Type type, + IReadOnlyDictionary attrs, + out IReadOnlyDictionary remaining, + out string? error, + out IReadOnlyList notes) + { + remaining = attrs; + error = null; + notes = Array.Empty(); + if (!type.IsGenericTypeDefinition) return type; + + var typeParams = type.GetGenericArguments(); + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (k, v) in attrs) lookup[k] = v; + + var args = new Type[typeParams.Length]; + var messages = new List(); + for (var i = 0; i < typeParams.Length; i++) + { + var name = typeParams[i].Name; + if (lookup.TryGetValue(name, out var raw)) + { + var resolved = ResolveTypeName(raw, type.Assembly); + if (resolved is null) + { + error = $"<{TagName(type)}>: can't resolve {name}=\"{raw}\". Use a type name the app references (e.g. \"int\", \"string\", \"MyApp.Models.Product\")."; + return null; + } + args[i] = resolved; + } + else + { + args[i] = typeof(object); + messages.Add($"<{TagName(type)}>: no {name}=\"…\" attribute; using object."); + } + } + + Type closed; + try { closed = type.MakeGenericType(args); } + catch (ArgumentException) + { + var names = string.Join(", ", typeParams.Select(p => p.Name + "=\"…\"")); + error = $"<{TagName(type)}> is generic and its type arguments don't satisfy its constraints. Set {names}."; + return null; + } + + var typeParamNames = new HashSet(typeParams.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + remaining = attrs.Where(kv => !typeParamNames.Contains(kv.Key)).ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.Ordinal); + notes = messages; + return closed; + } + + public static string TagName(Type type) => TypeRegistry.TagNameOf(type); + + // C# spellings: aliases, `T?`, `T[]`, `List`, simple or full type names. + // Searches the component's assembly first, then every loaded assembly. + internal static Type? ResolveTypeName(string raw, Assembly? preferred = null) + { + var name = raw.Trim(); + if (name.StartsWith('@')) name = name[1..].Trim(); + if (name.Length == 0) return null; + + if (name.EndsWith('?')) + { + var inner = ResolveTypeName(name[..^1], preferred); + if (inner is null) return null; + return inner.IsValueType ? typeof(Nullable<>).MakeGenericType(inner) : inner; + } + if (name.EndsWith("[]", StringComparison.Ordinal)) + return ResolveTypeName(name[..^2], preferred)?.MakeArrayType(); + + var lt = name.IndexOf('<'); + if (lt > 0 && name.EndsWith('>')) + { + var argNames = SplitTopLevel(name[(lt + 1)..^1]); + var def = FindType($"{name[..lt].Trim()}`{argNames.Count}", preferred); + if (def is null) return null; + var typeArgs = new Type[argNames.Count]; + for (var i = 0; i < argNames.Count; i++) + { + var a = ResolveTypeName(argNames[i], preferred); + if (a is null) return null; + typeArgs[i] = a; + } + try { return def.MakeGenericType(typeArgs); } + catch (ArgumentException) { return null; } + } + + if (Aliases.TryGetValue(name, out var alias)) return alias; + return FindType(name, preferred); + } + + private static readonly ConcurrentDictionary<(string, Assembly?), Type?> _found = new(); + + private static Type? FindType(string name, Assembly? preferred) + => _found.GetOrAdd((name, preferred), key => Search(key.Item1, key.Item2)); + + private static Type? Search(string name, Assembly? preferred) + { + var direct = Type.GetType(name, throwOnError: false) ?? Type.GetType("System." + name, throwOnError: false); + if (direct is not null) return direct; + + var assemblies = AppDomain.CurrentDomain.GetAssemblies().Where(a => !a.IsDynamic); + if (preferred is not null) assemblies = assemblies.OrderBy(a => a == preferred ? 0 : 1); + + Type? bySimpleName = null; + foreach (var assembly in assemblies) + { + var full = assembly.GetType(name, throwOnError: false); + if (full is not null) return full; + if (bySimpleName is not null || name.Contains('.')) continue; + bySimpleName = ExportedTypes(assembly).FirstOrDefault(t => t.Name == name); + } + return bySimpleName; + } + + private static IEnumerable ExportedTypes(Assembly assembly) + { + try { return assembly.GetExportedTypes(); } + catch { return Array.Empty(); } + } + + private static List SplitTopLevel(string s) + { + var parts = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < s.Length; i++) + { + if (s[i] == '<') depth++; + else if (s[i] == '>') depth--; + else if (s[i] == ',' && depth == 0) + { + parts.Add(s[start..i].Trim()); + start = i + 1; + } + } + parts.Add(s[start..].Trim()); + return parts; + } +} diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor b/src/ShellDocs.Components/Content/MarkdownContent.razor index 12bf0ad..1354526 100644 --- a/src/ShellDocs.Components/Content/MarkdownContent.razor +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor @@ -3,6 +3,7 @@ @inject IJSRuntime JS @inject DocsPageState PageState @inject ILogger Logger +@inject ShellDocsOptions Options
@if (_parts is null) @@ -13,6 +14,16 @@ { @* Ordinal ids keep anchors and saved tab state stable across renders. *@ var previewIndex = 0; + @if (_title is not null) + { +
+

@_title

+ @if (!string.IsNullOrWhiteSpace(_description)) + { +

@_description

+ } +
+ } @foreach (var part in _parts) { @if (part is HtmlPart html) @@ -23,7 +34,7 @@ { @if (slot.Slot is ComponentSlot comp) { - + @SlotRenderer.Component(Renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, Logger) } else if (slot.Slot is PreviewSlot preview) { @@ -39,20 +50,38 @@ [Parameter] public RenderedDocument? Document { get; set; } private IReadOnlyList? _parts; + private string? _title; + private string? _description; protected override void OnParametersSet() { var doc = Document ?? (Markdown is not null ? Renderer.Render(Markdown) : null); _parts = doc is null ? null : SlotSplitter.Split(doc); + _title = _description = null; + if (doc is not null && Options.RenderPageTitle && !StartsWithH1(doc.Source.Body)) + { + _title = NonEmpty(doc.Source.Frontmatter.GetValue("title")); + _description = _title is null ? null : NonEmpty(doc.Source.Frontmatter.GetValue("description")); + } PageState.SetDocument(doc); } + private static bool StartsWithH1(string body) + { + foreach (var line in body.Split('\n')) + { + var t = line.Trim(); + if (t.Length == 0) continue; + return t.StartsWith("# ", StringComparison.Ordinal) || t.StartsWith(" string.IsNullOrWhiteSpace(s) ? null : s.Trim(); + protected override async Task OnAfterRenderAsync(bool firstRender) { if (_parts is null) return; try { await JS.InvokeVoidAsync("shelldocsHighlight"); } catch { } } - - private IDictionary BuildParameters(ComponentSlot slot) => - SlotRenderer.BuildParameters(Renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw, Logger); } diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor index 5bf4f12..c7df446 100644 --- a/src/ShellDocs.Components/Content/PreviewFrame.razor +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -81,8 +81,7 @@ } else { - + @SlotRenderer.Component(Renderer, Preview!.ComponentType!, Preview!.Parameters, Preview!.ChildContentRaw, Logger, razorChildren: true) }
@@ -132,13 +131,9 @@ _bugHref = _suggestHref = null; return; } - var name = Name ?? Preview?.ComponentType?.Name ?? Title ?? "docs"; + var name = Name ?? (Preview?.ComponentType is { } t ? GenericComponents.TagName(t) : null) ?? Title ?? "docs"; var pageUrl = PreviewLinks.PageUrl(Options, path, Id); _bugHref = PreviewLinks.BugReport(tracker, name, pageUrl); _suggestHref = PreviewLinks.Suggestion(tracker, name, pageUrl); } - - // Only reached when ErrorMessage is null, i.e. ComponentType is non-null. - private IDictionary BuildParameters(IReadOnlyDictionary attrs, string? childContentRaw) => - SlotRenderer.BuildParameters(Renderer, Preview!.ComponentType!, attrs, childContentRaw, Logger, razorChildren: true); } diff --git a/src/ShellDocs.Components/Content/SlotRenderer.cs b/src/ShellDocs.Components/Content/SlotRenderer.cs index 9ce258b..0073de0 100644 --- a/src/ShellDocs.Components/Content/SlotRenderer.cs +++ b/src/ShellDocs.Components/Content/SlotRenderer.cs @@ -9,6 +9,9 @@ namespace ShellDocs.Components.Content; +// Sequence numbers follow the parsed markup, not source order; regions per node keep them stable. +#pragma warning disable ASP0006 + // Renders markdown/HTML containing component tags as real DynamicComponents, // recursively, so components nest inside previews and ChildContent. internal static class SlotRenderer @@ -42,12 +45,36 @@ public static RenderFragment FromRazor(MarkdownRenderer renderer, string raw, IL } private static void Emit(RenderTreeBuilder builder, ref int seq, MarkdownRenderer renderer, ComponentSlot slot, ILogger? logger) + => builder.AddContent(seq++, Component(renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw, logger)); + + /* One registered component from markdown. Generic definitions are closed from + their type-parameter attributes first; one that can't be closed renders a + visible error instead of throwing. */ + public static RenderFragment Component( + MarkdownRenderer renderer, + Type type, + IReadOnlyDictionary attrs, + string? childContentRaw, + ILogger? logger = null, + bool razorChildren = false) => builder => { - builder.OpenComponent(seq++); - builder.AddAttribute(seq++, "Type", slot.ComponentType); - builder.AddAttribute(seq++, "Parameters", BuildParameters(renderer, slot.ComponentType, slot.Parameters, slot.ChildContentRaw, logger)); + var closed = GenericComponents.Close(type, attrs, out var remaining, out var error, out var notes); + foreach (var note in notes) logger?.LogWarning("ShellDocs: {Note}", note); + if (closed is null) + { + logger?.LogWarning("ShellDocs: {Error}", error); + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "shelldocs-render-error"); + builder.AddAttribute(2, "role", "alert"); + builder.AddContent(3, error); + builder.CloseElement(); + return; + } + builder.OpenComponent(4); + builder.AddAttribute(5, "Type", closed); + builder.AddAttribute(6, "Parameters", BuildParameters(renderer, closed, remaining, childContentRaw, logger, razorChildren)); builder.CloseComponent(); - } + }; /* Elements are real render-tree elements, not markup strings, so wrappers keep their component children under interactive re-renders. One region per node @@ -67,10 +94,7 @@ private static void EmitNodes(RenderTreeBuilder builder, MarkdownRenderer render break; case PreviewComponentNode comp: - builder.OpenComponent(0); - builder.AddAttribute(1, "Type", comp.ComponentType); - builder.AddAttribute(2, "Parameters", BuildParameters(renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, logger, razorChildren: true)); - builder.CloseComponent(); + builder.AddContent(0, Component(renderer, comp.ComponentType, comp.Parameters, comp.ChildContentRaw, logger, razorChildren: true)); break; case PreviewElementNode el: diff --git a/src/ShellDocs.Components/DocsRedirectMiddleware.cs b/src/ShellDocs.Components/DocsRedirectMiddleware.cs new file mode 100644 index 0000000..544e6be --- /dev/null +++ b/src/ShellDocs.Components/DocsRedirectMiddleware.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +namespace ShellDocs.Components; + +/* Answers DocsRedirects before routing, so a version root whose last segment has a + dot (/docs/v0.2.1, which `{*Path:nonfile}` routes can't match) redirects too. + Also serves the full map to `shelldocs build`, which writes it as static pages. */ +internal sealed class DocsRedirectMiddleware +{ + public const string MapPath = "/_shelldocs/redirects.json"; + + private readonly RequestDelegate _next; + private readonly DocsRedirects _redirects; + + public DocsRedirectMiddleware(RequestDelegate next, DocsRedirects redirects) + { + _next = next; + _redirects = redirects; + } + + public Task InvokeAsync(HttpContext context) + { + var request = context.Request; + if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) + return _next(context); + + var path = request.Path.Value ?? "/"; + if (string.Equals(path, MapPath, StringComparison.OrdinalIgnoreCase)) + return context.Response.WriteAsJsonAsync(_redirects.All().Select(r => new { from = r.From, to = r.To })); + + var target = _redirects.Resolve(path); + if (target is null) return _next(context); + + context.Response.StatusCode = target.Permanent ? StatusCodes.Status301MovedPermanently : StatusCodes.Status302Found; + context.Response.Headers.Location = request.PathBase + target.Url + request.QueryString; + return Task.CompletedTask; + } +} + +// Puts the middleware first in the pipeline without a Program.cs change. +internal sealed class DocsRedirectStartupFilter : IStartupFilter +{ + public Action Configure(Action next) => app => + { + app.UseMiddleware(); + next(app); + }; +} diff --git a/src/ShellDocs.Components/DocsRedirects.cs b/src/ShellDocs.Components/DocsRedirects.cs new file mode 100644 index 0000000..79bb04e --- /dev/null +++ b/src/ShellDocs.Components/DocsRedirects.cs @@ -0,0 +1,140 @@ +using ShellDocs.Core; + +namespace ShellDocs.Components; + +public record DocsRedirectTarget(string Url, bool Permanent); + +/* Where a URL that isn't a page should go. Computed from the graph and versions: + content folders and version roots → their first page (a folder that holds version + folders → its first unversioned page, else the latest version's), and unversioned + URLs that exist in the latest version → that page. AddRedirect rules apply on top + and may chain into the computed ones. Existing pages are never redirected except + by an explicit rule. */ +public sealed class DocsRedirects +{ + private const int MaxHops = 8; + + private readonly ShellDocsOptions _options; + private readonly NavigationGraph _graph; + private readonly DocsVersionResolver _versions; + private readonly Lazy> _computed; + + public DocsRedirects(ShellDocsOptions options, NavigationGraph graph, DocsVersionResolver versions) + { + _options = options; + _graph = graph; + _versions = versions; + _computed = new Lazy>(Compute); + } + + public DocsRedirectTarget? Resolve(string? path) + { + var start = UrlPath.Normalize(path); + var current = start; + var permanent = true; + for (var hop = 0; hop < MaxHops; hop++) + { + string? next; + if (ApplyRule(current) is { } rule) + { + next = rule.Url; + permanent &= rule.Permanent; + } + else if (_graph.ResolveByUrl(current) is null && _computed.Value.TryGetValue(current, out var target)) + { + next = target; + permanent = false; + } + else break; + + if (string.Equals(next, current, StringComparison.OrdinalIgnoreCase)) break; + current = next; + } + return string.Equals(current, start, StringComparison.OrdinalIgnoreCase) ? null : new DocsRedirectTarget(current, permanent); + } + + // Every redirecting URL that can be listed up front, for static output. + public IReadOnlyList<(string From, string To)> All() + { + var sources = new HashSet(_computed.Value.Keys, StringComparer.OrdinalIgnoreCase); + foreach (var rule in _options.Redirects) + { + sources.Add(UrlPath.Normalize(rule.From)); + foreach (var page in _graph.Flatten().Where(n => n.Kind == NodeKind.Page && UrlPath.IsUnder(n.Url, rule.To))) + sources.Add(UrlPath.Combine(rule.From, UrlPath.RelativeTo(page.Url, rule.To))); + } + return sources + .Select(s => (From: s, Target: Resolve(s))) + .Where(x => x.Target is not null) + .Select(x => (x.From, x.Target!.Url)) + .OrderBy(x => x.From, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private DocsRedirectTarget? ApplyRule(string path) + { + foreach (var rule in _options.Redirects) + { + // Already inside the target: "/docs" → "/docs/v2" must not re-match /docs/v2/x. + if (!UrlPath.IsUnder(path, rule.From) || UrlPath.IsUnder(path, rule.To)) continue; + return new DocsRedirectTarget(UrlPath.Combine(rule.To, UrlPath.RelativeTo(path, rule.From)), rule.Permanent); + } + return null; + } + + private IReadOnlyDictionary Compute() + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + void Add(string from, string? to) + { + from = UrlPath.Normalize(from); + if (to is null || from == "/" || map.ContainsKey(from) || _graph.ResolveByUrl(from) is not null) return; + to = UrlPath.Normalize(to); + if (!string.Equals(from, to, StringComparison.OrdinalIgnoreCase)) map[from] = to; + } + + foreach (var version in _versions.Versions) + Add(version.RootUrl, _versions.GetVersionHref(null, version)); + + var folders = _graph.Folders().ToList(); + foreach (var (url, _) in folders) + Add(url, FolderTarget(url)); + + var latest = _versions.Versions.FirstOrDefault(v => v.IsLatest); + if (latest is not null) + { + var parent = Parent(latest.RootUrl); + foreach (var page in _graph.Flatten().Where(n => n.Kind == NodeKind.Page && UrlPath.IsUnder(n.Url, latest.RootUrl))) + AddLegacy(page.Url, page.Url); + foreach (var (url, _) in folders.Where(f => UrlPath.IsUnder(f.Url, latest.RootUrl))) + AddLegacy(url, FolderTarget(url)); + + void AddLegacy(string versionedUrl, string? target) + { + var legacy = UrlPath.Combine(parent, UrlPath.RelativeTo(versionedUrl, latest.RootUrl)); + if (_versions.FindContaining(legacy) is null) Add(legacy, target); + } + } + return map; + } + + // First page in the folder within the folder's own version scope. A folder outside + // every version (e.g. /docs holding v1/ and v2/) prefers its unversioned pages, as + // the sidebar does, then falls back to the latest version. + private string? FolderTarget(string folderUrl) + { + var scope = _versions.FindContaining(folderUrl); + var page = _graph.FirstPageUnder(folderUrl, p => _versions.FindContaining(p.Url)?.Id == scope?.Id); + if (page is not null) return page.Url; + if (scope is null && _versions.Current(folderUrl) is { } fallback && _versions.Versions.Any(v => UrlPath.IsUnder(v.RootUrl, folderUrl))) + return _versions.GetVersionHref(null, fallback); + return null; + } + + private static string Parent(string url) + { + var u = UrlPath.Normalize(url); + var slash = u.LastIndexOf('/'); + return slash <= 0 ? "/" : u[..slash]; + } +} diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs index 0e079b6..e2073a0 100644 --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ShellDocs.Components.Chrome; @@ -44,6 +45,9 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services, }); services.AddSingleton(sp => SearchIndex.FromGraph(sp.GetRequiredService())); services.AddSingleton(); + services.AddSingleton(); + if (options.EnableRedirects) + services.AddTransient(); return services; } diff --git a/src/ShellDocs.Components/ShellDocs.Components.csproj b/src/ShellDocs.Components/ShellDocs.Components.csproj index 957f09f..d2310d5 100644 --- a/src/ShellDocs.Components/ShellDocs.Components.csproj +++ b/src/ShellDocs.Components/ShellDocs.Components.csproj @@ -6,8 +6,9 @@ Blazor RCL for ShellDocs: DocsLayout, sidebar, header, search, TOC, version and package selectors, plus content primitives (Callout, Card, Steps, Tabs, CodeGroup, FileTree, TypeTable, live component previews). + - + @@ -15,8 +16,6 @@ - - diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs index 75d5fbd..a220e9b 100644 --- a/src/ShellDocs.Components/ShellDocsOptions.cs +++ b/src/ShellDocs.Components/ShellDocsOptions.cs @@ -25,6 +25,9 @@ public class ShellDocsOptions // Rendered as MarkupString — must be trusted content the consumer authored, not user input. public string? LogoSvg { get; set; } public ShellDocsTheme Theme { get; set; } = ShellDocsTheme.Shadcn; + // Renders frontmatter `title` (and `description` as a lead) as the page header + // when the markdown body doesn't start with its own `# Heading`. + public bool RenderPageTitle { get; set; } public DocsLayoutVariant LayoutVariant { get; set; } = DocsLayoutVariant.TopNav; public List PrimaryNav { get; } = new(); @@ -33,6 +36,12 @@ public class ShellDocsOptions // Fewer than 2 hides the version selector; any entry scopes sidebar, prev/next, // search and breadcrumb to the current version. public List Versions { get; } = new(); + /* Redirects for URLs that aren't pages: content folders and version roots go to + their first page, unversioned URLs that exist in the latest version go there, + plus AddRedirect rules. Served by middleware that AddShellDocs registers, and + written as redirect pages by `shelldocs build`. */ + public bool EnableRedirects { get; set; } = true; + public List Redirects { get; } = new(); // Searched recursively for X.razor to show as 's source. public string? DemoSourceRoot { get; set; } public List RegisteredComponents { get; } = new(); @@ -76,7 +85,8 @@ public ShellDocsOptions RegisterComponent(Type type, string tagName) return this; } - // Registers every public, concrete, non-generic component in the assembly, skipping [ShellDocsIgnore]. + // Registers every public, concrete component in the assembly, skipping [ShellDocsIgnore]. + // Generic components register under their bare name and are closed per use (TItem="…"). public ShellDocsOptions RegisterComponentsFromAssembly(Func? filter = null) => RegisterComponentsFromAssembly(typeof(TMarker).Assembly, filter); @@ -123,13 +133,22 @@ private static IEnumerable DiscoverComponentTypes(Assembly assembly) if (t is null) continue; if (!t.IsClass || t.IsAbstract) continue; if (!t.IsPublic && !t.IsNestedPublic) continue; - if (t.IsGenericTypeDefinition) continue; if (!typeof(ComponentBase).IsAssignableFrom(t)) continue; if (t.IsDefined(typeof(ShellDocsIgnoreAttribute), inherit: false)) continue; yield return t; } } + // Segment-aware prefix rule: AddRedirect("/docs/v0.3.0", "/docs/v0.3") also sends + // /docs/v0.3.0/intro to /docs/v0.3/intro. Permanent rules answer 301, others 302. + public ShellDocsOptions AddRedirect(string from, string to, bool permanent = true) + { + if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to)) + throw new ArgumentException("Redirect paths must be non-empty."); + Redirects.Add(new DocsRedirectRule(from, to, permanent)); + return this; + } + public ShellDocsOptions AddNavLink(string label, string href) { PrimaryNav.Add(new NavLink(label, href)); @@ -196,8 +215,16 @@ internal TypeRegistry BuildTypeRegistry() registry.Register(type); registry.Register(BuiltInAliasPrefix + type.Name, type); } + // A library shipping both Select and Select keeps