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 + +![Logo](../images/logo.png)"); + } + + _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 + +![Local Icon](./local-icon.png)"); + } + + 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![Content](images/content.png)"); + 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