From 2bf258d05e70177bad125bf9f80245e273bace22 Mon Sep 17 00:00:00 2001 From: Jan Ruhlaender Date: Tue, 10 Nov 2026 20:24:06 +0100 Subject: [PATCH] fix(tags): write tag pages into tags/ even next to a Tags.md page On a case-insensitive file system the redirect Tags/index.html of a vocabulary page Tags.md created the output folder as Tags/, so the tag pages landed in Tags/ while all links point to tags/. mdu check-site reported these links, and a case-sensitive web server would answer 404. The tag folder now gets exactly the name tags, renaming a folder that differs only in case. --- Source/Mdu.Test/TagsTest.cs | 18 +++++++++++++++ .../Service/Visitors/MarkdigVisitor.Tags.cs | 23 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Source/Mdu.Test/TagsTest.cs b/Source/Mdu.Test/TagsTest.cs index 3615ff5..fd2c405 100644 --- a/Source/Mdu.Test/TagsTest.cs +++ b/Source/Mdu.Test/TagsTest.cs @@ -170,6 +170,24 @@ public void WithoutVocabularyTheMostUsedSpellingNamesTheTagAndMduWritesTheOvervi }); } + [Fact] + public void TagFolderKeepsItsCasingNextToTheVocabularyPageTags() + { + WithSite(StandardSite(), root => + { + var site = Path.Combine(root, "site"); + // A folder left from an earlier render, as the redirect of Tags.md creates it on a case-insensitive disk. + Directory.CreateDirectory(Path.Combine(site, "Tags")); + var (result, errors) = Render(root); + Assert.True(result == 0, errors); + + Assert.Contains("tags", Directory.EnumerateDirectories(site).Select(Path.GetFileName), StringComparer.Ordinal); + Assert.True(File.Exists(Path.Combine(site, "tags", "security.html"))); + var (check, checkErrors) = Capture(() => CheckSiteCommand.Execute(new DirectoryInfo(site), [])); + Assert.True(check == 0, checkErrors); + }); + } + [Fact] public void DocumentationFolderNamedTagsCollidesOnlyWhenTheSiteUsesTags() { diff --git a/Source/Mdu/Service/Visitors/MarkdigVisitor.Tags.cs b/Source/Mdu/Service/Visitors/MarkdigVisitor.Tags.cs index 467c4ef..1821a10 100644 --- a/Source/Mdu/Service/Visitors/MarkdigVisitor.Tags.cs +++ b/Source/Mdu/Service/Visitors/MarkdigVisitor.Tags.cs @@ -27,7 +27,7 @@ public IReadOnlyList WriteTagPages() { if (_siteTags.Tags.Count == 0) return []; var folder = Path.Combine(_outPath, SiteTags.Folder); - Directory.CreateDirectory(folder); + CreateTagFolder(folder); var written = new List(); var anchorPage = _siteTags.VocabularyPage ?? _navigationRoots.FirstOrDefault(); var indexPath = Path.Combine(folder, "index.html"); @@ -80,6 +80,27 @@ private string ExpandTagLists(MarkdownFile page, string markdown, string outputD GetRelativeUrl(segments, [SiteTags.Folder, tag.Slug + ".html"]), tag.Pages.Count)).ToArray(); } + /// + /// Creates tags/ with exactly this casing. On a case-insensitive file system the redirect Tags/index.html + /// of a vocabulary page Tags.md, or an earlier render, may already have created the folder as Tags/; + /// links to tags/… would then break on a case-sensitive web server, so the folder is renamed. + /// + private static void CreateTagFolder(string folder) + { + var parent = Path.GetDirectoryName(folder)!; + var name = Path.GetFileName(folder); + if (Directory.Exists(folder) && Directory.EnumerateDirectories(parent).Select(Path.GetFileName).ToArray() is var names && + !names.Contains(name, StringComparer.Ordinal) && + names.FirstOrDefault(entry => string.Equals(entry, name, StringComparison.OrdinalIgnoreCase)) is { } existing) + { + // A case-only rename goes through a temporary name, which every file system accepts. + var temporary = Path.Combine(parent, name + "-" + Guid.NewGuid().ToString("N")); + Directory.Move(Path.Combine(parent, existing), temporary); + Directory.Move(temporary, folder); + } + Directory.CreateDirectory(folder); + } + private bool IsInTagFolder(string outputPath) => Path.GetFullPath(outputPath).StartsWith( Path.GetFullPath(Path.Combine(_outPath, SiteTags.Folder)) + Path.DirectorySeparatorChar, PathComparison);