From 3caa40a18c5ab3c6b42d14a206b8cb84193d63a0 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 19 Oct 2026 17:51:39 +0200 Subject: [PATCH 1/8] fix(core): use a folder's meta.json title for its section Fixes #41 --- docs/ARCHITECTURE.md | 1 + src/ShellDocs.Core/NavigationGraphBuilder.cs | 14 +++++++++----- .../NavigationGraphBuilderTests.cs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 19197fb..16a751d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. 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/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() { From d8c53741c92d30fe4e1490021add80af7f2561e5 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 19 Oct 2026 17:51:48 +0200 Subject: [PATCH 2/8] refactor(components): remove unused chrome state services SearchState, SidebarCollapseState and MobileNavState have had no users since search, the sidebar collapse and the mobile drawer moved to shelldocs.js. Closes #46 --- docs/ARCHITECTURE.md | 2 +- src/ShellDocs.Components/Chrome/MobileNavState.cs | 11 ----------- src/ShellDocs.Components/Chrome/SearchState.cs | 12 ------------ .../Chrome/SidebarCollapseState.cs | 12 ------------ .../ServiceCollectionExtensions.cs | 3 --- 5 files changed, 1 insertion(+), 39 deletions(-) delete mode 100644 src/ShellDocs.Components/Chrome/MobileNavState.cs delete mode 100644 src/ShellDocs.Components/Chrome/SearchState.cs delete mode 100644 src/ShellDocs.Components/Chrome/SidebarCollapseState.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 16a751d..23a9d2c 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`. 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/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(); From 7046c98456a5503ed8215110ad6ab9a0f1c57d37 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 19 Oct 2026 17:51:48 +0200 Subject: [PATCH 3/8] fix(markdown): decode entities in component attribute values Fixes #42 --- src/ShellDocs.Markdown/PreviewParser.cs | 7 ++++++- src/ShellDocs.Markdown/SlotExtractor.cs | 5 +++-- tests/ShellDocs.Tests/PreviewTests.cs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) 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/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/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( + "