Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht

## [Unreleased]

### Fixed

- **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 `<base href>`, 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.

## [0.1.17-alpha] — 2026-10-14

llms.txt for AI agents, and five fixes found while writing the ShellDocs docs site.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,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 `<DocsCard>`, `<DocsCallout>`, `<DocsTabs>`, and so on.
- **Content primitives.** `Callout`, `Card` / `CardGrid` / `LinkCard`, `Steps`, `FileTree`, `Tabs`, `CodeGroup`, `TypeTable` / `AutoTypeTable`, `ComponentPreview`, `DemoPreview`.
- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `<base href>` (`--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 (`<DocsNotFound />` in the docs page) and `build` writes the same view as `404.html`.
- **Static export.** `shelldocs build` prerenders every page to static HTML for GitHub Pages, Cloudflare Pages, Netlify or S3. Optional flags rewrite `<base href>` 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 (`<DocsNotFound />` in the docs page) and `build` writes the same view as `404.html`.

## Versioned docs

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ One neutral, shadcn-shaped palette of CSS variables in `ShellDocs.Tokens/tokens.
6. Copies the app's search index to `_shelldocs/search.json`.
7. With `EnableLlmsTxt`, reads `/_shelldocs/llms.json` and writes each listed file (`llms.txt`, `llms-full.txt`, every page's `.md`, the agent files) at its path. Links use `--site-url`, then the app's `SiteUrl`, else they're root-relative under `--base-href`.
8. Merges the published `wwwroot/` into the output without overwriting prerendered HTML.
9. Optionally rewrites `<base href>` in every HTML file, copies `index.html` to `404.html` (replacing the not-found page), and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta.
9. Optionally rewrites `<base href>` in every HTML file and puts the base in front of root-relative `href`, `src` and `data-src` values (they ignore `<base href>`; protocol-relative and already-prefixed ones are left alone), copies `index.html` to `404.html` (replacing the not-found page), and with `--site-url` writes `sitemap.xml`, `robots.txt` and `og:*` meta.

---

Expand Down
2 changes: 1 addition & 1 deletion examples/ShellDocs.Preview/content/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ shelldocs build

`shelldocs build` prerenders every page to static HTML in `publish/` (change it with `--output`). Deploy it to GitHub Pages, Cloudflare Pages, Netlify or S3. Useful flags:

- `--base-href /my-repo/` rewrites `<base href>` for sites served from a subpath.
- `--base-href /my-repo/` rewrites `<base href>` for sites served from a subpath, and puts `/my-repo` in front of root-relative links (`/docs/introduction` becomes `/my-repo/docs/introduction`).
- `--spa-fallback` copies `index.html` to `404.html`. Without it, `404.html` is the site's own not-found page, which GitHub Pages, Netlify and Cloudflare Pages serve for unknown URLs.
- `--site-url https://shelldocs.shellui.dev` emits `sitemap.xml`, `robots.txt` and `og:` meta tags.

Expand Down
9 changes: 8 additions & 1 deletion src/ShellDocs.CLI/Commands/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,16 @@ internal static void CopyDirectoryMerging(string source, string dest)
}
}

// `baseHref` must include leading + trailing slashes ("/repo-name/").
/* `baseHref` must include leading + trailing slashes ("/repo-name/"). Root-relative
links ignore <base href>, so the sidebar, breadcrumb, header, content links and
favicons ("/docs/intro") get the base put in front too ("/repo-name/docs/intro"). */
internal static int RewriteBaseHrefInAllHtml(string outputDir, string baseHref)
{
var count = 0;
var prefix = baseHref.TrimStart('/');
var rootRelative = new Regex(
@"(\s(?:href|src|data-src)\s*=\s*([""']))/(?!/)" + (prefix.Length > 0 ? "(?!" + Regex.Escape(prefix) + ")" : ""),
RegexOptions.IgnoreCase);
foreach (var html in Directory.EnumerateFiles(outputDir, "*.html", SearchOption.AllDirectories))
{
var original = File.ReadAllText(html);
Expand All @@ -165,6 +171,7 @@ internal static int RewriteBaseHrefInAllHtml(string outputDir, string baseHref)
@"<base\s+href\s*=\s*(""[^""]*""|'[^']*'|[^\s>]+)\s*/?>",
$"<base href=\"{baseHref}\" />",
RegexOptions.IgnoreCase);
if (prefix.Length > 0) patched = rootRelative.Replace(patched, "$1" + baseHref);
if (patched != original)
{
File.WriteAllText(html, patched);
Expand Down
46 changes: 46 additions & 0 deletions tests/ShellDocs.Tests/BuildCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,52 @@ public void RewriteBaseHref_HandlesQuoteVariants(string original, string href, s
Assert.Contains(expected, File.ReadAllText(path));
}

// #40: root-relative links ignore <base href>, so they get the base too.
[Fact]
public void RewriteBaseHref_PrefixesRootRelativeLinks()
{
var path = Path.Combine(_tempDir, "index.html");
File.WriteAllText(path, """
<html><head><base href="/" /><link rel="icon" href="/favicon.png" /></head><body>
<a class="sidebar-item" href="/docs/introduction">Intro</a>
<a class="docs-header-brand" href='/'>Home</a>
<img src="/img/logo.svg" />
<div data-iframe-preview data-src="/preview/index.html"></div>
<a href="/repo/docs/already">Already</a>
<a href="//cdn.shellui.dev/x.js">CDN</a>
<a href="https://shellui.dev/docs">External</a>
<a href="docs/relative">Relative</a>
<a href="#install">Anchor</a>
<pre><code>&lt;a href=&quot;/docs/code&quot;&gt;</code></pre>
</body></html>
""");

RewriteBaseHrefInAllHtml(_tempDir, "/repo/");
var html = File.ReadAllText(path);

Assert.Contains("<base href=\"/repo/\" />", html);
Assert.Contains("href=\"/repo/favicon.png\"", html);
Assert.Contains("href=\"/repo/docs/introduction\"", html);
Assert.Contains("href='/repo/'", html);
Assert.Contains("src=\"/repo/img/logo.svg\"", html);
Assert.Contains("data-src=\"/repo/preview/index.html\"", html);
Assert.Contains("href=\"/repo/docs/already\"", html);
Assert.Contains("href=\"//cdn.shellui.dev/x.js\"", html);
Assert.Contains("href=\"https://shellui.dev/docs\"", html);
Assert.Contains("href=\"docs/relative\"", html);
Assert.Contains("href=\"#install\"", html);
Assert.Contains("&lt;a href=&quot;/docs/code&quot;&gt;", html);
}

[Fact]
public void RewriteBaseHref_AtTheRoot_LeavesLinksAlone()
{
var path = Path.Combine(_tempDir, "index.html");
File.WriteAllText(path, "<html><head><base href=\"/old/\" /></head><body><a href=\"/docs/x\">x</a></body></html>");
RewriteBaseHrefInAllHtml(_tempDir, "/");
Assert.Contains("<a href=\"/docs/x\">", File.ReadAllText(path));
}

[Fact]
public void RewriteBaseHref_LeavesOtherMarkupUntouched()
{
Expand Down
Loading