Skip to content

Commit b7cc15c

Browse files
authored
Merge pull request #22 from shellui-dev/feat/meta-json-hidden
feat: meta.json hidden field for URL-routable-but-sidebar-excluded pages (0.1.2-alpha)
2 parents f851881 + f2c120f commit b7cc15c

7 files changed

Lines changed: 150 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,26 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht
44

55
## [Unreleased]
66

7+
## [0.1.2-alpha] — 2026-07-28
8+
9+
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).
10+
11+
### Added
12+
13+
- **`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.
14+
```json
15+
{
16+
"title": "Documentation",
17+
"pages": ["introduction", "getting-started"],
18+
"hidden": ["components", "cli", "markdown"]
19+
}
20+
```
21+
- **`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.
22+
23+
### Test coverage
24+
25+
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.
26+
727
## [0.1.1-alpha] — 2026-07-25
828

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

112-
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.1-alpha...HEAD
132+
[Unreleased]: https://github.com/shellui-dev/shelldocs/compare/v0.1.2-alpha...HEAD
133+
[0.1.2-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.2-alpha
113134
[0.1.1-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.1-alpha
114135
[0.1.0-alpha]: https://github.com/shellui-dev/shelldocs/releases/tag/v0.1.0-alpha

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
<!-- Package metadata (applies to any project with IsPackable=true) -->
2020
<PropertyGroup>
21-
<Version>0.1.1-alpha</Version>
21+
<Version>0.1.2-alpha</Version>
2222
<Authors>ShellUI</Authors>
2323
<Company>ShellUI</Company>
2424
<Copyright>Copyright © 2026 ShellUI</Copyright>

src/ShellDocs.CLI/Commands/InitCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ internal static class InitCommand
2323
// Bump with <Version> in Directory.Build.props on every release. Determines
2424
// which ShellDocs.* versions the scaffold references. If stale, consumers
2525
// scaffolding via a new CLI get old packages that lack the fresh CLI's fixes.
26-
private const string ShellDocsVersion = "0.1.1-alpha";
26+
private const string ShellDocsVersion = "0.1.2-alpha";
2727

2828
public static int Run(string? path, string dir, bool attach, bool yes, string theme)
2929
{

src/ShellDocs.Core/MetaJson.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ public class MetaJson
1111
[JsonPropertyName("pages")]
1212
public List<MetaJsonEntry> Pages { get; set; } = new();
1313

14+
// Slugs of pages or subfolders that should route (URLs resolve) but not
15+
// appear in the sidebar tree. Useful for landing pages reached only via
16+
// the package selector, private drafts, or archived content.
17+
[JsonPropertyName("hidden")]
18+
public List<string> Hidden { get; set; } = new();
19+
1420
private static readonly JsonSerializerOptions Options = new()
1521
{
1622
PropertyNameCaseInsensitive = true,

src/ShellDocs.Core/NavigationGraph.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,27 @@ public class NavigationGraph
77
private readonly Dictionary<string, NavigationNode> _byUrl;
88
private readonly List<NavigationNode> _flatPages;
99

10-
public NavigationGraph(NavigationNode root)
10+
public NavigationGraph(NavigationNode root, IEnumerable<NavigationNode>? hiddenPages = null)
1111
{
1212
Root = root;
1313
_byUrl = new Dictionary<string, NavigationNode>(StringComparer.OrdinalIgnoreCase);
1414
_flatPages = new List<NavigationNode>();
1515
Index(root);
16+
// Hidden pages route (URLs resolve) but never appear in the visible
17+
// tree, so they're excluded from _flatPages (prev/next skips them).
18+
if (hiddenPages is not null)
19+
{
20+
foreach (var page in hiddenPages) IndexHidden(page);
21+
}
22+
}
23+
24+
private void IndexHidden(NavigationNode node)
25+
{
26+
if (node.Kind == NodeKind.Page && !string.IsNullOrEmpty(node.Url))
27+
{
28+
_byUrl[Normalize(node.Url)] = node;
29+
}
30+
foreach (var child in node.Children) IndexHidden(child);
1631
}
1732

1833
public NavigationNode? ResolveByUrl(string url)

src/ShellDocs.Core/NavigationGraphBuilder.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@ public static NavigationGraph Build(string contentRoot)
1818
Path = abs
1919
};
2020

21-
var children = BuildFolder(abs, abs, urlPrefix: "");
21+
var hidden = new List<NavigationNode>();
22+
var children = BuildFolder(abs, abs, urlPrefix: "", hidden);
2223
LinkChildren(rootNode, children);
23-
return new NavigationGraph(rootNode);
24+
return new NavigationGraph(rootNode, hidden);
2425
}
2526

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

3738
var folderNameToChildren = subfolders.ToDictionary(
3839
path => System.IO.Path.GetFileName(path),
39-
path => (folderPath: path, children: BuildFolder(path, root, CombineUrl(urlPrefix, System.IO.Path.GetFileName(path)))),
40+
path => (folderPath: path, children: BuildFolder(path, root, CombineUrl(urlPrefix, System.IO.Path.GetFileName(path)), hidden)),
4041
StringComparer.OrdinalIgnoreCase);
4142

4243
// No meta.json: alphabetical ordering, subfolders inline as sections.
@@ -61,6 +62,32 @@ private static List<NavigationNode> BuildFolder(string folder, string root, stri
6162
var result = new List<NavigationNode>();
6263
var consumedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
6364
var consumedFolders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
65+
66+
// Pages/folders in meta.hidden route (URLs still resolve) but never
67+
// appear in the sidebar tree. Seeded into consumed sets so both the
68+
// explicit-render loop and the auto-append loop skip them; the actual
69+
// nodes get pushed into `hidden` so the graph can still index their URLs.
70+
foreach (var slug in meta.Hidden)
71+
{
72+
consumedSlugs.Add(slug);
73+
consumedFolders.Add(slug);
74+
if (slugToNode.TryGetValue(slug, out var hiddenPage))
75+
hidden.Add(hiddenPage);
76+
if (folderNameToChildren.TryGetValue(slug, out var hiddenFolder))
77+
{
78+
// Wrap the folder's children in a section node so the graph's
79+
// Index() walk (which recurses .Children) reaches every page.
80+
var section = new NavigationNode
81+
{
82+
Title = TitleFromFolderName(slug),
83+
Kind = NodeKind.Section,
84+
Path = hiddenFolder.folderPath
85+
};
86+
LinkChildren(section, hiddenFolder.children);
87+
hidden.Add(section);
88+
}
89+
}
90+
6491
foreach (var entry in meta.Pages)
6592
{
6693
var node = ResolveEntry(entry, slugToNode, folderNameToChildren, root, urlPrefix, consumedSlugs, consumedFolders);
@@ -108,6 +135,10 @@ consumer touching meta.json. */
108135
switch (entry)
109136
{
110137
case MetaJsonPageRef pageRef:
138+
// Hidden entries were pre-seeded into these sets; skip so a
139+
// slug listed in both `hidden` and `pages` stays hidden.
140+
if (consumedSlugs.Contains(pageRef.Slug) || consumedFolders.Contains(pageRef.Slug))
141+
return null;
111142
if (slugToNode.TryGetValue(pageRef.Slug, out var page))
112143
{
113144
consumedSlugs.Add(pageRef.Slug);

tests/ShellDocs.Tests/NavigationGraphBuilderTests.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,75 @@ public void Build_UnknownSlugInMetaJson_IsSilentlySkipped()
139139
Assert.Equal(new[] { "Real" }, titles);
140140
}
141141

142+
[Fact]
143+
public void Build_HiddenSlug_IsExcludedFromSidebar_ButUrlStillResolves()
144+
{
145+
WriteMd("visible.md", "Visible");
146+
WriteMd("secret.md", "Secret");
147+
WriteMeta("", """{ "pages": ["visible"], "hidden": ["secret"] }""");
148+
149+
var graph = NavigationGraphBuilder.Build(_root);
150+
var titles = graph.Root.Children.Select(c => c.Title).ToList();
151+
152+
// Sidebar shows only visible (secret excluded despite being on disk).
153+
Assert.Equal(new[] { "Visible" }, titles);
154+
155+
// But secret's URL still resolves (the whole point of `hidden` vs
156+
// deleting the file: the dropdown/direct link still works).
157+
var secretNode = graph.ResolveByUrl("/secret");
158+
Assert.NotNull(secretNode);
159+
Assert.Equal("Secret", secretNode!.Title);
160+
}
161+
162+
[Fact]
163+
public void Build_HiddenFolder_IsExcludedFromSidebar_ButChildUrlsResolve()
164+
{
165+
WriteMd("visible.md", "Visible");
166+
WriteMd("packages/components.md", "Components");
167+
WriteMd("packages/cli.md", "CLI");
168+
WriteMeta("", """{ "pages": ["visible"], "hidden": ["packages"] }""");
169+
170+
var graph = NavigationGraphBuilder.Build(_root);
171+
var titles = graph.Root.Children.Select(c => c.Title).ToList();
172+
173+
// Sidebar has no "Packages" section.
174+
Assert.Equal(new[] { "Visible" }, titles);
175+
176+
// But child URLs still route via ResolveByUrl.
177+
Assert.NotNull(graph.ResolveByUrl("/packages/components"));
178+
Assert.NotNull(graph.ResolveByUrl("/packages/cli"));
179+
}
180+
181+
[Fact]
182+
public void Build_HiddenTakesPrecedenceOverPages()
183+
{
184+
// A slug listed in BOTH hidden and pages: hidden wins.
185+
WriteMd("alpha.md", "Alpha");
186+
WriteMd("beta.md", "Beta");
187+
WriteMeta("", """{ "pages": ["alpha", "beta"], "hidden": ["beta"] }""");
188+
189+
var graph = NavigationGraphBuilder.Build(_root);
190+
var titles = graph.Root.Children.Select(c => c.Title).ToList();
191+
192+
Assert.Equal(new[] { "Alpha" }, titles);
193+
Assert.NotNull(graph.ResolveByUrl("/beta"));
194+
}
195+
196+
[Fact]
197+
public void Build_HiddenSlug_IsAlsoExcludedFromAutoAppend()
198+
{
199+
// No `pages` array. Without hidden support, auto-append would surface
200+
// secret alongside visible. With hidden, secret still hidden.
201+
WriteMd("visible.md", "Visible");
202+
WriteMd("secret.md", "Secret");
203+
WriteMeta("", """{ "hidden": ["secret"] }""");
204+
205+
var graph = NavigationGraphBuilder.Build(_root);
206+
var titles = graph.Root.Children.Select(c => c.Title).ToList();
207+
208+
Assert.Equal(new[] { "Visible" }, titles);
209+
}
210+
142211
[Fact]
143212
public void Build_MdFileNotInMetaJson_IsAppendedAfterExplicitOrdering()
144213
{

0 commit comments

Comments
 (0)