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

## [Unreleased]

## [0.1.2-alpha] — 2026-07-28

Dogfood-driven addition. Surfaced while building shelldocs.dev: the framework had no way to route to a page without also showing it in the sidebar. Fine for typical docs, blocker for landing pages reached via the sidebar package selector (they'd render redundantly in the sidebar tree AND be the dropdown target).

### Added

- **`meta.json` `hidden` array.** New optional field alongside `title` / `pages`. Slugs listed there route (URLs resolve, direct links + package-selector navigation work) but never appear in the sidebar tree. Takes precedence over `pages` — a slug listed in both stays hidden.
```json
{
"title": "Documentation",
"pages": ["introduction", "getting-started"],
"hidden": ["components", "cli", "markdown"]
}
```
- **`NavigationGraph` constructor gains an optional `hiddenPages` parameter.** Hidden pages get indexed into the URL lookup but are excluded from `_flatPages` (so `GetPrevNext` skips them) and never appear as `Root.Children` (so sidebar tree and `Flatten()` skip them). Not intended for direct consumer use — `NavigationGraphBuilder.Build()` produces the collection during folder walking.

### Test coverage

Four new `NavigationGraphBuilderTests`: hidden slug excluded from sidebar but URL resolves, hidden folder excluded from sidebar but child URLs resolve, `hidden` takes precedence over `pages`, hidden slug excluded from auto-append.

## [0.1.1-alpha] — 2026-07-25

First point-release after the dogfood smoke of `0.1.0-alpha`. Three consumer-blocking fixes plus release-workflow hardening.
Expand Down Expand Up @@ -109,6 +129,7 @@ Published to NuGet:
- `<TypeTable>` is hand-authored today; XML-doc auto-generation ships in `ShellDocs.Xml` (Phase 4)
- No `<DocsBreadcrumb>` opt-out — currently hides when the trail has ≤ 1 node, otherwise always renders

[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.1-alpha...HEAD
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.2-alpha...HEAD
[0.1.2-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.2-alpha
[0.1.1-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.1-alpha
[0.1.0-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.0-alpha
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.1-alpha</Version>
<Version>0.1.2-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
2 changes: 1 addition & 1 deletion src/ShellDocs.CLI/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ internal static class InitCommand
// Bump with <Version> in Directory.Build.props on every release. Determines
// which ShellDocs.* versions the scaffold references. If stale, consumers
// scaffolding via a new CLI get old packages that lack the fresh CLI's fixes.
private const string ShellDocsVersion = "0.1.1-alpha";
private const string ShellDocsVersion = "0.1.2-alpha";

public static int Run(string? path, string dir, bool attach, bool yes, string theme)
{
Expand Down
6 changes: 6 additions & 0 deletions src/ShellDocs.Core/MetaJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ public class MetaJson
[JsonPropertyName("pages")]
public List<MetaJsonEntry> Pages { get; set; } = new();

// Slugs of pages or subfolders that should route (URLs resolve) but not
// appear in the sidebar tree. Useful for landing pages reached only via
// the package selector, private drafts, or archived content.
[JsonPropertyName("hidden")]
public List<string> Hidden { get; set; } = new();

private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
Expand Down
17 changes: 16 additions & 1 deletion src/ShellDocs.Core/NavigationGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,27 @@ public class NavigationGraph
private readonly Dictionary<string, NavigationNode> _byUrl;
private readonly List<NavigationNode> _flatPages;

public NavigationGraph(NavigationNode root)
public NavigationGraph(NavigationNode root, IEnumerable<NavigationNode>? hiddenPages = null)
{
Root = root;
_byUrl = new Dictionary<string, NavigationNode>(StringComparer.OrdinalIgnoreCase);
_flatPages = new List<NavigationNode>();
Index(root);
// Hidden pages route (URLs resolve) but never appear in the visible
// tree, so they're excluded from _flatPages (prev/next skips them).
if (hiddenPages is not null)
{
foreach (var page in hiddenPages) IndexHidden(page);
}
}

private void IndexHidden(NavigationNode node)
{
if (node.Kind == NodeKind.Page && !string.IsNullOrEmpty(node.Url))
{
_byUrl[Normalize(node.Url)] = node;
}
foreach (var child in node.Children) IndexHidden(child);
}

public NavigationNode? ResolveByUrl(string url)
Expand Down
39 changes: 35 additions & 4 deletions src/ShellDocs.Core/NavigationGraphBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ public static NavigationGraph Build(string contentRoot)
Path = abs
};

var children = BuildFolder(abs, abs, urlPrefix: "");
var hidden = new List<NavigationNode>();
var children = BuildFolder(abs, abs, urlPrefix: "", hidden);
LinkChildren(rootNode, children);
return new NavigationGraph(rootNode);
return new NavigationGraph(rootNode, hidden);
}

private static List<NavigationNode> BuildFolder(string folder, string root, string urlPrefix)
private static List<NavigationNode> BuildFolder(string folder, string root, string urlPrefix, List<NavigationNode> hidden)
{
var meta = ReadMeta(folder);
var mdFiles = Directory.GetFiles(folder, "*.md", SearchOption.TopDirectoryOnly);
Expand All @@ -36,7 +37,7 @@ private static List<NavigationNode> BuildFolder(string folder, string root, stri

var folderNameToChildren = subfolders.ToDictionary(
path => System.IO.Path.GetFileName(path),
path => (folderPath: path, children: BuildFolder(path, root, CombineUrl(urlPrefix, System.IO.Path.GetFileName(path)))),
path => (folderPath: path, children: BuildFolder(path, root, CombineUrl(urlPrefix, System.IO.Path.GetFileName(path)), hidden)),
StringComparer.OrdinalIgnoreCase);

// No meta.json: alphabetical ordering, subfolders inline as sections.
Expand All @@ -61,6 +62,32 @@ private static List<NavigationNode> BuildFolder(string folder, string root, stri
var result = new List<NavigationNode>();
var consumedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var consumedFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

// Pages/folders in meta.hidden route (URLs still resolve) but never
// appear in the sidebar tree. Seeded into consumed sets so both the
// explicit-render loop and the auto-append loop skip them; the actual
// nodes get pushed into `hidden` so the graph can still index their URLs.
foreach (var slug in meta.Hidden)
{
consumedSlugs.Add(slug);
consumedFolders.Add(slug);
if (slugToNode.TryGetValue(slug, out var hiddenPage))
hidden.Add(hiddenPage);
if (folderNameToChildren.TryGetValue(slug, out var hiddenFolder))
{
// Wrap the folder's children in a section node so the graph's
// Index() walk (which recurses .Children) reaches every page.
var section = new NavigationNode
{
Title = TitleFromFolderName(slug),
Kind = NodeKind.Section,
Path = hiddenFolder.folderPath
};
LinkChildren(section, hiddenFolder.children);
hidden.Add(section);
}
}

foreach (var entry in meta.Pages)
{
var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix, consumedSlugs, consumedFolders);
Expand Down Expand Up @@ -108,6 +135,10 @@ consumer touching meta.json. */
switch (entry)
{
case MetaJsonPageRef pageRef:
// Hidden entries were pre-seeded into these sets; skip so a
// slug listed in both `hidden` and `pages` stays hidden.
if (consumedSlugs.Contains(pageRef.Slug) || consumedFolders.Contains(pageRef.Slug))
return null;
if (slugToNode.TryGetValue(pageRef.Slug, out var page))
{
consumedSlugs.Add(pageRef.Slug);
Expand Down
69 changes: 69 additions & 0 deletions tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,75 @@ public void Build_UnknownSlugInMetaJson_IsSilentlySkipped()
Assert.Equal(new[] { "Real" }, titles);
}

[Fact]
public void Build_HiddenSlug_IsExcludedFromSidebar_ButUrlStillResolves()
{
WriteMd("visible.md", "Visible");
WriteMd("secret.md", "Secret");
WriteMeta("", """{ "pages": ["visible"], "hidden": ["secret"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

// Sidebar shows only visible (secret excluded despite being on disk).
Assert.Equal(new[] { "Visible" }, titles);

// But secret's URL still resolves (the whole point of `hidden` vs
// deleting the file: the dropdown/direct link still works).
var secretNode = graph.ResolveByUrl("/secret");
Assert.NotNull(secretNode);
Assert.Equal("Secret", secretNode!.Title);
}

[Fact]
public void Build_HiddenFolder_IsExcludedFromSidebar_ButChildUrlsResolve()
{
WriteMd("visible.md", "Visible");
WriteMd("packages/components.md", "Components");
WriteMd("packages/cli.md", "CLI");
WriteMeta("", """{ "pages": ["visible"], "hidden": ["packages"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

// Sidebar has no "Packages" section.
Assert.Equal(new[] { "Visible" }, titles);

// But child URLs still route via ResolveByUrl.
Assert.NotNull(graph.ResolveByUrl("/packages/components"));
Assert.NotNull(graph.ResolveByUrl("/packages/cli"));
}

[Fact]
public void Build_HiddenTakesPrecedenceOverPages()
{
// A slug listed in BOTH hidden and pages: hidden wins.
WriteMd("alpha.md", "Alpha");
WriteMd("beta.md", "Beta");
WriteMeta("", """{ "pages": ["alpha", "beta"], "hidden": ["beta"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

Assert.Equal(new[] { "Alpha" }, titles);
Assert.NotNull(graph.ResolveByUrl("/beta"));
}

[Fact]
public void Build_HiddenSlug_IsAlsoExcludedFromAutoAppend()
{
// No `pages` array. Without hidden support, auto-append would surface
// secret alongside visible. With hidden, secret still hidden.
WriteMd("visible.md", "Visible");
WriteMd("secret.md", "Secret");
WriteMeta("", """{ "hidden": ["secret"] }""");

var graph = NavigationGraphBuilder.Build(_root);
var titles = graph.Root.Children.Select(c => c.Title).ToList();

Assert.Equal(new[] { "Visible" }, titles);
}

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