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
+
+");
+ }
+
+ _page2File = await _testSourceFolder.CreateAlongRelativePathAsync("subfolder/page2.md", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create page2.md");
+ await using (var stream2 = await _page2File.OpenStreamAsync(FileAccess.Write))
+ await using (var writer2 = new StreamWriter(stream2))
+ {
+ await writer2.WriteAsync(@"---
+title: Page 2
+---
+
+# Page 2 Content
+
+");
+ }
+
+ var localIcon = await _testSourceFolder.CreateAlongRelativePathAsync("subfolder/local-icon.png", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create local-icon.png");
+ await using (var iconStream = await localIcon.OpenStreamAsync(FileAccess.Write))
+ {
+ // Empty file
+ }
+
+ _logoFile = await _testSourceFolder.CreateAlongRelativePathAsync("images/logo.png", StorableType.File).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create logo.png");
+ await using (var logoStream = await _logoFile.OpenStreamAsync(FileAccess.Write))
+ {
+ // Empty file
+ }
+
+ // Create template
+ _templateFolder = new MemoryFolder("template", "template");
+ var templateHtml = await _templateFolder.CreateAlongRelativePathAsync("index.html", StorableType.File, overwrite: true).LastAsync() as IFile
+ ?? throw new InvalidOperationException("Failed to create template");
+ await using (var templateStream = await templateHtml.OpenStreamAsync(FileAccess.Write))
+ await using (var templateWriter = new StreamWriter(templateStream))
+ {
+ await templateWriter.WriteAsync(@"
+
+{{ frontmatter.title }}
+{{ body }}
+");
+ }
+
+ // Instantiate composition root
+ _pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(
+ _testSourceFolder,
+ _templateFolder,
+ "index.html")
+ {
+ LinkDetector = new RegexAssetLinkDetector(),
+ Resolver = new RelativePathAssetResolver(),
+ AssetStrategy = 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");
+ }
+}