diff --git a/CHANGELOG.md b/CHANGELOG.md index fab9d3a..745f894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,31 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht ## [Unreleased] +## [0.1.18-alpha] — 2026-10-19 + +Colour presets, `shelldocs preview` for checking a static export locally, package pages on NuGet that render properly, plus fixes for sub-path links, folder titles and entities in component attributes. + +### Added + +- **`shelldocs preview`** ([#43](https://github.com/shellui-dev/shelldocs/issues/43)). Serves the output of `shelldocs build` (`publish/`, or `--output`) on `http://localhost:5050` (`--port`) the way static hosts do: folders serve their `index.html`, unknown URLs get `404.html` with a 404, and an export built with `--base-href` is served under that path (read from the export, or pass `--base-href`). Responses aren't cached, so a rebuild shows on reload. The old `preview ` stub is gone. The CLI now needs the ASP.NET Core runtime, which any machine building a Blazor site already has. +- **Colour presets** ([#44](https://github.com/shellui-dev/shelldocs/issues/44)). `o.Theme = ShellDocsTheme.Harbor` (or `shelldocs init --theme harbor`) swaps the neutral palette for a tinted one: `Harbor` (blue), `Lagoon` (teal), `Forest` (green), `Savanna` (amber on sand), `Ember` (red-orange) or `Jacaranda` (purple). Each covers light and dark mode, tints backgrounds, borders and muted text, and colours the active sidebar item, focus rings, links and other accents, with text at 4.5:1 or better. The default, `Graphite`, is the existing palette. Presets are CSS in `tokens.css` keyed on a `data-shelldocs-theme` attribute the layouts render, so they work in static exports and your own token overrides still win. +- **`CONTRIBUTING.md`** ([#45](https://github.com/shellui-dev/shelldocs/issues/45)): setup, trying a change in the example site and a static export, the static-host rule, and how commits and changelog entries are laid out. + ### Fixed +- **NuGet package pages showed raw HTML.** All six packages shipped the GitHub README, whose centred header and badges are HTML that nuget.org prints as text. Each package now has its own Markdown README, and `scripts/pack-dry-run` fails on HTML in a package README. The PowerShell version of that script also runs on Windows PowerShell 5.1 again. +- **A folder's `meta.json` `title` was ignored** ([#41](https://github.com/shellui-dev/shelldocs/issues/41)). The folder's section in the sidebar, breadcrumb and search took its title from the folder name (`maui` → "Maui"). It now uses the folder's own `meta.json` `title` when set, and falls back to the name otherwise. Sites whose folders set a `title` will see it, e.g. a top-level `docs/meta.json` titled "Documentation" now reads that way instead of "Docs". +- **HTML entities in component attributes showed literally** ([#42](https://github.com/shellui-dev/shelldocs/issues/42)). `` in Markdown or a `razor:preview` fence now passes "Tom & Jerry", as Razor does. `@` expressions, directive attributes (`@onclick`, `@bind-…`) and code spans inside a value stay as written. - **Links broke on sites served from a sub-path** ([#40](https://github.com/shellui-dev/shelldocs/issues/40)). Sidebar, breadcrumb, header, prev/next, content and favicon links are root-relative (`/docs/introduction`), which ignores ``, so an export built with `--base-href /my-repo/` linked outside the site. `shelldocs build --base-href` now also puts the base in front of root-relative `href`, `src` and `data-src` values in every exported page. +### Changed + +- **`ShellDocsTheme.Shadcn`, `Fuma` and `Nextra` are obsolete** aliases of `Graphite`. They never changed the palette; `Fuma` and `Nextra` promised a look that a palette can't give. `shelldocs init --theme` takes the new preset names. + +### Removed + +- **`SearchState`, `SidebarCollapseState` and `MobileNavState`** ([#46](https://github.com/shellui-dev/shelldocs/issues/46)). Search, the sidebar collapse and the mobile drawer run in `shelldocs.js`, so nothing used these services; `AddShellDocs` no longer registers them. Code that injects one needs its own copy. + ## [0.1.17-alpha] — 2026-10-14 llms.txt for AI agents, and five fixes found while writing the ShellDocs docs site. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0d6ac75 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to ShellDocs + +Thanks for helping. Bug reports, fixes and docs improvements are all welcome. For anything bigger than a fix, open an issue first so we can agree on the shape before you write it. + +ShellDocs is in alpha: minor versions may break APIs until `1.0`. + +## Setup + +You need the .NET 10 SDK (the version is pinned in `global.json`). + +```bash +dotnet build shelldocs.slnx +dotnet test shelldocs.slnx +``` + +CI runs the same two commands in Release, and a pull request should pass both with no new warnings. + +## Trying a change + +`examples/ShellDocs.Preview` is a docs site wired to the projects in this repo, so changes show up without packing anything: + +```bash +dotnet run --project examples/ShellDocs.Preview +``` + +Anything in the chrome or in `shelldocs.js` also has to work in a static export. Build one with the CLI from source into the example's `publish/` (ignored by git) and serve it: + +```bash +dotnet run --project src/ShellDocs.CLI -- build --dir examples/ShellDocs.Preview +dotnet run --project src/ShellDocs.CLI -- preview --dir examples/ShellDocs.Preview +``` + +Add `--base-href /sub/` to the build to check a site served from a sub-path. + +## The static-host rule + +Static exports have no Blazor runtime, so `@onclick` handlers and component state do nothing there. Chrome that must work on a static host follows one pattern: + +- the server renders the initial state into `data-*` attributes and classes; +- `shelldocs.js` handles the interaction with delegated, document-level listeners; +- state that a Blazor circuit replaces when it starts is restored by `shelldocs.js`. + +`docs/ARCHITECTURE.md` describes how each piece of chrome does this. Interactive Blazor is fine for demos and previews, which are allowed to need a running app. + +## Commits and pull requests + +- Branch off `main` and open the pull request against it. +- Use [Conventional Commits](https://www.conventionalcommits.org/): `fix(markdown): …`, `feat(cli): …`, `docs: …`, `chore: …`. +- One logical change per commit, and each commit builds and passes the tests on its own. +- Add tests for what you fix or add (`tests/ShellDocs.Tests`). +- Add a line to `CHANGELOG.md` under `[Unreleased]`, saying what changed for someone using ShellDocs. +- Update `README.md` or `docs/ARCHITECTURE.md` when behaviour they describe changes. +- Keep code comments to what isn't obvious from the code. + +Releases are cut by the maintainers (see `docs/RELEASING.md`). + +## License + +By contributing you agree that your contributions are licensed under the [MIT License](LICENSE). diff --git a/Directory.Build.props b/Directory.Build.props index 7208d2a..9d0a536 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,7 +17,7 @@ - 0.1.17-alpha + 0.1.18-alpha ShellUI ShellUI Copyright © 2026 ShellUI @@ -39,7 +39,9 @@ - + + diff --git a/README.md b/README.md index c926943..a424c42 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ shelldocs add guide getting-started # Run with hot reload on http://localhost:5000 shelldocs dev -# Prerender a static site into publish/ +# Prerender a static site into publish/, then serve it as a static host would shelldocs build +shelldocs preview ``` Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds the packages and content, and writes `SHELLDOCS_SETUP.md` with the `Program.cs` / `App.razor` snippets instead of editing your code. @@ -51,6 +52,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds - **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 at startup and searched in the browser, so there's no external service and it works in the static export too. - **llms.txt for AI agents.** `o.EnableLlmsTxt()` serves `/llms.txt`, `/llms-full.txt` and every page as Markdown at its URL + `.md`, plus `AGENTS.md`, `CLAUDE.md`, Copilot and Cursor instruction files, and adds a **Copy page / View as Markdown / Open in Claude / Open in ChatGPT** menu by the title. See [AI agents](#ai-agents-llmstxt). +- **Colour presets.** `o.Theme = ShellDocsTheme.Harbor`: Graphite (default), Harbor, Lagoon, Forest, Savanna, Ember or Jacaranda, in light and dark. Or override the tokens yourself. - **Blazor-native.** Your components render as real Razor components, not iframes. - **Composable.** Bring your own component library (ShellUI, MudBlazor, Radzen, hand-rolled) and register it in one line: ```csharp @@ -58,7 +60,7 @@ Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds ``` Your components win name collisions with ShellDocs' built-ins, which stay available as ``, ``, ``, and so on. - **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`. -- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `` and root-relative links for a sub-path (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections and collapse, the mobile menu, selectors, tabs, preview toolbars, the TOC, code copy, the theme toggle and search work there through `shelldocs.js`. Only stateful demos need a running Blazor app. Unknown pages answer 404 (`` in the docs page) and `build` writes the same view as `404.html`. +- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `` and root-relative links for a sub-path (`--base-href`), add a SPA `404.html` (`--spa-fallback`), and write sitemap / robots / `og:` meta (`--site-url`). Navigation, sidebar sections and collapse, the mobile menu, selectors, tabs, preview toolbars, the TOC, code copy, the theme toggle and search work there through `shelldocs.js`. Only stateful demos need a running Blazor app. Unknown pages answer 404 (`` in the docs page) and `build` writes the same view as `404.html`. `shelldocs preview` serves the export locally, under the `--base-href` path if it has one, to check it before deploying. ## Versioned docs @@ -170,7 +172,7 @@ The Markdown is the page source with ShellDocs tags turned into Markdown: demos ## Contributing -The alpha is API-fluid: we're taking freedom to break minor versions until `1.0`. Bug reports and dogfood-driven fixes are welcome via issues. A proper `CONTRIBUTING.md` lands with the `0.2.0-alpha` cut. +The alpha is API-fluid: we're taking freedom to break minor versions until `1.0`. Bug reports and fixes are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) for setup, the static-host rule and how commits are laid out. ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19197fb..eea92d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -79,7 +79,7 @@ The `shelldocs` global tool and the file content it writes. See [CLI](#cli). `AddShellDocs` (in `ServiceCollectionExtensions.cs`): 1. Builds `ShellDocsOptions` and runs the consumer's `configure` callback. -2. Registers the scoped chrome state (`MobileNavState`, `ThemeState`, `SearchState`, `SidebarCollapseState`, `CodeGroupSyncState`, `DocsPageState`). +2. Registers the scoped chrome state (`ThemeState`, `CodeGroupSyncState`, `DocsPageState`). 3. Adds every component in `ShellDocs.Components.Content` as a built-in. 4. Registers singletons: `TypeRegistry` (built lazily from the options, collisions logged), `MarkdownRenderer`, `NavigationGraph` (empty graph if `ContentRoot` doesn't exist), `SearchIndex`, `DocsVersionResolver`. @@ -144,6 +144,7 @@ Component child markup is rendered recursively. Inline tags in prose take markdo - Every `.md` file is a page; `index.md` takes the folder's URL. Frontmatter supplies `title` (else derived from the slug), `description`, `category`, `order`. - Every subfolder is a section titled from its name. Folder names keep their dots (`v0.2.1`). +- A folder's section takes the `title` from its own `meta.json`, else one made from the folder name. - Without `meta.json`: subfolders first, then pages by `order` and title. - With `meta.json`: `pages` sets the order for what it lists: a slug, `"---"` for a divider, or `{ "title", "pages" }` for a subsection. Unlisted files and folders are appended alphabetically, and unknown slugs are ignored. `hidden` entries route but stay out of the sidebar and prev/next. @@ -242,17 +243,19 @@ Chrome that must work without a Blazor runtime follows one contract: ## Theming -One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens.css` (`:root` and `:root.dark`). Component styles are scoped CSS reading those variables, plus `shelldocs-theme.css` for global prose, code-block and Shiki styling. Restyle by overriding variables in a stylesheet loaded after `tokens.css`. `ShellDocsOptions.Theme` exists but doesn't switch palettes yet. +A neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens.css` (`:root` and `:root.dark`), called Graphite. Component styles are scoped CSS reading those variables, plus `shelldocs-theme.css` for global prose, code-block and Shiki styling. Restyle by overriding variables in a stylesheet loaded after `tokens.css`. + +Colour presets (`ShellDocsOptions.Theme`: Harbor, Lagoon, Forest, Savanna, Ember, Jacaranda) are further blocks in `tokens.css`. `DocsLayout` and `HomeLayout` put `data-shelldocs-theme="harbor"` on their shell (nothing for Graphite), and `:root:has(:where([data-shelldocs-theme="harbor"]))` / `:root.dark:has(…)` set every colour token from it. The `:where()` keeps those rules at the specificity of `:root` / `:root.dark`, so a later stylesheet still overrides them, and the variables sit on `:root`, so the body, previews and the search dialog cloned onto `` follow too. Each preset tints the neutrals with its hue and sets `--primary`, `--accent` / `--accent-foreground` (the active sidebar item) and `--ring`; text pairs are at least 4.5:1 in light and dark. --- ## CLI -- `shelldocs init [path] [--attach] [--dir] [--yes] [--theme]` — create mode runs `dotnet new blazor` (default `docs/.Docs`), adds the packages at the CLI's own version, writes starter content and patches `Program.cs` / `App.razor`. Attach mode augments an existing project and writes `SHELLDOCS_SETUP.md` instead of editing code. Both are idempotent. +- `shelldocs init [path] [--attach] [--dir] [--yes] [--theme graphite|harbor|…]` — create mode runs `dotnet new blazor` (default `docs/.Docs`), adds the packages at the CLI's own version, writes starter content and patches `Program.cs` / `App.razor`. Attach mode augments an existing project and writes `SHELLDOCS_SETUP.md` instead of editing code. Both are idempotent. - `shelldocs add [--force]` — scaffolds a page into `content/docs/`. - `shelldocs dev [--port 5000]` — `dotnet watch run`, with `content/**/*.md` added to the watch set. - `shelldocs build [--output publish] [--base-href] [--spa-fallback] [--site-url]` — see below. -- `shelldocs preview` — reserved, not implemented. +- `shelldocs preview [--output publish] [--port 5050] [--base-href]` — serves an export with Kestrel as static hosts do: a file, else the folder's `index.html`, else `{path}.html`, else `404.html` with a 404. The base path defaults to the export's ``; responses are `no-cache` so a rebuild shows on reload. ### Static site generation diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1fb1554..0e92900 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,7 @@ Living design doc. Updated as decisions land. -> **Status:** this is the original product design. Several parts landed differently — search builds its index in memory and needs a running Blazor app, previews use a Preview | Code toolbar, there is a single token palette (no Fuma / Nextra presets yet), and ShellDocs doesn't depend on `ShellUI.Components`. For what's actually built see [ARCHITECTURE.md](ARCHITECTURE.md) and the [CHANGELOG](../CHANGELOG.md). +> **Status:** this is the original product design. Several parts landed differently — search builds its index in memory and needs a running Blazor app, previews use a Preview | Code toolbar, colour presets (Harbor, Lagoon, …) replaced the Fuma / Nextra theme idea, and ShellDocs doesn't depend on `ShellUI.Components`. For what's actually built see [ARCHITECTURE.md](ARCHITECTURE.md) and the [CHANGELOG](../CHANGELOG.md). --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ebce0b9..6597e71 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -209,7 +209,7 @@ Ships to `ShellDocs.Components` + `ShellDocs.Templates` + `ShellDocs.CLI` + `She **Authoring fix (`ShellDocs.Markdown`)** - `SlotExtractor.ReplaceComponentTags` no longer `.Trim()`s the raw child content of inline component tags. The Trim was stripping the first line's indent and defeating `SlotRenderer.Dedent` — Markdig then interpreted the remaining 4-space-indented lines as an indented code block. Symptom was the same "literal `
` around placeholder divs" bug that had already been fixed for `razor:preview` fences; the inline-tag code path was still hitting it.
 
-### ✅ Hardening releases (`0.1.3`–`0.1.17-alpha`) — shipped
+### ✅ Hardening releases (`0.1.3`–`0.1.18-alpha`) — shipped
 Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHANGELOG):
 
 - `feat/build-static-prerender` (0.1.5) — `shelldocs build` prerenders every URL into a static site.
@@ -224,6 +224,7 @@ Driven by building the ShellUI docs on ShellDocs. Highlights (details in the CHA
 - `feat/static-search` (0.1.15) — search runs in `shelldocs.js` against `/_shelldocs/search.json`, which `build` copies into the export, so static sites have search.
 - `fix/iframe-preview-navigation` (0.1.16) — an `` reused for the next page after in-site navigation is unloaded and set up again.
 - `fix/docs-site-findings` (0.1.17) — llms.txt for AI agents (`EnableLlmsTxt`: `/llms.txt`, `/llms-full.txt`, per-page `.md`, agent instruction files, page actions menu, static export); HTML-only `razor:preview` fences render live, `404.html` shows the visitor's path, `LogoSvg` sizing, search on `HomeLayout`, spacing in the missing-tab note.
+- `fix/base-href-links`, `fix/nuget-readme-and-issues` (0.1.18) — `shelldocs preview` serves an export locally; Markdown READMEs per package for nuget.org; `--base-href` prefixes root-relative links in the export; folder sections use their `meta.json` `title`; entities in component attribute values are decoded; colour presets (`o.Theme`: Graphite, Harbor, Lagoon, Forest, Savanna, Ember, Jacaranda) in place of the never-built Fuma / Nextra; unused chrome state services removed; CONTRIBUTING.md.
 - `fix/preview-fidelity`, `feat/generics-redirects-page-title` (0.1.10) — `not-prose` previews, Razor child content, stretch layout, generic components in markdown, redirects (middleware + static pages), `RenderPageTitle`, theme sync and a static-host `ThemeToggle`.
 
 ### `feat/animation-polish`
@@ -298,6 +299,8 @@ Ships to new package `ShellDocs.Xml`.
 
 ## Phase 5 — Ecosystem push (ongoing)
 
+*Replaced in 0.1.18 by colour presets in `ShellDocs.Tokens` (`o.Theme`: Graphite, Harbor, Lagoon, Forest, Savanna, Ember, Jacaranda). Presets that also change layout or typography would still be new work.*
+
 ### `feat/theme-fuma`
 Ships to new package `ShellDocs.Themes.Fuma`.
 
diff --git a/docs/TOKENS.md b/docs/TOKENS.md
index 3eb5bc1..ec7f86c 100644
--- a/docs/TOKENS.md
+++ b/docs/TOKENS.md
@@ -127,6 +127,19 @@ Deferred to `feat/shellui-nuget-interop` (Phase 3). ShellUI's RCL will offer an
 
 You can depend on `ShellDocs.Tokens` alone if you want *just the palette* for a custom-styled Blazor site — no ShellDocs sidebar, no ShellDocs header. The tokens are all you get.
 
+## Colour presets
+
+`tokens.css` also holds six presets: Harbor (blue), Lagoon (teal), Forest (green), Savanna (amber on sand), Ember (red-orange) and Jacaranda (purple). Each is a light and a dark block that sets every colour token above:
+
+```css
+:root:has(:where([data-shelldocs-theme="harbor"])) { --primary: oklch(0.520 0.170 255); /* … */ }
+:root.dark:has(:where([data-shelldocs-theme="harbor"])) { /* … */ }
+```
+
+`ShellDocsOptions.Theme` makes the layouts render the attribute; without it you get the default palette above (Graphite). Using `ShellDocs.Tokens` on its own, put `data-shelldocs-theme="harbor"` on any element of the page. The `:where()` keeps these rules at the specificity of `:root` and `:root.dark`, so overrides in a later stylesheet still win.
+
+Neutrals are tinted with the preset's hue at low chroma; `--primary` and `--ring` are the brand colour, `--accent` is the brand at 12% (16% in dark) and `--accent-foreground` is a darker (lighter in dark) brand for text on it. Every text pair is at least 4.5:1, including `--primary` as text on `--background`. The semantic colours (`--info`, `--warning`, `--error`, `--success`) don't change.
+
 ## Stability
 
 - **Names** — stable across major versions. Renames are breaking.
diff --git a/examples/ShellDocs.Preview/content/docs/installation.md b/examples/ShellDocs.Preview/content/docs/installation.md
index 3e79bd6..db689c7 100644
--- a/examples/ShellDocs.Preview/content/docs/installation.md
+++ b/examples/ShellDocs.Preview/content/docs/installation.md
@@ -58,3 +58,5 @@ shelldocs build
 In the static output, navigation, sidebar sections and collapse, the mobile menu, the version and package selectors, tabs, preview tabs and menus, the table of contents, the theme toggle, search and code copy all work through `shelldocs.js`. Stateful demo components still need a running Blazor Server app (interactive server rendering). With `EnableLlmsTxt`, the export also has `llms.txt`, `llms-full.txt` and every page as `.md`.
 
 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.
+
+Check the export before deploying with `shelldocs preview`. It serves `publish/` (or `--output`) at `http://localhost:5050` the way static hosts do: folders serve their `index.html`, unknown URLs get `404.html`, and an export built with `--base-href` is served under that path.
diff --git a/examples/ShellDocs.Preview/content/docs/theming.md b/examples/ShellDocs.Preview/content/docs/theming.md
index a96b9bb..7c3240b 100644
--- a/examples/ShellDocs.Preview/content/docs/theming.md
+++ b/examples/ShellDocs.Preview/content/docs/theming.md
@@ -6,7 +6,28 @@ order: 1
 
 # Theming
 
