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..0558752 --- /dev/null +++ b/src/Blog/Assets/RegexAssetLinkDetector.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using OwlCore.Diagnostics; +using OwlCore.Storage; + +namespace WindowsAppCommunity.Blog.Assets; + +/// +/// Detects relative asset links in rendered using path-pattern regex (no element parsing). +/// +public sealed partial class RegexAssetLinkDetector : IAssetLinkDetector +{ + /// + /// Regex pattern for relative path segments: alphanumerics, underscore, hyphen, dot. + /// Matches paths with optional ./ or ../ prefixes and / or \ separators. + /// + [GeneratedRegex(@"(?:\.\.?/(?:[A-Za-z0-9_\-\.]+/)*[A-Za-z0-9_\-\.]+|[A-Za-z0-9_\-\.]+(?:/[A-Za-z0-9_\-\.]+)+)", RegexOptions.Compiled)] + private static partial Regex RelativePathPattern(); + + /// + /// Regex pattern to detect protocol schemes (e.g., http://, custom://, drive://). + /// + [GeneratedRegex(@"[A-Za-z][A-Za-z0-9+\-\.]*://", RegexOptions.Compiled)] + private static partial Regex ProtocolSchemePattern(); + + [GeneratedRegex(@"\b[A-Za-z0-9_\-]+\.[A-Za-z0-9]+\b", RegexOptions.Compiled)] + private static partial Regex FilenamePattern(); + + /// + public async IAsyncEnumerable DetectAsync(IFile source, [EnumeratorCancellation] CancellationToken ct = default) + { + var text = await source.ReadTextAsync(ct); + + foreach (Match match in RelativePathPattern().Matches(text)) + { + if (ct.IsCancellationRequested) + yield break; + + var path = match.Value; + + // Filter out non-relative patterns + if (string.IsNullOrWhiteSpace(path)) + continue; + + // Exclude absolute root paths (optional - treating these as non-relative) + if (path.StartsWith('/') || path.StartsWith('\\')) + continue; + + // Check if this path is preceded by a protocol scheme (e.g., custom://path/to/file) + // Look back to see if there's a protocol before this match + var startIndex = match.Index; + if (startIndex > 0) + { + // Check up to 50 characters before the match for a protocol scheme + var lookbackLength = Math.Min(50, startIndex); + var precedingText = text.Substring(startIndex - lookbackLength, lookbackLength); + + // If the preceding text ends with a protocol scheme (e.g., "custom://"), skip this match + if (ProtocolSchemePattern().IsMatch(precedingText) && precedingText.TrimEnd().EndsWith("://")) + continue; + } + + yield return path; + } + + foreach (Match match in FilenamePattern().Matches(text)) + { + yield return match.Value; + } + } +} diff --git a/src/Blog/Assets/RelativePathAssetResolver.cs b/src/Blog/Assets/RelativePathAssetResolver.cs new file mode 100644 index 0000000..f06d07a --- /dev/null +++ b/src/Blog/Assets/RelativePathAssetResolver.cs @@ -0,0 +1,36 @@ +using OwlCore.Storage; + +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 + var normalizedPath = relativePath.Replace('\\', '/'); + + // Resolve relative to markdown file's containing location (pre-folderization) + // The markdown file itself is the base for relative path resolution + var item = await sourceFile.GetItemByRelativePathAsync($"../{normalizedPath}", ct); + + // Return only if it's a file + return item as IFile; + } + catch + { + // Path resolution failed (invalid path, not found, etc.) + return null; + } + } +} diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs new file mode 100644 index 0000000..c0e5ccc --- /dev/null +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs @@ -0,0 +1,119 @@ +using OwlCore.Diagnostics; +using OwlCore.Storage; +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 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 = html.Replace(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); + } + } +} 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 82% rename from src/Blog/PostPage/IndexHtmlFile.cs rename to src/Blog/Page/HtmlTemplatedMarkdownFile.cs index 0e4435f..0d02aa8 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)) @@ -222,9 +224,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 +257,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 +270,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..6421995 --- /dev/null +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -0,0 +1,97 @@ +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 => SanitizeFilename(_markdownSource.Name); + + /// + /// 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" + }; + } + } + + /// + /// 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; + } + } +} \ 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/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/PageCommand.cs b/src/Commands/Blog/PostPage/PageCommand.cs new file mode 100644 index 0000000..1ffba7e --- /dev/null +++ b/src/Commands/Blog/PostPage/PageCommand.cs @@ -0,0 +1,116 @@ +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); + + // Create virtual PostPageFolder (lazy generation - no I/O during construction) + var postPageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(markdownFile, templateSource, templateFileName) + { + Id = markdownFile.Id.HashMD5Fast(), + // Single-file page output includes assets by default + // Unlike multi-page output which references assets by default + AssetStrategy = new KnownAssetStrategy(), + Resolver = new RelativePathAssetResolver(), + LinkDetector = new RegexAssetLinkDetector(), + }; + + // Create output folder for this page + var pageOutputFolder = await outputFolder.CreateFolderAsync(postPageFolder.Name, overwrite: true); + + // Materialize virtual structure by recursively copying all files + await foreach (AssetAwareHtmlTemplatedMarkdownFile file in new DepthFirstRecursiveFolder(postPageFolder).GetFilesAsync()) + { + // TODO, see https://discord.com/channels/372137812037730304/1396673230013464636/1441902694196449505 + } + + 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..5ea0216 --- /dev/null +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -0,0 +1,139 @@ +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 + HashSet templateFileIds = [.. (templateSource is IFile file) ? [file.Id] : await new DepthFirstRecursiveFolder((IFolder)templateSource).GetFilesAsync().Select(x => x.Id).ToListAsync()]; + HashSet markdownSourceFileIds = [.. await new DepthFirstRecursiveFolder(markdownSourceFolder).GetFilesAsync().Select(x => x.Id).ToListAsync()]; + var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName) + { + LinkDetector = new RegexAssetLinkDetector(), + Resolver = new RelativePathAssetResolver(), + AssetStrategy = new KnownAssetStrategy() + { + IncludedAssetFileIds = templateFileIds, + ReferencedAssetFileIds = markdownSourceFileIds, + UnknownAssetFaultStrategy = FaultStrategy.LogWarn, + UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop, + }, + }; + + // Materialize virtual folderized markdown pages, then files within each markdown page folder. + await foreach (IChildFolder pageFolder in new DepthFirstRecursiveFolder(pagesFolder).GetItemsAsync(StorableType.Folder)) + { + // Get path to markdown page folder (mirrors original source file without extension) + var relativePathToPagesPageFolder = await pagesFolder.GetRelativePathToAsync(pageFolder); + var pageOutputFolder = (IModifiableFolder)await outputFolder.CreateFoldersAlongRelativePathAsync(relativePathToPagesPageFolder, 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 + var copiedIndexFile = await pageOutputFolder.CreateCopyOfAsync(indexFile, overwrite: true); + + // Copy all assets referenced in index.html to the rewritten asset path + // Logger.LogInformation($"Included: {indexFile.Assets.Count}"); + foreach (var asset in indexFile.Assets) + { + if (Path.GetExtension(asset.ResolvedFile.Name) == ".md") + { + // + } + + var assetOutputFolder = (IModifiableFolder)await pageOutputFolder.CreateFoldersAlongRelativePathAsync(asset.RewrittenPath, overwrite: false).LastAsync(); + await assetOutputFolder.CreateCopyOfAsync(asset.ResolvedFile, overwrite: true); + } + } + } + + // 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..b737d1e --- /dev/null +++ b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs @@ -0,0 +1,239 @@ +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!; + + [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 = new ReferenceOnlyAssetStrategy() + }; + } + + [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 = new ReferenceOnlyAssetStrategy(); + 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.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"); + } +}