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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht

## [Unreleased]

### Fixed

- **The table of contents on interactive sites.** With `<Routes @rendermode="InteractiveServer" />`, the "On this page" highlight stopped moving once the circuit started, and clicking an entry went to the home page. The scroll-spy now re-attaches when Blazor replaces the list or the headings (or changes `data-toc-ids` on navigation), TOC links carry the page's path (`docs/setup#install`; a bare `#install` resolves against `<base href>`), and same-page links, including `[x](#id)` in content, scroll in place and keep the path in the address bar. Sites that added their own `toc.js` workaround can drop it.
- **The last short sections of a page never became active in the TOC.** At the bottom of the page the last heading on screen is highlighted.
- **`border-*` utilities lost to the base border colour.** The `*, *::before, *::after` rule in `shelldocs-theme.css` was outside any cascade layer, so it beat Tailwind's `border-destructive` and friends in previews and home pages. It's in `@layer base` now.
- **`llms.txt` listed a group twice** when top-level pages sat on both sides of a folder ("## Pages" before and after it). Each group now appears once, in first-seen order.
- **The page menu's chat links used the visitor's origin instead of `SiteUrl`**, so a static export previewed on localhost asked about a localhost URL. With `SiteUrl` set they name the published page.

## [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.
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Chrome that must work without a Blazor runtime follows one contract:
| Iframe previews | `[data-iframe-preview][data-src][data-lazy]` | sets the iframe's `src` (plus `?theme=`) when visible (IntersectionObserver), on `[data-iframe-run]` or at once; posts `{ type: "shelldocs-theme", theme }` to loaded frames on theme change; a host Blazor reuses for another page (`data-src` or `data-lazy` changed) is unloaded and set up again |
| Mobile nav | page shell `[data-mobile-open]` | hamburger toggles; backdrop, Escape, link or navigation closes; page scroll locked on `<html>` |
| Sidebar collapse | page shell `[data-sidebar-collapsed]`, `[data-sidebar-collapse-toggle]` | toggles collapse; remembered in `localStorage` and restored as soon as `shelldocs.js` runs, so the sidebar doesn't flash open |
| TOC | `[data-toc-list][data-toc-ids]` | IntersectionObserver scroll-spy, re-attached on `enhancedload` |
| TOC | `[data-toc-list][data-toc-ids]`, `.toc-link` hrefs with the page path | IntersectionObserver scroll-spy (plus a scroll check for the page bottom), re-attached on `enhancedload` and when a render replaces the list or headings or changes `data-toc-ids`; same-page links (`#id` or the page's own path) are handled in a window capture listener: scroll, `replaceState` with the full path |
| Search | `[data-search-dialog]` template, `[data-search-open]` | Cmd/Ctrl+K or a button opens a clone of the template; ranks `/_shelldocs/search.json`; arrows, Enter, Escape |
| Theme | `<html class="dark">`, `[data-theme-toggle]` | inline head script applies the saved / system theme; the toggle flips the class and CSS picks its icon; a `MutationObserver` saves any change (including another library's toggle) and pushes it to `ThemeState`; re-applied after enhanced navigation |

Expand Down
9 changes: 6 additions & 3 deletions src/ShellDocs.Components/Chrome/DocsPageActions.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
with the page's absolute .md URL ([data-page-ai]), so it works in static exports. *@
@if (_markdownHref is not null)
{
<div class="page-actions not-prose" data-open="false" data-page-md="@_markdownHref">
<div class="page-actions not-prose" data-open="false" data-page-md="@_markdownHref" data-page-md-url="@SiteMarkdownUrl">
<button type="button" class="page-actions-trigger" aria-haspopup="menu" aria-expanded="false" aria-label="Page actions" title="Page actions">
<Ellipsis />
</button>
Expand Down Expand Up @@ -50,10 +50,13 @@
: null;
}

// Absolute with SiteUrl; otherwise shelldocs.js puts the visitor's origin in on click.
// The canonical .md URL with SiteUrl; null leaves the attribute out.
private string? SiteMarkdownUrl => string.IsNullOrWhiteSpace(Options.SiteUrl) ? null : Options.SiteUrl.TrimEnd('/') + "/" + _markdownHref;

// shelldocs.js fills in the visitor's origin when there is no SiteUrl.
private string ChatHref(string chat)
{
var url = string.IsNullOrWhiteSpace(Options.SiteUrl) ? "/" + _markdownHref : Options.SiteUrl.TrimEnd('/') + "/" + _markdownHref;
var url = SiteMarkdownUrl ?? "/" + _markdownHref;
return chat + Uri.EscapeDataString($"Read {url} and help me with it.");
}
}
10 changes: 7 additions & 3 deletions src/ShellDocs.Components/Chrome/TableOfContents.razor
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
@inject DocsPageState PageState
@inject NavigationManager Nav
@implements IDisposable
@using ShellDocs.Core

@* shelldocs.js attaches the scroll-spy from [data-toc-list] / [data-toc-ids];
links are plain #anchors, so it works without a Blazor runtime. *@
@* shelldocs.js attaches the scroll-spy from [data-toc-list] / [data-toc-ids] and
scrolls on click, so it works without a Blazor runtime. Links carry the page's
base-relative path: a bare #id resolves against <base href> to the home page. *@
@if (_items.Count > 0)
{
<nav class="toc" aria-label="On this page">
Expand All @@ -16,7 +18,7 @@
@foreach (var h in _items)
{
<li class="toc-item" data-level="@h.Level">
<a class="toc-link" href="#@h.Id" data-toc-id="@h.Id">@h.Text</a>
<a class="toc-link" href="@($"{_page}#{h.Id}")" data-toc-id="@h.Id">@h.Text</a>
</li>
}
</ul>
Expand All @@ -28,6 +30,7 @@

private List<Heading> _items = new();
private string _idsAttr = "";
private string _page = "";

protected override void OnInitialized() => PageState.OnChange += OnPageChanged;

Expand All @@ -40,6 +43,7 @@
var source = Headings ?? PageState.Document?.Headings ?? Array.Empty<Heading>();
_items = source.Where(h => h.Level == 2 || h.Level == 3).ToList();
_idsAttr = string.Join(",", _items.Select(h => h.Id));
_page = DocsVersionResolver.PathOf(Nav).TrimStart('/');
}

public void Dispose() => PageState.OnChange -= OnPageChanged;
Expand Down
22 changes: 16 additions & 6 deletions src/ShellDocs.Components/Llms/LlmsTxtFiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,26 @@ private string Index(string root, DocsVersion? version, string baseUrl)
var install = InstallPattern();
// Pages outside every version listed with the latest one: its groups carry its label.
var mixed = pages.Any(p => _versions.FindContaining(p.Url) is null) && pages.Any(p => _versions.FindContaining(p.Url) is not null);
string? group = null;
// One heading per group, in first-seen order: top-level pages between folders stay together.
var groups = new List<(string Name, List<NavigationNode> Pages)>();
foreach (var page in pages)
{
var g = Group(page, top);
if (mixed && _versions.FindContaining(page.Url) is { } owner) g = g == "Pages" ? owner.Label : $"{owner.Label} / {g}";
if (g != group) { sb.Append($"\n## {g}\n\n"); group = g; }
sb.Append($"- [{page.Title}]({md.Url(page.Url)})");
if (OneLine(page.Description).TrimEnd('.') is { Length: > 0 } d) sb.Append(": ").Append(d).Append('.');
if (install is not null && InstallCommand(page, install) is { } cmd) sb.Append($" Install: `{cmd}`");
sb.Append('\n');
var existing = groups.FindIndex(x => x.Name == g);
if (existing < 0) groups.Add((g, [page]));
else groups[existing].Pages.Add(page);
}
foreach (var (name, groupPages) in groups)
{
sb.Append($"\n## {name}\n\n");
foreach (var page in groupPages)
{
sb.Append($"- [{page.Title}]({md.Url(page.Url)})");
if (OneLine(page.Description).TrimEnd('.') is { Length: > 0 } d) sb.Append(": ").Append(d).Append('.');
if (install is not null && InstallCommand(page, install) is { } cmd) sb.Append($" Install: `{cmd}`");
sb.Append('\n');
}
}
return sb.ToString();
}
Expand Down
7 changes: 5 additions & 2 deletions src/ShellDocs.Components/wwwroot/shelldocs-theme.css
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/* Base resets, .shelldocs-prose typography and code-block chrome.
Every colour and spacing comes from ShellDocs.Tokens (tokens.css). */

