diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c706e8..d580952 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,6 +26,7 @@ See [DESIGN.md](DESIGN.md) for the high-level design and [ARCHITECTURE.md](ARCHI | `ShellDocs.Core` | Navigation graph, search index model, routing helpers | | `ShellDocs.Markdown` | Markdig pipeline + frontmatter + Razor component embedding | | `ShellDocs.Components` | RCL — UI primitives (DocsLayout, CodeBlock, SearchDialog, etc.) | +| `ShellDocs.Tokens` | Shared CSS variable definitions — the palette + spacing scale that both ShellDocs and ShellUI-in-docs consume. Single source of truth for `--background`, `--foreground`, `--primary`, `--radius`, etc. | | `ShellDocs.CLI` | `shelldocs init`, `shelldocs new`, `shelldocs dev`, `shelldocs build` | | `ShellDocs.Templates` | Content for CLI scaffolding — starter markdown, meta.json, .csproj patches | | `ShellDocs.Xml` | v2 — extract `` from XML doc comments | @@ -38,17 +39,17 @@ Each branch below annotates which package it touches. Multi-package branches cal Goal: a consumer can `shelldocs init` an empty Blazor WASM project and get a working docs site with sidebar, header, code blocks, and one theme. -### `chore/repo-scaffolding` +### ✅ `chore/repo-scaffolding` — shipped Bootstrap the monorepo. -- `.sln` with the six package projects (`ShellDocs.Core`, `ShellDocs.Markdown`, `ShellDocs.Components`, `ShellDocs.CLI`, `ShellDocs.Templates`, plus `ShellDocs.Tests`) +- `.slnx` (.NET 10 XML solution format) with all package projects + tests - `.csproj` files with correct `TargetFramework`, `IsPackable`, `PackageId` - `.gitignore`, `Directory.Build.props`, `Directory.Packages.props` for central package management - `.github/workflows/ci.yml` — build + test on every push - `.github/workflows/release.yml` — pack + push to NuGet on tag - Nothing shipped; groundwork only -### `feat/core-navigation-graph` +### ✅ `feat/core-navigation-graph` — shipped Ships to `ShellDocs.Core`. - `NavigationGraph` — tree of `NavigationNode { Url, Title, Description, Category, Order, Headings, Path }` @@ -57,7 +58,7 @@ Ships to `ShellDocs.Core`. - Route resolution — `NavigationGraph.ResolveByUrl("/docs/button")` → node - Unit tests: markdown → tree, meta.json ordering, deep-nested folders -### `feat/markdown-pipeline` +### ✅ `feat/markdown-pipeline` — shipped Ships to `ShellDocs.Markdown`. - Markdig extension for YAML frontmatter (YamlDotNet) @@ -67,15 +68,31 @@ Ships to `ShellDocs.Markdown`. - Type registry — `RegisterComponent()` API for consumer to expose their components to inline tags - Unit tests: frontmatter parsing, fenced block replacement, tag resolution, unknown-tag graceful degradation -### `feat/components-shell` +### ✅ `feat/components-shell` — shipped Ships to `ShellDocs.Components`. -- `DocsLayout` — full-page grid: header + sidebar + main + TOC placeholder + footer -- `DocsHeader` — logo, primary nav, search-button placeholder, theme toggle, GitHub link -- `DocsSidebar` — grouped nav from navigation graph, collapsible sections, active highlighting +- `DocsLayout` — full-page grid: header + sidebar + main + TOC + footer, fumadocs-shaped +- `DocsHeader` — logo, primary nav with hover mega-menu (icon cards), search-button placeholder, theme toggle, GitHub link, hamburger for mobile +- `DocsSidebar` — grouped nav from navigation graph, collapsible sections (fumadocs pattern — closed by default, active-path auto-open), lucide-style icons per section/page, package selector (ShellDocs · Markdown · Core · CLI · Components), footer bar with GitHub + theme toggle - `MarkdownContent` — renders a doc page from a `.md` path via `MarkdownRenderer` -- One theme preset baked in (`Shadcn`) -- No search, no TOC, no code highlighting yet — those come in later branches +- `TableOfContents` — right-rail nav (h2/h3), scroll-tracked via multi-active headings so the thumb slides smoothly, SVG-mask + coloured thumb pattern lifted from fumadocs' `ClerkTOCItems` +- `PrevNextNav` — bordered cards, arrow icon square, translate-on-hover +- `MobileNavState` service + fixed drawer + backdrop + auto-close on route change +- Prism.js syntax highlighting (via CDN for now — Shiki lands in the next branch) +- Neutral fumadocs-shaped palette (`--background`, `--foreground`, `--primary`, `--muted`, `--accent`, `--border`) — will move to `ShellDocs.Tokens` in the next branch + +### `feat/design-tokens` — **next** +Ships to new package `ShellDocs.Tokens`. + +Extracts the palette + scale from `ShellDocs.Components/wwwroot/shelldocs-theme.css` into its own package so ShellUI (and any third-party consumer) can depend on the *tokens* without pulling in the whole components RCL. + +- New `ShellDocs.Tokens` project — RCL that ships a single `wwwroot/tokens.css` with `:root` + `:root.dark` variable definitions +- `ShellDocs.Components` and `ShellDocs.Preview` update their `App.razor` link to `_content/ShellDocs.Tokens/tokens.css` and remove the inline theme file +- Add a `tokens-full.css` variant for consumers who want the extended set (semantic + chart colors), and a `tokens-base.css` for consumers who only want the core palette +- Document the token contract in `docs/TOKENS.md`: which names are stable, which are internal, and how to override +- **ShellUI integration path (Tailwind install):** ShellUI's Tailwind config reads the same `--primary`, `--background`, `--border` etc. — nothing changes on their side. Consumer just references `tokens.css` and both design systems light up together. +- **ShellUI integration path (NuGet install):** ShellUI's RCL detects `ShellDocs.Tokens` at runtime and skips emitting its own token file. Deferred to Phase 3 — needs a small opt-in flag on `AddShellUI()`. +- Unit tests: token file emits, dark-mode class toggling, no duplicate declarations across bundles ### `feat/codeblock-shiki` Ships to `ShellDocs.Components`. @@ -303,6 +320,14 @@ ShellDocs depends on `ShellUI.Components` for base primitives: **Locked ShellUI version:** ShellDocs targets `ShellUI.Components >= 0.5.0` (the version that ships `feat/data-selection-suite` — CommandPalette is required). Bumps require a ShellDocs major/minor. +### Install path — Tailwind-first, NuGet later + +Mirrors how shadcn interops with fumadocs: shared CSS variables on `:root`, both design systems read them, one visual language. + +- **Phase 1–2 (now):** ShellUI ships as a Tailwind consumer. Doc site owners install ShellUI the same way they would in any Blazor app — via `shellui add card` etc. — and the components read the same `--primary`, `--muted`, `--border` tokens that ShellDocs emits. Zero interop work; a `` written mid-markdown just picks up the ShellDocs palette. `ShellDocs.Tokens` (next branch) formalizes the contract so both packages point at the same source of truth. +- **Phase 3+ (`feat/shellui-nuget-interop`):** teach the ShellUI NuGet RCL to defer to `ShellDocs.Tokens` when it's on the classpath, so shipping both packages doesn't double-emit `:root` blocks. Small change — an `AddShellUI(o => o.UseSharedTokens())` opt-in. Deferred because it's not blocking for real docs sites — Tailwind consumers get 90% of the value today, and the NuGet story only matters for pure-server projects that don't run Tailwind. +- **Not doing:** shipping a "ShellUI-NuGet-only" install story for now. It'd double the QA surface for zero customer wins on day one. Revisit when a real consumer asks. + --- ## Effort estimates (rough) diff --git a/examples/ShellDocs.Preview/Components/App.razor b/examples/ShellDocs.Preview/Components/App.razor new file mode 100644 index 0000000..9c92bd2 --- /dev/null +++ b/examples/ShellDocs.Preview/Components/App.razor @@ -0,0 +1,37 @@ +@using ShellDocs.Preview.Components + + + + + + ShellDocs — the docs framework for .NET + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/ShellDocs.Preview/Components/Demos/Callout.razor b/examples/ShellDocs.Preview/Components/Demos/Callout.razor new file mode 100644 index 0000000..d022fc5 --- /dev/null +++ b/examples/ShellDocs.Preview/Components/Demos/Callout.razor @@ -0,0 +1,68 @@ +@* Demo Callout — a temporary implementation local to the preview app. + The production ships in feat/content-primitives (Phase 2). *@ + +
+ +
+ @if (!string.IsNullOrEmpty(Title)) + { +
@Title
+ } +
+ @if (ChildContent is not null) { @ChildContent } + else { @Text } +
+
+
+ +@code { + [Parameter] public string Variant { get; set; } = "info"; + [Parameter] public string? Title { get; set; } + [Parameter] public string? Text { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} + + diff --git a/examples/ShellDocs.Preview/Components/Pages/Blog.razor b/examples/ShellDocs.Preview/Components/Pages/Blog.razor new file mode 100644 index 0000000..e491d12 --- /dev/null +++ b/examples/ShellDocs.Preview/Components/Pages/Blog.razor @@ -0,0 +1,120 @@ +@page "/blog" +@layout HomeLayout + +Blog — ShellDocs + +
+
+ Blog +

Notes from the ShellDocs team

+

Release notes, deep-dives, and the occasional postmortem. Written by the same people building the framework.

+
+ + +
+ +@code { + private record Post(string Title, string Excerpt, string Tag, string Date, string Author, string AuthorInitials, string AvatarBg, string ReadTime, string Href); + + private static readonly List _posts = new() + { + new("Announcing ShellDocs 0.1.0-alpha", + "The docs framework we wanted for .NET. Markdown-first, Razor-native, Cmd+K searchable, deploys anywhere. Here's what shipped and what's next on the roadmap.", + "Release", "Jul 12 · 2026", "Shephard Tseisi", "ST", "oklch(0.65 0.18 260)", "6 min read", "#"), + + new("Why we built the TOC three times", + "The story of every fumadocs-inspired scroll spy we shipped, why they broke on Blazor Server, and the 50-line version we ended up with. A lesson in doing less.", + "Engineering", "Jul 9 · 2026", "Shephard Tseisi", "ST", "oklch(0.7 0.18 200)", "9 min read", "#"), + + new("Frontmatter, not folders", + "How ShellDocs decides sidebar ordering, breadcrumbs, and prev/next navigation from a single YAML block. Plus: the meta.json escape hatch for when you need it.", + "Design", "Jul 3 · 2026", "Shephard Tseisi", "ST", "oklch(0.72 0.19 320)", "5 min read", "#"), + + new("Razor components mid-markdown, live", + "A short walkthrough of how + + + @(!string.IsNullOrEmpty(Options.SiteName) ? Options.SiteName : "Docs") + + + + +
+ + + + + @if (!string.IsNullOrEmpty(Options.GitHubRepo)) + { + + + + } +
+ + + +@implements IDisposable + +@code { + private const string DefaultIcon = + ""; + + protected override void OnInitialized() => MobileNav.OnChange += StateHasChanged; + public void Dispose() => MobileNav.OnChange -= StateHasChanged; +} diff --git a/src/ShellDocs.Components/Chrome/DocsHeader.razor.css b/src/ShellDocs.Components/Chrome/DocsHeader.razor.css new file mode 100644 index 0000000..b12c875 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsHeader.razor.css @@ -0,0 +1,256 @@ +.docs-header { + position: sticky; + top: 0; + z-index: 50; + height: var(--header-height); + border-bottom: 1px solid var(--border); + background: color-mix(in oklch, var(--background) 80%, transparent); + backdrop-filter: saturate(180%) blur(14px); + -webkit-backdrop-filter: saturate(180%) blur(14px); +} + +.docs-header-inner { + display: flex; + align-items: center; + gap: 2rem; + height: 100%; + padding: 0 1.5rem; + max-width: 1400px; + margin: 0 auto; +} + +.docs-header-hamburger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + cursor: pointer; + transition: color 150ms, background 150ms; + margin-right: 0.25rem; + flex-shrink: 0; +} +.docs-header-hamburger:hover { color: var(--foreground); background: var(--muted); } +.docs-header-hamburger svg { width: 1.15rem; height: 1.15rem; } +@media (min-width: 1024px) { + .docs-header-hamburger { display: none; } +} + +.docs-header-brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: var(--foreground); + font-weight: 600; + font-size: 0.9375rem; + letter-spacing: -0.015em; + transition: opacity 150ms; + flex-shrink: 0; +} +.docs-header-brand:hover { opacity: 0.85; } + +.docs-header-logo-dot { + width: 1.125rem; + height: 1.125rem; + border-radius: 4px; + background: var(--foreground); + flex-shrink: 0; +} + +.docs-header-brand-name { font-weight: 600; letter-spacing: -0.01em; } + +.docs-header-nav { + display: none; + align-items: center; + gap: 0.125rem; + flex: 1; +} +@media (min-width: 768px) { + .docs-header-nav { display: flex; } +} + +.docs-header-nav-link { + color: var(--muted-foreground); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + padding: 0.375rem 0.75rem; + border-radius: calc(var(--radius) - 3px); + transition: color 150ms, background 150ms; +} +.docs-header-nav-link:hover { color: var(--foreground); background: var(--muted); } + +.docs-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.docs-header-search { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.5rem 0.4rem 0.625rem; + background: var(--muted); + border: 1px solid transparent; + color: var(--muted-foreground); + border-radius: calc(var(--radius) - 2px); + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + transition: background 150ms, color 150ms, border-color 150ms; + min-width: 14rem; +} +.docs-header-search:hover { + color: var(--foreground); + border-color: var(--border); +} +.docs-header-search:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +.docs-header-search svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; } +.docs-header-search-label { flex: 1; text-align: left; } +.docs-header-kbd { display: inline-flex; gap: 0.2rem; } +.docs-header-kbd kbd { + font-family: var(--font-mono); + font-size: 0.625rem; + font-weight: 600; + background: var(--background); + border: 1px solid var(--border); + padding: 0.075rem 0.3rem; + border-radius: 4px; + color: var(--muted-foreground); + line-height: 1; + min-width: 1.2rem; + text-align: center; +} + +.docs-header-icon-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: calc(var(--radius) - 2px); + color: var(--muted-foreground); + transition: color 150ms, background 150ms; +} +.docs-header-icon-link:hover { color: var(--foreground); background: var(--muted); } +.docs-header-icon-link svg { width: 1rem; height: 1rem; } + +@media (max-width: 640px) { + .docs-header-search-label, + .docs-header-kbd { display: none; } + .docs-header-search { min-width: 0; padding: 0.5rem; } +} + +/* Mega-menu on hover */ +.docs-header-nav-item.has-menu { position: relative; } +.docs-header-nav-item.has-menu .docs-header-nav-link { + background: transparent; + border: 0; + cursor: pointer; + font-family: inherit; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.docs-header-nav-item.has-menu .chevron { + width: 0.7rem; + height: 0.7rem; + transition: transform 200ms; +} +.docs-header-nav-item.has-menu:hover > .docs-header-nav-link { + color: var(--foreground); + background: var(--muted); +} +.docs-header-nav-item.has-menu:hover .chevron { transform: rotate(180deg); } + +/* Invisible hover-bridge below the trigger so the cursor can travel to the menu + without dropping :hover on the parent nav-item. */ +.docs-header-nav-item.has-menu::after { + content: ''; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 0.6rem; +} + +.docs-header-mega { + position: absolute; + left: 0; + top: calc(100% + 0.5rem); + min-width: 32rem; + background: var(--popover); + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.18), 0 4px 12px -4px rgb(0 0 0 / 0.06); + padding: 0.5rem; + opacity: 0; + transform: translateY(-6px) scale(0.98); + pointer-events: none; + transition: opacity 160ms ease, transform 180ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 60; +} +.docs-header-nav-item.has-menu:hover .docs-header-mega, +.docs-header-nav-item.has-menu:focus-within .docs-header-mega { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} + +.docs-header-mega-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.25rem; +} + +.docs-header-mega-card { + display: flex; + gap: 0.65rem; + padding: 0.7rem 0.75rem; + border-radius: var(--radius); + text-decoration: none; + color: var(--foreground); + transition: background 150ms; +} +.docs-header-mega-card:hover { background: var(--accent); } + +.docs-header-mega-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 6px; + background: var(--muted); + color: var(--foreground); + flex-shrink: 0; +} +.docs-header-mega-icon svg { width: 1rem; height: 1rem; } + +.docs-header-mega-body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.docs-header-mega-title { + font-weight: 600; + font-size: 0.875rem; + letter-spacing: -0.005em; +} +.docs-header-mega-desc { + font-size: 0.775rem; + color: var(--muted-foreground); + line-height: 1.4; +} diff --git a/src/ShellDocs.Components/Chrome/DocsSidebar.razor b/src/ShellDocs.Components/Chrome/DocsSidebar.razor new file mode 100644 index 0000000..64ed2ed --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsSidebar.razor @@ -0,0 +1,44 @@ +@inject NavigationGraph Graph +@inject NavigationManager Nav +@inject ShellDocsOptions Options + + + +@code { + private string _currentPath = "/"; + + protected override void OnInitialized() + { + _currentPath = Nav.ToBaseRelativePath(Nav.Uri).TrimEnd('/'); + if (!_currentPath.StartsWith('/')) _currentPath = "/" + _currentPath; + Nav.LocationChanged += OnLocationChanged; + } + + private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + { + _currentPath = Nav.ToBaseRelativePath(e.Location).TrimEnd('/'); + if (!_currentPath.StartsWith('/')) _currentPath = "/" + _currentPath; + InvokeAsync(StateHasChanged); + } + + public void Dispose() => Nav.LocationChanged -= OnLocationChanged; +} diff --git a/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css b/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css new file mode 100644 index 0000000..6a90cf2 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css @@ -0,0 +1,193 @@ +.docs-sidebar { + display: flex; + flex-direction: column; + height: 100%; + font-size: 0.8125rem; + padding: 0.85rem 0.75rem 0 1rem; +} + +.sidebar-tree { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.25rem; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + padding: 0 0 2rem; + /* fumadocs — soft fade at top/bottom of scroll viewport */ + mask-image: linear-gradient(to bottom, transparent, black 12px, black calc(100% - 12px), transparent); + -webkit-mask-image: linear-gradient(to bottom, transparent, black 12px, black calc(100% - 12px), transparent); +} + +.sidebar-footer { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.65rem 0.35rem; + border-top: 1px solid var(--border); + margin-top: auto; +} +.sidebar-footer-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + transition: color 150ms, background 150ms; +} +.sidebar-footer-link:hover { color: var(--foreground); background: var(--muted); } +.sidebar-footer-link svg { width: 1rem; height: 1rem; } + +::deep .sidebar-section { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +::deep .sidebar-section[data-depth="0"] > .sidebar-section-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--foreground); + padding: 0 0.5rem; + margin: 0.35rem 0 0.5rem 0; + letter-spacing: -0.005em; +} +::deep .sidebar-section-icon { + width: 0.9rem; + height: 0.9rem; + color: var(--muted-foreground); + flex-shrink: 0; +} +::deep .sidebar-item { + gap: 0.5rem; +} +::deep .sidebar-item-icon { + width: 0.85rem; + height: 0.85rem; + color: var(--muted-foreground); + flex-shrink: 0; + transition: color 150ms; +} +::deep .sidebar-item:hover .sidebar-item-icon, +::deep .sidebar-item.active .sidebar-item-icon { color: var(--foreground); } + +::deep .sidebar-section[data-depth="1"] > .sidebar-section-label, +::deep .sidebar-section[data-depth="2"] > .sidebar-section-label, +::deep .sidebar-section[data-depth="3"] > .sidebar-section-label { + display: inline-flex; + align-items: center; + gap: 0.55rem; + text-transform: none; + letter-spacing: -0.005em; + font-size: 0.8125rem; + font-weight: 600; + color: var(--foreground); + padding: 0.45rem 0.625rem 0.45rem 0.55rem; + width: 100%; +} + +/* Toggle button variant — clickable section header */ +::deep .sidebar-section-toggle { + background: transparent; + border: 0; + cursor: pointer; + font-family: inherit; + text-align: left; + border-radius: calc(var(--radius) - 3px); + transition: background 150ms, color 150ms; +} +::deep .sidebar-section-toggle:hover { background: var(--muted); } +::deep .sidebar-section-toggle:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +::deep .sidebar-section-title { flex: 1; min-width: 0; } +::deep .sidebar-section-chevron { + width: 0.95rem; + height: 0.95rem; + color: var(--muted-foreground); + flex-shrink: 0; + margin-left: 0.25rem; + transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); +} +::deep .sidebar-section[data-open="true"] > .sidebar-section-toggle .sidebar-section-chevron { + transform: rotate(90deg); +} + +::deep .sidebar-section-items { + display: flex; + flex-direction: column; + gap: 0.05rem; + padding: 0; +} + +/* Tree lines — vertical guide + hover accent */ +::deep .sidebar-section[data-depth="1"] > .sidebar-section-items, +::deep .sidebar-section[data-depth="2"] > .sidebar-section-items, +::deep .sidebar-section[data-depth="3"] > .sidebar-section-items { + position: relative; + padding-left: 0.75rem; + margin-left: 0.625rem; +} +::deep .sidebar-section[data-depth="1"] > .sidebar-section-items::before, +::deep .sidebar-section[data-depth="2"] > .sidebar-section-items::before, +::deep .sidebar-section[data-depth="3"] > .sidebar-section-items::before { + content: ''; + position: absolute; + left: 0; + top: 0.25rem; + bottom: 0.25rem; + width: 1px; + background: var(--border); + transition: background 200ms; +} + +::deep .sidebar-item { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.625rem; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + text-decoration: none; + font-size: 0.8125rem; + line-height: 1.5; + font-weight: 400; + transition: background 150ms, color 150ms, transform 150ms; + position: relative; + letter-spacing: -0.005em; +} +::deep .sidebar-item:hover { + color: var(--foreground); + background: var(--muted); +} +::deep .sidebar-item.active { + color: var(--foreground); + background: var(--accent); + font-weight: 500; +} + +::deep .sidebar-section[data-depth="1"] > .sidebar-section-items > .sidebar-item.active::before, +::deep .sidebar-section[data-depth="2"] > .sidebar-section-items > .sidebar-item.active::before, +::deep .sidebar-section[data-depth="3"] > .sidebar-section-items > .sidebar-item.active::before { + content: ''; + position: absolute; + left: -0.75rem; + top: 0.25rem; + bottom: 0.25rem; + width: 1px; + background: var(--foreground); +} + +::deep .sidebar-item-label { flex: 1; } + +::deep .sidebar-divider { + border: 0; + border-top: 1px solid var(--border); + margin: 0.75rem 0.625rem; +} diff --git a/src/ShellDocs.Components/Chrome/DocsSidebarNode.razor b/src/ShellDocs.Components/Chrome/DocsSidebarNode.razor new file mode 100644 index 0000000..8bdeef0 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsSidebarNode.razor @@ -0,0 +1,113 @@ +@using ShellDocs.Components.Chrome + +@if (Node.Kind == NodeKind.Divider) +{ + +} +else if (Node.Kind == NodeKind.Section) +{ + +} +else +{ + var isActive = string.Equals(Node.Url.TrimEnd('/'), CurrentPath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + var itemIcon = Depth <= 1 ? SidebarIcons.GetPath(Node.Title) : null; + + @if (itemIcon is not null) + { + + } + @Node.Title + +} + +@code { + [Parameter, EditorRequired] public NavigationNode Node { get; set; } = default!; + [Parameter] public int Depth { get; set; } + [Parameter] public string CurrentPath { get; set; } = "/"; + + private bool _isOpen; + private bool _isToggleable; + private bool _initialized; + private string _lastPath = ""; + + protected override void OnParametersSet() + { + _isToggleable = Node.Kind == NodeKind.Section + && Depth >= 1 + && Node.Children.Any(c => c.Kind == NodeKind.Page); + + var containsActive = ContainsPath(Node, CurrentPath); + + /* Open on first render if we contain the active page. + Re-open on later renders only when the active path just moved into us — + this preserves the user's manual collapses on the current section. */ + if (!_initialized) + { + _isOpen = containsActive; + _initialized = true; + } + else if (containsActive && !ContainsPath(Node, _lastPath)) + { + _isOpen = true; + } + + _lastPath = CurrentPath; + } + + private void Toggle() => _isOpen = !_isOpen; + + private static bool ContainsPath(NavigationNode node, string path) + { + if (string.IsNullOrEmpty(path)) return false; + var target = path.TrimEnd('/'); + foreach (var child in node.Children) + { + if (child.Kind == NodeKind.Page && string.Equals(child.Url.TrimEnd('/'), target, StringComparison.OrdinalIgnoreCase)) + return true; + if (child.Kind == NodeKind.Section && ContainsPath(child, path)) + return true; + } + return false; + } + + private RenderFragment RenderLabelInner() => builder => + { + var iconD = Depth <= 1 ? SidebarIcons.GetPath(Node.Title) : null; + if (iconD is not null) + { + builder.AddMarkupContent(0, + $""); + } + builder.OpenElement(1, "span"); + builder.AddAttribute(2, "class", "sidebar-section-title"); + builder.AddContent(3, Node.Title); + builder.CloseElement(); + }; +} diff --git a/src/ShellDocs.Components/Chrome/MobileNavState.cs b/src/ShellDocs.Components/Chrome/MobileNavState.cs new file mode 100644 index 0000000..9b75136 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/MobileNavState.cs @@ -0,0 +1,11 @@ +namespace ShellDocs.Components.Chrome; + +public class MobileNavState +{ + public bool IsOpen { get; private set; } + public event Action? OnChange; + + public void Toggle() { IsOpen = !IsOpen; OnChange?.Invoke(); } + public void Open() { if (!IsOpen) { IsOpen = true; OnChange?.Invoke(); } } + public void Close() { if (IsOpen) { IsOpen = false; OnChange?.Invoke(); } } +} diff --git a/src/ShellDocs.Components/Chrome/PackageSelector.razor b/src/ShellDocs.Components/Chrome/PackageSelector.razor new file mode 100644 index 0000000..65cec79 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/PackageSelector.razor @@ -0,0 +1,68 @@ +@inject NavigationManager Nav + +
+ + + @if (_open) + { +
+ @foreach (var pkg in Packages) + { + var isSel = pkg.Id == Selected.Id; + + } +
+ } +
+ +@code { + private bool _open; + + private record Package(string Id, string Title, string Description, string IconPath, string RootUrl); + + private static readonly List Packages = new() + { + new("shelldocs", "ShellDocs", "The docs framework itself.", "M12 2 4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6z", "/docs/introduction"), + new("shelldocs.markdown", "ShellDocs.Markdown", "Markdig extensions, slots, MDX.", "M4 4h16v16H4z M4 9h16 M9 4v16", "/docs/markdown"), + new("shelldocs.core", "ShellDocs.Core", "Navigation graph + content model.","M12 2 2 7l10 5 10-5-10-5z M2 17l10 5 10-5 M2 12l10 5 10-5", "/docs/core"), + new("shelldocs.cli", "ShellDocs.CLI", "Scaffold, build, publish.", "m8 6-6 6 6 6 M16 6l6 6-6 6", "/docs/cli"), + new("shelldocs.components","ShellDocs.Components","Layouts, header, sidebar, TOC.","M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", "/docs/components") + }; + + private Package Selected => Packages.FirstOrDefault(p => Nav.Uri.Contains(p.RootUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "")) ?? Packages[0]; + + private void Toggle() => _open = !_open; + + private void Choose(Package pkg) + { + _open = false; + Nav.NavigateTo(pkg.RootUrl); + } + + private void OnBlur(FocusEventArgs _) { /* closes via next click cycle */ } +} diff --git a/src/ShellDocs.Components/Chrome/PackageSelector.razor.css b/src/ShellDocs.Components/Chrome/PackageSelector.razor.css new file mode 100644 index 0000000..b789d9a --- /dev/null +++ b/src/ShellDocs.Components/Chrome/PackageSelector.razor.css @@ -0,0 +1,123 @@ +.pkg { + position: relative; + margin-bottom: 1rem; +} + +.pkg-trigger { + display: inline-flex; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 0.7rem 0.75rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + color: var(--foreground); + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 150ms, border-color 150ms; +} +.pkg-trigger:hover { background: var(--muted); border-color: var(--border-strong); } +.pkg-trigger:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } + +.pkg-mark { + display: inline-flex; + width: 1.75rem; + height: 1.75rem; + align-items: center; + justify-content: center; + background: var(--muted); + color: var(--foreground); + border-radius: 5px; + flex-shrink: 0; +} +.pkg-mark svg { width: 1rem; height: 1rem; } + +.pkg-value { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Stacked select-style chevrons — fumadocs pattern */ +.pkg-chevrons { + width: 0.9rem; + height: 0.9rem; + color: var(--muted-foreground); + flex-shrink: 0; + margin-left: 0.25rem; +} + +.pkg-menu { + position: absolute; + top: calc(100% + 0.35rem); + left: 0; + right: 0; + background: var(--popover); + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 2px); + box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.18), 0 4px 12px -4px rgb(0 0 0 / 0.06); + padding: 0.35rem; + z-index: 30; + display: flex; + flex-direction: column; + gap: 0.15rem; + max-height: 22rem; + overflow-y: auto; +} + +.pkg-option { + display: flex; + align-items: flex-start; + gap: 0.55rem; + padding: 0.55rem 0.55rem; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--foreground); + cursor: pointer; + font-family: inherit; + text-align: left; + width: 100%; + transition: background 150ms; +} +.pkg-option:hover { background: var(--muted); } +.pkg-option.selected { background: var(--accent); } + +.pkg-option-icon { + display: inline-flex; + width: 1.5rem; + height: 1.5rem; + align-items: center; + justify-content: center; + background: var(--muted); + color: var(--foreground); + border-radius: 5px; + flex-shrink: 0; + margin-top: 1px; +} +.pkg-option-icon svg { width: 0.85rem; height: 0.85rem; } + +.pkg-option-body { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + flex: 1; +} +.pkg-option-title { font-size: 0.8125rem; font-weight: 600; letter-spacing: -0.005em; } +.pkg-option-desc { font-size: 0.7rem; color: var(--muted-foreground); line-height: 1.35; } +.pkg-option-check { + width: 0.9rem; + height: 0.9rem; + color: var(--foreground); + flex-shrink: 0; + margin-top: 0.15rem; +} diff --git a/src/ShellDocs.Components/Chrome/PrevNextNav.razor b/src/ShellDocs.Components/Chrome/PrevNextNav.razor new file mode 100644 index 0000000..5f326d1 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/PrevNextNav.razor @@ -0,0 +1,41 @@ +@using ShellDocs.Core + +@if (Prev is not null || Next is not null) +{ + +} + +@code { + [Parameter] public NavigationNode? Prev { get; set; } + [Parameter] public NavigationNode? Next { get; set; } +} diff --git a/src/ShellDocs.Components/Chrome/PrevNextNav.razor.css b/src/ShellDocs.Components/Chrome/PrevNextNav.razor.css new file mode 100644 index 0000000..747b3ed --- /dev/null +++ b/src/ShellDocs.Components/Chrome/PrevNextNav.razor.css @@ -0,0 +1,61 @@ +.prevnext { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem; + margin-top: 3rem; +} + +.prevnext-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.9rem 1.1rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: transparent; + color: var(--foreground); + text-decoration: none; + transition: border-color 200ms, background 200ms; + min-width: 0; +} +.prevnext-card:hover { border-color: var(--border-strong); background: var(--muted); } +.prevnext-card.next { text-align: right; align-items: flex-end; } + +.prevnext-hint { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--muted-foreground); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: -0.005em; + transition: color 200ms; +} +.prevnext-card:hover .prevnext-hint { color: var(--foreground); } +.prevnext-card.prev .prevnext-hint > span { line-height: 1.3; } +.prevnext-card.next .prevnext-hint > span { line-height: 1.3; } + +.chev { + width: 0.95rem; + height: 0.95rem; + color: var(--muted-foreground); + transition: color 200ms, transform 200ms cubic-bezier(0.16, 1, 0.3, 1); +} +.prevnext-card:hover .chev { color: var(--foreground); } +.prevnext-card.prev:hover .chev { transform: translateX(-2px); } +.prevnext-card.next:hover .chev { transform: translateX(2px); } + +.prevnext-desc { + display: block; + color: var(--muted-foreground); + font-size: 0.8125rem; + line-height: 1.5; + letter-spacing: -0.003em; +} + +.prevnext-placeholder { display: block; } + +@media (max-width: 640px) { + .prevnext { grid-template-columns: 1fr; } + .prevnext-placeholder { display: none; } +} diff --git a/src/ShellDocs.Components/Chrome/SidebarIcons.cs b/src/ShellDocs.Components/Chrome/SidebarIcons.cs new file mode 100644 index 0000000..243826c --- /dev/null +++ b/src/ShellDocs.Components/Chrome/SidebarIcons.cs @@ -0,0 +1,36 @@ +namespace ShellDocs.Components.Chrome; + +internal static class SidebarIcons +{ + /* Lightweight title→lucide-svg map — matches fumadocs' inline icon feel + without forcing content authors to configure per-page icons. */ + private static readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase) + { + ["Getting Started"] = "M13 2 3 14h9l-1 8 10-12h-9l1-8z", + ["Introduction"] = "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 8v4l3 3", + ["Installation"] = "M12 2v10m0 0-4-4m4 4 4-4M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3", + ["Quickstart"] = "M13 2 3 14h9l-1 8 10-12h-9l1-8z", + ["Markdown"] = "M4 4h16v16H4z M4 9h16 M9 4v16", + ["Frontmatter"] = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M9 13h6 M9 17h6", + ["Components"] = "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", + ["Callout"] = "M12 8v4 M12 16h.01 M22 12A10 10 0 1 1 12 2a10 10 0 0 1 10 10z", + ["Card"] = "M3 5h18v14H3z M3 10h18", + ["Tabs"] = "M3 3h18v6H3z M3 13h8v8H3z M13 13h8v8h-8z", + ["Code Group"] = "m8 6-6 6 6 6 M16 6l6 6-6 6", + ["Theming"] = "M2 12a10 10 0 1 0 10 10c0-3-3-3-3-6a4 4 0 0 1 4-4h3a6 6 0 0 0 6-6 10 10 0 0 0-20 6z", + ["Palette"] = "M12 22a10 10 0 1 1 10-10 5 5 0 0 1-5 5h-2a2 2 0 0 0-2 2v3a2 2 0 0 1-1 0z", + ["Dark Mode"] = "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z", + ["Deployment"] = "M22 12h-4l-3 9L9 3l-3 9H2", + ["Reference"] = "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20", + ["API"] = "M8 3H5a2 2 0 0 0-2 2v3 M21 8V5a2 2 0 0 0-2-2h-3 M3 16v3a2 2 0 0 0 2 2h3 M16 21h3a2 2 0 0 0 2-2v-3", + ["Blog"] = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6", + ["Changelog"] = "M12 8v4l3 3 M22 12a10 10 0 1 1-10-10", + ["Showcase"] = "M12 2l3 7 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z" + }; + + public static string? GetPath(string? title) + { + if (string.IsNullOrWhiteSpace(title)) return null; + return _map.TryGetValue(title.Trim(), out var d) ? d : null; + } +} diff --git a/src/ShellDocs.Components/Chrome/TableOfContents.razor b/src/ShellDocs.Components/Chrome/TableOfContents.razor new file mode 100644 index 0000000..8213b80 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/TableOfContents.razor @@ -0,0 +1,67 @@ +@inject IJSRuntime JS +@implements IAsyncDisposable +@using ShellDocs.Core + +@if (_items.Count > 0) +{ + +} + +@code { + [Parameter] public IReadOnlyList? Headings { get; set; } + + private List _items = new(); + private ElementReference _listEl; + private IJSObjectReference? _handle; + private string _sig = ""; + + protected override void OnParametersSet() + { + _items = (Headings ?? Array.Empty()) + .Where(h => h.Level == 2 || h.Level == 3) + .ToList(); + } + + protected override async Task OnAfterRenderAsync(bool _) + { + if (_items.Count == 0) return; + var sig = string.Join(",", _items.Select(h => h.Id)); + if (sig == _sig) return; + + if (_handle is not null) + { + try { await _handle.InvokeVoidAsync("dispose"); await _handle.DisposeAsync(); } catch { } + _handle = null; + } + try + { + _handle = await JS.InvokeAsync( + "shelldocsToc.attach", _listEl, _items.Select(h => h.Id).ToArray()); + _sig = sig; + } + catch { } + } + + private Task Scroll(string id) => JS.InvokeVoidAsync("shelldocsToc.scrollTo", id).AsTask(); + + public async ValueTask DisposeAsync() + { + if (_handle is null) return; + try { await _handle.InvokeVoidAsync("dispose"); await _handle.DisposeAsync(); } catch { } + } +} diff --git a/src/ShellDocs.Components/Chrome/TableOfContents.razor.css b/src/ShellDocs.Components/Chrome/TableOfContents.razor.css new file mode 100644 index 0000000..f2bb079 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/TableOfContents.razor.css @@ -0,0 +1,68 @@ +.toc { + font-size: 0.8125rem; +} + +.toc-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--muted-foreground); + padding: 2rem 0 0.75rem 0.75rem; + /* Sticks to the top of the scrolling TOC slot; solid background so links + scroll cleanly underneath. A subtle fade below hints at more content. */ + position: sticky; + top: 0; + z-index: 5; + background: linear-gradient(to bottom, + var(--background) 0%, + var(--background) calc(100% - 8px), + color-mix(in oklch, var(--background) 40%, transparent) 100%); +} +.toc-title svg { width: 0.9rem; height: 0.9rem; } + +/* Grey rail = left border on the list. The coloured highlight rides on top of it. */ +.toc-list { + list-style: none; + padding: 0; + margin: 0; + position: relative; + border-left: 1px solid color-mix(in oklch, var(--foreground) 12%, transparent); +} + +/* The moving highlight — 1px vertical bar sitting on the border. JS drives its + transform + height as the active heading changes. */ +::deep .toc-bar { + position: absolute; + left: -1px; + top: 0; + width: 1px; + height: 0; + background: var(--primary); + transform: translateY(0); + transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1), height 220ms cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +::deep .toc-item { + padding: 0; + margin: 0; + list-style: none; +} +::deep .toc-item[data-level="3"] .toc-link { padding-left: 1.75rem; } + +::deep .toc-link { + display: block; + padding: 0.35rem 0.5rem 0.35rem 0.75rem; + color: var(--muted-foreground); + text-decoration: none; + font-size: 0.8125rem; + line-height: 1.4; + transition: color 150ms; +} +::deep .toc-link:hover { color: var(--foreground); } +::deep .toc-link.active { + color: var(--foreground); + font-weight: 500; +} diff --git a/src/ShellDocs.Components/Chrome/ThemeToggle.razor b/src/ShellDocs.Components/Chrome/ThemeToggle.razor new file mode 100644 index 0000000..bcc1438 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/ThemeToggle.razor @@ -0,0 +1,40 @@ +@inject IJSRuntime JS + + + +@code { + private bool _isDark; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + try + { + var current = await JS.InvokeAsync("eval", "document.documentElement.classList.contains('dark') ? 'dark' : 'light'"); + _isDark = current == "dark"; + StateHasChanged(); + } + catch { } + } + + private async Task Toggle() + { + _isDark = !_isDark; + try + { + await JS.InvokeVoidAsync("eval", _isDark + ? "document.documentElement.classList.add('dark'); try { localStorage.setItem('shelldocs-theme','dark'); } catch{}" + : "document.documentElement.classList.remove('dark'); try { localStorage.setItem('shelldocs-theme','light'); } catch{}"); + } + catch { } + } +} diff --git a/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css b/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css new file mode 100644 index 0000000..cb8abdd --- /dev/null +++ b/src/ShellDocs.Components/Chrome/ThemeToggle.razor.css @@ -0,0 +1,17 @@ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: calc(var(--radius) - 2px); + background: transparent; + color: var(--muted-foreground); + border: 1px solid transparent; + cursor: pointer; + transition: background 150ms, color 150ms, border-color 150ms; + padding: 0; +} +.theme-toggle:hover { color: var(--foreground); background: var(--muted); } +.theme-toggle:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.theme-toggle svg { width: 1rem; height: 1rem; } diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor b/src/ShellDocs.Components/Content/MarkdownContent.razor new file mode 100644 index 0000000..31d29d2 --- /dev/null +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor @@ -0,0 +1,64 @@ +@inject MarkdownRenderer Renderer +@inject IJSRuntime JS + +
+ @if (_parts is null) + { +
Loading…
+ } + else + { + @foreach (var part in _parts) + { + @if (part is HtmlPart html) + { + @((MarkupString)html.Html) + } + else if (part is SlotPart slot) + { + @if (slot.Slot is ComponentSlot comp) + { + + } + else if (slot.Slot is PreviewSlot preview) + { + + } + } + } + } +
+ +@code { + [Parameter] public string? Markdown { get; set; } + [Parameter] public RenderedDocument? Document { get; set; } + + private IReadOnlyList? _parts; + + protected override void OnParametersSet() + { + var doc = Document ?? (Markdown is not null ? Renderer.Render(Markdown) : null); + _parts = doc is null ? null : SlotSplitter.Split(doc); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_parts is null) return; + try { await JS.InvokeVoidAsync("shelldocsHighlight"); } catch { } + } + + private IDictionary BuildParameters(ComponentSlot slot) => + BuildParameters(slot.Parameters, slot.ChildContentRaw); + + private IDictionary BuildParameters(IReadOnlyDictionary attrs, string? childContentRaw) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (k, v) in attrs) dict[k] = v; + if (!string.IsNullOrEmpty(childContentRaw)) + { + dict["ChildContent"] = (RenderFragment)(builder => + builder.AddMarkupContent(0, childContentRaw)); + } + return dict; + } +} diff --git a/src/ShellDocs.Components/Content/MarkdownContent.razor.css b/src/ShellDocs.Components/Content/MarkdownContent.razor.css new file mode 100644 index 0000000..cb65e3f --- /dev/null +++ b/src/ShellDocs.Components/Content/MarkdownContent.razor.css @@ -0,0 +1,49 @@ +.shelldocs-loading { + color: var(--muted-foreground); + font-size: 0.875rem; + padding: 2rem 0; +} + +::deep .shelldocs-preview { + border: 1px solid var(--border); + border-radius: var(--radius); + margin: 1.25rem 0; + overflow: hidden; + background: var(--card); +} + +::deep .shelldocs-preview-render { + padding: 2rem; + display: flex; + align-items: center; + justify-content: center; + min-height: 8rem; +} + +::deep .shelldocs-preview-code { + border-top: 1px solid var(--border); + background: var(--muted); +} + +::deep .shelldocs-preview-code summary { + cursor: pointer; + padding: 0.5rem 0.875rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--muted-foreground); + list-style: none; + user-select: none; +} +::deep .shelldocs-preview-code summary:hover { color: var(--foreground); } +::deep .shelldocs-preview-code summary::-webkit-details-marker { display: none; } + +::deep .shelldocs-preview-code pre { + margin: 0; + padding: 0.875rem; + background: transparent; + border-radius: 0; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.6; + overflow-x: auto; +} diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor b/src/ShellDocs.Components/Content/PreviewFrame.razor new file mode 100644 index 0000000..7807917 --- /dev/null +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor @@ -0,0 +1,84 @@ +@using ShellDocs.Markdown + +
+
+ + +
+ @if (_showCode) + { + + } +
+
+ @if (!_showCode) + { +
+ +
+ } + else + { + @* Prism ships no razor grammar; markup/html grammar handles the tag+attr+string structure just fine. *@ +
@Preview!.Code
+ } +
+
+ +@code { + [Parameter, EditorRequired] public PreviewSlot Preview { get; set; } = default!; + + private bool _showCode; + private bool _copied; + private ElementReference _codeEl; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + /* Prism's global highlightAll runs once when MarkdownContent mounts, but + the code tab renders lazily — re-highlight this specific block whenever + the code tab becomes visible. */ + if (_showCode) + { + try { await JS.InvokeVoidAsync("shelldocsHighlightElement", _codeEl); } catch { } + } + } + + private async Task Copy() + { + try + { + await JS.InvokeVoidAsync("navigator.clipboard.writeText", Preview.Code); + _copied = true; + await Task.Delay(1400); + _copied = false; + } + catch { } + } + + [Inject] private IJSRuntime JS { get; set; } = default!; + + private IDictionary BuildParameters(IReadOnlyDictionary attrs) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var (k, v) in attrs) dict[k] = v; + return dict; + } +} diff --git a/src/ShellDocs.Components/Content/PreviewFrame.razor.css b/src/ShellDocs.Components/Content/PreviewFrame.razor.css new file mode 100644 index 0000000..40e60ea --- /dev/null +++ b/src/ShellDocs.Components/Content/PreviewFrame.razor.css @@ -0,0 +1,99 @@ +.preview-frame { + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + overflow: hidden; + margin: 1.5rem 0; +} + +.preview-tabs { + display: flex; + align-items: center; + gap: 0.15rem; + padding: 0.35rem 0.4rem; + background: color-mix(in oklch, var(--card) 55%, var(--background)); + border-bottom: 1px solid var(--border); +} + +.preview-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.7rem; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + font-family: inherit; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: color 150ms, background 150ms; +} +.preview-tab:hover { color: var(--foreground); } +.preview-tab.active { + color: var(--foreground); + background: var(--muted); +} +.preview-tab svg { width: 0.875rem; height: 0.875rem; } + +.preview-spacer { flex: 1; } + +.preview-copy { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.55rem 0.3rem 0.5rem; + background: transparent; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: color 150ms, background 150ms, border-color 150ms; +} +.preview-copy:hover { + color: var(--foreground); + background: var(--muted); + border-color: var(--border); +} +.preview-copy.copied { color: var(--success, oklch(0.723 0.219 149.579)); } +.preview-copy svg { width: 0.8125rem; height: 0.8125rem; } + +.preview-body { position: relative; } + +.preview-render { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + min-height: 12rem; + padding: 2rem 1.5rem; + background: + repeating-linear-gradient(45deg, + color-mix(in oklch, var(--foreground) 2.5%, transparent) 0, + color-mix(in oklch, var(--foreground) 2.5%, transparent) 1px, + transparent 1px, transparent 8px); +} + +.preview-code { + margin: 0; + padding: 1.15rem 1.25rem; + background: transparent; + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.65; + color: var(--foreground); + overflow-x: auto; +} +.preview-code code { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + font-family: var(--font-mono) !important; + font-size: inherit !important; + color: inherit !important; +} diff --git a/src/ShellDocs.Components/Content/SlotSplitter.cs b/src/ShellDocs.Components/Content/SlotSplitter.cs new file mode 100644 index 0000000..4d67754 --- /dev/null +++ b/src/ShellDocs.Components/Content/SlotSplitter.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; +using ShellDocs.Markdown; + +namespace ShellDocs.Components.Content; + +public static class SlotSplitter +{ + private static readonly Regex PlaceholderRegex = new( + @"[^""]+)""\s+data-shelldocs-id=""(?[^""]+)""\s*>", + RegexOptions.Compiled); + + public static IReadOnlyList Split(RenderedDocument document) + { + var byId = document.Slots.ToDictionary(s => s.Id); + var parts = new List(); + var cursor = 0; + + foreach (Match m in PlaceholderRegex.Matches(document.Html)) + { + if (m.Index > cursor) + { + var html = document.Html.Substring(cursor, m.Index - cursor); + if (html.Length > 0) parts.Add(new HtmlPart(html)); + } + if (byId.TryGetValue(m.Groups["id"].Value, out var slot)) + { + parts.Add(new SlotPart(slot)); + } + cursor = m.Index + m.Length; + } + + if (cursor < document.Html.Length) + { + parts.Add(new HtmlPart(document.Html[cursor..])); + } + + return parts; + } +} + +public abstract record RenderPart; +public record HtmlPart(string Html) : RenderPart; +public record SlotPart(Slot Slot) : RenderPart; diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor b/src/ShellDocs.Components/Layouts/DocsLayout.razor new file mode 100644 index 0000000..94d679a --- /dev/null +++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor @@ -0,0 +1,43 @@ +@inherits LayoutComponentBase +@inject NavigationManager Nav +@inject ShellDocs.Components.Chrome.MobileNavState MobileNav +@implements IDisposable +@using Microsoft.AspNetCore.Components.Sections + +
+ +
+ + @if (MobileNav.IsOpen) + { + + } +
+
+ @Body +
+
+ +
+
+ +@code { + protected override void OnInitialized() + { + MobileNav.OnChange += StateHasChanged; + Nav.LocationChanged += OnRouteChanged; + } + + private void OnRouteChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) + => MobileNav.Close(); + + public void Dispose() + { + MobileNav.OnChange -= StateHasChanged; + Nav.LocationChanged -= OnRouteChanged; + } +} diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor.css b/src/ShellDocs.Components/Layouts/DocsLayout.razor.css new file mode 100644 index 0000000..311b90f --- /dev/null +++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor.css @@ -0,0 +1,120 @@ +.docs-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--background); + color: var(--foreground); +} + +.docs-shell[data-mobile-open="True"] { overflow: hidden; } + +.docs-body { + display: grid; + grid-template-columns: 1fr; + flex: 1; + max-width: 1400px; + width: 100%; + margin: 0 auto; + padding: 0 1.25rem; + position: relative; +} + +@media (min-width: 1024px) { + .docs-body { + grid-template-columns: var(--sidebar-width) 1fr; + padding: 0 1.5rem; + } +} + +@media (min-width: 1280px) { + .docs-body { + grid-template-columns: var(--sidebar-width) 1fr var(--toc-width); + } +} + +.docs-sidebar-slot { + position: fixed; + top: var(--header-height); + left: 0; + width: min(18rem, 85vw); + height: calc(100vh - var(--header-height)); + background: var(--background); + border-right: 1px solid var(--border); + transform: translateX(-100%); + transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 45; +} +.docs-sidebar-slot.open { + transform: translateX(0); + box-shadow: 4px 0 24px -8px rgb(0 0 0 / 0.25); +} + +@media (min-width: 1024px) { + .docs-sidebar-slot { + position: sticky; + top: var(--header-height); + left: auto; + width: auto; + border-right: 1px solid var(--border); + margin-right: 1.5rem; + transform: none; + box-shadow: none; + z-index: auto; + transition: none; + } +} + +.docs-sidebar-backdrop { + position: fixed; + inset: var(--header-height) 0 0 0; + background: rgb(0 0 0 / 0.4); + border: 0; + padding: 0; + z-index: 40; + cursor: pointer; + animation: docs-backdrop-in 200ms ease; +} +@keyframes docs-backdrop-in { + from { opacity: 0; } + to { opacity: 1; } +} +@media (min-width: 1024px) { + .docs-sidebar-backdrop { display: none; } +} + +.docs-main { + min-width: 0; + padding: 2rem 0 4rem; +} + +@media (min-width: 1024px) { + .docs-main { padding: 2.5rem 0 4rem; } +} + +.docs-content { + max-width: 46rem; + margin: 0 auto; + padding: 0 1rem; +} + +.docs-toc-slot { + display: none; + position: sticky; + top: var(--header-height); + height: calc(100vh - var(--header-height)); + padding: 0 1rem 2rem 1.5rem; + margin-left: 1rem; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.docs-toc-slot::-webkit-scrollbar { width: 6px; } +.docs-toc-slot::-webkit-scrollbar-track { background: transparent; } +.docs-toc-slot::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 3px; +} + +@media (min-width: 1280px) { + .docs-toc-slot { display: block; } +} diff --git a/src/ShellDocs.Components/Layouts/HomeLayout.razor b/src/ShellDocs.Components/Layouts/HomeLayout.razor new file mode 100644 index 0000000..8c7f546 --- /dev/null +++ b/src/ShellDocs.Components/Layouts/HomeLayout.razor @@ -0,0 +1,8 @@ +@inherits LayoutComponentBase + +
+ +
+ @Body +
+
diff --git a/src/ShellDocs.Components/Layouts/HomeLayout.razor.css b/src/ShellDocs.Components/Layouts/HomeLayout.razor.css new file mode 100644 index 0000000..00afea9 --- /dev/null +++ b/src/ShellDocs.Components/Layouts/HomeLayout.razor.css @@ -0,0 +1,11 @@ +.home-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--background); + color: var(--foreground); +} + +.home-main { + flex: 1; +} diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs index 3ce46f2..0abb2c4 100644 --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs @@ -1,39 +1,31 @@ using Microsoft.Extensions.DependencyInjection; +using ShellDocs.Components.Chrome; using ShellDocs.Core; +using ShellDocs.Markdown; namespace ShellDocs.Components; -/// Registers ShellDocs services with the consumer's DI container. -/// Consumer's Program.cs calls this once — everything else is discovered by convention. public static class ServiceCollectionExtensions { - public static IServiceCollection AddShellDocs( - this IServiceCollection services, - Action? configure = null) + public static IServiceCollection AddShellDocs(this IServiceCollection services, Action? configure = null) { var options = new ShellDocsOptions(); configure?.Invoke(options); services.AddSingleton(options); - // Real service wiring lands in feat/core-navigation-graph + feat/markdown-pipeline. - // Scaffolding: keep the surface consumers will call, no implementation yet. - return services; - } -} + services.AddScoped(); + services.AddSingleton(_ => options.BuildTypeRegistry()); + services.AddSingleton(sp => new MarkdownRenderer(sp.GetRequiredService())); -/// Fluent options bag consumers configure in Program.cs. -public class ShellDocsOptions -{ - public string ContentRoot { get; set; } = "content"; - public string SiteName { get; set; } = ""; - public string? GitHubRepo { get; set; } - public bool EnableSearch { get; set; } = true; - public string SearchIndexPath { get; set; } = "search-index.json"; - public List RegisteredComponents { get; } = new(); + services.AddSingleton(_ => + { + if (!Directory.Exists(options.ContentRoot)) + { + return new NavigationGraph(new NavigationNode { Url = "/", Kind = NodeKind.Section }); + } + return NavigationGraphBuilder.Build(options.ContentRoot); + }); - public ShellDocsOptions RegisterComponent() where T : Microsoft.AspNetCore.Components.ComponentBase - { - RegisteredComponents.Add(typeof(T)); - return this; + return services; } } diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs new file mode 100644 index 0000000..4b59870 --- /dev/null +++ b/src/ShellDocs.Components/ShellDocsOptions.cs @@ -0,0 +1,50 @@ +using ShellDocs.Markdown; + +namespace ShellDocs.Components; + +public class ShellDocsOptions +{ + public string ContentRoot { get; set; } = "content"; + public string SiteName { get; set; } = ""; + public string? SiteTagline { get; set; } + public string? GitHubRepo { get; set; } + public ShellDocsTheme Theme { get; set; } = ShellDocsTheme.Shadcn; + + public List PrimaryNav { get; } = new(); + public List RegisteredComponents { get; } = new(); + + public ShellDocsOptions RegisterComponent() where T : Microsoft.AspNetCore.Components.ComponentBase + { + RegisteredComponents.Add(typeof(T)); + return this; + } + + public ShellDocsOptions AddNavLink(string label, string href) + { + PrimaryNav.Add(new NavLink(label, href)); + return this; + } + + public ShellDocsOptions AddNavMenu(string label, params NavMenuItem[] items) + { + PrimaryNav.Add(new NavLink(label, "#", Children: items.ToList())); + return this; + } + + internal TypeRegistry BuildTypeRegistry() + { + var registry = new TypeRegistry(); + foreach (var type in RegisteredComponents) registry.Register(type); + return registry; + } +} + +public record NavLink(string Label, string Href, List? Children = null); +public record NavMenuItem(string Label, string Href, string? Description = null, string? IconSvg = null); + +public enum ShellDocsTheme +{ + Shadcn, + Fuma, + Nextra +} diff --git a/src/ShellDocs.Components/_Imports.razor b/src/ShellDocs.Components/_Imports.razor index 612f80f..7ebce9c 100644 --- a/src/ShellDocs.Components/_Imports.razor +++ b/src/ShellDocs.Components/_Imports.razor @@ -1,9 +1,15 @@ @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.CompilerServices @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using Microsoft.Extensions.DependencyInjection @using Microsoft.JSInterop @using ShellDocs.Components +@using ShellDocs.Components.Chrome +@using ShellDocs.Components.Content +@using ShellDocs.Components.Layouts @using ShellDocs.Core +@using ShellDocs.Markdown diff --git a/src/ShellDocs.Components/wwwroot/shelldocs-theme.css b/src/ShellDocs.Components/wwwroot/shelldocs-theme.css new file mode 100644 index 0000000..02f7e59 --- /dev/null +++ b/src/ShellDocs.Components/wwwroot/shelldocs-theme.css @@ -0,0 +1,262 @@ +@import url('https://rsms.me/inter/inter.css'); + +/* + Neutral palette ported from fumadocs base-ui. + Accent is a subtle translucent gray, not a color — vibrancy is reserved for semantic states. +*/ + +:root { + --background: hsl(0, 0%, 96%); + --foreground: hsl(0, 0%, 3.9%); + + --card: hsl(0, 0%, 94.7%); + --card-foreground: hsl(0, 0%, 3.9%); + + --popover: hsl(0, 0%, 98%); + --popover-foreground: hsl(0, 0%, 15.1%); + + --primary: hsl(0, 0%, 9%); + --primary-foreground: hsl(0, 0%, 98%); + + --secondary: hsl(0, 0%, 93.1%); + --secondary-foreground: hsl(0, 0%, 9%); + + --muted: hsl(0, 0%, 96.1%); + --muted-foreground: hsl(0, 0%, 45.1%); + + --accent: hsla(0, 0%, 82%, 0.5); + --accent-foreground: hsl(0, 0%, 9%); + + --border: hsla(0, 0%, 80%, 0.5); + --border-strong: hsl(0, 0%, 78%); + --input: hsl(0, 0%, 89%); + --ring: hsl(0, 0%, 63.9%); + + /* Semantic — the only vibrant tokens */ + --info: oklch(0.623 0.214 259.815); + --warning: oklch(0.769 0.188 70.08); + --error: oklch(0.637 0.237 25.331); + --success: oklch(0.723 0.219 149.579); + + --radius: 0.5rem; + --sidebar-width: 17.5rem; + --toc-width: 14rem; + --header-height: 3.5rem; + + --font-sans: 'Inter var', 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; + + color-scheme: light; +} + +:root.dark { + --background: hsl(0, 0%, 7.04%); + --foreground: hsl(0, 0%, 92%); + + --card: hsl(0, 0%, 9.8%); + --card-foreground: hsl(0, 0%, 98%); + + --popover: hsl(0, 0%, 11.6%); + --popover-foreground: hsl(0, 0%, 86.9%); + + --primary: hsl(0, 0%, 98%); + --primary-foreground: hsl(0, 0%, 9%); + + --secondary: hsl(0, 0%, 12.9%); + --secondary-foreground: hsl(0, 0%, 92%); + + --muted: hsl(0, 0%, 12.9%); + --muted-foreground: hsla(0, 0%, 70%, 0.8); + + --accent: hsla(0, 0%, 40.9%, 0.3); + --accent-foreground: hsl(0, 0%, 90%); + + --border: hsla(0, 0%, 40%, 0.2); + --border-strong: hsla(0, 0%, 40%, 0.5); + --input: hsl(0, 0%, 15%); + --ring: hsl(0, 0%, 54.9%); + + color-scheme: dark; +} + +/* Base */ +*, *::before, *::after { box-sizing: border-box; border-color: var(--border); } +html { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11', 'ss01'; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + scroll-behavior: smooth; + scrollbar-gutter: stable; /* fumadocs — no jumpy shift when a modal opens */ +} +body { margin: 0; min-height: 100vh; } + +::selection { background: color-mix(in oklch, var(--primary) 20%, transparent); color: var(--foreground); } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; border: 2px solid var(--background); } +::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +/* Prose */ +.shelldocs-prose { color: var(--foreground); font-size: 0.95rem; line-height: 1.7; } +.shelldocs-prose > *:first-child { margin-top: 0; } +.shelldocs-prose > *:last-child { margin-bottom: 0; } + +.shelldocs-prose h1 { font-size: 2.125rem; font-weight: 700; letter-spacing: -0.025em; margin: 0 0 0.75rem; line-height: 1.2; color: var(--foreground); } +.shelldocs-prose h2 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; margin: 2.5rem 0 0.75rem; line-height: 1.3; scroll-margin-top: calc(var(--header-height) + 2rem); } +.shelldocs-prose h3 { font-size: 1.125rem; font-weight: 600; letter-spacing: -0.015em; margin: 1.75rem 0 0.5rem; line-height: 1.4; scroll-margin-top: calc(var(--header-height) + 2rem); } +.shelldocs-prose h4 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; } + +.shelldocs-prose p { margin: 1rem 0; color: var(--foreground); } + +.shelldocs-prose a { + color: var(--foreground); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-thickness: 1px; + text-decoration-color: color-mix(in oklch, var(--foreground) 30%, transparent); + transition: text-decoration-color 150ms; + font-weight: 500; +} +.shelldocs-prose a:hover { text-decoration-color: var(--foreground); } + +.shelldocs-prose strong { font-weight: 600; color: var(--foreground); } + +.shelldocs-prose code { + font-family: var(--font-mono); + font-size: 0.85em; + background: var(--muted); + padding: 0.15rem 0.4rem; + border-radius: 4px; + color: var(--foreground); + border: 1px solid var(--border); + font-feature-settings: 'ss01'; +} + +.shelldocs-prose ul, .shelldocs-prose ol { padding-left: 1.5rem; margin: 1rem 0; } +.shelldocs-prose li { margin: 0.35rem 0; } +.shelldocs-prose li::marker { color: var(--muted-foreground); } + +.shelldocs-prose blockquote { + border-left: 3px solid var(--border-strong); + padding: 0.75rem 0 0.75rem 1rem; + margin: 1.25rem 0; + color: var(--muted-foreground); + background: color-mix(in oklch, var(--muted) 60%, transparent); + border-radius: 0 var(--radius) var(--radius) 0; +} +.shelldocs-prose blockquote p { margin: 0; } + +.shelldocs-prose table { + border-collapse: collapse; + width: 100%; + margin: 1.25rem 0; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.shelldocs-prose th, .shelldocs-prose td { padding: 0.625rem 0.875rem; text-align: left; border-bottom: 1px solid var(--border); } +.shelldocs-prose th { font-weight: 600; color: var(--foreground); background: var(--muted); font-size: 0.8125rem; } +.shelldocs-prose tr:last-child td { border-bottom: 0; } +.shelldocs-prose tr:hover { background: color-mix(in oklch, var(--muted) 40%, transparent); } + +.shelldocs-prose hr { border: 0; border-top: 1px solid var(--border); margin: 2.5rem 0; } +.shelldocs-prose img { border-radius: var(--radius); border: 1px solid var(--border); max-width: 100%; height: auto; } + +/* Code blocks — wrapped by CodeBlockEnhancer */ +.shelldocs-codeblock { + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + overflow: hidden; + margin: 1.5rem 0; +} +.shelldocs-codeblock-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.375rem 0.5rem 0.375rem 0.875rem; + border-bottom: 1px solid var(--border); + background: color-mix(in oklch, var(--card) 60%, var(--background)); +} +.shelldocs-code-lang { + font-size: 0.7rem; + font-weight: 500; + color: var(--muted-foreground); + text-transform: lowercase; + letter-spacing: 0.02em; + font-family: var(--font-mono); +} +.shelldocs-codeblock-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + background: transparent; + border: 1px solid transparent; + border-radius: 5px; + color: var(--muted-foreground); + cursor: pointer; + transition: color 150ms, background 150ms, border-color 150ms; + padding: 0; + margin-left: auto; +} +.shelldocs-codeblock-copy:hover { + color: var(--foreground); + background: var(--muted); + border-color: var(--border); +} +.shelldocs-codeblock-copy svg { width: 0.875rem; height: 0.875rem; } +.shelldocs-codeblock-copy .icon-check { display: none; color: var(--success); } +.shelldocs-codeblock-copy.copied .icon-copy { display: none; } +.shelldocs-codeblock-copy.copied .icon-check { display: block; } + +.shelldocs-codeblock pre { + margin: 0; + padding: 1rem 1.125rem; + background: transparent; + border: 0; + border-radius: 0; + font-size: 0.85rem; + line-height: 1.7; + overflow-x: auto; +} +.shelldocs-codeblock pre code { + background: transparent; + padding: 0; + border: 0; + font-family: var(--font-mono); + font-size: inherit; + color: var(--foreground); + font-feature-settings: 'ss01', 'cv02'; +} + +/* Prism.js override — let Prism color tokens, keep our frame chrome */ +.shelldocs-codeblock pre[class*="language-"], +.shelldocs-prose pre[class*="language-"] { + background: transparent !important; + margin: 0 !important; + padding: 1rem 1.125rem !important; + border: 0 !important; + border-radius: 0 !important; + font-family: var(--font-mono) !important; + font-size: 0.85rem !important; + line-height: 1.7 !important; + text-shadow: none !important; +} +.shelldocs-codeblock code[class*="language-"], +.shelldocs-prose code[class*="language-"] { + background: transparent !important; + border: 0 !important; + padding: 0 !important; + font-family: var(--font-mono) !important; + font-size: inherit !important; + text-shadow: none !important; +} diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js index 3514324..aaec54d 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs.js +++ b/src/ShellDocs.Components/wwwroot/shelldocs.js @@ -1,9 +1,148 @@ -// ShellDocs JS interop — mirrors the ShellUI pattern: -// window.ShellDocs monolith for classic consumption + ES module exports for dynamic import. -// Real implementation lands with the primitives that need JS (SearchDialog hotkey, scroll-spy, etc.) - window.ShellDocs = window.ShellDocs || {}; -Object.assign(window.ShellDocs, { - // Populated in Phase 1 branches. Kept here as the extension point. -}); +// Re-highlight all code blocks. Called from MarkdownContent after render. +window.shelldocsHighlight = function () { + if (window.Prism) { + try { window.Prism.highlightAll(); } catch (e) {} + } +}; + +// Highlight a specific
 element — used by PreviewFrame when the code tab
