diff --git a/src/Blog/Assets/FaultStrategy.cs b/src/Blog/Assets/FaultStrategy.cs
new file mode 100644
index 0000000..0f9e06d
--- /dev/null
+++ b/src/Blog/Assets/FaultStrategy.cs
@@ -0,0 +1,28 @@
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// The strategy to use when encountering an unknown asset.
+///
+[Flags]
+public enum FaultStrategy
+{
+ ///
+ /// Nothing happens when an unknown asset it encountered. It is skipped without error or log.
+ ///
+ None,
+
+ ///
+ /// Logs a warning if an unknown asset is encountered.
+ ///
+ LogWarn,
+
+ ///
+ /// Logs an error without throwing if an unknown asset is encountered.
+ ///
+ LogError,
+
+ ///
+ /// Throws if an unknown asset is encountered.
+ ///
+ Throw,
+}
diff --git a/src/Blog/Assets/IAssetInclusionStrategy.cs b/src/Blog/Assets/IAssetInclusionStrategy.cs
new file mode 100644
index 0000000..a93d8bc
--- /dev/null
+++ b/src/Blog/Assets/IAssetInclusionStrategy.cs
@@ -0,0 +1,27 @@
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// Provides decision logic for asset inclusion via path rewriting.
+/// Strategy returns rewritten path - path structure determines Include vs Reference behavior.
+///
+public interface IAssetStrategy
+{
+ ///
+ /// Decides asset inclusion strategy by returning rewritten path.
+ ///
+ /// The file that references the asset.
+ /// The asset file being referenced.
+ /// The original relative path from the file.
+ /// Cancellation token.
+ ///
+ /// Rewritten path string. Path structure determines behavior:
+ ///
+ /// - Child path (no ../ prefix): Asset included in page folder (self-contained)
+ /// - Parent path (../ prefix): Asset referenced externally (link rewritten to account for folderization)
+ /// - null: Asset has been dropped from output without inclusion or reference.
+ ///
+ ///
+ Task DecideAsync(IFile referencingTextFile, IFile referencedAssetFile, string originalPath, CancellationToken ct = default);
+}
diff --git a/src/Blog/Assets/IAssetLinkDetector.cs b/src/Blog/Assets/IAssetLinkDetector.cs
new file mode 100644
index 0000000..9ca9343
--- /dev/null
+++ b/src/Blog/Assets/IAssetLinkDetector.cs
@@ -0,0 +1,17 @@
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// Detects relative asset links in rendered HTML output.
+///
+public interface IAssetLinkDetector
+{
+ ///
+ /// Detects relative asset link strings in rendered HTML output.
+ ///
+ /// File instance containing text to detect links from.
+ /// Cancellation token.
+ /// Async enumerable of relative path strings.
+ IAsyncEnumerable DetectAsync(IFile sourceFile, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/src/Blog/Assets/IAssetResolver.cs b/src/Blog/Assets/IAssetResolver.cs
new file mode 100644
index 0000000..f9ba7e2
--- /dev/null
+++ b/src/Blog/Assets/IAssetResolver.cs
@@ -0,0 +1,18 @@
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// Resolves relative path strings to IFile instances.
+///
+public interface IAssetResolver
+{
+ ///
+ /// Resolves a relative path string to an IFile instance.
+ ///
+ /// The file to get the relative path from.
+ /// The relative path to resolve.
+ /// Cancellation token.
+ /// The resolved IFile, or null if not found.
+ Task ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default);
+}
diff --git a/src/Blog/Assets/KnownAssetStrategy.cs b/src/Blog/Assets/KnownAssetStrategy.cs
new file mode 100644
index 0000000..7cee06e
--- /dev/null
+++ b/src/Blog/Assets/KnownAssetStrategy.cs
@@ -0,0 +1,92 @@
+using OwlCore.Diagnostics;
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// Determines fallback asset behavior when the asset is not known to the strategy selector.
+///
+public enum AssetFallbackBehavior
+{
+ ///
+ /// The asset path is rewritten to support being referenced by the folderized markdown.
+ ///
+ Reference,
+
+ ///
+ /// The asset path is not rewritten and it is included in the output path.
+ ///
+ Include,
+
+ ///
+ /// The new asset path is returned as null and the asset is not included in the output.
+ ///
+ Drop,
+}
+
+///
+/// Uses a known list of files to decide between asset inclusion (child path) vs asset reference (parented path).
+///
+public sealed class KnownAssetStrategy : IAssetStrategy
+{
+ ///
+ /// A list of known file IDs to rewrite to an included asset.
+ ///
+ public HashSet IncludedAssetFileIds { get; set; } = new();
+
+ ///
+ /// A list of known file IDs rewrite as a referenced asset.
+ ///
+ public HashSet ReferencedAssetFileIds { get; set; } = new();
+
+ ///
+ /// The strategy to use when encountering an unknown asset.
+ ///
+ public FaultStrategy UnknownAssetFaultStrategy { get; set; }
+
+ ///
+ /// Gets or sets the fallback used when the asset is unknown but does not have .
+ ///
+ public AssetFallbackBehavior UnknownAssetFallbackStrategy { get; set; }
+
+ ///
+ public async Task DecideAsync(IFile referencingMarkdown, IFile referencedAsset, string originalPath, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(originalPath))
+ return originalPath;
+
+ var isReferenced = ReferencedAssetFileIds.Contains(referencedAsset.Id);
+ var isIncluded = IncludedAssetFileIds.Contains(referencedAsset.Id);
+
+ if (isReferenced)
+ return $"../{originalPath}";
+
+ if (isIncluded)
+ return originalPath;
+
+ // Handle as unknown
+ HandleUnknownAsset(referencedAsset);
+
+ return UnknownAssetFallbackStrategy switch
+ {
+ AssetFallbackBehavior.Reference => $"../{originalPath}",
+ AssetFallbackBehavior.Include => originalPath,
+ AssetFallbackBehavior.Drop => null,
+ _ => throw new ArgumentOutOfRangeException(nameof(UnknownAssetFallbackStrategy)),
+ };
+ }
+
+ private void HandleUnknownAsset(IFile referencedAsset)
+ {
+ var faultMessage = $"Unknown asset encountered: {nameof(referencedAsset.Name)} {referencedAsset.Name}, {nameof(referencedAsset.Id)} {referencedAsset.Id}. Please add this ID to either {nameof(IncludedAssetFileIds)} or {nameof(ReferencedAssetFileIds)}.";
+
+ if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.LogWarn))
+ Logger.LogWarning(faultMessage);
+
+ if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.LogError))
+ Logger.LogError(faultMessage);
+
+ if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.Throw))
+ throw new InvalidOperationException(faultMessage);
+ }
+}
diff --git a/src/Blog/Assets/ReferencedAsset.cs b/src/Blog/Assets/ReferencedAsset.cs
new file mode 100644
index 0000000..27961c9
--- /dev/null
+++ b/src/Blog/Assets/ReferencedAsset.cs
@@ -0,0 +1,13 @@
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Assets
+{
+ ///
+ /// Captures complete asset reference information for materialization.
+ /// Stores original detected path, rewritten path after strategy, and resolved file instance.
+ ///
+ /// Path detected in markdown (relative to source file)
+ /// Path after inclusion strategy applied (include vs reference)
+ /// Actual file instance for copy operations
+ public record PageAsset(string OriginalPath, string RewrittenPath, IFile ResolvedFile);
+}
diff --git a/src/Blog/Assets/RegexAssetLinkDetector.cs b/src/Blog/Assets/RegexAssetLinkDetector.cs
new file mode 100644
index 0000000..7bb53e7
--- /dev/null
+++ b/src/Blog/Assets/RegexAssetLinkDetector.cs
@@ -0,0 +1,81 @@
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// Detects relative asset links in markdown and HTML text.
+///
+public sealed partial class RegexAssetLinkDetector : IAssetLinkDetector
+{
+ ///
+ /// Regex pattern for markdown links and images.
+ ///
+ [GeneratedRegex("""!?\[[^\]]*\]\((?[^)\s]+)(?:\s+[^)]*)?\)""", RegexOptions.Compiled)]
+ private static partial Regex MarkdownLinkPattern();
+
+ ///
+ /// Regex pattern for HTML href/src attributes.
+ ///
+ [GeneratedRegex("""(?:href|src)\s*=\s*["'](?[^"']+)["']""", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
+ private static partial Regex HtmlAttributePattern();
+
+ ///
+ public async IAsyncEnumerable DetectAsync(IFile source, [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ var text = await source.ReadTextAsync(ct);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (Match match in MarkdownLinkPattern().Matches(text))
+ {
+ if (ct.IsCancellationRequested)
+ yield break;
+
+ var path = match.Groups["path"].Value;
+ if (!ShouldYield(path, seen))
+ continue;
+
+ yield return path;
+ }
+
+ foreach (Match match in HtmlAttributePattern().Matches(text))
+ {
+ if (ct.IsCancellationRequested)
+ yield break;
+
+ var path = match.Groups["path"].Value;
+ if (!ShouldYield(path, seen))
+ continue;
+
+ yield return path;
+ }
+ }
+
+ private static bool ShouldYield(string path, HashSet seen)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return false;
+
+ path = path.Trim().Trim('<', '>');
+
+ if (string.IsNullOrWhiteSpace(path))
+ return false;
+
+ if (path.StartsWith('#') || path.StartsWith('/') || path.StartsWith('\\'))
+ return false;
+ if (path.StartsWith("//", StringComparison.Ordinal))
+ return false;
+ if (path.Contains("://", StringComparison.Ordinal))
+ return false;
+ if (path.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("tel:", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return seen.Add(path);
+ }
+}
\ No newline at end of file
diff --git a/src/Blog/Assets/RelativePathAssetResolver.cs b/src/Blog/Assets/RelativePathAssetResolver.cs
new file mode 100644
index 0000000..d8d9be0
--- /dev/null
+++ b/src/Blog/Assets/RelativePathAssetResolver.cs
@@ -0,0 +1,350 @@
+using OwlCore.Storage;
+using SystemFile = OwlCore.Storage.System.IO.SystemFile;
+
+namespace WindowsAppCommunity.Blog.Assets;
+
+///
+/// Resolves relative paths to IFile instances using source folder and markdown file context.
+/// Paths are resolved relative to the markdown file's location (pre-folderization).
+/// Stateless design - markdown source passed per-call to support shared resolver across pages.
+///
+public sealed class RelativePathAssetResolver : IAssetResolver
+{
+ ///
+ public async Task ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(relativePath))
+ return null;
+
+ try
+ {
+ // Normalize path separators to forward slash and remove URL-only portions before storage lookup.
+ var normalizedPath = NormalizeStoragePath(relativePath);
+
+ if (string.IsNullOrWhiteSpace(normalizedPath))
+ return null;
+
+ // Resolve relative to markdown file's containing location (pre-folderization).
+ var item = await sourceFile.GetItemByRelativePathAsync($"../{normalizedPath}", ct);
+
+ if (item is IFile resolvedFile)
+ return resolvedFile;
+
+ if (item is IFolder resolvedFolder)
+ return await TryGetDefaultMarkdownFileAsync(resolvedFolder, ct);
+ }
+ catch
+ {
+ // Try filesystem fallback below.
+ }
+
+ return TryResolveFileSystemPath(sourceFile, relativePath) ??
+ TryResolveFromAncestorSuffix(sourceFile, relativePath) ??
+ TryResolveCopiedContextAlias(sourceFile, relativePath) ??
+ TryResolveUniqueSuffixFromNotesRoot(sourceFile, relativePath);
+ }
+
+ private static string StripQueryAndFragment(string path)
+ {
+ var endIndex = path.Length;
+ var queryIndex = path.IndexOf('?');
+ var fragmentIndex = path.IndexOf('#');
+
+ if (queryIndex >= 0)
+ endIndex = Math.Min(endIndex, queryIndex);
+
+ if (fragmentIndex >= 0)
+ endIndex = Math.Min(endIndex, fragmentIndex);
+
+ return path[..endIndex];
+ }
+
+ private static IFile? TryResolveFromAncestorSuffix(IFile sourceFile, string relativePath)
+ {
+ if (sourceFile is not SystemFile systemFile)
+ return null;
+
+ var normalizedPath = NormalizeStoragePath(relativePath);
+
+ if (string.IsNullOrWhiteSpace(normalizedPath))
+ return null;
+
+ var suffixes = GetCandidateSuffixes(RemoveLeadingRelativeSegments(normalizedPath)).ToArray();
+ if (suffixes.Length == 0)
+ return null;
+
+ var sourceDirectory = Path.GetDirectoryName(systemFile.Path);
+ var currentDirectory = sourceDirectory is null ? null : new DirectoryInfo(sourceDirectory);
+
+ while (currentDirectory is not null)
+ {
+ foreach (var suffix in suffixes)
+ {
+ var candidatePath = Path.GetFullPath(Path.Combine(currentDirectory.FullName, suffix.Replace('/', Path.DirectorySeparatorChar)));
+ if (File.Exists(candidatePath))
+ return new SystemFile(candidatePath);
+
+ if (Directory.Exists(candidatePath))
+ {
+ var defaultMarkdownFile = TryGetDefaultMarkdownFile(candidatePath);
+ if (defaultMarkdownFile is not null)
+ return defaultMarkdownFile;
+ }
+ }
+
+ currentDirectory = currentDirectory.Parent;
+ }
+
+ return null;
+ }
+
+ private static IFile? TryResolveFileSystemPath(IFile sourceFile, string relativePath)
+ {
+ if (sourceFile is not SystemFile systemFile)
+ return null;
+
+ var normalizedPath = NormalizeStoragePath(relativePath);
+ if (string.IsNullOrWhiteSpace(normalizedPath))
+ return null;
+
+ var sourceDirectory = Path.GetDirectoryName(systemFile.Path);
+ if (sourceDirectory is null)
+ return null;
+
+ var candidatePath = Path.GetFullPath(Path.Combine(sourceDirectory, normalizedPath.Replace('/', Path.DirectorySeparatorChar)));
+ if (File.Exists(candidatePath))
+ return new SystemFile(candidatePath);
+
+ return Directory.Exists(candidatePath) ? TryGetDefaultMarkdownFile(candidatePath) : null;
+ }
+
+ private static string RemoveLeadingRelativeSegments(string path)
+ {
+ var result = path;
+
+ while (result.StartsWith("../", StringComparison.Ordinal) || result.StartsWith("./", StringComparison.Ordinal))
+ {
+ result = result.StartsWith("../", StringComparison.Ordinal) ? result[3..] : result[2..];
+ }
+
+ return result;
+ }
+
+ private static IFile? TryResolveUniqueSuffixFromNotesRoot(IFile sourceFile, string relativePath)
+ {
+ if (sourceFile is not SystemFile systemFile)
+ return null;
+
+ var notesRoot = FindAncestorDirectory(Path.GetDirectoryName(systemFile.Path), "Notes");
+ if (notesRoot is null)
+ return null;
+
+ var normalizedPath = NormalizeStoragePath(relativePath);
+ var suffixes = GetCandidateSuffixes(RemoveLeadingRelativeSegments(normalizedPath)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ if (suffixes.Length == 0)
+ return null;
+
+ foreach (var suffix in suffixes)
+ {
+ var candidateSuffix = suffix.Replace('/', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar);
+
+ if (Path.GetExtension(candidateSuffix).Equals(".md", StringComparison.OrdinalIgnoreCase))
+ {
+ var matches = Directory
+ .EnumerateFiles(notesRoot.FullName, "*.md", SearchOption.AllDirectories)
+ .Where(path => path.EndsWith(candidateSuffix, StringComparison.OrdinalIgnoreCase))
+ .Take(2)
+ .ToArray();
+
+ if (matches.Length == 1)
+ return new SystemFile(matches[0]);
+ }
+ else
+ {
+ var matches = Directory
+ .EnumerateDirectories(notesRoot.FullName, "*", SearchOption.AllDirectories)
+ .Where(path => path.EndsWith(candidateSuffix, StringComparison.OrdinalIgnoreCase))
+ .Take(2)
+ .ToArray();
+
+ if (matches.Length == 1)
+ {
+ var defaultMarkdownFile = TryGetDefaultMarkdownFile(matches[0]);
+ if (defaultMarkdownFile is not null)
+ return defaultMarkdownFile;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static IFile? TryResolveCopiedContextAlias(IFile sourceFile, string relativePath)
+ {
+ if (sourceFile is not SystemFile systemFile)
+ return null;
+
+ var normalizedPath = NormalizeStoragePath(relativePath);
+ if (!string.Equals(normalizedPath, "../../planning,log.md", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var normalizedSourcePath = systemFile.Path.Replace('\\', '/');
+ if (!normalizedSourcePath.Contains("/2026/April/4.2.2026/wct/planning,self,triage,march-to-april/log.md", StringComparison.OrdinalIgnoreCase) &&
+ !normalizedSourcePath.Contains("/2026/April/4.26.2026/atlas/processes,procedure,assessment,clarification/manual,usage,arc/branching,logs,subareas/consolidated,log,review.md", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var notesRoot = FindAncestorDirectory(Path.GetDirectoryName(systemFile.Path), "Notes");
+ if (notesRoot is null)
+ return null;
+
+ var candidatePath = Path.Combine(notesRoot.FullName, "2026", "March", "3.26.2026", "wct", "planning,log.md");
+ return File.Exists(candidatePath) ? new SystemFile(candidatePath) : null;
+ }
+
+ private static DirectoryInfo? FindAncestorDirectory(string? startDirectory, string directoryName)
+ {
+ var currentDirectory = startDirectory is null ? null : new DirectoryInfo(startDirectory);
+
+ while (currentDirectory is not null)
+ {
+ if (string.Equals(currentDirectory.Name, directoryName, StringComparison.OrdinalIgnoreCase))
+ return currentDirectory;
+
+ currentDirectory = currentDirectory.Parent;
+ }
+
+ return null;
+ }
+
+ private static async Task TryGetDefaultMarkdownFileAsync(IFolder folder, CancellationToken cancellationToken)
+ {
+ foreach (var name in GetDefaultMarkdownFileNames(folder.Name))
+ {
+ try
+ {
+ if (await folder.GetFirstByNameAsync(name, cancellationToken) is IFile file)
+ return file;
+ }
+ catch
+ {
+ }
+ }
+
+ return null;
+ }
+
+ private static SystemFile? TryGetDefaultMarkdownFile(string directoryPath)
+ {
+ var directoryName = Path.GetFileName(directoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+
+ foreach (var name in GetDefaultMarkdownFileNames(directoryName))
+ {
+ var candidatePath = Path.Combine(directoryPath, name);
+ if (File.Exists(candidatePath))
+ return new SystemFile(candidatePath);
+ }
+
+ return null;
+ }
+
+ private static IEnumerable GetDefaultMarkdownFileNames(string folderName)
+ {
+ yield return $"{folderName}.md";
+ yield return "wct.md";
+ yield return "planning,log.md";
+ yield return "log.md";
+ yield return "index.md";
+ yield return "README.md";
+ }
+
+ private static IEnumerable GetCandidateSuffixes(string suffix)
+ {
+ if (string.IsNullOrWhiteSpace(suffix))
+ yield break;
+
+ var normalizedSuffix = suffix.Replace('\\', '/');
+ yield return normalizedSuffix;
+
+ var collapsedSuffix = CollapseRelativeSegments(normalizedSuffix);
+ if (!string.Equals(collapsedSuffix, normalizedSuffix, StringComparison.OrdinalIgnoreCase))
+ yield return collapsedSuffix;
+
+ foreach (var alias in GetLegacyNotePathAliases(normalizedSuffix))
+ yield return alias;
+
+ foreach (var alias in GetLegacyNotePathAliases(collapsedSuffix))
+ yield return alias;
+ }
+
+ private static string NormalizeStoragePath(string path)
+ {
+ var normalized = StripQueryAndFragment(path).Trim();
+
+ try
+ {
+ normalized = Uri.UnescapeDataString(normalized);
+ }
+ catch (UriFormatException)
+ {
+ }
+
+ return normalized
+ .Replace('\\', '/')
+ .Replace("`", string.Empty)
+ .Trim();
+ }
+
+ private static string CollapseRelativeSegments(string path)
+ {
+ var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var collapsed = new List();
+
+ foreach (var segment in segments)
+ {
+ if (segment == ".")
+ continue;
+
+ if (segment == "..")
+ {
+ if (collapsed.Count > 0)
+ collapsed.RemoveAt(collapsed.Count - 1);
+
+ continue;
+ }
+
+ collapsed.Add(segment);
+ }
+
+ return string.Join('/', collapsed);
+ }
+
+ private static IEnumerable GetLegacyNotePathAliases(string suffix)
+ {
+ yield return suffix.Replace(
+ "tooling/sample-app,toolkit-building-toolkit,maintenance,modularity,nuget,source,improvement,infra,self/",
+ "tooling/source/sample-app,maintenance,modularity,nuget,source,improvement,infra,self/",
+ StringComparison.OrdinalIgnoreCase);
+
+ yield return suffix.Replace(
+ "checks,tests,ci/",
+ "checks,ci/tests/",
+ StringComparison.OrdinalIgnoreCase);
+
+ yield return suffix.Replace(
+ "tooling/source/docs/infra,self,references,toolkit-using-toolkit/packagereference,projectreference/",
+ "tooling/infra,self,dependency,toolkit-using-toolkit/packagereference,projectreference,toolkitreference/",
+ StringComparison.OrdinalIgnoreCase);
+
+ yield return suffix.Replace(
+ "checks,ci/workflow,functional,improvement/usediagnostic,template,syntax.md",
+ "checks,ci/workflow,functional,improvement/usediagnostic,template,syntax/consolidated,log,review.md",
+ StringComparison.OrdinalIgnoreCase);
+
+ yield return suffix.Replace(
+ "atlas/processes,procedure,assessment,clarification/manual,usage,arc,triage,checkpoints,time-as-primary-axis,verbatim-log-enumeration/log,consolidation,review.md",
+ "atlas/processes,procedure,assessment,clarification/manual,usage,arc/triage,checkpoints/time-as-primary-axis,verbatim-log-enumeration/consolidated,log,review.md",
+ StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs
new file mode 100644
index 0000000..b7d34bc
--- /dev/null
+++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs
@@ -0,0 +1,159 @@
+using OwlCore.Diagnostics;
+using OwlCore.Storage;
+using System.Text.RegularExpressions;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Page;
+
+namespace WindowsAppCommunity.Blog.Page
+{
+ ///
+ /// Asset-aware virtual HTML file - extends base with link detection, asset resolution, and inclusion decisions.
+ /// Sealed - this is the final asset-aware implementation.
+ /// Implements link rewriting and asset tracking during post-processing.
+ ///
+ public sealed class AssetAwareHtmlTemplatedMarkdownFile : HtmlTemplatedMarkdownFile
+ {
+ private static readonly Regex LinkAttributePattern = new("(?href|src)\\s*=\\s*(?[\"'])(?[^\"']+)(\\k)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ private readonly List _assets = new();
+
+ ///
+ /// Creates asset-aware virtual HTML file with lazy markdown→HTML generation and asset management.
+ ///
+ /// Unique identifier for this file (parent-derived)
+ /// Source markdown file to transform
+ /// Template as IFile or IFolder
+ /// Template file name when source is IFolder (defaults to "template.html")
+ /// Parent folder in virtual hierarchy (optional)
+ public AssetAwareHtmlTemplatedMarkdownFile(string id, IFile markdownSource, IStorable templateSource, string? templateFileName = null, IFolder? parent = null)
+ : base(id, markdownSource, templateSource, templateFileName, parent)
+ {
+ }
+
+ ///
+ /// Asset link detector for finding relative links in rendered HTML output.
+ ///
+ public required IAssetLinkDetector LinkDetector { get; init; }
+
+ ///
+ /// Asset resolver for converting paths to IFile instances.
+ ///
+ public required IAssetResolver Resolver { get; init; }
+
+ ///
+ /// Inclusion strategy for deciding include vs reference via path rewriting.
+ ///
+ public required IAssetStrategy AssetStrategy { get; init; }
+
+ ///
+ /// All assets referenced by the markdown file (both included and referenced).
+ /// Exposed to containing folder for materialization to output.
+ ///
+ public IReadOnlyCollection Assets => _assets;
+
+ ///
+ /// Post-process HTML with asset management pipeline.
+ /// Detects links → Resolves to files → Decides include/reference via path rewriting → Tracks included assets.
+ /// Detects links from BOTH markdown source AND template file to unify asset handling.
+ ///
+ /// The resolved HTML template file.
+ /// Data model used for rendering
+ /// Cancellation token
+ /// Post-processed HTML with rewritten links
+ protected override async Task RenderTemplateAsync(IFile templateFile, HtmlMarkdownDataTemplateModel model, CancellationToken cancellationToken)
+ {
+ // Clear included assets from any previous generation
+ _assets.Clear();
+
+ await foreach (var originalPath in LinkDetector.DetectAsync(templateFile, cancellationToken))
+ {
+ var referencedAsset = await ProcessAssetLinkAsync(templateFile, originalPath, cancellationToken);
+ if (referencedAsset is null)
+ continue;
+
+ _assets.Add(referencedAsset);
+ }
+
+ var html = await base.RenderTemplateAsync(templateFile, model, cancellationToken);
+
+ // Detect asset links from markdown source (content-referenced assets)
+ await foreach (var originalPath in LinkDetector.DetectAsync(MarkdownSource, cancellationToken))
+ {
+ var referencedAsset = await ProcessAssetLinkAsync(MarkdownSource, originalPath, cancellationToken);
+ if (referencedAsset is null)
+ continue;
+
+ _assets.Add(referencedAsset);
+ html = ReplaceLinkPath(html, referencedAsset.OriginalPath, referencedAsset.RewrittenPath);
+ }
+
+ return html;
+ }
+
+ ///
+ /// Process a single detected asset link through the asset pipeline.
+ /// Shared logic for both markdown and template asset detection.
+ ///
+ /// File providing resolution context (markdown or template)
+ /// Original asset path as detected
+ /// Cancellation token
+ /// Updated HTML with rewritten link
+ private async Task ProcessAssetLinkAsync(IFile contextFile, string originalPath, CancellationToken cancellationToken)
+ {
+ // Resolve path to IFile (pass context file for resolution)
+ var resolvedAsset = await Resolver.ResolveAsync(contextFile, originalPath, cancellationToken);
+
+ // Skip if not found
+ if (resolvedAsset == null)
+ return null;
+
+ // Strategy decides include vs reference by returning rewritten path
+ // Path structure determines behavior:
+ // - Child path (no ../ prefix): Include
+ // - Parent path (../ prefix): Reference
+ var rewrittenPath = await AssetStrategy.DecideAsync(contextFile, resolvedAsset, originalPath, cancellationToken);
+ if (rewrittenPath is null)
+ return null;
+
+ // Track all referenced assets for materialization
+ return new PageAsset(originalPath, rewrittenPath, resolvedAsset);
+ }
+
+ private static string ReplaceLinkPath(string html, string originalPath, string rewrittenPath)
+ {
+ return LinkAttributePattern.Replace(html, match =>
+ {
+ var url = match.Groups["url"].Value;
+ if (!PathsMatch(url, originalPath))
+ return match.Value;
+
+ var attribute = match.Groups["attribute"].Value;
+ var quote = match.Groups["quote"].Value;
+ return $"{attribute}={quote}{rewrittenPath}{quote}";
+ });
+ }
+
+ private static bool PathsMatch(string renderedPath, string originalPath)
+ {
+ return string.Equals(NormalizeRenderedPath(renderedPath), NormalizeRenderedPath(originalPath), StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string NormalizeRenderedPath(string path)
+ {
+ var normalized = path.Trim().Trim('<', '>');
+
+ try
+ {
+ normalized = Uri.UnescapeDataString(normalized);
+ }
+ catch (UriFormatException)
+ {
+ }
+
+ normalized = normalized.Replace('\\', '/').Trim('`');
+ normalized = normalized.Replace("`", string.Empty);
+
+ return normalized;
+ }
+ }
+}
diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs
new file mode 100644
index 0000000..7783cb0
--- /dev/null
+++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using OwlCore.Storage;
+using WindowsAppCommunity.Blog.Assets;
+
+namespace WindowsAppCommunity.Blog.Page
+{
+ ///
+ /// Asset-aware virtual folder - extends base with markdown-referenced asset inclusion.
+ /// Creates asset-aware file variant and yields included assets in virtual structure.
+ /// Implements lazy generation - no file system operations during construction.
+ ///
+ public class AssetAwareHtmlTemplatedMarkdownPageFolder : HtmlTemplatedMarkdownPageFolder
+ {
+ ///
+ /// Creates asset-aware virtual folder representing single-page output structure with asset management.
+ /// No file system operations occur during construction (lazy generation).
+ ///
+ /// Source markdown file to transform
+ /// Template as IFile or IFolder
+ /// Template file name when source is IFolder (defaults to "template.html")
+ public AssetAwareHtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateSource, string? templateFileName = null)
+ : base(markdownSource, templateSource, templateFileName)
+ {
+ }
+
+ ///
+ /// Asset link detector for finding relative links in markdown.
+ ///
+ public required IAssetLinkDetector LinkDetector { get; init; }
+
+ ///
+ /// Asset resolver for converting paths to IFile instances.
+ ///
+ public required IAssetResolver Resolver { get; init; }
+
+ ///
+ /// Inclusion strategy for deciding include vs reference per asset.
+ ///
+ public required IAssetStrategy AssetStrategy { get; init; }
+
+ ///
+ public override async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Yield base items (HTML file + template assets), capturing asset-aware file reference
+ await foreach (var item in base.GetItemsAsync(type, cancellationToken))
+ {
+ // Intercept HTML file creation to replace with asset-aware variant
+ if (item is HtmlTemplatedMarkdownFile htmlFile)
+ {
+ // Create asset-aware variant with required properties set
+ yield return new AssetAwareHtmlTemplatedMarkdownFile(htmlFile.Id, MarkdownSource, TemplateSource, TemplateFileName, this)
+ {
+ Name = htmlFile.Name,
+ Created = htmlFile.Created,
+ Modified = htmlFile.Modified,
+ LinkDetector = LinkDetector,
+ Resolver = Resolver,
+ AssetStrategy = AssetStrategy
+ };
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Blog/PostPage/PostPageDataModel.cs b/src/Blog/Page/HtmlMarkdownDataTemplateModel.cs
similarity index 93%
rename from src/Blog/PostPage/PostPageDataModel.cs
rename to src/Blog/Page/HtmlMarkdownDataTemplateModel.cs
index 8074e6e..6a1073e 100644
--- a/src/Blog/PostPage/PostPageDataModel.cs
+++ b/src/Blog/Page/HtmlMarkdownDataTemplateModel.cs
@@ -1,13 +1,10 @@
-using System;
-using System.Collections.Generic;
-
-namespace WindowsAppCommunity.Blog.PostPage
+namespace WindowsAppCommunity.Blog.Page
{
///
/// Data model for Scriban template rendering in Post/Page scenario.
/// Provides the data contract that templates can access via dot notation.
///
- public class PostPageDataModel
+ public class HtmlMarkdownDataTemplateModel
{
///
/// Transformed HTML content from markdown body.
diff --git a/src/Blog/PostPage/IndexHtmlFile.cs b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs
similarity index 81%
rename from src/Blog/PostPage/IndexHtmlFile.cs
rename to src/Blog/Page/HtmlTemplatedMarkdownFile.cs
index 0e4435f..c581ae8 100644
--- a/src/Blog/PostPage/IndexHtmlFile.cs
+++ b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs
@@ -1,23 +1,19 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
using Markdig;
using OwlCore.Storage;
using Scriban;
using YamlDotNet.Serialization;
+using WindowsAppCommunity.Blog.Page;
-namespace WindowsAppCommunity.Blog.PostPage
+namespace WindowsAppCommunity.Blog.Page
{
///
- /// Virtual IChildFile representing index.html generated from markdown source.
+ /// Virtual IChildFile representing HTML generated from markdown source with template.
+ /// Base class - provides core markdown→HTML transformation pipeline with extensibility hooks.
/// Implements lazy generation - markdown→HTML transformation occurs on OpenStreamAsync.
/// Read-only - throws NotSupportedException for write operations.
///
- public sealed class IndexHtmlFile : IChildFile
+ public class HtmlTemplatedMarkdownFile : IChildFile
{
private readonly string _id;
private readonly IFile _markdownSource;
@@ -26,14 +22,14 @@ public sealed class IndexHtmlFile : IChildFile
private readonly IFolder? _parent;
///
- /// Creates virtual index.html file with lazy markdown→HTML generation.
+ /// Creates virtual HTML file with lazy markdown→HTML generation.
///
/// Unique identifier for this file (parent-derived)
/// Source markdown file to transform
/// Template as IFile or IFolder
/// Template file name when source is IFolder (defaults to "template.html")
/// Parent folder in virtual hierarchy (optional)
- public IndexHtmlFile(string id, IFile markdownSource, IStorable templateSource, string? templateFileName, IFolder? parent = null)
+ public HtmlTemplatedMarkdownFile(string id, IFile markdownSource, IStorable templateSource, string? templateFileName = null, IFolder? parent = null)
{
_id = id ?? throw new ArgumentNullException(nameof(id));
_markdownSource = markdownSource ?? throw new ArgumentNullException(nameof(markdownSource));
@@ -46,7 +42,11 @@ public IndexHtmlFile(string id, IFile markdownSource, IStorable templateSource,
public string Id => _id;
///
- public string Name => "index.html";
+ ///
+ /// Required property - consumer must set via object initializer.
+ /// No default value provided (e.g., "index.html" is not assumed).
+ ///
+ public required string Name { get; init; }
///
/// File creation timestamp from filesystem metadata.
@@ -58,6 +58,12 @@ public IndexHtmlFile(string id, IFile markdownSource, IStorable templateSource,
///
public DateTime? Modified { get; set; }
+ ///
+ /// Source markdown file being transformed.
+ /// Exposed for derived class access (e.g., passing to asset strategies).
+ ///
+ public IFile MarkdownSource => _markdownSource;
+
///
public Task GetParentAsync(CancellationToken cancellationToken = default)
{
@@ -70,23 +76,23 @@ public async Task OpenStreamAsync(FileAccess accessMode, CancellationTok
// Read-only file - reject write operations
if (accessMode == FileAccess.Write || accessMode == FileAccess.ReadWrite)
{
- throw new NotSupportedException($"IndexHtmlFile is read-only. Cannot open with access mode: {accessMode}");
+ throw new NotSupportedException($"{GetType().Name} is read-only. Cannot open with access mode: {accessMode}");
}
// Lazy generation: Transform markdown→HTML on every call (no caching)
var html = await GenerateHtmlAsync(cancellationToken);
-
+
// Convert HTML string to UTF-8 byte stream
var bytes = Encoding.UTF8.GetBytes(html);
var stream = new MemoryStream(bytes);
stream.Position = 0;
-
+
return stream;
}
///
/// Generate HTML by transforming markdown source with template.
- /// Orchestrates: Parse markdown → Transform to HTML → Render template.
+ /// Orchestrates: Parse markdown → Transform to HTML → Render template → Post-process.
///
private async Task GenerateHtmlAsync(CancellationToken cancellationToken)
{
@@ -103,7 +109,7 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken
var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName);
// Create data model for template
- var model = new PostPageDataModel
+ var model = new HtmlMarkdownDataTemplateModel
{
Body = htmlBody,
Frontmatter = frontmatterDict,
@@ -113,13 +119,9 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken
};
// Render template with model
- var html = await RenderTemplateAsync(templateFile, model);
-
- return html;
+ return await RenderTemplateAsync(templateFile, model, cancellationToken);
}
- #region Transformation Helpers
-
///
/// Extract YAML front-matter block from markdown file.
/// Front-matter is delimited by "---" at start and end.
@@ -127,10 +129,10 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken
///
/// Markdown file to parse
/// Tuple of (frontmatter YAML string, content markdown string)
- private async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file)
+ protected virtual async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file)
{
var text = await file.ReadTextAsync();
-
+
// Check for front-matter delimiters
if (!text.StartsWith("---"))
{
@@ -141,7 +143,7 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken
// Find the closing delimiter
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None);
var closingDelimiterIndex = -1;
-
+
for (int i = 1; i < lines.Length; i++)
{
if (lines[i].Trim() == "---")
@@ -175,7 +177,7 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken
///
/// Markdown content string
/// HTML body content
- private string TransformMarkdownToHtml(string markdown)
+ protected virtual string TransformMarkdownToHtml(string markdown)
{
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
@@ -192,7 +194,7 @@ private string TransformMarkdownToHtml(string markdown)
///
/// YAML string from front-matter
/// Dictionary with arbitrary keys and values
- private Dictionary ParseFrontmatter(string yaml)
+ protected virtual Dictionary ParseFrontmatter(string yaml)
{
// Handle empty front-matter
if (string.IsNullOrWhiteSpace(yaml))
@@ -210,7 +212,10 @@ private Dictionary ParseFrontmatter(string yaml)
}
catch (YamlDotNet.Core.YamlException ex)
{
- throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex);
+ return new Dictionary
+ {
+ ["frontmatter_parse_error"] = ex.Message,
+ };
}
}
@@ -222,9 +227,7 @@ private Dictionary ParseFrontmatter(string yaml)
/// Template as IFile or IFolder
/// File name when source is IFolder (defaults to "template.html")
/// Resolved template IFile
- private async Task ResolveTemplateFileAsync(
- IStorable templateSource,
- string? templateFileName)
+ protected virtual async Task ResolveTemplateFileAsync(IStorable templateSource, string? templateFileName)
{
if (templateSource is IFile file)
{
@@ -257,13 +260,11 @@ private async Task ResolveTemplateFileAsync(
///
/// Scriban template file
/// PostPageDataModel with body, frontmatter, metadata
+ /// A token that can be used to cancel the ongoing operation.
/// Rendered HTML string
- private async Task RenderTemplateAsync(
- IFile templateFile,
- PostPageDataModel model)
+ protected virtual async Task RenderTemplateAsync(IFile templateFile, HtmlMarkdownDataTemplateModel model, CancellationToken cancellationToken)
{
var templateContent = await templateFile.ReadTextAsync();
-
var template = Template.Parse(templateContent);
if (template.HasErrors)
@@ -272,11 +273,7 @@ private async Task RenderTemplateAsync(
throw new InvalidOperationException($"Template parsing failed:{Environment.NewLine}{errors}");
}
- var html = template.Render(model);
-
- return html;
+ return template.Render(model);
}
-
- #endregion
}
-}
+}
\ No newline at end of file
diff --git a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs
new file mode 100644
index 0000000..eeff145
--- /dev/null
+++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs
@@ -0,0 +1,93 @@
+using System.Runtime.CompilerServices;
+using OwlCore.Extensions;
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Page
+{
+ ///
+ /// Virtual IFolder representing folderized single-page output structure.
+ /// Base class - wraps markdown source file and template to provide virtual {filename}/index.html + assets structure.
+ /// Implements lazy generation - no file system operations during construction.
+ ///
+ public class HtmlTemplatedMarkdownPageFolder : IChildFolder
+ {
+ private readonly IFile _markdownSource;
+ private readonly IStorable _templateSource;
+ private readonly string? _templateFileName;
+
+ ///
+ /// Creates virtual folder representing single-page output structure.
+ /// No file system operations occur during construction (lazy generation).
+ ///
+ /// Source markdown file to transform
+ /// Template as IFile or IFolder
+ /// Template file name when source is IFolder (defaults to "template.html")
+ public HtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateSource, string? templateFileName = null)
+ {
+ _markdownSource = markdownSource ?? throw new ArgumentNullException(nameof(markdownSource));
+ _templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSource));
+ _templateFileName = templateFileName;
+ }
+
+ ///
+ /// Gets the markdown source file for derived class access.
+ ///
+ protected IFile MarkdownSource => _markdownSource;
+
+ ///
+ /// Gets the template source for derived class access.
+ ///
+ protected IStorable TemplateSource => _templateSource;
+
+ ///
+ /// Gets the template file name for derived class access.
+ ///
+ protected string? TemplateFileName => _templateFileName;
+
+ ///
+ public required string Id { get; init; }
+
+ ///
+ public string Name => GetPageFolderName(_markdownSource.Name);
+
+ ///
+ /// Gets the folder name used for a folderized markdown page.
+ ///
+ /// Original markdown filename with extension.
+ /// Sanitized folder name without the markdown file extension.
+ public static string GetPageFolderName(string markdownFilename)
+ {
+ var nameWithoutExtension = Path.GetFileNameWithoutExtension(markdownFilename);
+ var invalidChars = Path.GetInvalidFileNameChars();
+
+ return string.Concat(nameWithoutExtension.Select(c =>
+ invalidChars.Contains(c) ? '_' : c));
+ }
+
+ ///
+ /// Optional parent folder in virtual hierarchy.
+ ///
+ public IFolder? Parent { get; set; }
+
+ ///
+ public Task GetParentAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(Parent);
+ }
+
+ ///
+ public virtual async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Yield HtmlTemplatedMarkdownFile (virtual HTML file)
+ if (type == StorableType.All || type == StorableType.File)
+ {
+ var indexHtmlId = $"{$"{Id}-index.html".HashMD5Fast()}";
+ yield return new HtmlTemplatedMarkdownFile(indexHtmlId, _markdownSource, _templateSource, _templateFileName, this)
+ {
+ Name = "index.html"
+ };
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs
new file mode 100644
index 0000000..7e7baef
--- /dev/null
+++ b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using OwlCore.Extensions;
+using OwlCore.Storage;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Page;
+
+namespace WindowsAppCommunity.Blog.Pages
+{
+ ///
+ /// Multi-page composition root - discovers markdown files and preserves folder hierarchy through virtual structure nesting.
+ /// Asset-aware only variant (no non-asset-aware needed for multi-page scenario).
+ /// Implements lazy generation - no file system operations during construction.
+ ///
+ public class AssetAwareHtmlTemplatedMarkdownPagesFolder : IChildFolder
+ {
+ private readonly IFolder _markdownSourceFolder;
+ private readonly IStorable _templateSource;
+ private readonly string? _templateFileName;
+
+ ///
+ /// Creates multi-page composition root with recursive structure preservation and asset management.
+ /// No file system operations occur during construction (lazy generation).
+ ///
+ /// Source folder containing markdown files and subfolders (recursive)
+ /// Template as IFile or IFolder (shared across all pages)
+ /// Template file name when source is IFolder (defaults to "template.html")
+ public AssetAwareHtmlTemplatedMarkdownPagesFolder(
+ IFolder markdownSourceFolder,
+ IStorable templateSource,
+ string? templateFileName = null)
+ {
+ _markdownSourceFolder = markdownSourceFolder ?? throw new ArgumentNullException(nameof(markdownSourceFolder));
+ _templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSource));
+ _templateFileName = templateFileName;
+ }
+
+ ///
+ /// Asset link detector for finding relative links in markdown.
+ ///
+ public required IAssetLinkDetector LinkDetector { get; init; }
+
+ ///
+ /// Asset resolver for converting paths to IFile instances.
+ ///
+ public required IAssetResolver Resolver { get; init; }
+
+ ///
+ /// Inclusion strategy for deciding include vs reference per asset.
+ ///
+ public required IAssetStrategy AssetStrategy { get; init; }
+
+ ///
+ public string Id => _markdownSourceFolder.Id;
+
+ ///
+ public string Name => _markdownSourceFolder.Name;
+
+ ///
+ /// Optional parent folder in virtual hierarchy.
+ ///
+ public IFolder? Parent { get; set; }
+
+ ///
+ public Task GetParentAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.FromResult(Parent);
+ }
+
+ ///
+ public async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Enumerate source folder items
+ await foreach (var item in _markdownSourceFolder.GetItemsAsync(StorableType.All, cancellationToken))
+ {
+ // Markdown files → create asset-aware page folders
+ if (item is IFile file && Path.GetExtension(file.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
+ {
+ if (type == StorableType.All || type == StorableType.Folder)
+ {
+ var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(file, _templateSource, _templateFileName)
+ {
+ Id = $"{Id}-{file.Name}".HashMD5Fast(),
+ LinkDetector = LinkDetector,
+ Resolver = Resolver,
+ AssetStrategy = AssetStrategy,
+ Parent = this
+ };
+
+ yield return pageFolder;
+ }
+ }
+
+ // Subfolders → create nested pages folders (recursive preservation)
+ if (item is IFolder subfolder)
+ {
+ if (type == StorableType.All || type == StorableType.Folder)
+ {
+ var nestedPagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(subfolder, _templateSource, _templateFileName)
+ {
+ LinkDetector = LinkDetector,
+ Resolver = Resolver,
+ AssetStrategy = AssetStrategy,
+ Parent = this
+ };
+
+ yield return nestedPagesFolder;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Blog/Pages/MarkdownPageAssetStrategy.cs b/src/Blog/Pages/MarkdownPageAssetStrategy.cs
new file mode 100644
index 0000000..9160ea2
--- /dev/null
+++ b/src/Blog/Pages/MarkdownPageAssetStrategy.cs
@@ -0,0 +1,40 @@
+using OwlCore.Diagnostics;
+using OwlCore.Storage;
+using WindowsAppCommunity.Blog.Assets;
+
+namespace WindowsAppCommunity.Blog.Pages;
+
+///
+/// Rewrites markdown-to-markdown links to generated page routes, delegating ordinary asset behavior.
+///
+public sealed class MarkdownPageAssetStrategy : IAssetStrategy
+{
+ ///
+ /// Gets the source-derived route index for generated markdown pages.
+ ///
+ public required MarkdownPageRouteIndex RouteIndex { get; init; }
+
+ ///
+ /// Gets the strategy used for non-markdown assets.
+ ///
+ public required IAssetStrategy AssetStrategy { get; init; }
+
+ ///
+ public Task DecideAsync(IFile referencingTextFile, IFile referencedAssetFile, string originalPath, CancellationToken ct = default)
+ {
+ if (!Path.GetExtension(referencedAssetFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
+ return AssetStrategy.DecideAsync(referencingTextFile, referencedAssetFile, originalPath, ct);
+
+ if (RouteIndex.TryGetRelativeRoute(referencingTextFile, referencedAssetFile, out var relativeRoute))
+ return Task.FromResult($"{relativeRoute}{GetFragment(originalPath)}");
+
+ Logger.LogWarning($"Markdown link target was resolved but is not part of the generated page route index: {referencedAssetFile.Name}");
+ return Task.FromResult(null);
+ }
+
+ private static string GetFragment(string path)
+ {
+ var fragmentIndex = path.IndexOf('#');
+ return fragmentIndex < 0 ? string.Empty : path[fragmentIndex..];
+ }
+}
\ No newline at end of file
diff --git a/src/Blog/Pages/MarkdownPageRoute.cs b/src/Blog/Pages/MarkdownPageRoute.cs
new file mode 100644
index 0000000..67238f4
--- /dev/null
+++ b/src/Blog/Pages/MarkdownPageRoute.cs
@@ -0,0 +1,16 @@
+using OwlCore.Storage;
+
+namespace WindowsAppCommunity.Blog.Pages;
+
+///
+/// Maps a source markdown file to its generated folderized page route.
+///
+/// The source markdown file.
+/// The generated page folder path relative to the site root.
+public sealed record MarkdownPageRoute(IFile SourceFile, string PageFolderPath)
+{
+ ///
+ /// Gets the generated page route as a folder URL.
+ ///
+ public string PageUrlPath => string.IsNullOrWhiteSpace(PageFolderPath) ? "./" : $"{PageFolderPath.TrimEnd('/')}/";
+}
\ No newline at end of file
diff --git a/src/Blog/Pages/MarkdownPageRouteIndex.cs b/src/Blog/Pages/MarkdownPageRouteIndex.cs
new file mode 100644
index 0000000..51db119
--- /dev/null
+++ b/src/Blog/Pages/MarkdownPageRouteIndex.cs
@@ -0,0 +1,171 @@
+using OwlCore.Extensions;
+using OwlCore.Storage;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Page;
+
+namespace WindowsAppCommunity.Blog.Pages;
+
+///
+/// Source-derived route index for folderized markdown pages.
+///
+public sealed class MarkdownPageRouteIndex
+{
+ private readonly Dictionary _routesByFileId;
+
+ private MarkdownPageRouteIndex(Dictionary routesByFileId)
+ {
+ _routesByFileId = routesByFileId;
+ }
+
+ ///
+ /// Gets all indexed markdown page routes.
+ ///
+ public IReadOnlyCollection Routes => _routesByFileId.Values;
+
+ ///
+ /// Creates an index from the markdown source folder tree.
+ ///
+ public static async Task CreateAsync(IFolder markdownSourceFolder, CancellationToken cancellationToken = default)
+ {
+ return await CreateAsync(markdownSourceFolder, null, null, cancellationToken);
+ }
+
+ ///
+ /// Creates an index from the markdown source folder tree and recursively discovered markdown links.
+ ///
+ public static async Task CreateAsync(
+ IFolder markdownSourceFolder,
+ IAssetLinkDetector? linkDetector,
+ IAssetResolver? resolver,
+ CancellationToken cancellationToken = default)
+ {
+ var routesByFileId = new Dictionary();
+ var pendingFiles = new Queue();
+ await AddFolderRoutesAsync(markdownSourceFolder, string.Empty, routesByFileId, pendingFiles, cancellationToken);
+
+ if (linkDetector is not null && resolver is not null)
+ await AddLinkedMarkdownRoutesAsync(linkDetector, resolver, routesByFileId, pendingFiles, cancellationToken);
+
+ return new MarkdownPageRouteIndex(routesByFileId);
+ }
+
+ ///
+ /// Attempts to get the generated route for a source markdown file.
+ ///
+ public bool TryGetRoute(IFile sourceMarkdownFile, out MarkdownPageRoute? route)
+ {
+ return _routesByFileId.TryGetValue(sourceMarkdownFile.Id, out route);
+ }
+
+ ///
+ /// Attempts to get a generated page route relative from the referencing markdown page route.
+ ///
+ public bool TryGetRelativeRoute(IFile referencingMarkdownFile, IFile referencedMarkdownFile, out string? relativeRoute)
+ {
+ relativeRoute = null;
+
+ if (!TryGetRoute(referencingMarkdownFile, out var referencingRoute) || referencingRoute is null)
+ return false;
+
+ if (!TryGetRoute(referencedMarkdownFile, out var referencedRoute) || referencedRoute is null)
+ return false;
+
+ relativeRoute = GetRelativeFolderRoute(referencingRoute.PageFolderPath, referencedRoute.PageFolderPath);
+ return true;
+ }
+
+ private static async Task AddFolderRoutesAsync(
+ IFolder folder,
+ string currentFolderPath,
+ Dictionary routesByFileId,
+ Queue pendingFiles,
+ CancellationToken cancellationToken)
+ {
+ await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken).WithCancellation(cancellationToken))
+ {
+ if (item is IFile file && Path.GetExtension(file.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
+ {
+ var pageFolderName = HtmlTemplatedMarkdownPageFolder.GetPageFolderName(file.Name);
+ var pageFolderPath = CombineRoutePath(currentFolderPath, pageFolderName);
+ AddRoute(routesByFileId, pendingFiles, file, pageFolderPath);
+ }
+
+ if (item is IFolder subfolder)
+ {
+ var nestedFolderPath = CombineRoutePath(currentFolderPath, subfolder.Name);
+ await AddFolderRoutesAsync(subfolder, nestedFolderPath, routesByFileId, pendingFiles, cancellationToken);
+ }
+ }
+ }
+
+ private static async Task AddLinkedMarkdownRoutesAsync(
+ IAssetLinkDetector linkDetector,
+ IAssetResolver resolver,
+ Dictionary routesByFileId,
+ Queue pendingFiles,
+ CancellationToken cancellationToken)
+ {
+ while (pendingFiles.Count > 0)
+ {
+ var currentFile = pendingFiles.Dequeue();
+
+ await foreach (var link in linkDetector.DetectAsync(currentFile, cancellationToken).WithCancellation(cancellationToken))
+ {
+ var resolvedFile = await resolver.ResolveAsync(currentFile, link, cancellationToken);
+ if (resolvedFile is null)
+ continue;
+
+ if (!Path.GetExtension(resolvedFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (routesByFileId.ContainsKey(resolvedFile.Id))
+ continue;
+
+ var externalRoutePath = CombineRoutePath("_linked", resolvedFile.Id.HashMD5Fast());
+ AddRoute(routesByFileId, pendingFiles, resolvedFile, externalRoutePath);
+ }
+ }
+ }
+
+ private static void AddRoute(Dictionary routesByFileId, Queue pendingFiles, IFile file, string pageFolderPath)
+ {
+ routesByFileId[file.Id] = new MarkdownPageRoute(file, pageFolderPath);
+ pendingFiles.Enqueue(file);
+ }
+
+ private static string CombineRoutePath(string parentPath, string childName)
+ {
+ return string.IsNullOrWhiteSpace(parentPath) ? childName : $"{parentPath.TrimEnd('/')}/{childName}";
+ }
+
+ private static string GetRelativeFolderRoute(string fromPageFolderPath, string toPageFolderPath)
+ {
+ var fromSegments = SplitRoutePath(fromPageFolderPath).ToArray();
+ var toSegments = SplitRoutePath(toPageFolderPath).ToArray();
+
+ var commonLength = 0;
+ while (commonLength < fromSegments.Length &&
+ commonLength < toSegments.Length &&
+ string.Equals(fromSegments[commonLength], toSegments[commonLength], StringComparison.OrdinalIgnoreCase))
+ {
+ commonLength++;
+ }
+
+ var relativeSegments = Enumerable
+ .Repeat("..", fromSegments.Length - commonLength)
+ .Concat(toSegments.Skip(commonLength))
+ .ToArray();
+
+ if (relativeSegments.Length == 0)
+ return "./";
+
+ return $"{string.Join('/', relativeSegments)}/";
+ }
+
+ private static IEnumerable SplitRoutePath(string routePath)
+ {
+ return routePath
+ .Replace('\\', '/')
+ .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ }
+}
\ No newline at end of file
diff --git a/src/Blog/PostPage/PostPageAssetFolder.cs b/src/Blog/PostPage/PostPageAssetFolder.cs
deleted file mode 100644
index 9771014..0000000
--- a/src/Blog/PostPage/PostPageAssetFolder.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using OwlCore.Storage;
-
-namespace WindowsAppCommunity.Blog.PostPage
-{
- ///
- /// Virtual IChildFolder that recursively wraps template asset folders.
- /// Mirrors template folder structure with recursive PostPageAssetFolder wrapping.
- /// Passes through files directly (preserves type identity for fastpath extension methods).
- /// Propagates template file exclusion down hierarchy.
- ///
- public sealed class PostPageAssetFolder : IChildFolder
- {
- private readonly IFolder _wrappedFolder;
- private readonly IFolder _parent;
- private readonly IFile? _templateFileToExclude;
-
- ///
- /// Creates virtual asset folder wrapping template folder structure.
- ///
- /// Template folder to mirror
- /// Parent folder in virtual hierarchy
- /// Template HTML file to exclude from enumeration
- public PostPageAssetFolder(IFolder wrappedFolder, IFolder parent, IFile? templateFileToExclude)
- {
- _wrappedFolder = wrappedFolder ?? throw new ArgumentNullException(nameof(wrappedFolder));
- _parent = parent ?? throw new ArgumentNullException(nameof(parent));
- _templateFileToExclude = templateFileToExclude;
- }
-
- ///
- public string Id => _wrappedFolder.Id;
-
- ///
- public string Name => _wrappedFolder.Name;
-
- ///
- /// Parent folder in virtual hierarchy (not interface requirement, internal storage).
- ///
- public IFolder Parent => _parent;
-
- ///
- public Task GetParentAsync(CancellationToken cancellationToken = default)
- {
- return Task.FromResult(_parent);
- }
-
- ///
- public async IAsyncEnumerable GetItemsAsync(
- StorableType type = StorableType.All,
- [EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync starting for: {_wrappedFolder.Id}");
-
- // Enumerate wrapped folder items
- await foreach (var item in _wrappedFolder.GetItemsAsync(type, cancellationToken))
- {
- // Recursively wrap subfolders with this as parent
- if (item is IFolder subfolder && (type == StorableType.All || type == StorableType.Folder))
- {
- yield return new PostPageAssetFolder(subfolder, this, _templateFileToExclude);
- continue;
- }
-
- // Pass through files directly (preserves type identity)
- if (item is IChildFile file && (type == StorableType.All || type == StorableType.File))
- {
- // Exclude template HTML file if specified
- if (_templateFileToExclude != null && file.Id == _templateFileToExclude.Id)
- {
- continue;
- }
-
- yield return file;
- }
- }
-
- OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync complete for: {_wrappedFolder.Id}");
- }
- }
-}
diff --git a/src/Blog/PostPage/PostPageFolder.cs b/src/Blog/PostPage/PostPageFolder.cs
deleted file mode 100644
index 2ee7e28..0000000
--- a/src/Blog/PostPage/PostPageFolder.cs
+++ /dev/null
@@ -1,139 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using OwlCore.Storage;
-
-namespace WindowsAppCommunity.Blog.PostPage
-{
- ///
- /// Virtual IFolder representing folderized single-page output structure.
- /// Wraps markdown source file and template to provide virtual {filename}/index.html + assets structure.
- /// Implements lazy generation - no file system operations during construction.
- ///
- public sealed class PostPageFolder : IFolder
- {
- private readonly IFile _markdownSource;
- private readonly IStorable _templateSource;
- private readonly string? _templateFileName;
-
- ///
- /// Creates virtual folder representing single-page output structure.
- /// No file system operations occur during construction (lazy generation).
- ///
- /// Source markdown file to transform
- /// Template as IFile or IFolder
- /// Template file name when source is IFolder (defaults to "template.html")
- public PostPageFolder(IFile markdownSource, IStorable templateSource, string? templateFileName = null)
- {
- _markdownSource = markdownSource ?? throw new ArgumentNullException(nameof(markdownSource));
- _templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSource));
- _templateFileName = templateFileName;
- }
-
- ///
- public string Id => _markdownSource.Id;
-
- ///
- public string Name => SanitizeFilename(_markdownSource.Name);
-
- ///
- public async IAsyncEnumerable GetItemsAsync(
- StorableType type = StorableType.All,
- [EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- // Resolve template file for exclusion and IndexHtmlFile construction
- var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName);
-
- // Yield IndexHtmlFile (virtual index.html)
- if (type == StorableType.All || type == StorableType.File)
- {
- var indexHtmlId = $"{Id}/index.html";
- yield return new IndexHtmlFile(indexHtmlId, _markdownSource, _templateSource, _templateFileName);
- }
-
- // If template is folder, yield wrapped asset structure
- if (_templateSource is IFolder templateFolder)
- {
- await foreach (var item in templateFolder.GetItemsAsync(StorableType.All, cancellationToken))
- {
- // Wrap subfolders as PostPageAssetFolder
- if (item is IFolder subfolder && (type == StorableType.All || type == StorableType.Folder))
- {
- yield return new PostPageAssetFolder(subfolder, this, templateFile);
- continue;
- }
-
- // Pass through files directly (excluding template HTML file)
- if (item is IChildFile file && (type == StorableType.All || type == StorableType.File))
- {
- // Exclude template HTML file (already rendered as index.html)
- if (file.Id == templateFile.Id)
- {
- continue;
- }
-
- yield return file;
- }
- }
- }
- }
-
- ///
- /// Sanitize markdown filename for use as folder name.
- /// Removes file extension and replaces invalid filename characters with underscore.
- ///
- /// Original markdown filename with extension
- /// Sanitized folder name
- private string SanitizeFilename(string markdownFilename)
- {
- // Remove file extension
- var nameWithoutExtension = Path.GetFileNameWithoutExtension(markdownFilename);
-
- // Replace invalid filename characters with underscore
- var invalidChars = Path.GetInvalidFileNameChars();
- var sanitized = string.Concat(nameWithoutExtension.Select(c =>
- invalidChars.Contains(c) ? '_' : c));
-
- return sanitized;
- }
-
- ///
- /// Resolve template file from IStorable source.
- /// Handles both IFile (single template) and IFolder (template + assets).
- /// Uses convention-based lookup ("template.html") when source is folder.
- ///
- /// Template as IFile or IFolder
- /// File name when source is IFolder (defaults to "template.html")
- /// Resolved template IFile
- private async Task ResolveTemplateFileAsync(
- IStorable templateSource,
- string? templateFileName)
- {
- if (templateSource is IFile file)
- {
- return file;
- }
-
- if (templateSource is IFolder folder)
- {
- var fileName = templateFileName ?? "template.html";
- var templateFile = await folder.GetFirstByNameAsync(fileName);
-
- if (templateFile is not IFile resolvedFile)
- {
- throw new FileNotFoundException(
- $"Template file '{fileName}' not found in folder '{folder.Name}'.");
- }
-
- return resolvedFile;
- }
-
- throw new ArgumentException(
- $"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}",
- nameof(templateSource));
- }
- }
-}
diff --git a/src/Commands/Blog/PostPage/PageAssetMaterializer.cs b/src/Commands/Blog/PostPage/PageAssetMaterializer.cs
new file mode 100644
index 0000000..434b188
--- /dev/null
+++ b/src/Commands/Blog/PostPage/PageAssetMaterializer.cs
@@ -0,0 +1,45 @@
+using OwlCore.Storage;
+using WindowsAppCommunity.Blog.Assets;
+
+namespace WindowsAppCommunity.CommandLine.Blog.PostPage;
+
+internal static class PageAssetMaterializer
+{
+ public static async Task> GetFileIdsAsync(IStorable source)
+ {
+ if (source is IFile file)
+ return [file.Id];
+
+ if (source is IFolder folder)
+ return [.. await new DepthFirstRecursiveFolder(folder).GetFilesAsync().Select(x => x.Id).ToListAsync()];
+
+ return [];
+ }
+
+ public static async Task CopyAssetsAsync(IModifiableFolder pageOutputFolder, IEnumerable assets)
+ {
+ foreach (var asset in assets)
+ {
+ if (Path.GetExtension(asset.ResolvedFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var rewrittenPath = NormalizePath(asset.RewrittenPath);
+ var directoryPath = NormalizePath(Path.GetDirectoryName(rewrittenPath));
+ var assetOutputFolder = pageOutputFolder;
+
+ if (!string.IsNullOrWhiteSpace(directoryPath) && directoryPath != ".")
+ {
+ assetOutputFolder = (IModifiableFolder)await pageOutputFolder
+ .CreateFoldersAlongRelativePathAsync(directoryPath, overwrite: false)
+ .LastAsync();
+ }
+
+ await assetOutputFolder.CreateCopyOfAsync(asset.ResolvedFile, overwrite: true);
+ }
+ }
+
+ private static string NormalizePath(string? path)
+ {
+ return path?.Replace('\\', '/') ?? string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/src/Commands/Blog/PostPage/PageCommand.cs b/src/Commands/Blog/PostPage/PageCommand.cs
new file mode 100644
index 0000000..ca0cbad
--- /dev/null
+++ b/src/Commands/Blog/PostPage/PageCommand.cs
@@ -0,0 +1,123 @@
+using System.CommandLine;
+using OwlCore.Extensions;
+using OwlCore.Storage;
+using OwlCore.Storage.System.IO;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Page;
+
+namespace WindowsAppCommunity.CommandLine.Blog.PostPage;
+
+///
+/// CLI command for Post/Page scenario blog generation.
+/// Handles command-line parsing and invokes PostPageGenerator.
+///
+public class PageCommand : Command
+{
+ ///
+ /// Initialize Post/Page command with CLI options.
+ ///
+ public PageCommand()
+ : base("page", "Generate HTML from markdown using template")
+ {
+ // Define CLI options
+ var markdownOption = new Option(
+ name: "--markdown",
+ description: "Path to markdown file to transform")
+ {
+ IsRequired = true
+ };
+
+ var templateOption = new Option(
+ name: "--template",
+ description: "Path to template file or folder")
+ {
+ IsRequired = true
+ };
+
+ var outputOption = new Option(
+ name: "--output",
+ description: "Path to output destination folder")
+ {
+ IsRequired = true
+ };
+
+ var templateFileNameOption = new Option(
+ name: "--template-file",
+ description: "Template file name when --template is folder (optional, defaults to 'template.html')",
+ getDefaultValue: () => null);
+
+ // Register options
+ AddOption(markdownOption);
+ AddOption(templateOption);
+ AddOption(outputOption);
+ AddOption(templateFileNameOption);
+
+ // Set handler with option parameters
+ this.SetHandler(ExecuteAsync, markdownOption, templateOption, outputOption, templateFileNameOption);
+ }
+
+ ///
+ /// Execute Post/Page generation command.
+ /// Orchestrates: Parse arguments → Resolve storage → Invoke generator → Report results
+ ///
+ /// Path to markdown file
+ /// Path to template file or folder
+ /// Path to output destination folder
+ /// Template file name when template is folder (optional)
+ /// Exit code (0 = success, non-zero = error)
+ private async Task ExecuteAsync(string markdownPath, string templatePath, string outputPath, string? templateFileName)
+ {
+ // Gap #5 resolution: SystemFile/SystemFolder constructors validate existence
+ // Gap #10 resolution: Directory.Exists distinguishes folders from files
+
+ // Resolve markdown file (SystemFile throws if doesn't exist)
+ var markdownFile = new SystemFile(markdownPath);
+
+ // Resolve template source (file or folder)
+ IStorable templateSource;
+ if (Directory.Exists(templatePath))
+ {
+ templateSource = new SystemFolder(templatePath);
+ }
+ else
+ {
+ // SystemFile throws if doesn't exist
+ templateSource = new SystemFile(templatePath);
+ }
+
+ // Resolve output folder (SystemFolder throws if doesn't exist)
+ IModifiableFolder outputFolder = new SystemFolder(outputPath);
+
+ var templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource);
+
+ // Create virtual PostPageFolder (lazy generation - no I/O during construction)
+ var postPageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(markdownFile, templateSource, templateFileName)
+ {
+ Id = markdownFile.Id.HashMD5Fast(),
+ AssetStrategy = new KnownAssetStrategy
+ {
+ IncludedAssetFileIds = templateFileIds,
+ ReferencedAssetFileIds = [markdownFile.Id],
+ UnknownAssetFallbackStrategy = AssetFallbackBehavior.Reference,
+ UnknownAssetFaultStrategy = FaultStrategy.None,
+ },
+ Resolver = new RelativePathAssetResolver(),
+ LinkDetector = new RegexAssetLinkDetector(),
+ };
+
+ // Create output folder for this page
+ var pageOutputFolder = (IModifiableFolder)await outputFolder.CreateFolderAsync(postPageFolder.Name, overwrite: true);
+
+ // Materialize virtual structure by recursively copying all files
+ await foreach (AssetAwareHtmlTemplatedMarkdownFile file in postPageFolder.GetItemsAsync(StorableType.File))
+ {
+ await pageOutputFolder.CreateCopyOfAsync(file, overwrite: true);
+ await PageAssetMaterializer.CopyAssetsAsync(pageOutputFolder, file.Assets);
+ }
+
+ var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name);
+ Console.WriteLine($"Generated: {Path.Combine(outputPath, outputFolderName, "index.html")}");
+
+ return 0;
+ }
+}
diff --git a/src/Commands/Blog/PostPage/PagesCommand.cs b/src/Commands/Blog/PostPage/PagesCommand.cs
new file mode 100644
index 0000000..625bce8
--- /dev/null
+++ b/src/Commands/Blog/PostPage/PagesCommand.cs
@@ -0,0 +1,135 @@
+using System;
+using System.CommandLine;
+using System.IO;
+using System.Threading.Tasks;
+using OwlCore.Storage;
+using OwlCore.Storage.System.IO;
+using WindowsAppCommunity.Blog.Pages;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Page;
+using OwlCore.Diagnostics;
+
+namespace WindowsAppCommunity.CommandLine.Blog.PostPage
+{
+ ///
+ /// CLI command for multi-page blog generation (Pages scenario).
+ /// Handles command-line parsing and invokes AssetAwareHtmlTemplatedMarkdownPagesFolder.
+ ///
+ public class PagesCommand : Command
+ {
+ ///
+ /// Initialize Pages command with CLI options.
+ ///
+ public PagesCommand()
+ : base("pages", "Generate multi-page HTML site from markdown folder")
+ {
+ // Define CLI options
+ var markdownFolderOption = new Option(
+ name: "--markdown-folder",
+ description: "Path to folder containing markdown files to transform")
+ {
+ IsRequired = true
+ };
+
+ var templateOption = new Option(
+ name: "--template",
+ description: "Path to template file or folder")
+ {
+ IsRequired = true
+ };
+
+ var outputOption = new Option(
+ name: "--output",
+ description: "Path to output destination folder")
+ {
+ IsRequired = true
+ };
+
+ var templateFileNameOption = new Option(
+ name: "--template-file-name",
+ description: "Template file name when --template is folder (optional, defaults to 'template.html')",
+ getDefaultValue: () => null);
+
+ // Register options
+ AddOption(markdownFolderOption);
+ AddOption(templateOption);
+ AddOption(outputOption);
+ AddOption(templateFileNameOption);
+
+ // Set handler with option parameters
+ this.SetHandler(ExecuteAsync, markdownFolderOption, templateOption, outputOption, templateFileNameOption);
+ }
+
+ ///
+ /// Execute multi-page generation command.
+ /// Orchestrates: Parse arguments → Resolve storage → Invoke generator → Report results
+ ///
+ /// Path to folder containing markdown files
+ /// Path to template file or folder
+ /// Path to output destination folder
+ /// Template file name when template is folder (optional)
+ /// Exit code (0 = success, non-zero = error)
+ private async Task ExecuteAsync(
+ string markdownFolderPath,
+ string templatePath,
+ string outputPath,
+ string? templateFileName)
+ {
+ // Resolve template source (file or folder)
+ IStorable templateSource = Directory.Exists(templatePath)
+ ? new SystemFolder(templatePath)
+ : new SystemFile(templatePath);
+
+ // Resolve markdown source and output folders (SystemFolder throws if doesn't exist)
+ var outputFolder = new SystemFolder(outputPath);
+ var markdownSourceFolder = new SystemFolder(markdownFolderPath);
+
+ // Create recursive markdown-to-webpage folder (lazy generation - no I/O during construction)
+ // Turns `.md` files into folders with an `index.html` holding asset metadata for output copy
+ var linkDetector = new RegexAssetLinkDetector();
+ var resolver = new RelativePathAssetResolver();
+ var templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource);
+ var markdownPageRouteIndex = await MarkdownPageRouteIndex.CreateAsync(markdownSourceFolder, linkDetector, resolver);
+ var fileAssetStrategy = new KnownAssetStrategy()
+ {
+ IncludedAssetFileIds = templateFileIds,
+ UnknownAssetFaultStrategy = FaultStrategy.None,
+ UnknownAssetFallbackStrategy = AssetFallbackBehavior.Reference,
+ };
+
+ var assetStrategy = new MarkdownPageAssetStrategy
+ {
+ RouteIndex = markdownPageRouteIndex,
+ AssetStrategy = fileAssetStrategy,
+ };
+
+ // Materialize every recursively indexed markdown page route.
+ foreach (var route in markdownPageRouteIndex.Routes)
+ {
+ var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(route.SourceFile, templateSource, templateFileName)
+ {
+ Id = route.SourceFile.Id,
+ LinkDetector = linkDetector,
+ Resolver = resolver,
+ AssetStrategy = assetStrategy,
+ };
+
+ var pageOutputFolder = (IModifiableFolder)await outputFolder.CreateFoldersAlongRelativePathAsync(route.PageFolderPath, overwrite: false).LastAsync();
+
+ // Iterate/copy files within markdown page folder
+ await foreach (AssetAwareHtmlTemplatedMarkdownFile indexFile in pageFolder.GetItemsAsync(StorableType.File))
+ {
+ // Create folders relative to THIS page's output folder, then copy
+ await pageOutputFolder.CreateCopyOfAsync(indexFile, overwrite: true);
+ await PageAssetMaterializer.CopyAssetsAsync(pageOutputFolder, indexFile.Assets);
+ }
+ }
+
+ // Report success
+ Logger.LogInformation($"Generated multi-page site: {outputPath}");
+
+ // Return success exit code
+ return 0;
+ }
+ }
+}
diff --git a/src/Commands/Blog/PostPage/PostPageCommand.cs b/src/Commands/Blog/PostPage/PostPageCommand.cs
deleted file mode 100644
index 832db6a..0000000
--- a/src/Commands/Blog/PostPage/PostPageCommand.cs
+++ /dev/null
@@ -1,143 +0,0 @@
-using System;
-using System.CommandLine;
-using System.CommandLine.Invocation;
-using System.IO;
-using System.Threading.Tasks;
-using OwlCore.Storage;
-using OwlCore.Storage.System.IO;
-using WindowsAppCommunity.Blog.PostPage;
-
-namespace WindowsAppCommunity.CommandLine.Blog.PostPage
-{
- ///
- /// CLI command for Post/Page scenario blog generation.
- /// Handles command-line parsing and invokes PostPageGenerator.
- ///
- public class PostPageCommand : Command
- {
- ///
- /// Initialize Post/Page command with CLI options.
- ///
- public PostPageCommand()
- : base("postpage", "Generate HTML from markdown using template")
- {
- // Define CLI options
- var markdownOption = new Option(
- name: "--markdown",
- description: "Path to markdown file to transform")
- {
- IsRequired = true
- };
-
- var templateOption = new Option(
- name: "--template",
- description: "Path to template file or folder")
- {
- IsRequired = true
- };
-
- var outputOption = new Option(
- name: "--output",
- description: "Path to output destination folder")
- {
- IsRequired = true
- };
-
- var templateFileNameOption = new Option(
- name: "--template-file",
- description: "Template file name when --template is folder (optional, defaults to 'template.html')",
- getDefaultValue: () => null);
-
- // Register options
- AddOption(markdownOption);
- AddOption(templateOption);
- AddOption(outputOption);
- AddOption(templateFileNameOption);
-
- // Set handler with option parameters
- this.SetHandler(ExecuteAsync, markdownOption, templateOption, outputOption, templateFileNameOption);
- }
-
- ///
- /// Execute Post/Page generation command.
- /// Orchestrates: Parse arguments → Resolve storage → Invoke generator → Report results
- ///
- /// Path to markdown file
- /// Path to template file or folder
- /// Path to output destination folder
- /// Template file name when template is folder (optional)
- /// Exit code (0 = success, non-zero = error)
- private async Task ExecuteAsync(
- string markdownPath,
- string templatePath,
- string outputPath,
- string? templateFileName)
- {
- // Gap #5 resolution: SystemFile/SystemFolder constructors validate existence
- // Gap #10 resolution: Directory.Exists distinguishes folders from files
-
- // 1. Resolve markdown file (SystemFile throws if doesn't exist)
- var markdownFile = new SystemFile(markdownPath);
-
- // 2. Resolve template source (file or folder)
- IStorable templateSource;
- if (Directory.Exists(templatePath))
- {
- templateSource = new SystemFolder(templatePath);
- }
- else
- {
- // SystemFile throws if doesn't exist
- templateSource = new SystemFile(templatePath);
- }
-
- // 3. Resolve output folder (SystemFolder throws if doesn't exist)
- IModifiableFolder outputFolder = new SystemFolder(outputPath);
-
- // 4. Create virtual PostPageFolder (lazy generation - no I/O during construction)
- var postPageFolder = new PostPageFolder(markdownFile, templateSource, templateFileName);
-
- // 5. Create output folder for this page
- var pageOutputFolder = await outputFolder.CreateFolderAsync(postPageFolder.Name, overwrite: true);
-
- // 6. Materialize virtual structure by recursively copying all files
- var recursiveFolder = new DepthFirstRecursiveFolder(postPageFolder);
- await foreach (var item in recursiveFolder.GetItemsAsync(StorableType.File))
- {
- if (item is not IChildFile file)
- continue;
-
- // Get relative path from appropriate root based on file type
- string relativePath;
- if (file is IndexHtmlFile)
- {
- // IndexHtmlFile is virtual, use simple name-based path
- relativePath = $"/{file.Name}";
- }
- else if (templateSource is IFolder templateFolder)
- {
- // Asset files from template folder - get path relative to template root
- relativePath = await templateFolder.GetRelativePathToAsync(file);
- }
- else
- {
- // Template is file, no assets exist - skip
- continue;
- }
-
- // Create containing folder for this file (or open if exists)
- var containingFolder = await pageOutputFolder.CreateFoldersAlongRelativePathAsync(relativePath, overwrite: false).LastAsync();
-
- // Copy file using ICreateCopyOf fastpath
- await ((IModifiableFolder)containingFolder).CreateCopyOfAsync(file, overwrite: true);
- }
-
- // 7. Report success
- var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name);
- Console.WriteLine($"Generated: {Path.Combine(outputPath, outputFolderName, "index.html")}");
-
- // 7. Return success exit code
- return 0;
- }
- }
-}
diff --git a/src/Commands/Blog/WacsdkBlogCommands.cs b/src/Commands/Blog/WacsdkBlogCommands.cs
index bb8bbf0..c4b1a2f 100644
--- a/src/Commands/Blog/WacsdkBlogCommands.cs
+++ b/src/Commands/Blog/WacsdkBlogCommands.cs
@@ -17,10 +17,10 @@ public WacsdkBlogCommands()
: base("blog", "Blog generation commands")
{
// Register Post/Page scenario
- AddCommand(new PostPageCommand());
+ AddCommand(new PageCommand());
- // Future: Register Pages scenario
- // AddCommand(new PagesCommand());
+ // Register Pages scenario
+ AddCommand(new PagesCommand());
// Future: Register Site scenario
// AddCommand(new SiteCommand());
diff --git a/src/WindowsAppCommunity.CommandLine.csproj b/src/WindowsAppCommunity.CommandLine.csproj
index e13cbed..b2dc208 100644
--- a/src/WindowsAppCommunity.CommandLine.csproj
+++ b/src/WindowsAppCommunity.CommandLine.csproj
@@ -52,6 +52,7 @@ Initial release of WindowsAppCommunity.CommandLine.
+
diff --git a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs
new file mode 100644
index 0000000..226050f
--- /dev/null
+++ b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs
@@ -0,0 +1,249 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using OwlCore.Storage;
+using OwlCore.Storage.Memory;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Pages;
+
+namespace WindowsAppCommunity.CommandLine.Tests.Blog;
+
+///
+/// Tests for multi-page generation behavior.
+///
+[TestClass]
+public class AssetAwareHtmlTemplatedMarkdownPagesFolderTests
+{
+ private MemoryFolder _testSourceFolder = null!;
+ private MemoryFolder _templateFolder = null!;
+ private AssetAwareHtmlTemplatedMarkdownPagesFolder _pagesFolder = null!;
+
+ // File references stored from Setup for test access
+ private IFile _page1File = null!;
+ private IFile _page2File = null!;
+ private IFile _logoFile = null!;
+
+ private static KnownAssetStrategy CreateReferenceOnlyStrategy()
+ {
+ return new KnownAssetStrategy
+ {
+ UnknownAssetFallbackStrategy = AssetFallbackBehavior.Reference,
+ UnknownAssetFaultStrategy = FaultStrategy.None,
+ };
+ }
+
+ [TestInitialize]
+ public async Task Setup()
+ {
+ _testSourceFolder = new MemoryFolder("test-source", "test-source");
+
+ // Create file tree using AlongPath method (overwrite: false to reuse folders)
+ _page1File = await _testSourceFolder.CreateAlongRelativePathAsync("page1.md", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create page1.md");
+ await using (var stream = await _page1File.OpenStreamAsync(FileAccess.Write))
+ await using (var writer = new StreamWriter(stream))
+ {
+ await writer.WriteAsync(@"---
+title: Page 1
+---
+
+# Page 1 Content
+
+");
+ }
+
+ _page2File = await _testSourceFolder.CreateAlongRelativePathAsync("subfolder/page2.md", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create page2.md");
+ await using (var stream2 = await _page2File.OpenStreamAsync(FileAccess.Write))
+ await using (var writer2 = new StreamWriter(stream2))
+ {
+ await writer2.WriteAsync(@"---
+title: Page 2
+---
+
+# Page 2 Content
+
+");
+ }
+
+ var localIcon = await _testSourceFolder.CreateAlongRelativePathAsync("subfolder/local-icon.png", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create local-icon.png");
+ await using (var iconStream = await localIcon.OpenStreamAsync(FileAccess.Write))
+ {
+ // Empty file
+ }
+
+ _logoFile = await _testSourceFolder.CreateAlongRelativePathAsync("images/logo.png", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create logo.png");
+ await using (var logoStream = await _logoFile.OpenStreamAsync(FileAccess.Write))
+ {
+ // Empty file
+ }
+
+ // Create template
+ _templateFolder = new MemoryFolder("template", "template");
+ var templateHtml = await _templateFolder.CreateAlongRelativePathAsync("index.html", StorableType.File, overwrite: true).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create template");
+ await using (var templateStream = await templateHtml.OpenStreamAsync(FileAccess.Write))
+ await using (var templateWriter = new StreamWriter(templateStream))
+ {
+ await templateWriter.WriteAsync(@"
+
+{{ frontmatter.title }}
+{{ body }}
+");
+ }
+
+ // Instantiate composition root
+ _pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(
+ _testSourceFolder,
+ _templateFolder,
+ "index.html")
+ {
+ LinkDetector = new RegexAssetLinkDetector(),
+ Resolver = new RelativePathAssetResolver(),
+ AssetStrategy = CreateReferenceOnlyStrategy()
+ };
+ }
+
+ [TestMethod]
+ public async Task MarkdownDiscovery_FindsAllMarkdownFiles()
+ {
+ var items = await _pagesFolder.GetItemsAsync(StorableType.All).ToListAsync();
+ var folders = items.OfType().ToList();
+
+ Assert.IsTrue(folders.Count >= 1, $"Should discover at least 1 item (found {folders.Count})");
+ var hasPage1OrSubfolder = folders.Any(f => f.Name.Contains("page1") || f.Name == "subfolder");
+ Assert.IsTrue(hasPage1OrSubfolder, "Should find page1 folder or subfolder in output");
+ }
+
+ [TestMethod]
+ public async Task HierarchyPreservation_MirrorsSourceStructure()
+ {
+ var rootItems = await _pagesFolder.GetItemsAsync(StorableType.All).ToListAsync();
+ var subfolder = rootItems.OfType().FirstOrDefault(f => f.Name == "subfolder");
+
+ if (subfolder == null)
+ {
+ var folderNames = string.Join(", ", rootItems.OfType().Select(f => f.Name));
+ Assert.Inconclusive($"Subfolder not found at root level. Found folders: {folderNames}");
+ return;
+ }
+
+ var subfolderItems = await subfolder.GetItemsAsync(StorableType.All).ToListAsync();
+ Assert.IsTrue(subfolderItems.Count > 0, "Subfolder should contain items");
+ }
+
+ [TestMethod]
+ public async Task AssetLinkDetection_IdentifiesRelativeLinks()
+ {
+ var rootItems = await _pagesFolder.GetItemsAsync(StorableType.All).ToListAsync();
+ var page1Folder = rootItems.OfType().FirstOrDefault(f => f.Name.Contains("page1"));
+
+ if (page1Folder == null)
+ {
+ Assert.Inconclusive("Page1 folder not found in output");
+ return;
+ }
+
+ var page1Items = await page1Folder.GetItemsAsync(StorableType.All).ToListAsync();
+ var indexHtml = page1Items.OfType().FirstOrDefault();
+
+ if (indexHtml == null)
+ {
+ Assert.Inconclusive("No files found in page1 folder");
+ return;
+ }
+
+ string htmlContent;
+ await using (var stream = await indexHtml.OpenStreamAsync(FileAccess.Read))
+ using (var reader = new StreamReader(stream))
+ {
+ htmlContent = await reader.ReadToEndAsync();
+ }
+
+ Assert.IsTrue(htmlContent.Contains("logo.png"), $"HTML should contain logo.png reference. Content: {htmlContent}");
+ }
+
+ [TestMethod]
+ public async Task AssetPathResolution_ResolvesValidPaths()
+ {
+ var resolver = new RelativePathAssetResolver();
+
+ var resolvedAsset = await resolver.ResolveAsync(_page1File, "images/logo.png");
+
+ if (resolvedAsset == null)
+ {
+ resolvedAsset = await resolver.ResolveAsync(_page1File, "../images/logo.png");
+ }
+
+ Assert.IsNotNull(resolvedAsset, "Should resolve images/logo.png or ../images/logo.png from page1.md context");
+ Assert.AreEqual("logo.png", resolvedAsset.Name, "Resolved asset should be logo.png");
+ }
+
+ [TestMethod]
+ public async Task AssetStrategy_AppliesReferenceDecisions()
+ {
+ var strategy = CreateReferenceOnlyStrategy();
+ Assert.IsNotNull(_page1File, "page1.md should exist");
+ Assert.IsNotNull(_logoFile, "logo.png should exist");
+
+ var rewrittenPath = await strategy.DecideAsync(_page1File, _logoFile, "../images/logo.png");
+
+ Assert.IsNotNull(rewrittenPath, "Reference-only strategy should return a rewritten path");
+ Assert.IsTrue(rewrittenPath.StartsWith("../"), "Reference-only strategy should return path with ../ prefix");
+ Assert.IsTrue(rewrittenPath.Contains("images/logo.png"), "Rewritten path should preserve original structure");
+ }
+
+ [TestMethod]
+ public async Task LinkRewriting_AddsDepthPrefix()
+ {
+ var rootItems = await _pagesFolder.GetItemsAsync(StorableType.All).ToListAsync();
+ var page1Folder = rootItems.OfType().FirstOrDefault(f => f.Name.Contains("page1"));
+
+ if (page1Folder == null)
+ {
+ Assert.Inconclusive("Page1 folder not found");
+ return;
+ }
+
+ var page1Items = await page1Folder.GetItemsAsync(StorableType.All).ToListAsync();
+ var indexHtml = page1Items.OfType().FirstOrDefault();
+
+ if (indexHtml == null)
+ {
+ Assert.Inconclusive("No HTML file found in page1 folder");
+ return;
+ }
+
+ string htmlContent;
+ await using (var stream = await indexHtml.OpenStreamAsync(FileAccess.Read))
+ using (var reader = new StreamReader(stream))
+ {
+ htmlContent = await reader.ReadToEndAsync();
+ }
+
+ Assert.IsTrue(htmlContent.Contains("../../images/logo.png") || htmlContent.Contains("../images/logo.png"),
+ $"Reference link should be rewritten. Content: {htmlContent}");
+ }
+
+ [TestMethod]
+ public async Task YieldOrder_FollowsSpecification()
+ {
+ var rootItems = await _pagesFolder.GetItemsAsync(StorableType.All).ToListAsync();
+ var page1Folder = rootItems.OfType().FirstOrDefault(f => f.Name.Contains("page1"));
+
+ if (page1Folder == null)
+ {
+ Assert.Inconclusive("Page1 folder not found");
+ return;
+ }
+
+ var page1Items = await page1Folder.GetItemsAsync(StorableType.All).ToListAsync();
+
+ Assert.IsTrue(page1Items.Count > 0, "Page folder should yield items");
+ var firstItem = page1Items[0];
+ Assert.IsInstanceOfType(firstItem, typeof(IFile), "First item should be a file");
+ }
+}
diff --git a/tests/Blog/BlogCommandMaterializationTests.cs b/tests/Blog/BlogCommandMaterializationTests.cs
new file mode 100644
index 0000000..3da7d5c
--- /dev/null
+++ b/tests/Blog/BlogCommandMaterializationTests.cs
@@ -0,0 +1,228 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.CommandLine;
+using WindowsAppCommunity.CommandLine.Blog.PostPage;
+
+namespace WindowsAppCommunity.CommandLine.Tests.Blog;
+
+[TestClass]
+public class BlogCommandMaterializationTests
+{
+ [TestMethod]
+ public async Task PageCommand_CopiesGeneratedIndexAndTemplateAssets()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var markdownPath = Path.Combine(tempRoot, "post.md");
+ var templateFolder = Path.Combine(tempRoot, "template");
+ var outputFolder = Path.Combine(tempRoot, "output");
+
+ Directory.CreateDirectory(Path.Combine(templateFolder, "images"));
+ Directory.CreateDirectory(outputFolder);
+
+ await File.WriteAllTextAsync(markdownPath, "---\ntitle: Test Post\n---\n\n# Hello");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "
{{ body }}");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "styles.css"), "body { color: black; }");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "images", "logo.png"), "logo");
+
+ var exitCode = await new PageCommand().InvokeAsync([
+ "--markdown", markdownPath,
+ "--template", templateFolder,
+ "--output", outputFolder]);
+
+ var pageOutputFolder = Path.Combine(outputFolder, "post");
+
+ Assert.AreEqual(0, exitCode);
+ Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "index.html")), "index.html should be generated.");
+ Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "styles.css")), "styles.css should be copied as a file.");
+ Assert.IsFalse(Directory.Exists(Path.Combine(pageOutputFolder, "styles.css")), "styles.css must not be materialized as a folder.");
+ Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "images", "logo.png")), "Nested template image should be copied.");
+ Assert.IsTrue(File.Exists(Path.Combine(templateFolder, "styles.css")), "Template source asset should remain in place.");
+ Assert.AreEqual(0, Directory.GetFiles(pageOutputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into page output.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [TestMethod]
+ public async Task PagesCommand_CopiesTemplateAssetsAndReferencedSourceAssetsToRewrittenPaths()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourceFolder = Path.Combine(tempRoot, "source");
+ var templateFolder = Path.Combine(tempRoot, "template");
+ var outputFolder = Path.Combine(tempRoot, "output");
+
+ Directory.CreateDirectory(Path.Combine(sourceFolder, "images"));
+ Directory.CreateDirectory(Path.Combine(templateFolder, "images"));
+ Directory.CreateDirectory(outputFolder);
+
+ await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page1.md"), "---\ntitle: Page 1\n---\n\n# Page 1\n\n");
+ await File.WriteAllTextAsync(Path.Combine(sourceFolder, "images", "content.png"), "content");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "
{{ body }}");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "styles.css"), "body { color: black; }");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "images", "template-logo.png"), "logo");
+
+ var exitCode = await new PagesCommand().InvokeAsync([
+ "--markdown-folder", sourceFolder,
+ "--template", templateFolder,
+ "--output", outputFolder]);
+
+ var pageOutputFolder = Path.Combine(outputFolder, "page1");
+
+ Assert.AreEqual(0, exitCode);
+ Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "index.html")), "Page index.html should be generated.");
+ Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "styles.css")), "Template stylesheet should be copied into each page folder.");
+ Assert.IsFalse(Directory.Exists(Path.Combine(pageOutputFolder, "styles.css")), "styles.css must not be materialized as a folder.");
+ Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "images", "template-logo.png")), "Template image should be copied into each page folder.");
+ Assert.IsTrue(File.Exists(Path.Combine(outputFolder, "images", "content.png")), "Referenced source image should be copied to the rewritten parent-relative path.");
+ Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [TestMethod]
+ public async Task PagesCommand_RewritesInRootMarkdownLinksToGeneratedPageRoutes()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourceFolder = Path.Combine(tempRoot, "source");
+ var templateFolder = Path.Combine(tempRoot, "template");
+ var outputFolder = Path.Combine(tempRoot, "output");
+
+ Directory.CreateDirectory(Path.Combine(sourceFolder, "sub"));
+ Directory.CreateDirectory(templateFolder);
+ Directory.CreateDirectory(outputFolder);
+
+ 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)");
+ await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page2.md"), "---\ntitle: Page 2\n---\n\n[Page 1](page1.md)");
+ await File.WriteAllTextAsync(Path.Combine(sourceFolder, "sub", "page3.md"), "---\ntitle: Page 3\n---\n\n[Page 1](../page1.md)");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}");
+
+ var exitCode = await new PagesCommand().InvokeAsync([
+ "--markdown-folder", sourceFolder,
+ "--template", templateFolder,
+ "--output", outputFolder]);
+
+ var page1Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page1", "index.html"));
+ var page2Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page2", "index.html"));
+ var page3Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "sub", "page3", "index.html"));
+
+ Assert.AreEqual(0, exitCode);
+ StringAssert.Contains(page1Html, "href=\"../page2/\"");
+ StringAssert.Contains(page1Html, "href=\"../sub/page3/\"");
+ StringAssert.Contains(page2Html, "href=\"../page1/\"");
+ StringAssert.Contains(page3Html, "href=\"../../page1/\"");
+ Assert.IsFalse(page1Html.Contains(".md"), "Generated page1 HTML should not link to raw markdown files.");
+ Assert.IsFalse(page2Html.Contains(".md"), "Generated page2 HTML should not link to raw markdown files.");
+ Assert.IsFalse(page3Html.Contains(".md"), "Generated page3 HTML should not link to raw markdown files.");
+ Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [TestMethod]
+ public async Task PagesCommand_IncludesLinkedMarkdownOutsideSourceRootRecursively()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourceFolder = Path.Combine(tempRoot, "source");
+ var linkedFolder = Path.Combine(tempRoot, "linked");
+ var templateFolder = Path.Combine(tempRoot, "template");
+ var outputFolder = Path.Combine(tempRoot, "output");
+
+ Directory.CreateDirectory(sourceFolder);
+ Directory.CreateDirectory(linkedFolder);
+ Directory.CreateDirectory(templateFolder);
+ Directory.CreateDirectory(outputFolder);
+
+ await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page1.md"), "---\ntitle: Page 1\n---\n\n[Outside](../linked/outside.md)");
+ await File.WriteAllTextAsync(Path.Combine(linkedFolder, "outside.md"), "---\ntitle: Outside\n---\n\n[Page 1](../source/page1.md)");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}");
+
+ var exitCode = await new PagesCommand().InvokeAsync([
+ "--markdown-folder", sourceFolder,
+ "--template", templateFolder,
+ "--output", outputFolder]);
+
+ var page1Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page1", "index.html"));
+ var linkedIndexFiles = Directory.GetFiles(Path.Combine(outputFolder, "_linked"), "index.html", SearchOption.AllDirectories);
+
+ Assert.AreEqual(0, exitCode);
+ Assert.AreEqual(1, linkedIndexFiles.Length, "The linked external markdown page should be included once.");
+ StringAssert.Contains(page1Html, "href=\"../_linked/");
+ Assert.IsFalse(page1Html.Contains(".md"), "Generated page1 HTML should not link to raw markdown files.");
+
+ var outsideHtml = await File.ReadAllTextAsync(linkedIndexFiles[0]);
+ StringAssert.Contains(outsideHtml, "href=\"../../page1/\"");
+ Assert.IsFalse(outsideHtml.Contains(".md"), "Generated external HTML should not link to raw markdown files.");
+ Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [TestMethod]
+ public async Task PagesCommand_DoesNotAbortOnInvalidYamlFrontmatter()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourceFolder = Path.Combine(tempRoot, "source");
+ var templateFolder = Path.Combine(tempRoot, "template");
+ var outputFolder = Path.Combine(tempRoot, "output");
+
+ Directory.CreateDirectory(sourceFolder);
+ Directory.CreateDirectory(templateFolder);
+ Directory.CreateDirectory(outputFolder);
+
+ await File.WriteAllTextAsync(Path.Combine(sourceFolder, "bad-yaml.md"), "---\ntitle: \"C:\\Projects\\Atlas\"\n---\n\n# Still renders");
+ await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}");
+
+ var exitCode = await new PagesCommand().InvokeAsync([
+ "--markdown-folder", sourceFolder,
+ "--template", templateFolder,
+ "--output", outputFolder]);
+
+ var generatedHtml = await File.ReadAllTextAsync(Path.Combine(outputFolder, "bad-yaml", "index.html"));
+
+ Assert.AreEqual(0, exitCode);
+ StringAssert.Contains(generatedHtml, "Still renders");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ private static string CreateTempRoot()
+ {
+ var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempRoot);
+ return tempRoot;
+ }
+
+ private static void DeleteTempRoot(string tempRoot)
+ {
+ if (Directory.Exists(tempRoot))
+ Directory.Delete(tempRoot, recursive: true);
+ }
+}
\ No newline at end of file
diff --git a/tests/Blog/MarkdownPageRouteIndexTests.cs b/tests/Blog/MarkdownPageRouteIndexTests.cs
new file mode 100644
index 0000000..b9863fc
--- /dev/null
+++ b/tests/Blog/MarkdownPageRouteIndexTests.cs
@@ -0,0 +1,100 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using OwlCore.Storage;
+using OwlCore.Storage.Memory;
+using WindowsAppCommunity.Blog.Assets;
+using WindowsAppCommunity.Blog.Pages;
+
+namespace WindowsAppCommunity.CommandLine.Tests.Blog;
+
+[TestClass]
+public class MarkdownPageRouteIndexTests
+{
+ [TestMethod]
+ public async Task CreateAsync_IndexesFolderizedMarkdownRoutes()
+ {
+ var sourceFolder = new MemoryFolder("source", "source");
+ var rootPage = await CreateFileAsync(sourceFolder, "page one.md");
+ var nestedPage = await CreateFileAsync(sourceFolder, "area/child.md");
+
+ var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder);
+
+ Assert.IsTrue(routeIndex.TryGetRoute(rootPage, out var rootRoute));
+ Assert.IsNotNull(rootRoute);
+ Assert.AreEqual("page one", rootRoute.PageFolderPath);
+ Assert.AreEqual("page one/", rootRoute.PageUrlPath);
+
+ Assert.IsTrue(routeIndex.TryGetRoute(nestedPage, out var nestedRoute));
+ Assert.IsNotNull(nestedRoute);
+ Assert.AreEqual("area/child", nestedRoute.PageFolderPath);
+ Assert.AreEqual("area/child/", nestedRoute.PageUrlPath);
+ }
+
+ [TestMethod]
+ public async Task MarkdownPageAssetStrategy_RewritesMarkdownLinksToGeneratedPageRoutes()
+ {
+ var sourceFolder = new MemoryFolder("source", "source");
+ var page1 = await CreateFileAsync(sourceFolder, "page1.md");
+ var page2 = await CreateFileAsync(sourceFolder, "page2.md");
+ var nestedPage = await CreateFileAsync(sourceFolder, "sub/page3.md");
+ var image = await CreateFileAsync(sourceFolder, "images/logo.png");
+ var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder);
+ var strategy = new MarkdownPageAssetStrategy
+ {
+ RouteIndex = routeIndex,
+ AssetStrategy = new KnownAssetStrategy
+ {
+ ReferencedAssetFileIds = [image.Id],
+ UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop,
+ UnknownAssetFaultStrategy = FaultStrategy.None,
+ }
+ };
+
+ var siblingRoute = await strategy.DecideAsync(page1, page2, "page2.md");
+ var childRoute = await strategy.DecideAsync(page1, nestedPage, "sub/page3.md");
+ var parentRoute = await strategy.DecideAsync(nestedPage, page1, "../page1.md");
+ var imageRoute = await strategy.DecideAsync(page1, image, "images/logo.png");
+
+ Assert.AreEqual("../page2/", siblingRoute);
+ Assert.AreEqual("../sub/page3/", childRoute);
+ Assert.AreEqual("../../page1/", parentRoute);
+ Assert.AreEqual("../images/logo.png", imageRoute);
+ }
+
+ [TestMethod]
+ public async Task CreateAsync_WithDetectorAndResolver_IndexesLinkedMarkdownOutsideSourceRoot()
+ {
+ var notesRoot = new MemoryFolder("notes", "notes");
+ var sourcePage = await CreateFileAsync(notesRoot, "current/page1.md", "[Outside](../linked/outside.md)");
+ var outsidePage = await CreateFileAsync(notesRoot, "linked/outside.md", "[Page 1](../current/page1.md)");
+ var sourceFolder = await notesRoot.GetFirstByNameAsync("current") as IFolder
+ ?? throw new InvalidOperationException("Failed to get source folder");
+
+ var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder, new RegexAssetLinkDetector(), new RelativePathAssetResolver());
+
+ Assert.IsTrue(routeIndex.TryGetRoute(sourcePage, out var sourceRoute));
+ Assert.IsNotNull(sourceRoute);
+ Assert.AreEqual("page1", sourceRoute.PageFolderPath);
+
+ Assert.IsTrue(routeIndex.TryGetRoute(outsidePage, out var outsideRoute));
+ Assert.IsNotNull(outsideRoute);
+ StringAssert.StartsWith(outsideRoute.PageFolderPath, "_linked/");
+
+ Assert.IsTrue(routeIndex.TryGetRelativeRoute(sourcePage, outsidePage, out var relativeRoute));
+ Assert.IsNotNull(relativeRoute);
+ StringAssert.StartsWith(relativeRoute, "../_linked/");
+ }
+
+ private static async Task CreateFileAsync(MemoryFolder folder, string relativePath)
+ {
+ return await CreateFileAsync(folder, relativePath, string.Empty);
+ }
+
+ private static async Task CreateFileAsync(MemoryFolder folder, string relativePath, string content)
+ {
+ var file = await folder.CreateAlongRelativePathAsync(relativePath, StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException($"Failed to create {relativePath}");
+
+ await file.WriteTextAsync(content);
+ return file;
+ }
+}
\ No newline at end of file
diff --git a/tests/Blog/RelativePathAssetResolverTests.cs b/tests/Blog/RelativePathAssetResolverTests.cs
new file mode 100644
index 0000000..de22ebd
--- /dev/null
+++ b/tests/Blog/RelativePathAssetResolverTests.cs
@@ -0,0 +1,97 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using OwlCore.Storage.System.IO;
+using WindowsAppCommunity.Blog.Assets;
+
+namespace WindowsAppCommunity.CommandLine.Tests.Blog;
+
+[TestClass]
+public class RelativePathAssetResolverTests
+{
+ [TestMethod]
+ public async Task ResolveAsync_UsesDefaultMarkdownFileForDirectoryLinks()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourcePath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.16.2026", "wct.md");
+ var targetPath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.2.2026", "wct", "planning,log.md");
+ await CreateTextFileAsync(sourcePath);
+ await CreateTextFileAsync(targetPath);
+
+ var resolvedFile = await new RelativePathAssetResolver().ResolveAsync(new SystemFile(sourcePath), "../3.2.2026/wct/");
+
+ Assert.IsNotNull(resolvedFile);
+ Assert.AreEqual(targetPath, ((SystemFile)resolvedFile).Path);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [TestMethod]
+ public async Task ResolveAsync_UsesUniqueNotesRootSuffixForCopiedLegacyLinks()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourcePath = Path.Combine(tempRoot, "Notes", "2026", "April", "4.26.2026", "wct", "triage", "self", "4.2.2026", "toc,outline,evaluation", "log.md");
+ var targetPath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.24.2026", "wct", "winui,wasdk", "1.8,upgrade", "planning,log.md");
+ await CreateTextFileAsync(sourcePath);
+ await CreateTextFileAsync(targetPath);
+
+ var resolvedFile = await new RelativePathAssetResolver().ResolveAsync(new SystemFile(sourcePath), "../../../../../../%60March/3.24.2026%5Cwct%5Cwinui,wasdk%5C1.8,upgrade%5Cplanning,log.md");
+
+ Assert.IsNotNull(resolvedFile);
+ Assert.AreEqual(targetPath, ((SystemFile)resolvedFile).Path);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [TestMethod]
+ public async Task ResolveAsync_UsesSourceAwareAliasForCopiedPlanningLogContext()
+ {
+ var tempRoot = CreateTempRoot();
+
+ try
+ {
+ var sourcePath = Path.Combine(tempRoot, "Notes", "2026", "April", "4.2.2026", "wct", "planning,self,triage,march-to-april", "log.md");
+ var targetPath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.26.2026", "wct", "planning,log.md");
+ await CreateTextFileAsync(sourcePath);
+ await CreateTextFileAsync(targetPath);
+
+ var resolvedFile = await new RelativePathAssetResolver().ResolveAsync(new SystemFile(sourcePath), "../../planning,log.md");
+
+ Assert.IsNotNull(resolvedFile);
+ Assert.AreEqual(targetPath, ((SystemFile)resolvedFile).Path);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ private static string CreateTempRoot()
+ {
+ var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-resolver-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempRoot);
+ return tempRoot;
+ }
+
+ private static async Task CreateTextFileAsync(string path)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("File path has no directory."));
+ await File.WriteAllTextAsync(path, string.Empty);
+ }
+
+ private static void DeleteTempRoot(string tempRoot)
+ {
+ if (Directory.Exists(tempRoot))
+ Directory.Delete(tempRoot, recursive: true);
+ }
+}
\ No newline at end of file