/* Base */
*, *::before, *::after { box-sizing: border-box; border-color: var(--border); }
/* Base. In a cascade layer, so Tailwind utilities in previews and home pages
(border-destructive, …) win; unlayered rules beat every layer. */
@layer base {
*, *::before, *::after { box-sizing: border-box; border-color: var(--border); }
}
html {
background: var(--background);
color: var(--foreground);
Expand Down
70 changes: 66 additions & 4 deletions src/ShellDocs.Components/wwwroot/shelldocs.js
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,15 @@ window.shelldocsToc = {
}
}
if (!pick) pick = ids[0];
/* At the bottom of the page the last short sections can't scroll up into
the band, so the last heading on screen wins. */
var doc = document.documentElement;
if (window.innerHeight + (window.pageYOffset || 0) >= doc.scrollHeight - 2) {
for (var j = ids.length - 1; j >= 0; j--) {
var last = document.getElementById(ids[j]);
if (last && last.getBoundingClientRect().top < window.innerHeight) { pick = ids[j]; break; }
}
}
if (pick === currentActive) return;

if (currentActive) {
Expand Down Expand Up @@ -632,11 +641,23 @@ window.shelldocsToc = {
if (el) observer.observe(el);
});

// Reaching the bottom moves no heading across the band, so scroll checks too.
var queued = false;
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(function () { queued = false; update(); });
}
window.addEventListener('scroll', onScroll, { passive: true });