-ShellDocs ships one neutral, shadcn-shaped palette, defined as CSS custom properties in `ShellDocs.Tokens` (`tokens.css`) that every component reads from. `ShellDocsOptions.Theme` (and `shelldocs init --theme`) is reserved for future presets and doesn't change the palette yet; restyle by overriding tokens.
+ShellDocs' palette is a set of CSS custom properties in `ShellDocs.Tokens` (`tokens.css`) that every component reads from. The default is Graphite, a neutral, shadcn-shaped palette; colour presets swap it for a tinted one, and you can override any token yourself.
+
+## Colour presets
+
+```csharp
+builder.Services.AddShellDocs(o =>
+{
+    o.Theme = ShellDocsTheme.Harbor;
+});
+```
+
+| Preset | Colour |
+|---|---|
+| `Graphite` | Neutral greys (the default) |
+| `Harbor` | Blue |
+| `Lagoon` | Teal |
+| `Forest` | Green |
+| `Savanna` | Amber on sand |
+| `Ember` | Red-orange |
+| `Jacaranda` | Purple |
+
+Each preset tints the backgrounds, borders and muted text with its hue and uses its colour for the active sidebar item, focus rings, links and other accents, in light and dark mode. Text stays at a contrast of at least 4.5:1. Component previews follow the preset too, as long as the components read the same tokens. `shelldocs init --theme harbor` writes the line for you.
 
 ## The token layer
 
