Skip to content

Commit d0f76a0

Browse files
committed
fix: rewrite markdown page links to generated routes
1 parent e40c1c0 commit d0f76a0

7 files changed

Lines changed: 308 additions & 18 deletions

File tree

src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,21 @@ public HtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateS
4848
public required string Id { get; init; }
4949

5050
/// <inheritdoc />
51-
public string Name => SanitizeFilename(_markdownSource.Name);
51+
public string Name => GetPageFolderName(_markdownSource.Name);
52+
53+
/// <summary>
54+
/// Gets the folder name used for a folderized markdown page.
55+
/// </summary>
56+
/// <param name="markdownFilename">Original markdown filename with extension.</param>
57+
/// <returns>Sanitized folder name without the markdown file extension.</returns>
58+
public static string GetPageFolderName(string markdownFilename)
59+
{
60+
var nameWithoutExtension = Path.GetFileNameWithoutExtension(markdownFilename);
61+
var invalidChars = Path.GetInvalidFileNameChars();
62+
63+
return string.Concat(nameWithoutExtension.Select(c =>
64+
invalidChars.Contains(c) ? '_' : c));
65+
}
5266

5367
/// <summary>
5468
/// Optional parent folder in virtual hierarchy.
@@ -81,17 +95,5 @@ public virtual async IAsyncEnumerable<IStorableChild> GetItemsAsync(StorableType
8195
/// </summary>
8296
/// <param name="markdownFilename">Original markdown filename with extension</param>
8397
/// <returns>Sanitized folder name</returns>
84-
private string SanitizeFilename(string markdownFilename)
85-
{
86-
// Remove file extension
87-
var nameWithoutExtension = Path.GetFileNameWithoutExtension(markdownFilename);
88-
89-
// Replace invalid filename characters with underscore
90-
var invalidChars = Path.GetInvalidFileNameChars();
91-
var sanitized = string.Concat(nameWithoutExtension.Select(c =>
92-
invalidChars.Contains(c) ? '_' : c));
93-
94-
return sanitized;
95-
}
9698
}
9799
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using OwlCore.Diagnostics;
2+
using OwlCore.Storage;
3+
using WindowsAppCommunity.Blog.Assets;
4+
5+
namespace WindowsAppCommunity.Blog.Pages;
6+
7+
/// <summary>
8+
/// Rewrites markdown-to-markdown links to generated page routes, delegating ordinary asset behavior.
9+
/// </summary>
10+
public sealed class MarkdownPageAssetStrategy : IAssetStrategy
11+
{
12+
/// <summary>
13+
/// Gets the source-derived route index for generated markdown pages.
14+
/// </summary>
15+
public required MarkdownPageRouteIndex RouteIndex { get; init; }
16+
17+
/// <summary>
18+
/// Gets the strategy used for non-markdown assets.
19+
/// </summary>
20+
public required IAssetStrategy AssetStrategy { get; init; }
21+
22+
/// <inheritdoc/>
23+
public Task<string?> DecideAsync(IFile referencingTextFile, IFile referencedAssetFile, string originalPath, CancellationToken ct = default)
24+
{
25+
if (!Path.GetExtension(referencedAssetFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
26+
return AssetStrategy.DecideAsync(referencingTextFile, referencedAssetFile, originalPath, ct);
27+
28+
if (RouteIndex.TryGetRelativeRoute(referencingTextFile, referencedAssetFile, out var relativeRoute))
29+
return Task.FromResult(relativeRoute);
30+
31+
Logger.LogWarning($"Markdown link target was resolved but is not part of the generated page route index: {referencedAssetFile.Name}");
32+
return Task.FromResult<string?>(null);
33+
}
34+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using OwlCore.Storage;
2+
3+
namespace WindowsAppCommunity.Blog.Pages;
4+
5+
/// <summary>
6+
/// Maps a source markdown file to its generated folderized page route.
7+
/// </summary>
8+
/// <param name="SourceFile">The source markdown file.</param>
9+
/// <param name="PageFolderPath">The generated page folder path relative to the site root.</param>
10+
public sealed record MarkdownPageRoute(IFile SourceFile, string PageFolderPath)
11+
{
12+
/// <summary>
13+
/// Gets the generated page route as a folder URL.
14+
/// </summary>
15+
public string PageUrlPath => string.IsNullOrWhiteSpace(PageFolderPath) ? "./" : $"{PageFolderPath.TrimEnd('/')}/";
16+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System.Runtime.CompilerServices;
2+
using OwlCore.Storage;
3+
using WindowsAppCommunity.Blog.Page;
4+
5+
namespace WindowsAppCommunity.Blog.Pages;
6+
7+
/// <summary>
8+
/// Source-derived route index for folderized markdown pages.
9+
/// </summary>
10+
public sealed class MarkdownPageRouteIndex
11+
{
12+
private readonly Dictionary<string, MarkdownPageRoute> _routesByFileId;
13+
14+
private MarkdownPageRouteIndex(Dictionary<string, MarkdownPageRoute> routesByFileId)
15+
{
16+
_routesByFileId = routesByFileId;
17+
}
18+
19+
/// <summary>
20+
/// Gets all indexed markdown page routes.
21+
/// </summary>
22+
public IReadOnlyCollection<MarkdownPageRoute> Routes => _routesByFileId.Values;
23+
24+
/// <summary>
25+
/// Creates an index from the markdown source folder tree.
26+
/// </summary>
27+
public static async Task<MarkdownPageRouteIndex> CreateAsync(IFolder markdownSourceFolder, CancellationToken cancellationToken = default)
28+
{
29+
var routesByFileId = new Dictionary<string, MarkdownPageRoute>();
30+
await AddFolderRoutesAsync(markdownSourceFolder, string.Empty, routesByFileId, cancellationToken);
31+
32+
return new MarkdownPageRouteIndex(routesByFileId);
33+
}
34+
35+
/// <summary>
36+
/// Attempts to get the generated route for a source markdown file.
37+
/// </summary>
38+
public bool TryGetRoute(IFile sourceMarkdownFile, out MarkdownPageRoute? route)
39+
{
40+
return _routesByFileId.TryGetValue(sourceMarkdownFile.Id, out route);
41+
}
42+
43+
/// <summary>
44+
/// Attempts to get a generated page route relative from the referencing markdown page route.
45+
/// </summary>
46+
public bool TryGetRelativeRoute(IFile referencingMarkdownFile, IFile referencedMarkdownFile, out string? relativeRoute)
47+
{
48+
relativeRoute = null;
49+
50+
if (!TryGetRoute(referencingMarkdownFile, out var referencingRoute) || referencingRoute is null)
51+
return false;
52+
53+
if (!TryGetRoute(referencedMarkdownFile, out var referencedRoute) || referencedRoute is null)
54+
return false;
55+
56+
relativeRoute = GetRelativeFolderRoute(referencingRoute.PageFolderPath, referencedRoute.PageFolderPath);
57+
return true;
58+
}
59+
60+
private static async Task AddFolderRoutesAsync(
61+
IFolder folder,
62+
string currentFolderPath,
63+
Dictionary<string, MarkdownPageRoute> routesByFileId,
64+
CancellationToken cancellationToken)
65+
{
66+
await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken).WithCancellation(cancellationToken))
67+
{
68+
if (item is IFile file && Path.GetExtension(file.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
69+
{
70+
var pageFolderName = HtmlTemplatedMarkdownPageFolder.GetPageFolderName(file.Name);
71+
var pageFolderPath = CombineRoutePath(currentFolderPath, pageFolderName);
72+
routesByFileId[file.Id] = new MarkdownPageRoute(file, pageFolderPath);
73+
}
74+
75+
if (item is IFolder subfolder)
76+
{
77+
var nestedFolderPath = CombineRoutePath(currentFolderPath, subfolder.Name);
78+
await AddFolderRoutesAsync(subfolder, nestedFolderPath, routesByFileId, cancellationToken);
79+
}
80+
}
81+
}
82+
83+
private static string CombineRoutePath(string parentPath, string childName)
84+
{
85+
return string.IsNullOrWhiteSpace(parentPath) ? childName : $"{parentPath.TrimEnd('/')}/{childName}";
86+
}
87+
88+
private static string GetRelativeFolderRoute(string fromPageFolderPath, string toPageFolderPath)
89+
{
90+
var fromSegments = SplitRoutePath(fromPageFolderPath).ToArray();
91+
var toSegments = SplitRoutePath(toPageFolderPath).ToArray();
92+
93+
var commonLength = 0;
94+
while (commonLength < fromSegments.Length &&
95+
commonLength < toSegments.Length &&
96+
string.Equals(fromSegments[commonLength], toSegments[commonLength], StringComparison.OrdinalIgnoreCase))
97+
{
98+
commonLength++;
99+
}
100+
101+
var relativeSegments = Enumerable
102+
.Repeat("..", fromSegments.Length - commonLength)
103+
.Concat(toSegments.Skip(commonLength))
104+
.ToArray();
105+
106+
if (relativeSegments.Length == 0)
107+
return "./";
108+
109+
return $"{string.Join('/', relativeSegments)}/";
110+
}
111+
112+
private static IEnumerable<string> SplitRoutePath(string routePath)
113+
{
114+
return routePath
115+
.Replace('\\', '/')
116+
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
117+
}
118+
}

src/Commands/Blog/PostPage/PagesCommand.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,23 @@ private async Task<int> ExecuteAsync(
8888
// Turns `.md` files into folders with an `index.html` holding asset metadata for output copy
8989
var templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource);
9090
var markdownSourceFileIds = await PageAssetMaterializer.GetFileIdsAsync(markdownSourceFolder);
91+
var markdownPageRouteIndex = await MarkdownPageRouteIndex.CreateAsync(markdownSourceFolder);
92+
var fileAssetStrategy = new KnownAssetStrategy()
93+
{
94+
IncludedAssetFileIds = templateFileIds,
95+
ReferencedAssetFileIds = markdownSourceFileIds,
96+
UnknownAssetFaultStrategy = FaultStrategy.LogWarn,
97+
UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop,
98+
};
99+
91100
var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName)
92101
{
93102
LinkDetector = new RegexAssetLinkDetector(),
94103
Resolver = new RelativePathAssetResolver(),
95-
AssetStrategy = new KnownAssetStrategy()
104+
AssetStrategy = new MarkdownPageAssetStrategy
96105
{
97-
IncludedAssetFileIds = templateFileIds,
98-
ReferencedAssetFileIds = markdownSourceFileIds,
99-
UnknownAssetFaultStrategy = FaultStrategy.LogWarn,
100-
UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop,
106+
RouteIndex = markdownPageRouteIndex,
107+
AssetStrategy = fileAssetStrategy,
101108
},
102109
};
103110

tests/Blog/BlogCommandMaterializationTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,51 @@ public async Task PagesCommand_CopiesTemplateAssetsAndReferencedSourceAssetsToRe
8989
}
9090
}
9191

92+
[TestMethod]
93+
public async Task PagesCommand_RewritesInRootMarkdownLinksToGeneratedPageRoutes()
94+
{
95+
var tempRoot = CreateTempRoot();
96+
97+
try
98+
{
99+
var sourceFolder = Path.Combine(tempRoot, "source");
100+
var templateFolder = Path.Combine(tempRoot, "template");
101+
var outputFolder = Path.Combine(tempRoot, "output");
102+
103+
Directory.CreateDirectory(Path.Combine(sourceFolder, "sub"));
104+
Directory.CreateDirectory(templateFolder);
105+
Directory.CreateDirectory(outputFolder);
106+
107+
await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page1.md"), "---\ntitle: Page 1\n---\n\n[Page 2](page2.md)\n\n[Page 3](sub/page3.md)");
108+
await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page2.md"), "---\ntitle: Page 2\n---\n\n[Page 1](page1.md)");
109+
await File.WriteAllTextAsync(Path.Combine(sourceFolder, "sub", "page3.md"), "---\ntitle: Page 3\n---\n\n[Page 1](../page1.md)");
110+
await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "<html><body>{{ body }}</body></html>");
111+
112+
var exitCode = await new PagesCommand().InvokeAsync([
113+
"--markdown-folder", sourceFolder,
114+
"--template", templateFolder,
115+
"--output", outputFolder]);
116+
117+
var page1Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page1", "index.html"));
118+
var page2Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page2", "index.html"));
119+
var page3Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "sub", "page3", "index.html"));
120+
121+
Assert.AreEqual(0, exitCode);
122+
StringAssert.Contains(page1Html, "href=\"../page2/\"");
123+
StringAssert.Contains(page1Html, "href=\"../sub/page3/\"");
124+
StringAssert.Contains(page2Html, "href=\"../page1/\"");
125+
StringAssert.Contains(page3Html, "href=\"../../page1/\"");
126+
Assert.IsFalse(page1Html.Contains(".md"), "Generated page1 HTML should not link to raw markdown files.");
127+
Assert.IsFalse(page2Html.Contains(".md"), "Generated page2 HTML should not link to raw markdown files.");
128+
Assert.IsFalse(page3Html.Contains(".md"), "Generated page3 HTML should not link to raw markdown files.");
129+
Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output.");
130+
}
131+
finally
132+
{
133+
DeleteTempRoot(tempRoot);
134+
}
135+
}
136+
92137
private static string CreateTempRoot()
93138
{
94139
var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-tests", Guid.NewGuid().ToString("N"));
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Microsoft.VisualStudio.TestTools.UnitTesting;
2+
using OwlCore.Storage;
3+
using OwlCore.Storage.Memory;
4+
using WindowsAppCommunity.Blog.Assets;
5+
using WindowsAppCommunity.Blog.Pages;
6+
7+
namespace WindowsAppCommunity.CommandLine.Tests.Blog;
8+
9+
[TestClass]
10+
public class MarkdownPageRouteIndexTests
11+
{
12+
[TestMethod]
13+
public async Task CreateAsync_IndexesFolderizedMarkdownRoutes()
14+
{
15+
var sourceFolder = new MemoryFolder("source", "source");
16+
var rootPage = await CreateFileAsync(sourceFolder, "page one.md");
17+
var nestedPage = await CreateFileAsync(sourceFolder, "area/child.md");
18+
19+
var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder);
20+
21+
Assert.IsTrue(routeIndex.TryGetRoute(rootPage, out var rootRoute));
22+
Assert.IsNotNull(rootRoute);
23+
Assert.AreEqual("page one", rootRoute.PageFolderPath);
24+
Assert.AreEqual("page one/", rootRoute.PageUrlPath);
25+
26+
Assert.IsTrue(routeIndex.TryGetRoute(nestedPage, out var nestedRoute));
27+
Assert.IsNotNull(nestedRoute);
28+
Assert.AreEqual("area/child", nestedRoute.PageFolderPath);
29+
Assert.AreEqual("area/child/", nestedRoute.PageUrlPath);
30+
}
31+
32+
[TestMethod]
33+
public async Task MarkdownPageAssetStrategy_RewritesMarkdownLinksToGeneratedPageRoutes()
34+
{
35+
var sourceFolder = new MemoryFolder("source", "source");
36+
var page1 = await CreateFileAsync(sourceFolder, "page1.md");
37+
var page2 = await CreateFileAsync(sourceFolder, "page2.md");
38+
var nestedPage = await CreateFileAsync(sourceFolder, "sub/page3.md");
39+
var image = await CreateFileAsync(sourceFolder, "images/logo.png");
40+
var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder);
41+
var strategy = new MarkdownPageAssetStrategy
42+
{
43+
RouteIndex = routeIndex,
44+
AssetStrategy = new KnownAssetStrategy
45+
{
46+
ReferencedAssetFileIds = [image.Id],
47+
UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop,
48+
UnknownAssetFaultStrategy = FaultStrategy.None,
49+
}
50+
};
51+
52+
var siblingRoute = await strategy.DecideAsync(page1, page2, "page2.md");
53+
var childRoute = await strategy.DecideAsync(page1, nestedPage, "sub/page3.md");
54+
var parentRoute = await strategy.DecideAsync(nestedPage, page1, "../page1.md");
55+
var imageRoute = await strategy.DecideAsync(page1, image, "images/logo.png");
56+
57+
Assert.AreEqual("../page2/", siblingRoute);
58+
Assert.AreEqual("../sub/page3/", childRoute);
59+
Assert.AreEqual("../../page1/", parentRoute);
60+
Assert.AreEqual("../images/logo.png", imageRoute);
61+
}
62+
63+
private static async Task<IFile> CreateFileAsync(MemoryFolder folder, string relativePath)
64+
{
65+
return await folder.CreateAlongRelativePathAsync(relativePath, StorableType.File).LastAsync() as IFile
66+
?? throw new InvalidOperationException($"Failed to create {relativePath}");
67+
}
68+
}

0 commit comments

Comments
 (0)