+// mounts, so we don't re-scan the whole page on every tab flip.
+window.shelldocsHighlightElement = function (preEl) {
+    if (!preEl || !window.Prism) return;
+    var code = preEl.querySelector('code');
+    if (!code) return;
+    try { window.Prism.highlightElement(code); } catch (e) {}
+};
+
+// Copy-to-clipboard for code blocks.
+window.shelldocsCopyCode = function (button) {
+    var block = button.closest('.shelldocs-codeblock');
+    if (!block) return;
+    var code = block.querySelector('pre code');
+    if (!code) return;
+    var text = code.innerText;
+    var writeText = navigator.clipboard && navigator.clipboard.writeText
+        ? navigator.clipboard.writeText(text)
+        : Promise.reject(new Error('clipboard unavailable'));
+
+    writeText.then(function () {
+        button.classList.add('copied');
+        setTimeout(function () { button.classList.remove('copied'); }, 1400);
+    }).catch(function () {
+        var range = document.createRange();
+        range.selectNodeContents(code);
+        var sel = window.getSelection();
+        sel.removeAllRanges();
+        sel.addRange(range);
+    });
+};
+
+/* Simple TOC scroll spy. IntersectionObserver on the content headings; on
+   change, move .toc-bar to the active TOC link and toggle .active. No masks,
+   no SVGs, no polling. */
+window.shelldocsToc = {
+    attach: function (listEl, ids) {
+        if (!listEl || !ids || ids.length === 0) return null;
+        var bar = listEl.querySelector('.toc-bar');
+
+        // Walk up for the first scrollable ancestor — the TOC slot — so we can
+        // scroll ONLY it (not the window) to keep the active link visible.
+        var scrollHost = (function () {
+            var p = listEl.parentElement;
+            while (p) {
+                var oy = getComputedStyle(p).overflowY;
+                if (oy === 'auto' || oy === 'scroll') return p;
+                p = p.parentElement;
+            }
+            return null;
+        })();
+
+        function keepVisible(link) {
+            if (!scrollHost || !link) return;
+            var lr = link.getBoundingClientRect();
+            var cr = scrollHost.getBoundingClientRect();
+            var pad = 24;
+            if (lr.top < cr.top + pad) {
+                scrollHost.scrollBy({ top: lr.top - cr.top - pad, behavior: 'smooth' });
+            } else if (lr.bottom > cr.bottom - pad) {
+                scrollHost.scrollBy({ top: lr.bottom - cr.bottom + pad, behavior: 'smooth' });
+            }
+        }
+
+        var visible = new Set();
+        var currentActive = null;
+
+        function update() {
+            // Pick the topmost visible heading. If none visible (between sections),
+            // pick the last heading whose top is above the viewport top.
+            var pick = null;
+            if (visible.size > 0) {
+                var top = Infinity;
+                visible.forEach(function (id) {
+                    var el = document.getElementById(id);
+                    if (!el) return;
+                    var t = el.getBoundingClientRect().top;
+                    if (t < top) { top = t; pick = id; }
+                });
+            } else {
+                var scrollY = window.pageYOffset || 0;
+                for (var i = 0; i < ids.length; i++) {
+                    var el = document.getElementById(ids[i]);
+                    if (!el) continue;
+                    var t = el.getBoundingClientRect().top + scrollY;
+                    if (t <= scrollY + 120) pick = ids[i];
+                    else break;
+                }
+            }
+            if (!pick) pick = ids[0];
+            if (pick === currentActive) return;
+
+            if (currentActive) {
+                var prev = listEl.querySelector('a[data-toc-id="' + currentActive + '"]');
+                if (prev) prev.classList.remove('active');
+            }
+            var next = listEl.querySelector('a[data-toc-id="' + pick + '"]');
+            if (next) {
+                next.classList.add('active');
+                if (bar) {
+                    var li = next.parentElement;
+                    bar.style.transform = 'translateY(' + li.offsetTop + 'px)';
+                    bar.style.height = li.offsetHeight + 'px';
+                }
+                keepVisible(next);
+            }
+            currentActive = pick;
+        }
+
+        var observer = new IntersectionObserver(function (entries) {
+            entries.forEach(function (e) {
+                if (e.isIntersecting) visible.add(e.target.id);
+                else visible.delete(e.target.id);
+            });
+            update();
+        }, { rootMargin: '-80px 0px -70% 0px', threshold: 0 });
+
+        ids.forEach(function (id) {
+            var el = document.getElementById(id);
+            if (el) observer.observe(el);
+        });
+
+        // Initial position — no scroll event yet, so we compute manually.
+        setTimeout(update, 0);
+
+        return {
+            dispose: function () { observer.disconnect(); }
+        };
+    },
+
+    scrollTo: function (id) {
+        var el = document.getElementById(id);
+        if (!el) return;
+        el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+        if (window.history && window.history.replaceState) {
+            window.history.replaceState(null, '', '#' + id);
+        }
+    }
+};
diff --git a/src/ShellDocs.Markdown/CodeBlockEnhancer.cs b/src/ShellDocs.Markdown/CodeBlockEnhancer.cs
new file mode 100644
index 0000000..3748802
--- /dev/null
+++ b/src/ShellDocs.Markdown/CodeBlockEnhancer.cs
@@ -0,0 +1,41 @@
+using System.Text.RegularExpressions;
+
+namespace ShellDocs.Markdown;
+
+public static class CodeBlockEnhancer
+{
+    private static readonly Regex CodeBlock = new(
+        @"
\s*[^>]*)>(?[\s\S]*?)\s*
", + RegexOptions.Compiled); + + private static readonly Regex LanguageClass = new( + @"language-(?[a-zA-Z0-9+#-]+)", + RegexOptions.Compiled); + + public static string Enhance(string html) + { + if (string.IsNullOrEmpty(html)) return html; + return CodeBlock.Replace(html, m => + { + var attrs = m.Groups["attrs"].Value; + var code = m.Groups["code"].Value; + var langMatch = LanguageClass.Match(attrs); + var langName = langMatch.Success ? langMatch.Groups["lang"].Value : ""; + + var langBadge = string.IsNullOrEmpty(langName) + ? "" + : $"{langName}"; + + return $@"
+
+ {langBadge} + +
+
{code}
+
"; + }); + } +} diff --git a/src/ShellDocs.Markdown/MarkdownRenderer.cs b/src/ShellDocs.Markdown/MarkdownRenderer.cs index 7a1dced..beb68f6 100644 --- a/src/ShellDocs.Markdown/MarkdownRenderer.cs +++ b/src/ShellDocs.Markdown/MarkdownRenderer.cs @@ -28,7 +28,7 @@ public RenderedDocument Render(string markdown) var doc = Markdig.Markdown.Parse(processed, _pipeline); var headings = HeadingExtractor.Extract(doc); - var html = doc.ToHtml(_pipeline); + var html = CodeBlockEnhancer.Enhance(doc.ToHtml(_pipeline)); return new RenderedDocument(html, slots, parsed, headings); } diff --git a/tests/ShellDocs.Tests/AddShellDocsTests.cs b/tests/ShellDocs.Tests/AddShellDocsTests.cs new file mode 100644 index 0000000..c366d45 --- /dev/null +++ b/tests/ShellDocs.Tests/AddShellDocsTests.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using ShellDocs.Components; +using ShellDocs.Core; +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +public class AddShellDocsTests +{ + [Fact] + public void AddShellDocs_RegistersCoreServices() + { + var services = new ServiceCollection(); + services.AddShellDocs(o => o.SiteName = "Test"); + var sp = services.BuildServiceProvider(); + + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + } + + [Fact] + public void AddShellDocs_MissingContentRoot_ReturnsEmptyGraph() + { + var services = new ServiceCollection(); + services.AddShellDocs(o => o.ContentRoot = "does-not-exist-" + Guid.NewGuid().ToString("N")); + var sp = services.BuildServiceProvider(); + + var graph = sp.GetRequiredService(); + Assert.Empty(graph.Root.Children); + } + + [Fact] + public void AddShellDocs_RegisteredComponents_FlowIntoTypeRegistry() + { + var services = new ServiceCollection(); + services.AddShellDocs(o => o.RegisterComponent()); + var sp = services.BuildServiceProvider(); + + var registry = sp.GetRequiredService(); + Assert.Equal(typeof(Callout), registry.Resolve("Callout")); + } + + [Fact] + public void ShellDocsOptions_FluentAddNavLink_AppendsInOrder() + { + var options = new ShellDocsOptions() + .AddNavLink("Docs", "/docs") + .AddNavLink("Blog", "/blog"); + + Assert.Equal(2, options.PrimaryNav.Count); + Assert.Equal("Docs", options.PrimaryNav[0].Label); + Assert.Equal("/blog", options.PrimaryNav[1].Href); + } + + public class Callout : Microsoft.AspNetCore.Components.ComponentBase { } +} diff --git a/tests/ShellDocs.Tests/ShellDocs.Tests.csproj b/tests/ShellDocs.Tests/ShellDocs.Tests.csproj index ee33a79..e48a3b1 100644 --- a/tests/ShellDocs.Tests/ShellDocs.Tests.csproj +++ b/tests/ShellDocs.Tests/ShellDocs.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/ShellDocs.Tests/SlotSplitterTests.cs b/tests/ShellDocs.Tests/SlotSplitterTests.cs new file mode 100644 index 0000000..5f8099a --- /dev/null +++ b/tests/ShellDocs.Tests/SlotSplitterTests.cs @@ -0,0 +1,86 @@ +using ShellDocs.Components.Content; +using ShellDocs.Core; +using ShellDocs.Markdown; +using Xunit; + +namespace ShellDocs.Tests; + +public class SlotSplitterTests +{ + public class Fake : Microsoft.AspNetCore.Components.ComponentBase { } + + private static RenderedDocument DocumentWith(string html, params Slot[] slots) => + new(html, slots, new ParsedDocument(new Dictionary(), ""), Array.Empty()); + + [Fact] + public void Split_HtmlOnly_ReturnsSingleHtmlPart() + { + var doc = DocumentWith("

Hello world

"); + var parts = SlotSplitter.Split(doc); + + var only = Assert.Single(parts); + var html = Assert.IsType(only); + Assert.Equal("

Hello world

", html.Html); + } + + [Fact] + public void Split_SlotOnly_ReturnsSingleSlotPart() + { + var slot = new ComponentSlot("sabc", typeof(Fake), new Dictionary(), null); + var doc = DocumentWith( + "
", + slot); + + var parts = SlotSplitter.Split(doc); + + var only = Assert.Single(parts); + var sp = Assert.IsType(only); + Assert.Same(slot, sp.Slot); + } + + [Fact] + public void Split_MixedContent_InterleavesInOrder() + { + var slot1 = new ComponentSlot("s1", typeof(Fake), new Dictionary(), null); + var slot2 = new PreviewSlot("s2", typeof(Fake), new Dictionary(), "code", "razor"); + var html = + "

Before

" + + "
" + + "

Middle

" + + "
" + + "

After

"; + var doc = DocumentWith(html, slot1, slot2); + + var parts = SlotSplitter.Split(doc); + + Assert.Equal(5, parts.Count); + Assert.Equal("

Before

", ((HtmlPart)parts[0]).Html); + Assert.Same(slot1, ((SlotPart)parts[1]).Slot); + Assert.Equal("

Middle

", ((HtmlPart)parts[2]).Html); + Assert.Same(slot2, ((SlotPart)parts[3]).Slot); + Assert.Equal("

After

", ((HtmlPart)parts[4]).Html); + } + + [Fact] + public void Split_UnknownSlotId_DropsPlaceholder() + { + var html = + "

A

" + + "
" + + "

B

"; + var doc = DocumentWith(html); + + var parts = SlotSplitter.Split(doc); + + Assert.Equal(2, parts.Count); + Assert.Equal("

A

", ((HtmlPart)parts[0]).Html); + Assert.Equal("

B

", ((HtmlPart)parts[1]).Html); + } + + [Fact] + public void Split_EmptyHtml_ReturnsEmpty() + { + var doc = DocumentWith(""); + Assert.Empty(SlotSplitter.Split(doc)); + } +}