@@ -22,7 +43,7 @@ Every component reads from a small set of tokens:
 | `--primary` / `--primary-foreground` | Emphasis surfaces (CTAs) |
 | `--radius` | Corner radius scale |
 
-Override any of these in a stylesheet loaded after `tokens.css` and every component follows. The full list is in `docs/TOKENS.md`.
+Override any of these in a stylesheet loaded after `tokens.css` and every component follows, with or without a preset. The full list is in `docs/TOKENS.md`.
 
 ## Dark mode
 
diff --git a/scripts/pack-dry-run.ps1 b/scripts/pack-dry-run.ps1
index 12859df..cf61e53 100644
--- a/scripts/pack-dry-run.ps1
+++ b/scripts/pack-dry-run.ps1
@@ -1,10 +1,11 @@
 # Packs every IsPackable=true project into ./nupkgs-dryrun/ and validates each
-# .nupkg — verifies README embed, checks size, prints package ID + version.
+# .nupkg - verifies README embed, checks size, prints package ID + version.
 # Run from repo root.
 #
 #   ./scripts/pack-dry-run.ps1
 
 $ErrorActionPreference = "Stop"
+Add-Type -AssemblyName System.IO.Compression.FileSystem
 $repoRoot = Split-Path -Parent $PSScriptRoot
 Set-Location $repoRoot
 