// Initial position — no scroll event yet, so we compute manually.
setTimeout(update, 0);

return {
dispose: function () { observer.disconnect(); }
dispose: function () {
observer.disconnect();
window.removeEventListener('scroll', onScroll);
}
};
},

Expand All @@ -645,7 +666,8 @@ window.shelldocsToc = {
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', '#' + id);
// The full path: a bare #id resolves against <base href>.
window.history.replaceState(null, '', location.pathname + location.search + '#' + id);
}
}
};
Expand Down Expand Up @@ -899,10 +921,27 @@ window.shelldocsChrome = (function () {
});
}

/* An interactive render replaces the prerendered TOC and headings (or changes
data-toc-ids on navigation) without an enhancedload, which would leave the
scroll-spy watching detached elements. */
var tocQueued = false;
function queueToc() {
if (tocQueued) return;
tocQueued = true;
setTimeout(function () { tocQueued = false; initToc(); }, 50);
}

function touchesToc(node) {
if (node.nodeType !== 1) return false;
if (node.matches('[data-toc-list], h2[id], h3[id]')) return true;
return !!node.querySelector('[data-toc-list], h2[id], h3[id]');
}

function watchForReplacedFrames() {
if (!window.MutationObserver) return;
new MutationObserver(function (records) {
for (var r = 0; r < records.length; r++) {
if (records[r].type === 'attributes') { queueToc(); continue; }
var added = records[r].addedNodes;
for (var n = 0; n < added.length; n++) {
if (added[n].nodeType !== 1) continue;
Expand All @@ -911,11 +950,32 @@ window.shelldocsChrome = (function () {
window.shelldocsIframes.init(added[n]);
restoreMobileNav(added[n]);
restoreCollapse(added[n]);
if (touchesToc(added[n])) queueToc();
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-toc-ids'] });
}

/* In-page links (the TOC, [x](#id) in content): a bare #id resolves against
<base href>, so Blazor's router or the browser would go to the home page.
Same-page links scroll here and keep the path, before Blazor sees the click. */
window.addEventListener('click', function (e) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var link = e.target.closest && e.target.closest('a[href]');
if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download')) return;
var raw = link.getAttribute('href');
var url = new URL(link.href, document.baseURI);
// Static hosts serve /docs/x/ for docs/x, so a trailing slash doesn't make it another page.
var samePage = raw.charAt(0) === '#' || (url.origin === location.origin && url.search === location.search
&& url.pathname.replace(/\/$/, '') === location.pathname.replace(/\/$/, ''));
if (!samePage || !url.hash) return;
var id = decodeURIComponent(url.hash.slice(1));
if (!id || !document.getElementById(id)) return;
e.preventDefault();
e.stopPropagation();
window.shelldocsToc.scrollTo(id);
}, true);

/* Mobile nav: hamburgers toggle [data-mobile-open] on the page shell. CSS slides
in the docs sidebar or shows the home menu; the backdrop, Escape, picking a
link, or an enhanced navigation closes it. */
Expand Down Expand Up @@ -1023,9 +1083,11 @@ window.shelldocsChrome = (function () {
return root ? new URL(root.getAttribute('data-page-md'), document.baseURI).href : null;
}

// With SiteUrl the prompt names the published page, also when an export is previewed locally.
function fillPageActions(scope) {
scope.querySelectorAll('[data-page-ai]').forEach(function (link) {
var url = pageMarkdownUrl(link);
var root = link.closest('[data-page-md]');
var url = (root && root.getAttribute('data-page-md-url')) || pageMarkdownUrl(link);
if (url) link.href = link.getAttribute('data-page-ai') + encodeURIComponent('Read ' + url + ' and help me with it.');
});
}
Expand Down
30 changes: 30 additions & 0 deletions tests/ShellDocs.Tests/LlmsTxtTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,29 @@ public void Index_ListsTheLatestPages_WithRulesAndInstallCommands()
Assert.DoesNotContain("introduction", v1);
}

[Fact]
public void Index_GroupsByMetaTitle_OnceEach()
{
// Top-level pages on both sides of a folder; the folder titled by its meta.json.
var root = Path.Combine(_root, "grouped");
void Page(string rel, string title) => File.WriteAllText(Dir(Path.Combine(root, rel)), $"---\ntitle: {title}\n---\n# {title}\n");
string Dir(string path) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); return path; }
File.WriteAllText(Dir(Path.Combine(root, "docs", "meta.json")), "{ \"pages\": [\"introduction\", \"cli\", \"theming\"] }");
File.WriteAllText(Dir(Path.Combine(root, "docs", "cli", "meta.json")), "{ \"title\": \"CLI\", \"pages\": [\"build\"] }");
Page("docs/introduction.md", "Introduction");
Page("docs/cli/build.md", "shelldocs build");
Page("docs/theming.md", "Theming");

var files = new ComponentRenderHarness(o => { o.ContentRoot = root; o.SiteName = "ShellDocs"; o.EnableLlmsTxt(); })
.Services.GetRequiredService<LlmsTxtFiles>();
var index = files.Get("/llms.txt", "https://shelldocs.shellui.dev")!.Body;

var headings = Regex.Matches(index, "^## (.+)$", RegexOptions.Multiline).Select(m => m.Groups[1].Value).ToList();
Assert.Equal(["Pages", "CLI"], headings);
Assert.Contains("## Pages\n\n- [Introduction](https://shelldocs.shellui.dev/docs/introduction.md)\n- [Theming](https://shelldocs.shellui.dev/docs/theming.md)\n", index);
Assert.Contains("## CLI\n\n- [shelldocs build](https://shelldocs.shellui.dev/docs/cli/build.md)\n", index);
}

[Fact]
public void FullText_AndPages_AreMarkdown()
{
Expand Down Expand Up @@ -319,6 +342,13 @@ public async Task PageActions_AreStaticHostFriendly()
Assert.Contains("data-page-ai=\"https://chatgpt.com/?q=\"", html);
Assert.Contains("href=\"https://claude.ai/new?q=Read%20%2Fdocs%2Fintroduction.md%20and%20help%20me%20with%20it.\"", html);
Assert.DoesNotContain("blazor:on", html);
Assert.DoesNotContain("data-page-md-url", html);

// With SiteUrl the prompt names the published page, so a local preview of an export doesn't change it.
var site = await new ComponentRenderHarness(o => { o.ContentRoot = Path.Combine(_root, "content"); o.SiteUrl = "https://shellui.dev/"; o.EnableLlmsTxt(); },
"http://localhost/docs/introduction").RenderAsync<DocsPageActions>();
Assert.Contains("data-page-md-url=\"https://shellui.dev/docs/introduction.md\"", site);
Assert.Contains("q=Read%20https%3A%2F%2Fshellui.dev%2Fdocs%2Fintroduction.md%20and%20help%20me%20with%20it.", site);

Assert.Contains("data-page-md", await Harness().RenderAsync<MarkdownContent>(new() { ["Markdown"] = "# Hi" }));
Assert.DoesNotContain("page-actions", await Harness(enabled: false).RenderAsync<DocsPageActions>());
Expand Down
Loading
Loading