@@ -30,9 +31,10 @@ foreach ($nupkg in Get-ChildItem $outDir -Filter *.nupkg | Sort-Object Name) {
     $sizeKB = [math]::Round($nupkg.Length / 1KB, 1)
     Write-Host ("  {0}  ({1} KB)" -f $nupkg.Name, $sizeKB)
 
-    # A .nupkg is a zip — extract to a temp dir to inspect.
+    # A .nupkg is a zip - extract to a temp dir to inspect.
     $tmp = Join-Path ([IO.Path]::GetTempPath()) ("nupkg-check-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
-    Expand-Archive -Path $nupkg.FullName -DestinationPath $tmp -Force
+    # Expand-Archive in Windows PowerShell 5.1 only takes .zip names.
+    [System.IO.Compression.ZipFile]::ExtractToDirectory($nupkg.FullName, $tmp)
 
     # Every packable project ships README.md via .
     $readme = Get-ChildItem $tmp -Filter README.md -Recurse | Select-Object -First 1
@@ -40,6 +42,11 @@ foreach ($nupkg in Get-ChildItem $outDir -Filter *.nupkg | Sort-Object Name) {
         Write-Host "    MISSING README.md" -ForegroundColor Red
         $fail++
     }
+    # nuget.org shows HTML tags as text.
+    elseif (Select-String -Path $readme.FullName -Pattern '<(p|picture|img|h[1-6]|div|a|br)[ >/]' -Quiet) {
+        Write-Host "    README.md has HTML; nuget.org shows it as text" -ForegroundColor Red
+        $fail++
+    }
 
     # Sanity: nuspec present with expected version.
     $nuspec = Get-ChildItem $tmp -Filter *.nuspec | Select-Object -First 1
@@ -58,7 +65,7 @@ if ($fail -gt 0) {
     Write-Host "$fail package(s) failed validation" -ForegroundColor Red
     exit 1
 }
-Write-Host "OK — all packages passed validation" -ForegroundColor Green
+Write-Host "OK - all packages passed validation" -ForegroundColor Green
 Write-Host ""
 Write-Host "Ship it with:"
 Write-Host "  git tag v"
diff --git a/scripts/pack-dry-run.sh b/scripts/pack-dry-run.sh
index a4fc2df..b46df49 100644
--- a/scripts/pack-dry-run.sh
+++ b/scripts/pack-dry-run.sh
@@ -35,6 +35,10 @@ for nupkg in "$out_dir"/*.nupkg; do
     if ! find "$tmp" -name README.md -print -quit | grep -q .; then
         echo "    MISSING README.md" >&2
         fail=$((fail + 1))
+    # nuget.org shows HTML tags as text.
+    elif grep -Eq '<(p|picture|img|h[1-6]|div|a|br)[ >/]' "$tmp/README.md"; then
+        echo "    README.md has HTML; nuget.org shows it as text" >&2
+        fail=$((fail + 1))
     fi
 
     nuspec="$(find "$tmp" -name '*.nuspec' | head -n 1)"
diff --git a/src/ShellDocs.CLI/Commands/InitCommand.cs b/src/ShellDocs.CLI/Commands/InitCommand.cs
index 478f7c0..25bd88e 100644
--- a/src/ShellDocs.CLI/Commands/InitCommand.cs
+++ b/src/ShellDocs.CLI/Commands/InitCommand.cs
@@ -84,7 +84,7 @@ private static int CreateMode(string? explicitPath, string cwd, bool yes, string
         ScaffoldContent(outputAbs, changes);
         ScaffoldDocsPage(outputAbs, changes);
         StripFreshTemplate(outputAbs, changes);
-        PatchProgramCs(outputAbs, siteName, githubRepo, changes);
+        PatchProgramCs(outputAbs, siteName, githubRepo, theme, changes);
         PatchAppRazor(outputAbs, changes);
         RegisterWithSolution(csproj, cwdAbs, changes);
 
@@ -219,7 +219,7 @@ private static int AttachMode(string dir, bool yes, string theme)
         ScaffoldDocsPage(root, changes);
         WriteIfMissing(
             Path.Combine(root, "SHELLDOCS_SETUP.md"),
-            ScaffoldTemplates.SetupInstructionsMd(siteName, githubRepo),
+            ScaffoldTemplates.SetupInstructionsMd(siteName, githubRepo, theme),
             changes);
 
         AnsiConsole.WriteLine();
@@ -352,7 +352,7 @@ internal static void StripFreshTemplate(string projectRoot, List changes
     }
 
     // Anchor-based insertion is safe here: we know the fresh template's shape.
-    internal static void PatchProgramCs(string projectRoot, string siteName, string githubRepo, List changes)
+    internal static void PatchProgramCs(string projectRoot, string siteName, string githubRepo, string theme, List changes)
     {
         var path = Path.Combine(projectRoot, "Program.cs");
         if (!File.Exists(path)) return;
@@ -390,7 +390,7 @@ internal static void PatchProgramCs(string projectRoot, string siteName, string
             {
                 var insertAt = m.Index + m.Length;
                 src = src.Insert(insertAt,
-                    Environment.NewLine + Environment.NewLine + ScaffoldTemplates.ProgramAddShellDocs(siteName, githubRepo));
+                    Environment.NewLine + Environment.NewLine + ScaffoldTemplates.ProgramAddShellDocs(siteName, githubRepo, theme));
             }
         }
 
diff --git a/src/ShellDocs.CLI/Commands/PreviewCommand.cs b/src/ShellDocs.CLI/Commands/PreviewCommand.cs
new file mode 100644
index 0000000..407be4d
--- /dev/null
+++ b/src/ShellDocs.CLI/Commands/PreviewCommand.cs
@@ -0,0 +1,105 @@
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace ShellDocs.CLI.Commands;
+
+// `shelldocs preview` serves a `shelldocs build` export the way static hosts do,
+// so it can be checked before deploying.
+internal static class PreviewCommand
+{
+    private static readonly FileExtensionContentTypeProvider ContentTypes = new();
+
+    public static int Run(string dir, string output, int port, string? baseHref)
+    {
+        var root = Path.GetFullPath(Path.Combine(dir, output));
+        if (!File.Exists(Path.Combine(root, "index.html")))
+        {
+            AnsiConsole.MarkupLine($"[red]error:[/] no static site in [yellow]{root}[/]");
+            AnsiConsole.MarkupLine("[dim]run [cyan]shelldocs build[/] first, or point --output at its output folder.[/]");
+            return 1;
+        }
+
+        var basePath = NormalizeBase(baseHref ?? ReadBaseHref(Path.Combine(root, "index.html")));
+        var url = $"http://localhost:{port}{basePath}";
+        AnsiConsole.MarkupLine($"[dim]shelldocs preview →[/] [cyan]{root}[/]");
+        if (basePath != "/") AnsiConsole.MarkupLine($"[dim]base href:[/] [cyan]{basePath}[/]");
+        AnsiConsole.MarkupLine($"[green]serving[/] on [cyan]{url}[/] [dim](Ctrl+C to stop)[/]");
+
+        var builder = WebApplication.CreateSlimBuilder();
+        builder.WebHost.UseUrls($"http://localhost:{port}");
+        builder.Logging.ClearProviders();
+        var app = builder.Build();
+        app.Run(context => Serve(context, root, basePath));
+        app.Run();
+        return 0;
+    }
+
+    private static async Task Serve(HttpContext context, string root, string basePath)
+    {
+        var path = context.Request.Path.Value ?? "/";
+        if (basePath != "/" && path == "/")
+        {
+            context.Response.Redirect(basePath);
+            return;
+        }
+
+        var (file, status) = Resolve(root, basePath, path);
+        context.Response.StatusCode = status;
+        // A rebuild should show on the next reload.
+        context.Response.Headers.CacheControl = "no-cache";
+        if (file is null)
+        {
+            await context.Response.WriteAsync("404 Not Found");
+            return;
+        }
+        context.Response.ContentType = ContentTypes.TryGetContentType(file, out var type) ? type : "application/octet-stream";
+        await context.Response.SendFileAsync(file);
+    }
+
+    /* Like GitHub Pages, Netlify and Cloudflare Pages: a file, else the folder's
+       index.html, else {path}.html, else 404.html with a 404 status. */
+    internal static (string? File, int Status) Resolve(string root, string basePath, string requestPath)
+    {
+        root = Path.GetFullPath(root);
+        var notFound = Path.Combine(root, "404.html");
+        (string?, int) NotFound() => (File.Exists(notFound) ? notFound : null, StatusCodes.Status404NotFound);
+
+        var path = Uri.UnescapeDataString(requestPath);
+        if (basePath != "/")
+        {
+            if (path + "/" == basePath) path = basePath;
+            if (!path.StartsWith(basePath, StringComparison.Ordinal)) return NotFound();
+            path = "/" + path[basePath.Length..];
+        }
+
+        var full = Path.GetFullPath(Path.Combine(root, path.TrimStart('/')));
+        // Nothing outside the export, whatever the request says (`/../`).
+        if (full != root && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+            return NotFound();
+
+        if (File.Exists(full)) return (full, StatusCodes.Status200OK);
+        var index = Path.Combine(full, "index.html");
+        if (Directory.Exists(full) && File.Exists(index)) return (index, StatusCodes.Status200OK);
+        var html = full.TrimEnd(Path.DirectorySeparatorChar) + ".html";
+        if (File.Exists(html)) return (html, StatusCodes.Status200OK);
+        return NotFound();
+    }
+
+    // "/my-repo" or "my-repo/" → "/my-repo/".
+    internal static string NormalizeBase(string? baseHref)
+    {
+        var trimmed = (baseHref ?? "").Trim().Trim('/');
+        return trimmed.Length == 0 ? "/" : "/" + trimmed + "/";
+    }
+
+    private static string? ReadBaseHref(string indexHtml)
+    {
+        var match = Regex.Match(File.ReadAllText(indexHtml), @"("--yes") { Description = "Non-interactive mode with default options." };
         var theme = new Option("--theme")
         {
-            Description = "Theme preset: shadcn, fuma, nextra.",
-            DefaultValueFactory = _ => "shadcn"
+            Description = "Colour preset: " + string.Join(", ", ShellDocs.Templates.ScaffoldTemplates.ThemePresets) + ".",
+            DefaultValueFactory = _ => "graphite"
         };
+        theme.AcceptOnlyFromAmong(ShellDocs.Templates.ScaffoldTemplates.ThemePresets);
         var cmd = new Command("init", "Scaffold a new ShellDocs site (default) or attach to an existing Blazor project.")
         {
             path, dir, attach, yes, theme
@@ -62,7 +63,7 @@ private static Command CreateInitCommand()
                 pr.GetValue(dir) ?? Directory.GetCurrentDirectory(),
                 pr.GetValue(attach),
                 pr.GetValue(yes),
-                pr.GetValue(theme) ?? "shadcn");
+                pr.GetValue(theme) ?? "graphite");
         });
         return cmd;
     }
@@ -149,12 +150,35 @@ private static Command CreateBuildCommand()
 
     private static Command CreatePreviewCommand()
     {
-        var name = new Argument("name") { Description = "Component name to preview." };
-        var cmd = new Command("preview", "Render a single component in isolation for design review.") { name };
-        cmd.SetAction(pr =>
+        var dir = new Option("--dir")
         {
-            AnsiConsole.MarkupLine($"[yellow]shelldocs preview {pr.GetValue(name)}[/] — not yet implemented.");
-        });
+            Description = "Project directory (default: current dir).",
+            DefaultValueFactory = _ => Directory.GetCurrentDirectory()
+        };
+        var output = new Option("--output")
+        {
+            Description = "The static site to serve, as passed to shelldocs build.",
+            DefaultValueFactory = _ => "publish"
+        };
+        var port = new Option("--port")
+        {
+            Description = "Port to bind on.",
+            DefaultValueFactory = _ => 5050
+        };
+        var baseHref = new Option("--base-href")
+        {
+            Description = "Serve under this path (default: the  the build wrote)."
+        };
+        var cmd = new Command("preview", "Serve the output of shelldocs build locally, as a static host would.")
+        {
+            dir, output, port, baseHref
+        };
+        cmd.SetAction(pr =>
+            PreviewCommand.Run(
+                pr.GetValue(dir) ?? Directory.GetCurrentDirectory(),
+                pr.GetValue(output) ?? "publish",
+                pr.GetValue(port),
+                pr.GetValue(baseHref)));
         return cmd;
     }
 }
diff --git a/src/ShellDocs.CLI/README.md b/src/ShellDocs.CLI/README.md
new file mode 100644
index 0000000..dceeb65
--- /dev/null
+++ b/src/ShellDocs.CLI/README.md
@@ -0,0 +1,51 @@
+![ShellDocs](https://raw.githubusercontent.com/shellui-dev/shelldocs/main/assets/readme-logo.svg)
+
+# ShellDocs CLI
+
+`shelldocs` scaffolds, runs and exports [ShellDocs](https://shelldocs.shellui.dev) sites: Markdown-driven documentation for .NET with live Blazor component previews, versioned docs, `Cmd+K` search and a static export.
+
+## Install
+
+The tool needs the .NET 10 runtime:
+
+```bash
+dotnet tool install --global ShellDocs.CLI --prerelease
+```
+
+## Quick start
+
+```bash
+# From your repo root: scaffold a Blazor Web App in docs/.Docs
+shelldocs init
+cd docs/.Docs
+
+# Add pages (or drop .md files into content/docs/)
+shelldocs add component Button
+shelldocs add guide getting-started
+
+# Run with hot reload on http://localhost:5000
+shelldocs dev
+
+# Prerender a static site into publish/, then serve it as a static host would
+shelldocs build
+shelldocs preview
+```
+
+Already have a Blazor project? Run `shelldocs init --attach` inside it: it adds the packages and content, and writes `SHELLDOCS_SETUP.md` with the `Program.cs` / `App.razor` snippets instead of editing your code.
+
+## Commands
+
+| Command | What it does |
+|---|---|
+| `shelldocs init` | Scaffolds a docs site, or adds ShellDocs to an existing Blazor project with `--attach` |
+| `shelldocs add` | Adds a page from a template (`component`, `guide`, …) |
+| `shelldocs dev` | Runs the site with hot reload |
+| `shelldocs build` | Prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. `--base-href` for a sub-path, `--site-url` for sitemap, robots and `og:` meta |
+| `shelldocs preview` | Serves the export on `http://localhost:5050` as a static host would, to check it before deploying |
+
+## Links
+
+- Docs: [shelldocs.shellui.dev](https://shelldocs.shellui.dev)
+- Source, issues and changelog: [github.com/shellui-dev/shelldocs](https://github.com/shellui-dev/shelldocs)
+
+Alpha: APIs may change between minor versions. MIT licensed.
diff --git a/src/ShellDocs.CLI/ShellDocs.CLI.csproj b/src/ShellDocs.CLI/ShellDocs.CLI.csproj
index 7577d52..ba30994 100644
--- a/src/ShellDocs.CLI/ShellDocs.CLI.csproj
+++ b/src/ShellDocs.CLI/ShellDocs.CLI.csproj
@@ -6,12 +6,14 @@
     true
     shelldocs
     ShellDocs.CLI
-    Global tool for ShellDocs — the docs framework for .NET. Commands: init, add, dev, build.
+    Global tool for ShellDocs — the docs framework for .NET. Commands: init, add, dev, build, preview.
     ShellDocs.CLI
     shelldocs
   
 
   
+    
+    
     
     
     
diff --git a/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css b/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css
index e7ee97b..11f86b4 100644
--- a/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css
+++ b/src/ShellDocs.Components/Chrome/DocsSidebar.razor.css
@@ -77,7 +77,7 @@
     transition: color 150ms;
 }
 ::deep .sidebar-item:hover .sidebar-item-icon,
-::deep .sidebar-item.active .sidebar-item-icon { color: var(--foreground); }
+::deep .sidebar-item.active .sidebar-item-icon { color: var(--accent-foreground); }
 
 ::deep .sidebar-section[data-depth="1"] > .sidebar-section-label,
 ::deep .sidebar-section[data-depth="2"] > .sidebar-section-label,
@@ -214,7 +214,7 @@
     background: var(--muted);
 }
 ::deep .sidebar-item.active {
-    color: var(--foreground);
+    color: var(--accent-foreground);
     background: var(--accent);
     font-weight: 500;
 }
@@ -228,7 +228,7 @@
     top: 0.25rem;
     bottom: 0.25rem;
     width: 1px;
-    background: var(--foreground);
+    background: var(--primary);
 }
 
 ::deep .sidebar-item-label { flex: 1; }
diff --git a/src/ShellDocs.Components/Chrome/MobileNavState.cs b/src/ShellDocs.Components/Chrome/MobileNavState.cs
deleted file mode 100644
index 9b75136..0000000
--- a/src/ShellDocs.Components/Chrome/MobileNavState.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-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/SearchState.cs b/src/ShellDocs.Components/Chrome/SearchState.cs
deleted file mode 100644
index 44a54bc..0000000
--- a/src/ShellDocs.Components/Chrome/SearchState.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ShellDocs.Components.Chrome;
-
-// No longer used: search is driven by shelldocs.js (shelldocsSearch.open()). Kept so existing code compiles.
-public class SearchState
-{
-    public bool IsOpen { get; private set; }
-    public event Action? OnChange;
-
-    public void Open()  { if (!IsOpen) { IsOpen = true;  OnChange?.Invoke(); } }
-    public void Close() { if (IsOpen)  { IsOpen = false; OnChange?.Invoke(); } }
-    public void Toggle() { IsOpen = !IsOpen; OnChange?.Invoke(); }
-}
diff --git a/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs b/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs
deleted file mode 100644
index 40b4823..0000000
--- a/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ShellDocs.Components.Chrome;
-
-// No longer used: the sidebar collapse is driven by shelldocs.js. Kept so existing code compiles.
-public class SidebarCollapseState
-{
-    public bool IsCollapsed { get; private set; }
-    public event Action? OnChange;
-
-    public void Toggle() { IsCollapsed = !IsCollapsed; OnChange?.Invoke(); }
-    public void Collapse() { if (!IsCollapsed) { IsCollapsed = true;  OnChange?.Invoke(); } }
-    public void Expand()   { if (IsCollapsed)  { IsCollapsed = false; OnChange?.Invoke(); } }
-}
diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor b/src/ShellDocs.Components/Layouts/DocsLayout.razor
index 2d4d776..dc53812 100644
--- a/src/ShellDocs.Components/Layouts/DocsLayout.razor
+++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor
@@ -4,7 +4,7 @@
 
 @* [data-sidebar-collapsed] is flipped by shelldocs.js ([data-sidebar-collapse-toggle]),
    so collapsing works without a Blazor runtime. *@
-
+
@if (Options.LayoutVariant == DocsLayoutVariant.TopNav) { diff --git a/src/ShellDocs.Components/Layouts/HomeLayout.razor b/src/ShellDocs.Components/Layouts/HomeLayout.razor index 4ff7c2b..3240909 100644 --- a/src/ShellDocs.Components/Layouts/HomeLayout.razor +++ b/src/ShellDocs.Components/Layouts/HomeLayout.razor @@ -1,6 +1,7 @@ @inherits LayoutComponentBase +@inject ShellDocsOptions Options -
+
@Body diff --git a/src/ShellDocs.Components/README.md b/src/ShellDocs.Components/README.md new file mode 100644 index 0000000..dd18030 --- /dev/null +++ b/src/ShellDocs.Components/README.md @@ -0,0 +1,47 @@ +![ShellDocs](https://raw.githubusercontent.com/shellui-dev/shelldocs/main/assets/readme-logo.svg) + +# ShellDocs.Components + +The Blazor side of [ShellDocs](https://shelldocs.shellui.dev), the docs framework for .NET: the docs layout and chrome (sidebar, header, search, table of contents, version and package selectors) and the content components you write in Markdown (`Callout`, `Card`, `Steps`, `Tabs`, `CodeGroup`, `FileTree`, `TypeTable`, live component previews). + +## Install + +The quickest start is the CLI, which scaffolds a site with everything wired up: + +```bash +dotnet tool install --global ShellDocs.CLI --prerelease +shelldocs init +``` + +To add ShellDocs to an existing Blazor Web App: + +```bash +dotnet add package ShellDocs.Components --prerelease +``` + +```csharp +builder.Services.AddShellDocs(o => +{ + o.ContentRoot = Path.Combine(builder.Environment.ContentRootPath, "content"); + o.SiteName = "My Library"; + o.RegisterComponentsFromAssembly(); // your components, usable in Markdown +}); +``` + +`shelldocs init --attach` does this for you and writes the `App.razor` snippets into `SHELLDOCS_SETUP.md`. + +## What you get + +- **Markdown-first authoring.** Frontmatter, highlighted code fences, live `razor:preview` examples and component tags mid-prose. +- **File-based navigation.** Each `.md` under `content/docs/` is a page; `meta.json` sets order, dividers, subsections and hidden pages. +- **`Cmd+K` search** with body snippets, searched in the browser. +- **Colour presets** (`o.Theme = ShellDocsTheme.Harbor`), light and dark. +- **Versioned docs, redirects and llms.txt for AI agents.** +- **Works as a static site.** `shelldocs build` exports every page, and the chrome keeps working there through `shelldocs.js`. + +## Links + +- Docs: [shelldocs.shellui.dev](https://shelldocs.shellui.dev) +- Source, issues and changelog: [github.com/shellui-dev/shelldocs](https://github.com/shellui-dev/shelldocs) + +Alpha: APIs may change between minor versions. MIT licensed. diff --git a/src/ShellDocs.Components/ServiceCollectionExtensions.cs b/src/ShellDocs.Components/ServiceCollectionExtensions.cs index e2e2640..93fe0a8 100644 --- a/src/ShellDocs.Components/ServiceCollectionExtensions.cs +++ b/src/ShellDocs.Components/ServiceCollectionExtensions.cs @@ -17,10 +17,7 @@ public static IServiceCollection AddShellDocs(this IServiceCollection services, configure?.Invoke(options); services.AddSingleton(options); - services.AddScoped(); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/ShellDocs.Components/ShellDocsOptions.cs b/src/ShellDocs.Components/ShellDocsOptions.cs index 3966ed4..0b6631a 100644 --- a/src/ShellDocs.Components/ShellDocsOptions.cs +++ b/src/ShellDocs.Components/ShellDocsOptions.cs @@ -29,7 +29,11 @@ public class ShellDocsOptions public double LogoHeight { get; set; } = 1.375; // 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; + // Colour preset from tokens.css. Overriding the variables in a later stylesheet still wins. + public ShellDocsTheme Theme { get; set; } = ShellDocsTheme.Graphite; + + // The layouts' data-shelldocs-theme value; null for Graphite, the unmarked default. + internal string? ThemePresetName => Theme == ShellDocsTheme.Graphite ? null : Theme.ToString().ToLowerInvariant(); // 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; } @@ -281,11 +285,19 @@ public record DocsRedirectRule(string From, string To, bool Permanent = true); public record DocsSyncSwitch(string SyncKey, IReadOnlyList Options); +// Colour presets: Graphite is the neutral default, the others tint it with one hue. public enum ShellDocsTheme { - Shadcn, - Fuma, - Nextra + Graphite, + Harbor, + Lagoon, + Forest, + Savanna, + Ember, + Jacaranda, + [Obsolete("Use Graphite.")] Shadcn = Graphite, + [Obsolete("Never had its own palette. Use Graphite or a colour preset.")] Fuma = Graphite, + [Obsolete("Never had its own palette. Use Graphite or a colour preset.")] Nextra = Graphite, } // TopNav: DocsHeader across the top, sidebar below. Sidebar: no top nav — brand, diff --git a/src/ShellDocs.Components/wwwroot/shelldocs-theme.css b/src/ShellDocs.Components/wwwroot/shelldocs-theme.css index 6482846..4579850 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs-theme.css +++ b/src/ShellDocs.Components/wwwroot/shelldocs-theme.css @@ -42,11 +42,11 @@ body { margin: 0; min-height: 100vh; } text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; - text-decoration-color: color-mix(in oklch, var(--foreground) 30%, transparent); + text-decoration-color: color-mix(in oklch, var(--primary) 30%, transparent); transition: text-decoration-color 150ms; font-weight: 500; } -.shelldocs-prose a:hover:where(:not(.not-prose, .not-prose *)) { text-decoration-color: var(--foreground); } +.shelldocs-prose a:hover:where(:not(.not-prose, .not-prose *)) { text-decoration-color: var(--primary); } .shelldocs-prose strong:where(:not(.not-prose, .not-prose *)) { font-weight: 600; color: var(--foreground); } diff --git a/src/ShellDocs.Core/NavigationGraphBuilder.cs b/src/ShellDocs.Core/NavigationGraphBuilder.cs index 966984e..29f99f6 100644 --- a/src/ShellDocs.Core/NavigationGraphBuilder.cs +++ b/src/ShellDocs.Core/NavigationGraphBuilder.cs @@ -48,7 +48,7 @@ private static List BuildFolder(string folder, string root, stri { var section = new NavigationNode { - Title = TitleFromFolderName(name), + Title = FolderTitle(tuple.folderPath), Kind = NodeKind.Section, Path = tuple.folderPath }; @@ -76,7 +76,7 @@ private static List BuildFolder(string folder, string root, stri // Wrapped so the graph's index walk reaches every page in the folder. var section = new NavigationNode { - Title = TitleFromFolderName(slug), + Title = FolderTitle(hiddenFolder.folderPath), Kind = NodeKind.Section, Path = hiddenFolder.folderPath }; @@ -99,7 +99,7 @@ without touching meta.json. */ if (consumedFolders.Contains(name)) continue; var section = new NavigationNode { - Title = TitleFromFolderName(name), + Title = FolderTitle(tuple.folderPath), Kind = NodeKind.Section, Path = tuple.folderPath }; @@ -144,7 +144,7 @@ without touching meta.json. */ consumedFolders.Add(pageRef.Slug); var section = new NavigationNode { - Title = TitleFromFolderName(pageRef.Slug), + Title = FolderTitle(folder.folderPath), Kind = NodeKind.Section, Path = folder.folderPath }; @@ -214,7 +214,11 @@ private static string SlugFrom(string mdPath) private static string TitleFromSlug(string slug) => string.Join(' ', slug.Split('-').Select(w => w.Length == 0 ? w : char.ToUpper(w[0]) + w[1..])); - private static string TitleFromFolderName(string name) => TitleFromSlug(name); + // The folder's own meta.json title, else one made from its name. + private static string FolderTitle(string folderPath) + => ReadMeta(folderPath)?.Title is { Length: > 0 } title + ? title + : TitleFromSlug(System.IO.Path.GetFileName(folderPath)); private static string EnsureLeadingSlash(string s) => s.StartsWith('/') ? s : "/" + s; diff --git a/src/ShellDocs.Core/README.md b/src/ShellDocs.Core/README.md new file mode 100644 index 0000000..1b9bc9f --- /dev/null +++ b/src/ShellDocs.Core/README.md @@ -0,0 +1,19 @@ +![ShellDocs](https://raw.githubusercontent.com/shellui-dev/shelldocs/main/assets/readme-logo.svg) + +# ShellDocs.Core + +The content model behind [ShellDocs](https://shelldocs.shellui.dev), the docs framework for .NET: the navigation graph built from `content/docs` and its `meta.json` files, frontmatter parsing, the search index and URL helpers. + +You don't usually reference this package directly: `ShellDocs.Components` brings it in. Start with the CLI: + +```bash +dotnet tool install --global ShellDocs.CLI --prerelease +shelldocs init +``` + +## Links + +- Docs: [shelldocs.shellui.dev](https://shelldocs.shellui.dev) +- Source, issues and changelog: [github.com/shellui-dev/shelldocs](https://github.com/shellui-dev/shelldocs) + +Alpha: APIs may change between minor versions. MIT licensed. diff --git a/src/ShellDocs.Markdown/PreviewParser.cs b/src/ShellDocs.Markdown/PreviewParser.cs index 94ff239..89b000a 100644 --- a/src/ShellDocs.Markdown/PreviewParser.cs +++ b/src/ShellDocs.Markdown/PreviewParser.cs @@ -142,10 +142,15 @@ private void RecordUnknown(string text, RazorTag tag) internal static IReadOnlyDictionary ToParameters(IEnumerable> attrs) { var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (k, v) in attrs) dict[k] = v ?? ""; + foreach (var (k, v) in attrs) dict[k] = DecodeValue(k, v ?? ""); return dict; } + // Literal values decode entities (`&` → `&`) as Razor does; directive + // attributes and `@` expressions stay as written. + internal static string DecodeValue(string name, string value) + => name.StartsWith('@') || value.TrimStart().StartsWith('@') ? value : WebUtility.HtmlDecode(value); + private static bool TrySkipRazorConstruct(string text, ref int i) { // @* razor comment *@ diff --git a/src/ShellDocs.Markdown/README.md b/src/ShellDocs.Markdown/README.md new file mode 100644 index 0000000..86a21bb --- /dev/null +++ b/src/ShellDocs.Markdown/README.md @@ -0,0 +1,19 @@ +![ShellDocs](https://raw.githubusercontent.com/shellui-dev/shelldocs/main/assets/readme-logo.svg) + +# ShellDocs.Markdown + +The Markdig pipeline behind [ShellDocs](https://shelldocs.shellui.dev), the docs framework for .NET: frontmatter, `razor:preview` fences and Razor component tags written inline in Markdown, turned into slots that `ShellDocs.Components` renders as real Blazor components. + +You don't usually reference this package directly: `ShellDocs.Components` brings it in. Start with the CLI: + +```bash +dotnet tool install --global ShellDocs.CLI --prerelease +shelldocs init +``` + +## Links + +- Docs: [shelldocs.shellui.dev](https://shelldocs.shellui.dev) +- Source, issues and changelog: [github.com/shellui-dev/shelldocs](https://github.com/shellui-dev/shelldocs) + +Alpha: APIs may change between minor versions. MIT licensed. diff --git a/src/ShellDocs.Markdown/SlotExtractor.cs b/src/ShellDocs.Markdown/SlotExtractor.cs index 7de36e1..ab8a549 100644 --- a/src/ShellDocs.Markdown/SlotExtractor.cs +++ b/src/ShellDocs.Markdown/SlotExtractor.cs @@ -178,11 +178,12 @@ private static bool TryFindOpeningTag(string text, int from, out RazorTag tag) /* Attributes keep their full names (`@bind-Value`, not `Value`) so SlotRenderer can skip directive attributes. Code spans are restored per value, after the - tag is parsed, so quotes inside a span can't split the value. */ + tag is parsed, so quotes inside a span can't split the value, and after + entities are decoded, so a span keeps `&` as written. */ private IReadOnlyDictionary UnmaskValues(IEnumerable> attrs) { var dict = new Dictionary(StringComparer.Ordinal); - foreach (var (name, value) in attrs) dict[name] = Unmask(value ?? ""); + foreach (var (name, value) in attrs) dict[name] = Unmask(PreviewParser.DecodeValue(name, value ?? "")); return dict; } diff --git a/src/ShellDocs.Templates/README.md b/src/ShellDocs.Templates/README.md new file mode 100644 index 0000000..8b351eb --- /dev/null +++ b/src/ShellDocs.Templates/README.md @@ -0,0 +1,17 @@ +![ShellDocs](https://raw.githubusercontent.com/shellui-dev/shelldocs/main/assets/readme-logo.svg) + +# ShellDocs.Templates + +The templates `shelldocs init` and `shelldocs add` use to scaffold [ShellDocs](https://shelldocs.shellui.dev) sites and pages. It isn't meant to be referenced directly; install the CLI instead: + +```bash +dotnet tool install --global ShellDocs.CLI --prerelease +shelldocs init +``` + +## Links + +- Docs: [shelldocs.shellui.dev](https://shelldocs.shellui.dev) +- Source, issues and changelog: [github.com/shellui-dev/shelldocs](https://github.com/shellui-dev/shelldocs) + +Alpha: APIs may change between minor versions. MIT licensed. diff --git a/src/ShellDocs.Templates/ScaffoldTemplates.cs b/src/ShellDocs.Templates/ScaffoldTemplates.cs index d640240..93c81f1 100644 --- a/src/ShellDocs.Templates/ScaffoldTemplates.cs +++ b/src/ShellDocs.Templates/ScaffoldTemplates.cs @@ -162,13 +162,25 @@ @inherits LayoutComponentBase public static string ProgramWebHost => "builder.WebHost.UseStaticWebAssets();"; - public static string ProgramAddShellDocs(string siteName, string githubRepo) => $$""" + // ShellDocsTheme's colour presets, as `shelldocs init --theme` takes them. + public static readonly string[] ThemePresets = ["graphite", "harbor", "lagoon", "forest", "savanna", "ember", "jacaranda"]; + + // Graphite is the default, so it stays a commented-out example. + private static string ThemeLine(string theme) => + theme.Equals("graphite", StringComparison.OrdinalIgnoreCase) + ? "// o.Theme = ShellDocsTheme.Harbor;" + : $"o.Theme = ShellDocsTheme.{char.ToUpperInvariant(theme[0])}{theme[1..].ToLowerInvariant()};"; + + public static string ProgramAddShellDocs(string siteName, string githubRepo, string theme = "graphite") => $$""" builder.Services.AddShellDocs(o => { o.ContentRoot = System.IO.Path.Combine(builder.Environment.ContentRootPath, "content"); o.SiteName = "{{siteName}}"; o.GitHubRepo = "{{githubRepo}}"; + // Colour preset: Graphite (default), Harbor, Lagoon, Forest, Savanna, Ember or Jacaranda. + {{ThemeLine(theme)}} + // Layout: TopNav (default) = classic header + sidebar. // Sidebar = floating sidebar card, no top header (shadcn / fumadocs look). // o.LayoutVariant = DocsLayoutVariant.Sidebar; @@ -220,7 +232,7 @@ public static string ProgramAddShellDocs(string siteName, string githubRepo) => """; - public static string SetupInstructionsMd(string siteName, string githubRepo) => $$""" + public static string SetupInstructionsMd(string siteName, string githubRepo, string theme = "graphite") => $$""" # ShellDocs setup Two files in your Blazor project need small additions. Copy these snippets in, then delete this file. @@ -238,7 +250,7 @@ public static string SetupInstructionsMd(string siteName, string githubRepo) => ```csharp {{ProgramWebHost}} - {{ProgramAddShellDocs(siteName, githubRepo)}} + {{ProgramAddShellDocs(siteName, githubRepo, theme)}} ``` ## 2. `Components/App.razor` diff --git a/src/ShellDocs.Tokens/README.md b/src/ShellDocs.Tokens/README.md new file mode 100644 index 0000000..fece345 --- /dev/null +++ b/src/ShellDocs.Tokens/README.md @@ -0,0 +1,22 @@ +![ShellDocs](https://raw.githubusercontent.com/shellui-dev/shelldocs/main/assets/readme-logo.svg) + +# ShellDocs.Tokens + +The design tokens of [ShellDocs](https://shelldocs.shellui.dev), the docs framework for .NET: CSS variables on `:root` for palette, radius, spacing and typography, in light and dark, named the shadcn way (`--background`, `--foreground`, `--primary`, `--muted`, `--border`, …). + +`ShellDocs.Components` uses them, and any Blazor UI library that reads the same variables (ShellUI, or your own) matches the docs chrome without extra work. Besides the neutral default (Graphite) there are six colour presets, Harbor, Lagoon, Forest, Savanna, Ember and Jacaranda, picked with `o.Theme` in `ShellDocs.Components`. + +```bash +dotnet add package ShellDocs.Tokens --prerelease +``` + +```html + +``` + +## Links + +- Docs: [shelldocs.shellui.dev](https://shelldocs.shellui.dev) +- Source, issues and changelog: [github.com/shellui-dev/shelldocs](https://github.com/shellui-dev/shelldocs) + +Alpha: APIs may change between minor versions. MIT licensed. diff --git a/src/ShellDocs.Tokens/wwwroot/tokens.css b/src/ShellDocs.Tokens/wwwroot/tokens.css index 17e6a2f..a80b3ac 100644 --- a/src/ShellDocs.Tokens/wwwroot/tokens.css +++ b/src/ShellDocs.Tokens/wwwroot/tokens.css @@ -82,3 +82,266 @@ color-scheme: dark; } + +/* Colour presets (ShellDocsOptions.Theme). The layouts mark the page with + data-shelldocs-theme="…"; without it the palette above applies (Graphite). + :where() keeps these at the specificity of :root and :root.dark, so a + stylesheet loaded after this one still overrides them. */ + +/* Harbor */ +:root:has(:where([data-shelldocs-theme="harbor"])) { + --background: oklch(0.970 0.006 255); + --foreground: oklch(0.160 0.012 255); + --card: oklch(0.955 0.008 255); + --card-foreground: oklch(0.160 0.012 255); + --popover: oklch(0.985 0.005 255); + --popover-foreground: oklch(0.250 0.012 255); + --primary: oklch(0.520 0.170 255); + --primary-foreground: oklch(0.985 0.005 255); + --secondary: oklch(0.940 0.010 255); + --secondary-foreground: oklch(0.210 0.012 255); + --muted: oklch(0.960 0.008 255); + --muted-foreground: oklch(0.500 0.020 255); + --accent: oklch(0.520 0.170 255 / 0.12); + --accent-foreground: oklch(0.460 0.153 255); + --border: oklch(0.850 0.012 255 / 0.6); + --border-strong: oklch(0.800 0.014 255); + --input: oklch(0.900 0.010 255); + --ring: oklch(0.520 0.170 255); +} + +:root.dark:has(:where([data-shelldocs-theme="harbor"])) { + --background: oklch(0.180 0.010 255); + --foreground: oklch(0.930 0.006 255); + --card: oklch(0.210 0.012 255); + --card-foreground: oklch(0.970 0.005 255); + --popover: oklch(0.230 0.012 255); + --popover-foreground: oklch(0.890 0.008 255); + --primary: oklch(0.720 0.153 255); + --primary-foreground: oklch(0.180 0.020 255); + --secondary: oklch(0.260 0.014 255); + --secondary-foreground: oklch(0.930 0.006 255); + --muted: oklch(0.260 0.014 255); + --muted-foreground: oklch(0.740 0.020 255); + --accent: oklch(0.720 0.153 255 / 0.16); + --accent-foreground: oklch(0.820 0.102 255); + --border: oklch(0.550 0.020 255 / 0.25); + --border-strong: oklch(0.550 0.020 255 / 0.5); + --input: oklch(0.280 0.014 255); + --ring: oklch(0.720 0.153 255); +} + +/* Lagoon */ +:root:has(:where([data-shelldocs-theme="lagoon"])) { + --background: oklch(0.970 0.006 200); + --foreground: oklch(0.160 0.012 200); + --card: oklch(0.955 0.008 200); + --card-foreground: oklch(0.160 0.012 200); + --popover: oklch(0.985 0.005 200); + --popover-foreground: oklch(0.250 0.012 200); + --primary: oklch(0.520 0.110 200); + --primary-foreground: oklch(0.985 0.005 200); + --secondary: oklch(0.940 0.010 200); + --secondary-foreground: oklch(0.210 0.012 200); + --muted: oklch(0.960 0.008 200); + --muted-foreground: oklch(0.500 0.020 200); + --accent: oklch(0.520 0.110 200 / 0.12); + --accent-foreground: oklch(0.460 0.099 200); + --border: oklch(0.850 0.012 200 / 0.6); + --border-strong: oklch(0.800 0.014 200); + --input: oklch(0.900 0.010 200); + --ring: oklch(0.520 0.110 200); +} + +:root.dark:has(:where([data-shelldocs-theme="lagoon"])) { + --background: oklch(0.180 0.010 200); + --foreground: oklch(0.930 0.006 200); + --card: oklch(0.210 0.012 200); + --card-foreground: oklch(0.970 0.005 200); + --popover: oklch(0.230 0.012 200); + --popover-foreground: oklch(0.890 0.008 200); + --primary: oklch(0.760 0.099 200); + --primary-foreground: oklch(0.180 0.020 200); + --secondary: oklch(0.260 0.014 200); + --secondary-foreground: oklch(0.930 0.006 200); + --muted: oklch(0.260 0.014 200); + --muted-foreground: oklch(0.740 0.020 200); + --accent: oklch(0.760 0.099 200 / 0.16); + --accent-foreground: oklch(0.860 0.066 200); + --border: oklch(0.550 0.020 200 / 0.25); + --border-strong: oklch(0.550 0.020 200 / 0.5); + --input: oklch(0.280 0.014 200); + --ring: oklch(0.760 0.099 200); +} + +/* Forest */ +:root:has(:where([data-shelldocs-theme="forest"])) { + --background: oklch(0.970 0.006 150); + --foreground: oklch(0.160 0.012 150); + --card: oklch(0.955 0.008 150); + --card-foreground: oklch(0.160 0.012 150); + --popover: oklch(0.985 0.005 150); + --popover-foreground: oklch(0.250 0.012 150); + --primary: oklch(0.500 0.140 150); + --primary-foreground: oklch(0.985 0.005 150); + --secondary: oklch(0.940 0.010 150); + --secondary-foreground: oklch(0.210 0.012 150); + --muted: oklch(0.960 0.008 150); + --muted-foreground: oklch(0.500 0.020 150); + --accent: oklch(0.500 0.140 150 / 0.12); + --accent-foreground: oklch(0.460 0.126 150); + --border: oklch(0.850 0.012 150 / 0.6); + --border-strong: oklch(0.800 0.014 150); + --input: oklch(0.900 0.010 150); + --ring: oklch(0.500 0.140 150); +} + +:root.dark:has(:where([data-shelldocs-theme="forest"])) { + --background: oklch(0.180 0.010 150); + --foreground: oklch(0.930 0.006 150); + --card: oklch(0.210 0.012 150); + --card-foreground: oklch(0.970 0.005 150); + --popover: oklch(0.230 0.012 150); + --popover-foreground: oklch(0.890 0.008 150); + --primary: oklch(0.750 0.126 150); + --primary-foreground: oklch(0.180 0.020 150); + --secondary: oklch(0.260 0.014 150); + --secondary-foreground: oklch(0.930 0.006 150); + --muted: oklch(0.260 0.014 150); + --muted-foreground: oklch(0.740 0.020 150); + --accent: oklch(0.750 0.126 150 / 0.16); + --accent-foreground: oklch(0.850 0.084 150); + --border: oklch(0.550 0.020 150 / 0.25); + --border-strong: oklch(0.550 0.020 150 / 0.5); + --input: oklch(0.280 0.014 150); + --ring: oklch(0.750 0.126 150); +} + +/* Savanna */ +:root:has(:where([data-shelldocs-theme="savanna"])) { + --background: oklch(0.970 0.006 85); + --foreground: oklch(0.160 0.012 85); + --card: oklch(0.955 0.008 85); + --card-foreground: oklch(0.160 0.012 85); + --popover: oklch(0.985 0.005 85); + --popover-foreground: oklch(0.250 0.012 85); + --primary: oklch(0.550 0.130 70); + --primary-foreground: oklch(0.985 0.005 85); + --secondary: oklch(0.940 0.010 85); + --secondary-foreground: oklch(0.210 0.012 85); + --muted: oklch(0.960 0.008 85); + --muted-foreground: oklch(0.500 0.020 85); + --accent: oklch(0.550 0.130 70 / 0.12); + --accent-foreground: oklch(0.460 0.117 70); + --border: oklch(0.850 0.012 85 / 0.6); + --border-strong: oklch(0.800 0.014 85); + --input: oklch(0.900 0.010 85); + --ring: oklch(0.550 0.130 70); +} + +:root.dark:has(:where([data-shelldocs-theme="savanna"])) { + --background: oklch(0.180 0.010 85); + --foreground: oklch(0.930 0.006 85); + --card: oklch(0.210 0.012 85); + --card-foreground: oklch(0.970 0.005 85); + --popover: oklch(0.230 0.012 85); + --popover-foreground: oklch(0.890 0.008 85); + --primary: oklch(0.800 0.117 70); + --primary-foreground: oklch(0.180 0.020 85); + --secondary: oklch(0.260 0.014 85); + --secondary-foreground: oklch(0.930 0.006 85); + --muted: oklch(0.260 0.014 85); + --muted-foreground: oklch(0.740 0.020 85); + --accent: oklch(0.800 0.117 70 / 0.16); + --accent-foreground: oklch(0.880 0.078 70); + --border: oklch(0.550 0.020 85 / 0.25); + --border-strong: oklch(0.550 0.020 85 / 0.5); + --input: oklch(0.280 0.014 85); + --ring: oklch(0.800 0.117 70); +} + +/* Ember */ +:root:has(:where([data-shelldocs-theme="ember"])) { + --background: oklch(0.970 0.006 35); + --foreground: oklch(0.160 0.012 35); + --card: oklch(0.955 0.008 35); + --card-foreground: oklch(0.160 0.012 35); + --popover: oklch(0.985 0.005 35); + --popover-foreground: oklch(0.250 0.012 35); + --primary: oklch(0.560 0.180 35); + --primary-foreground: oklch(0.985 0.005 35); + --secondary: oklch(0.940 0.010 35); + --secondary-foreground: oklch(0.210 0.012 35); + --muted: oklch(0.960 0.008 35); + --muted-foreground: oklch(0.500 0.020 35); + --accent: oklch(0.560 0.180 35 / 0.12); + --accent-foreground: oklch(0.460 0.162 35); + --border: oklch(0.850 0.012 35 / 0.6); + --border-strong: oklch(0.800 0.014 35); + --input: oklch(0.900 0.010 35); + --ring: oklch(0.560 0.180 35); +} + +:root.dark:has(:where([data-shelldocs-theme="ember"])) { + --background: oklch(0.180 0.010 35); + --foreground: oklch(0.930 0.006 35); + --card: oklch(0.210 0.012 35); + --card-foreground: oklch(0.970 0.005 35); + --popover: oklch(0.230 0.012 35); + --popover-foreground: oklch(0.890 0.008 35); + --primary: oklch(0.720 0.162 35); + --primary-foreground: oklch(0.180 0.020 35); + --secondary: oklch(0.260 0.014 35); + --secondary-foreground: oklch(0.930 0.006 35); + --muted: oklch(0.260 0.014 35); + --muted-foreground: oklch(0.740 0.020 35); + --accent: oklch(0.720 0.162 35 / 0.16); + --accent-foreground: oklch(0.820 0.108 35); + --border: oklch(0.550 0.020 35 / 0.25); + --border-strong: oklch(0.550 0.020 35 / 0.5); + --input: oklch(0.280 0.014 35); + --ring: oklch(0.720 0.162 35); +} + +/* Jacaranda */ +:root:has(:where([data-shelldocs-theme="jacaranda"])) { + --background: oklch(0.970 0.006 300); + --foreground: oklch(0.160 0.012 300); + --card: oklch(0.955 0.008 300); + --card-foreground: oklch(0.160 0.012 300); + --popover: oklch(0.985 0.005 300); + --popover-foreground: oklch(0.250 0.012 300); + --primary: oklch(0.520 0.170 300); + --primary-foreground: oklch(0.985 0.005 300); + --secondary: oklch(0.940 0.010 300); + --secondary-foreground: oklch(0.210 0.012 300); + --muted: oklch(0.960 0.008 300); + --muted-foreground: oklch(0.500 0.020 300); + --accent: oklch(0.520 0.170 300 / 0.12); + --accent-foreground: oklch(0.460 0.153 300); + --border: oklch(0.850 0.012 300 / 0.6); + --border-strong: oklch(0.800 0.014 300); + --input: oklch(0.900 0.010 300); + --ring: oklch(0.520 0.170 300); +} + +:root.dark:has(:where([data-shelldocs-theme="jacaranda"])) { + --background: oklch(0.180 0.010 300); + --foreground: oklch(0.930 0.006 300); + --card: oklch(0.210 0.012 300); + --card-foreground: oklch(0.970 0.005 300); + --popover: oklch(0.230 0.012 300); + --popover-foreground: oklch(0.890 0.008 300); + --primary: oklch(0.740 0.153 300); + --primary-foreground: oklch(0.180 0.020 300); + --secondary: oklch(0.260 0.014 300); + --secondary-foreground: oklch(0.930 0.006 300); + --muted: oklch(0.260 0.014 300); + --muted-foreground: oklch(0.740 0.020 300); + --accent: oklch(0.740 0.153 300 / 0.16); + --accent-foreground: oklch(0.840 0.102 300); + --border: oklch(0.550 0.020 300 / 0.25); + --border-strong: oklch(0.550 0.020 300 / 0.5); + --input: oklch(0.280 0.014 300); + --ring: oklch(0.740 0.153 300); +} diff --git a/tests/ShellDocs.Tests/InitCommandTests.cs b/tests/ShellDocs.Tests/InitCommandTests.cs index 6febcee..77b698b 100644 --- a/tests/ShellDocs.Tests/InitCommandTests.cs +++ b/tests/ShellDocs.Tests/InitCommandTests.cs @@ -49,7 +49,7 @@ private void WriteBlazorCsproj(string content = null!) => """); private int InvokeAttach() => - (int)_run.Invoke(null, new object?[] { null, _tempDir, true, true, "shadcn" })!; + (int)_run.Invoke(null, new object?[] { null, _tempDir, true, true, "graphite" })!; [Fact] public void Attach_MissingCsproj_ReturnsError() @@ -166,7 +166,7 @@ public void PatchProgramCs_InsertsUsingWebHostAndAddShellDocs() { WriteFreshBlazorProgramCs(); var changes = new List(); - _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "myorg/myrepo", changes }); + _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "myorg/myrepo", "graphite", changes }); var src = File.ReadAllText(Path.Combine(_tempDir, "Program.cs")); Assert.Contains("using ShellDocs.Components;", src); @@ -182,11 +182,11 @@ public void PatchProgramCs_IsIdempotent() { WriteFreshBlazorProgramCs(); var changes = new List(); - _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "", changes }); + _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "", "graphite", changes }); var afterFirst = File.ReadAllText(Path.Combine(_tempDir, "Program.cs")); var secondChanges = new List(); - _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "", secondChanges }); + _patchProgram.Invoke(null, new object[] { _tempDir, "MySite", "", "graphite", secondChanges }); var afterSecond = File.ReadAllText(Path.Combine(_tempDir, "Program.cs")); Assert.Equal(afterFirst, afterSecond); diff --git a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs index dd576a3..52d16c7 100644 --- a/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs +++ b/tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs @@ -76,6 +76,22 @@ public void Build_NestedFolders_AreSections() Assert.Contains(sections, s => s.Title == "Docs"); } + [Fact] + public void Build_FolderSection_UsesItsMetaJsonTitle() + { + WriteMd("maui/getting-started.md", "Getting started"); + WriteMeta("maui", """{ "title": "MAUI (preview)", "pages": ["getting-started"] }"""); + WriteMd("avalonia/getting-started.md", "Getting started"); + WriteMd("wpf/intro.md", "Intro"); + WriteMd("listed/intro.md", "Intro"); + WriteMeta("listed", """{ "title": "Listed by parent" }"""); + WriteMeta("", """{ "pages": ["listed"] }"""); + + var titles = NavigationGraphBuilder.Build(_root).Root.Children.Select(c => c.Title).ToList(); + + Assert.Equal(new[] { "Listed by parent", "Avalonia", "MAUI (preview)", "Wpf" }, titles); + } + [Fact] public void Build_UsesMetaJsonOrdering() { diff --git a/tests/ShellDocs.Tests/PreviewCommandTests.cs b/tests/ShellDocs.Tests/PreviewCommandTests.cs new file mode 100644 index 0000000..9daac3a --- /dev/null +++ b/tests/ShellDocs.Tests/PreviewCommandTests.cs @@ -0,0 +1,81 @@ +using System.Reflection; +using Xunit; + +namespace ShellDocs.Tests; + +public class PreviewCommandTests : IDisposable +{ + private readonly string _root; + private static readonly Type Command = (AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "shelldocs") + ?? Assembly.Load("shelldocs")) + .GetType("ShellDocs.CLI.Commands.PreviewCommand", throwOnError: true)!; + + public PreviewCommandTests() + { + _root = Path.Combine(Path.GetTempPath(), "shelldocs-preview-" + Guid.NewGuid().ToString("N")); + Write("index.html"); + Write("404.html"); + Write("docs/introduction/index.html"); + Write("about.html"); + Write("_shelldocs/search.json"); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + private void Write(string relative) + { + var full = Path.Combine(_root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, relative); + } + + private (string? File, int Status) Resolve(string basePath, string path) + { + var result = Command.GetMethod("Resolve", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, [_root, basePath, path])!; + var tuple = ((string?, int))result; + return (tuple.Item1 is null ? null : Path.GetRelativePath(_root, tuple.Item1).Replace('\\', '/'), tuple.Item2); + } + + [Theory] + [InlineData("/", "index.html")] + [InlineData("/docs/introduction", "docs/introduction/index.html")] + [InlineData("/docs/introduction/", "docs/introduction/index.html")] + [InlineData("/about", "about.html")] + [InlineData("/_shelldocs/search.json", "_shelldocs/search.json")] + public void Resolve_ServesFiles_FolderIndexes_AndHtmlExtensions(string path, string expected) + { + Assert.Equal((expected, 200), Resolve("/", path)); + } + + [Theory] + [InlineData("/docs/nope")] + [InlineData("/../secret.txt")] + [InlineData("/%2e%2e/secret.txt")] + public void Resolve_UnknownOrOutside_Is404Html(string path) + { + Assert.Equal(("404.html", 404), Resolve("/", path)); + } + + [Fact] + public void Resolve_UnderABasePath() + { + Assert.Equal(("index.html", 200), Resolve("/my-repo/", "/my-repo/")); + Assert.Equal(("index.html", 200), Resolve("/my-repo/", "/my-repo")); + Assert.Equal(("docs/introduction/index.html", 200), Resolve("/my-repo/", "/my-repo/docs/introduction")); + Assert.Equal(("404.html", 404), Resolve("/my-repo/", "/docs/introduction")); + } + + [Theory] + [InlineData(null, "/")] + [InlineData("/", "/")] + [InlineData("my-repo", "/my-repo/")] + [InlineData("/my-repo", "/my-repo/")] + [InlineData("/my-repo/", "/my-repo/")] + public void NormalizeBase_AddsBothSlashes(string? input, string expected) + { + Assert.Equal(expected, (string)Command.GetMethod("NormalizeBase", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, [input])!); + } +} diff --git a/tests/ShellDocs.Tests/PreviewTests.cs b/tests/ShellDocs.Tests/PreviewTests.cs index ca10995..c785712 100644 --- a/tests/ShellDocs.Tests/PreviewTests.cs +++ b/tests/ShellDocs.Tests/PreviewTests.cs @@ -186,6 +186,29 @@ public void InlineComponentTag_BindAttribute_IsNotMisreadAsParameter() Assert.Equal("3", slot.Parameters["Size"]); Assert.Equal("n", slot.Parameters["@bind-Size"]); } + + [Fact] + public void ComponentAttributeValues_DecodeEntities() + { + var doc = Renderer().Render( + "