From ca8947d8f6f123a2142356cc0f39ca081de56697 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 16 Nov 2025 13:21:45 -0600 Subject: [PATCH 1/5] feat: Implement asset-aware structures for markdown processing with link detection and resolution --- src/Blog/Assets/IAssetInclusionStrategy.cs | 30 ++ src/Blog/Assets/IAssetLinkDetector.cs | 17 + src/Blog/Assets/IAssetResolver.cs | 27 ++ .../AssetAwareHtmlTemplatedMarkdownFile.cs | 109 ++++++ ...setAwareHtmlTemplatedMarkdownPageFolder.cs | 94 ++++++ src/Blog/Page/HtmlTemplatedMarkdownFile.cs | 312 ++++++++++++++++++ .../Page/HtmlTemplatedMarkdownPageFolder.cs | 170 ++++++++++ ...etAwareHtmlTemplatedMarkdownPagesFolder.cs | 122 +++++++ 8 files changed, 881 insertions(+) create mode 100644 src/Blog/Assets/IAssetInclusionStrategy.cs create mode 100644 src/Blog/Assets/IAssetLinkDetector.cs create mode 100644 src/Blog/Assets/IAssetResolver.cs create mode 100644 src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs create mode 100644 src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs create mode 100644 src/Blog/Page/HtmlTemplatedMarkdownFile.cs create mode 100644 src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs create mode 100644 src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs diff --git a/src/Blog/Assets/IAssetInclusionStrategy.cs b/src/Blog/Assets/IAssetInclusionStrategy.cs new file mode 100644 index 0000000..a7c086e --- /dev/null +++ b/src/Blog/Assets/IAssetInclusionStrategy.cs @@ -0,0 +1,30 @@ +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 IAssetInclusionStrategy +{ + /// + /// Decides asset inclusion strategy by returning rewritten path. + /// + /// The markdown file that references the asset. + /// The asset file being referenced. + /// The original relative path from markdown. + /// 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) + /// + /// + Task DecideAsync( + IFile referencingMarkdown, + IFile referencedAsset, + 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..d605248 --- /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. + /// + /// Virtual IFile representing rendered HTML output (in-memory representation). + /// Cancellation token. + /// Async enumerable of relative path strings. + IAsyncEnumerable DetectAsync(IFile htmlSource, CancellationToken ct = 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..4fe3fa4 --- /dev/null +++ b/src/Blog/Assets/IAssetResolver.cs @@ -0,0 +1,27 @@ +using OwlCore.Storage; + +namespace WindowsAppCommunity.Blog.Assets; + +/// +/// Resolves relative path strings to IFile instances. +/// +public interface IAssetResolver +{ + /// + /// Root folder for relative path resolution. + /// + IFolder SourceFolder { get; init; } + + /// + /// Markdown file for relative path context. + /// + IFile MarkdownSource { get; init; } + + /// + /// Resolves a relative path string to an IFile instance. + /// + /// The relative path to resolve. + /// Cancellation token. + /// The resolved IFile, or null if not found. + Task ResolveAsync(string relativePath, CancellationToken ct = default); +} diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs new file mode 100644 index 0000000..80ed02f --- /dev/null +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using OwlCore.Storage; +using WindowsAppCommunity.Blog.Assets; +using WindowsAppCommunity.Blog.PostPage; + +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 _includedAssets = 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 IAssetInclusionStrategy InclusionStrategy { get; init; } + + /// + /// Assets that were decided for inclusion (self-contained in page folder). + /// Exposed to containing folder for yielding in virtual structure. + /// + public IReadOnlyCollection IncludedAssets => _includedAssets.AsReadOnly(); + + /// + /// Post-process HTML with asset management pipeline. + /// Detects links → Resolves to files → Decides include/reference via path rewriting → Tracks included assets. + /// + /// Rendered HTML from template + /// Data model used for rendering + /// Cancellation token + /// Post-processed HTML with rewritten links + protected override async Task PostProcessHtmlAsync(string html, PostPageDataModel model, CancellationToken ct) + { + // Clear included assets from any previous generation + _includedAssets.Clear(); + + // Detect asset links in rendered HTML output (pass self as IFile) + await foreach (var originalPath in LinkDetector.DetectAsync(this, ct)) + { + // Resolve path to IFile + var resolvedAsset = await Resolver.ResolveAsync(originalPath, ct); + + // Null resolver policy: Skip if not found (preserve broken link) + if (resolvedAsset == null) + { + continue; + } + + // 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 InclusionStrategy.DecideAsync( + MarkdownSource, // Pass original markdown source (not virtual HTML) + resolvedAsset, + originalPath, + ct); + + // Implicit decision based on path structure + if (!rewrittenPath.StartsWith("../")) + { + // Include: Add to tracked assets (will be yielded by containing folder) + _includedAssets.Add(resolvedAsset); + } + // Reference: Asset not added to included list (stays external) + + // Rewrite link in HTML (applies to both Include and Reference) + html = html.Replace(originalPath, rewrittenPath); + } + + return html; + } + } +} diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs new file mode 100644 index 0000000..40e7ccf --- /dev/null +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs @@ -0,0 +1,94 @@ +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 IAssetInclusionStrategy InclusionStrategy { get; init; } + + /// + public override async IAsyncEnumerable GetItemsAsync( + StorableType type = StorableType.All, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AssetAwareHtmlTemplatedMarkdownFile? assetAwareFile = null; + + // 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 && assetAwareFile == null) + { + // Create asset-aware variant with required properties set + assetAwareFile = new AssetAwareHtmlTemplatedMarkdownFile( + htmlFile.Id, + MarkdownSource, + TemplateSource, + TemplateFileName, + this) + { + Name = htmlFile.Name, + Created = htmlFile.Created, + Modified = htmlFile.Modified, + LinkDetector = LinkDetector, + Resolver = Resolver, + InclusionStrategy = InclusionStrategy + }; + + yield return assetAwareFile; + continue; + } + + // Pass through other items (template assets) + yield return item; + } + + // Yield markdown-referenced assets that were decided for inclusion + if (assetAwareFile != null && (type == StorableType.All || type == StorableType.File)) + { + foreach (var includedAsset in assetAwareFile.IncludedAssets) + { + yield return (IStorableChild)includedAsset; + } + } + } + } +} \ No newline at end of file diff --git a/src/Blog/Page/HtmlTemplatedMarkdownFile.cs b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs new file mode 100644 index 0000000..8362279 --- /dev/null +++ b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs @@ -0,0 +1,312 @@ +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.PostPage; + +namespace WindowsAppCommunity.Blog.Page +{ + /// + /// 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 class HtmlTemplatedMarkdownFile : IChildFile + { + private readonly string _id; + private readonly IFile _markdownSource; + private readonly IStorable _templateSource; + private readonly string? _templateFileName; + private readonly IFolder? _parent; + + /// + /// 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 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)); + _templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSource)); + _templateFileName = templateFileName; + _parent = parent; + } + + /// + public string Id => _id; + + /// + /// + /// 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. + /// + public DateTime? Created { get; set; } + + /// + /// File modification timestamp from filesystem metadata. + /// + public DateTime? Modified { get; set; } + + /// + /// Source markdown file being transformed. + /// Exposed for derived class access (e.g., passing to asset strategies). + /// + protected IFile MarkdownSource => _markdownSource; + + /// + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(_parent); + } + + /// + public async Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) + { + // Read-only file - reject write operations + if (accessMode == FileAccess.Write || accessMode == FileAccess.ReadWrite) + { + 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 → Post-process. + /// + private async Task GenerateHtmlAsync(CancellationToken cancellationToken) + { + // Parse markdown file (extract front-matter + content) + var (frontmatter, content) = await ParseMarkdownAsync(_markdownSource); + + // Transform markdown content to HTML body + var htmlBody = TransformMarkdownToHtml(content); + + // Parse front-matter YAML to dictionary + var frontmatterDict = ParseFrontmatter(frontmatter); + + // Resolve template file from IStorable source + var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName); + + // Create data model for template + var model = new PostPageDataModel + { + Body = htmlBody, + Frontmatter = frontmatterDict, + Filename = _markdownSource.Name, + Created = Created, + Modified = Modified + }; + + // Render template with model + var html = await RenderTemplateAsync(templateFile, model); + + // Post-process HTML (extensibility point for derived classes) + html = await PostProcessHtmlAsync(html, model, cancellationToken); + + return html; + } + + #region Protected Virtual Hooks + + /// + /// Extract YAML front-matter block from markdown file. + /// Front-matter is delimited by "---" at start and end. + /// Handles files without front-matter (returns empty string for frontmatter). + /// + /// Markdown file to parse + /// Tuple of (frontmatter YAML string, content markdown string) + protected virtual async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file) + { + var text = await file.ReadTextAsync(); + + // Check for front-matter delimiters + if (!text.StartsWith("---")) + { + // No front-matter present + return (string.Empty, text); + } + + // 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() == "---") + { + closingDelimiterIndex = i; + break; + } + } + + if (closingDelimiterIndex == -1) + { + // No closing delimiter found - treat entire file as content + return (string.Empty, text); + } + + // Extract front-matter (lines between delimiters) + var frontmatterLines = lines.Skip(1).Take(closingDelimiterIndex - 1); + var frontmatter = string.Join(Environment.NewLine, frontmatterLines); + + // Extract content (everything after closing delimiter) + var contentLines = lines.Skip(closingDelimiterIndex + 1); + var content = string.Join(Environment.NewLine, contentLines); + + return (frontmatter, content); + } + + /// + /// Transform markdown content to HTML body using Markdig. + /// Returns HTML without wrapping elements - template controls structure. + /// Uses Advanced Extensions pipeline for full Markdown feature support. + /// + /// Markdown content string + /// HTML body content + protected virtual string TransformMarkdownToHtml(string markdown) + { + var pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseSoftlineBreakAsHardlineBreak() + .Build(); + + return Markdown.ToHtml(markdown, pipeline); + } + + /// + /// Parse YAML front-matter string to arbitrary dictionary. + /// No schema enforcement - accepts any valid YAML structure. + /// Handles empty/missing front-matter gracefully. + /// + /// YAML string from front-matter + /// Dictionary with arbitrary keys and values + protected virtual Dictionary ParseFrontmatter(string yaml) + { + // Handle empty front-matter + if (string.IsNullOrWhiteSpace(yaml)) + { + return new Dictionary(); + } + + try + { + var deserializer = new DeserializerBuilder() + .Build(); + + var result = deserializer.Deserialize>(yaml); + return result ?? new Dictionary(); + } + catch (YamlDotNet.Core.YamlException ex) + { + throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex); + } + } + + /// + /// 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 + protected virtual 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)); + } + + /// + /// Render Scriban template with data model to produce final HTML. + /// Template generates all HTML including meta tags from model.frontmatter. + /// Flow boundary: Generator provides data model, template generates HTML. + /// + /// Scriban template file + /// PostPageDataModel with body, frontmatter, metadata + /// Rendered HTML string + protected virtual async Task RenderTemplateAsync( + IFile templateFile, + PostPageDataModel model) + { + var templateContent = await templateFile.ReadTextAsync(); + + var template = Template.Parse(templateContent); + + if (template.HasErrors) + { + var errors = string.Join(Environment.NewLine, template.Messages); + throw new InvalidOperationException($"Template parsing failed:{Environment.NewLine}{errors}"); + } + + var html = template.Render(model); + + return html; + } + + /// + /// Post-process rendered HTML (extensibility point for derived classes). + /// Base implementation is pass-through - no modifications. + /// Derived classes can override to perform link rewriting, asset detection, etc. + /// + /// Rendered HTML from template + /// Data model used for rendering + /// Cancellation token + /// Post-processed HTML string + protected virtual Task PostProcessHtmlAsync(string html, PostPageDataModel model, CancellationToken ct) + { + // Base implementation: pass-through (no post-processing) + return Task.FromResult(html); + } + + #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..9b4e5eb --- /dev/null +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -0,0 +1,170 @@ +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.Storage; +using WindowsAppCommunity.Blog.PostPage; + +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 : 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 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 string Id => _markdownSource.Id; + + /// + 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) + { + // Resolve template file for exclusion and HtmlTemplatedMarkdownFile construction + var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName); + + // Yield HtmlTemplatedMarkdownFile (virtual HTML file) + if (type == StorableType.All || type == StorableType.File) + { + var indexHtmlId = $"{Id}/index.html"; + yield return new HtmlTemplatedMarkdownFile(indexHtmlId, _markdownSource, _templateSource, _templateFileName, this) + { + Name = "index.html" + }; + } + + // 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)); + } + } +} \ 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..5c9ced0 --- /dev/null +++ b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs @@ -0,0 +1,122 @@ +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.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 : IFolder + { + 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 IAssetInclusionStrategy InclusionStrategy { 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) + { + LinkDetector = LinkDetector, + Resolver = Resolver, + InclusionStrategy = InclusionStrategy, + Parent = this + }; + + yield return (IStorableChild)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, + InclusionStrategy = InclusionStrategy, + Parent = this + }; + + yield return (IStorableChild)nestedPagesFolder; + } + } + } + } + } +} From f7663a3b02ee08d7a93bbea5141720e90510283a Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 16 Nov 2025 14:04:38 -0600 Subject: [PATCH 2/5] feat: Add asset inclusion strategy and link detection for markdown processing --- .../Assets/ReferenceOnlyInclusionStrategy.cs | 26 +++++++++ src/Blog/Assets/RegexAssetLinkDetector.cs | 55 +++++++++++++++++++ src/Blog/Assets/RelativePathAssetResolver.cs | 41 ++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs create mode 100644 src/Blog/Assets/RegexAssetLinkDetector.cs create mode 100644 src/Blog/Assets/RelativePathAssetResolver.cs diff --git a/src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs b/src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs new file mode 100644 index 0000000..f28255a --- /dev/null +++ b/src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs @@ -0,0 +1,26 @@ +using OwlCore.Storage; + +namespace WindowsAppCommunity.Blog.Assets; + +/// +/// Reference-only inclusion strategy: always rewrites paths to add one level of parent navigation, +/// treating all assets as externally referenced (not included in page folder). +/// +public sealed class ReferenceOnlyInclusionStrategy : IAssetInclusionStrategy +{ + /// + public Task DecideAsync( + IFile referencingMarkdown, + IFile referencedAsset, + string originalPath, + CancellationToken ct = default) + { + // Reference-only behavior: add one parent directory prefix to account for folderization + // Markdown file becomes folder/index.html, so links need one extra "../" to reach original location + if (string.IsNullOrWhiteSpace(originalPath)) + return Task.FromResult(originalPath); + + var rewrittenPath = "../" + originalPath; + return Task.FromResult(rewrittenPath); + } +} diff --git a/src/Blog/Assets/RegexAssetLinkDetector.cs b/src/Blog/Assets/RegexAssetLinkDetector.cs new file mode 100644 index 0000000..22c4c02 --- /dev/null +++ b/src/Blog/Assets/RegexAssetLinkDetector.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using OwlCore.Storage; + +namespace WindowsAppCommunity.Blog.Assets; + +/// +/// Detects relative asset links in rendered HTML 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(@"(? + public async IAsyncEnumerable DetectAsync(IFile htmlSource, [EnumeratorCancellation] CancellationToken ct = default) + { + // Read HTML content + using var stream = await htmlSource.OpenStreamAsync(FileAccess.Read, ct); + using var reader = new StreamReader(stream); + var html = await reader.ReadToEndAsync(ct); + + // Find all matches + var matches = RelativePathPattern().Matches(html); + + foreach (Match match in matches) + { + if (ct.IsCancellationRequested) + yield break; + + var path = match.Value; + + // Filter out non-relative patterns + if (string.IsNullOrWhiteSpace(path)) + continue; + + // Exclude absolute schemes + if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("//", StringComparison.Ordinal)) + continue; + + // Exclude absolute root paths (optional - treating these as non-relative) + if (path.StartsWith('/') || path.StartsWith('\\')) + continue; + + yield return path; + } + } +} diff --git a/src/Blog/Assets/RelativePathAssetResolver.cs b/src/Blog/Assets/RelativePathAssetResolver.cs new file mode 100644 index 0000000..990471e --- /dev/null +++ b/src/Blog/Assets/RelativePathAssetResolver.cs @@ -0,0 +1,41 @@ +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). +/// +public sealed class RelativePathAssetResolver : IAssetResolver +{ + /// + public required IFolder SourceFolder { get; init; } + + /// + public required IFile MarkdownSource { get; init; } + + /// + public async Task ResolveAsync(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 location (pre-folderization) + // The markdown file itself is the base for relative path resolution + var item = await MarkdownSource.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; + } + } +} From ba2217188bb3d217943c94aea1421fb9c53fb33a Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 22 Nov 2025 14:43:02 -0600 Subject: [PATCH 3/5] refactor: Decouple asset resolver from markdown source and implement ReferencedAsset tracking Changes to asset resolution architecture: - Modified IAssetResolver to accept markdown source per-call instead of storing it as state - Updated RelativePathAssetResolver to be stateless, receiving context file in ResolveAsync() - Enables shared resolver instance across multiple pages without coupling Introduced ReferencedAsset record: - Captures complete asset reference info: original path, rewritten path, and resolved file - Facilitates materialization in consumer code (PagesCommand) - Replaces IFile collection with structured ReferencedAsset collection AssetAwareHtmlTemplatedMarkdownFile improvements: - Tracks all referenced assets (both included and referenced) via ReferencedAsset - Unified asset processing for markdown source and template file detection - Extracted ProcessAssetLinkAsync() method for shared pipeline logic Virtual structure simplification: - Removed automatic asset yielding from AssetAwareHtmlTemplatedMarkdownPageFolder - Removed template asset yielding from HtmlTemplatedMarkdownPageFolder base class - Assets now tracked in ReferencedAsset collection for explicit consumer materialization Implemented PagesCommand: - CLI command for multi-page blog generation using AssetAwareHtmlTemplatedMarkdownPagesFolder - Materializes virtual structure by iterating page folders and copying files - Uses ReferencedAsset.RewrittenPath for correct asset output placement Added comprehensive test coverage: - AssetAwareHtmlTemplatedMarkdownPagesFolderTests with 7 test cases - Covers markdown discovery, hierarchy preservation, asset resolution, and link rewriting Minor cleanup: - Removed debug logging from PostPageAssetFolder - Fixed formatting/whitespace in PostPageFolder and HtmlTemplatedMarkdownPageFolder - Changed return types to IChildFolder for Pages-related classes --- src/Blog/Assets/IAssetResolver.cs | 8 +- src/Blog/Assets/ReferencedAsset.cs | 17 ++ src/Blog/Assets/RelativePathAssetResolver.cs | 8 +- .../AssetAwareHtmlTemplatedMarkdownFile.cs | 84 +++--- ...setAwareHtmlTemplatedMarkdownPageFolder.cs | 9 - .../Page/HtmlTemplatedMarkdownPageFolder.cs | 42 +-- ...etAwareHtmlTemplatedMarkdownPagesFolder.cs | 16 +- src/Blog/PostPage/PostPageAssetFolder.cs | 4 - src/Blog/PostPage/PostPageFolder.cs | 10 +- src/Commands/Blog/PostPage/PagesCommand.cs | 170 ++++++++++++ src/Commands/Blog/WacsdkBlogCommands.cs | 4 +- ...reHtmlTemplatedMarkdownPagesFolderTests.cs | 245 ++++++++++++++++++ 12 files changed, 504 insertions(+), 113 deletions(-) create mode 100644 src/Blog/Assets/ReferencedAsset.cs create mode 100644 src/Commands/Blog/PostPage/PagesCommand.cs create mode 100644 tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs diff --git a/src/Blog/Assets/IAssetResolver.cs b/src/Blog/Assets/IAssetResolver.cs index 4fe3fa4..fcb6dee 100644 --- a/src/Blog/Assets/IAssetResolver.cs +++ b/src/Blog/Assets/IAssetResolver.cs @@ -12,16 +12,12 @@ public interface IAssetResolver /// IFolder SourceFolder { get; init; } - /// - /// Markdown file for relative path context. - /// - IFile MarkdownSource { get; init; } - /// /// Resolves a relative path string to an IFile instance. /// + /// Markdown file for relative path context (varies per page). /// The relative path to resolve. /// Cancellation token. /// The resolved IFile, or null if not found. - Task ResolveAsync(string relativePath, CancellationToken ct = default); + Task ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default); } diff --git a/src/Blog/Assets/ReferencedAsset.cs b/src/Blog/Assets/ReferencedAsset.cs new file mode 100644 index 0000000..48f1511 --- /dev/null +++ b/src/Blog/Assets/ReferencedAsset.cs @@ -0,0 +1,17 @@ +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 ReferencedAsset( + string OriginalPath, + string RewrittenPath, + IFile ResolvedFile + ); +} diff --git a/src/Blog/Assets/RelativePathAssetResolver.cs b/src/Blog/Assets/RelativePathAssetResolver.cs index 990471e..f96232e 100644 --- a/src/Blog/Assets/RelativePathAssetResolver.cs +++ b/src/Blog/Assets/RelativePathAssetResolver.cs @@ -5,6 +5,7 @@ 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 { @@ -12,10 +13,7 @@ public sealed class RelativePathAssetResolver : IAssetResolver public required IFolder SourceFolder { get; init; } /// - public required IFile MarkdownSource { get; init; } - - /// - public async Task ResolveAsync(string relativePath, CancellationToken ct = default) + public async Task ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(relativePath)) return null; @@ -27,7 +25,7 @@ public sealed class RelativePathAssetResolver : IAssetResolver // Resolve relative to markdown file's location (pre-folderization) // The markdown file itself is the base for relative path resolution - var item = await MarkdownSource.GetItemByRelativePathAsync(normalizedPath, ct); + var item = await markdownSource.GetItemByRelativePathAsync(normalizedPath, ct); // Return only if it's a file return item as IFile; diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs index 80ed02f..12faef2 100644 --- a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs @@ -15,7 +15,9 @@ namespace WindowsAppCommunity.Blog.Page /// public sealed class AssetAwareHtmlTemplatedMarkdownFile : HtmlTemplatedMarkdownFile { - private readonly List _includedAssets = new(); + private readonly List _includedAssets = new(); + private readonly IStorable _templateSource; + private readonly string? _templateFileName; /// /// Creates asset-aware virtual HTML file with lazy markdown→HTML generation and asset management. @@ -33,6 +35,8 @@ public AssetAwareHtmlTemplatedMarkdownFile( IFolder? parent = null) : base(id, markdownSource, templateSource, templateFileName, parent) { + _templateSource = templateSource; + _templateFileName = templateFileName; } /// @@ -51,14 +55,15 @@ public AssetAwareHtmlTemplatedMarkdownFile( public required IAssetInclusionStrategy InclusionStrategy { get; init; } /// - /// Assets that were decided for inclusion (self-contained in page folder). - /// Exposed to containing folder for yielding in virtual structure. + /// All assets referenced by the markdown file (both included and referenced). + /// Exposed to containing folder for materialization to output. /// - public IReadOnlyCollection IncludedAssets => _includedAssets.AsReadOnly(); + public IReadOnlyCollection IncludedAssets => _includedAssets.AsReadOnly(); /// /// 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. /// /// Rendered HTML from template /// Data model used for rendering @@ -69,41 +74,54 @@ protected override async Task PostProcessHtmlAsync(string html, PostPage // Clear included assets from any previous generation _includedAssets.Clear(); - // Detect asset links in rendered HTML output (pass self as IFile) - await foreach (var originalPath in LinkDetector.DetectAsync(this, ct)) + // Detect asset links from markdown source (content-referenced assets) + await foreach (var originalPath in LinkDetector.DetectAsync(MarkdownSource, ct)) { - // Resolve path to IFile - var resolvedAsset = await Resolver.ResolveAsync(originalPath, ct); - - // Null resolver policy: Skip if not found (preserve broken link) - if (resolvedAsset == null) - { - continue; - } + html = await ProcessAssetLinkAsync(html, MarkdownSource, originalPath, ct); + } - // 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 InclusionStrategy.DecideAsync( - MarkdownSource, // Pass original markdown source (not virtual HTML) - resolvedAsset, - originalPath, - ct); + return html; + } - // Implicit decision based on path structure - if (!rewrittenPath.StartsWith("../")) - { - // Include: Add to tracked assets (will be yielded by containing folder) - _includedAssets.Add(resolvedAsset); - } - // Reference: Asset not added to included list (stays external) + /// + /// Process a single detected asset link through the asset pipeline. + /// Shared logic for both markdown and template asset detection. + /// + /// HTML content to update + /// File providing resolution context (markdown or template) + /// Original asset path as detected + /// Cancellation token + /// Updated HTML with rewritten link + private async Task ProcessAssetLinkAsync( + string html, + IFile contextFile, + string originalPath, + CancellationToken ct) + { + // Resolve path to IFile (pass context file for resolution) + var resolvedAsset = await Resolver.ResolveAsync(contextFile, originalPath, ct); - // Rewrite link in HTML (applies to both Include and Reference) - html = html.Replace(originalPath, rewrittenPath); + // Null resolver policy: Skip if not found (preserve broken link) + if (resolvedAsset == null) + { + return html; } - return html; + // 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 InclusionStrategy.DecideAsync( + contextFile, + resolvedAsset, + originalPath, + ct); + + // Track all referenced assets for materialization + _includedAssets.Add(new ReferencedAsset(originalPath, rewrittenPath, resolvedAsset)); + + // Rewrite link in HTML (strategy determines path prefix) + return html.Replace(originalPath, rewrittenPath); } } } diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs index 40e7ccf..031964f 100644 --- a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs @@ -80,15 +80,6 @@ public override async IAsyncEnumerable GetItemsAsync( // Pass through other items (template assets) yield return item; } - - // Yield markdown-referenced assets that were decided for inclusion - if (assetAwareFile != null && (type == StorableType.All || type == StorableType.File)) - { - foreach (var includedAsset in assetAwareFile.IncludedAssets) - { - yield return (IStorableChild)includedAsset; - } - } } } } \ No newline at end of file diff --git a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs index 9b4e5eb..91eb787 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -15,7 +15,7 @@ namespace WindowsAppCommunity.Blog.Page /// 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 : IFolder + public class HtmlTemplatedMarkdownPageFolder : IChildFolder { private readonly IFile _markdownSource; private readonly IStorable _templateSource; @@ -72,10 +72,9 @@ public virtual async IAsyncEnumerable GetItemsAsync( StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Resolve template file for exclusion and HtmlTemplatedMarkdownFile construction - var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName); - - // Yield HtmlTemplatedMarkdownFile (virtual HTML file) + // Yield HtmlTemplatedMarkdownFile (virtual HTML file) only + // Template assets are NOT yielded here - they're detected as links in the template HTML + // and tracked in the HTML file's IncludedAssets collection for consumer materialization if (type == StorableType.All || type == StorableType.File) { var indexHtmlId = $"{Id}/index.html"; @@ -84,32 +83,6 @@ public virtual async IAsyncEnumerable GetItemsAsync( Name = "index.html" }; } - - // 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; - } - } - } } /// @@ -155,16 +128,13 @@ private async Task ResolveTemplateFileAsync( if (templateFile is not IFile resolvedFile) { - throw new FileNotFoundException( - $"Template file '{fileName}' not found in folder '{folder.Name}'."); + 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)); + throw new ArgumentException($"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}", nameof(templateSource)); } } } \ No newline at end of file diff --git a/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs index 5c9ced0..7be4819 100644 --- a/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs +++ b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs @@ -16,7 +16,7 @@ namespace WindowsAppCommunity.Blog.Pages /// 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 : IFolder + public class AssetAwareHtmlTemplatedMarkdownPagesFolder : IChildFolder { private readonly IFolder _markdownSourceFolder; private readonly IStorable _templateSource; @@ -82,10 +82,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = { if (type == StorableType.All || type == StorableType.Folder) { - var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder( - file, - _templateSource, - _templateFileName) + var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(file, _templateSource, _templateFileName) { LinkDetector = LinkDetector, Resolver = Resolver, @@ -93,7 +90,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = Parent = this }; - yield return (IStorableChild)pageFolder; + yield return pageFolder; } } @@ -102,10 +99,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = { if (type == StorableType.All || type == StorableType.Folder) { - var nestedPagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder( - subfolder, - _templateSource, - _templateFileName) + var nestedPagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(subfolder, _templateSource, _templateFileName) { LinkDetector = LinkDetector, Resolver = Resolver, @@ -113,7 +107,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = Parent = this }; - yield return (IStorableChild)nestedPagesFolder; + yield return nestedPagesFolder; } } } diff --git a/src/Blog/PostPage/PostPageAssetFolder.cs b/src/Blog/PostPage/PostPageAssetFolder.cs index 9771014..e35e52c 100644 --- a/src/Blog/PostPage/PostPageAssetFolder.cs +++ b/src/Blog/PostPage/PostPageAssetFolder.cs @@ -53,8 +53,6 @@ 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)) { @@ -77,8 +75,6 @@ public async IAsyncEnumerable GetItemsAsync( 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 index 2ee7e28..1f0d807 100644 --- a/src/Blog/PostPage/PostPageFolder.cs +++ b/src/Blog/PostPage/PostPageFolder.cs @@ -94,8 +94,7 @@ private string SanitizeFilename(string markdownFilename) // Replace invalid filename characters with underscore var invalidChars = Path.GetInvalidFileNameChars(); - var sanitized = string.Concat(nameWithoutExtension.Select(c => - invalidChars.Contains(c) ? '_' : c)); + var sanitized = string.Concat(nameWithoutExtension.Select(c => invalidChars.Contains(c) ? '_' : c)); return sanitized; } @@ -124,16 +123,13 @@ private async Task ResolveTemplateFileAsync( if (templateFile is not IFile resolvedFile) { - throw new FileNotFoundException( - $"Template file '{fileName}' not found in folder '{folder.Name}'."); + 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)); + throw new ArgumentException($"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}", nameof(templateSource)); } } } diff --git a/src/Commands/Blog/PostPage/PagesCommand.cs b/src/Commands/Blog/PostPage/PagesCommand.cs new file mode 100644 index 0000000..967b30f --- /dev/null +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -0,0 +1,170 @@ +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", + 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 markdown source folder (SystemFolder throws if doesn't exist) + var markdownSourceFolder = new SystemFolder(markdownFolderPath); + + // 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 AssetAwareHtmlTemplatedMarkdownPagesFolder (lazy generation - no I/O during construction) + var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName) + { + LinkDetector = new RegexAssetLinkDetector(), + Resolver = new RelativePathAssetResolver + { + // MarkdownSource passed per-call in ResolveAsync (varies per page) + SourceFolder = markdownSourceFolder + }, + InclusionStrategy = new ReferenceOnlyInclusionStrategy() + }; + + // Materialize virtual structure by iterating page folders, then files within each + // Pattern from PostPageCommand: Create output folder per page, copy files relative to it + await foreach (var item in pagesFolder.GetItemsAsync(StorableType.Folder)) + { + if (item is not IChildFolder pageFolder) + continue; + + Logger.LogInformation($"Processing page folder: {pageFolder.Name}"); + + // Create output folder for this page + var pageOutputFolder = await outputFolder.CreateFolderAsync(pageFolder.Name, overwrite: true); + + // Iterate files within this page folder recursively + var recursiveFolder = new DepthFirstRecursiveFolder(pageFolder); + await foreach (var fileItem in recursiveFolder.GetItemsAsync(StorableType.File)) + { + if (fileItem is not IChildFile file) + continue; + + Logger.LogInformation($" Yielded file: {file.Name} (Type: {file.GetType().Name})"); + + // Get relative path from page folder (not pagesFolder root) + string relativePath = await pageFolder.GetRelativePathToAsync(file); + Logger.LogInformation($" Relative path: {relativePath}"); + + // Create folders relative to THIS page's output folder + var containingFolder = (IModifiableFolder)await pageOutputFolder.CreateFoldersAlongRelativePathAsync(relativePath, overwrite: false).LastAsync(); + Logger.LogInformation($" Containing folder: {containingFolder.Id}"); + + // Copy file + Logger.LogInformation($" About to copy - file.Id: {file.Id}, file.GetType(): {file.GetType().Name}"); + Logger.LogInformation($" Target folder: {containingFolder.Id}"); + var copiedFile = await containingFolder.CreateCopyOfAsync(file, overwrite: true); + Logger.LogInformation($" Copied to: {copiedFile.Id}"); + + // Check what else got created + Logger.LogInformation($" Checking folder contents after copy:"); + await foreach (var folderItem in containingFolder.GetItemsAsync()) + { + Logger.LogInformation($" Found: {folderItem.Name} (Type: {folderItem.GetType().Name})"); + } + + // Copy all assets referenced in content using RewrittenPath + if (file is AssetAwareHtmlTemplatedMarkdownFile htmlFile) + { + foreach (var asset in htmlFile.IncludedAssets) + { + // Navigate FROM htmlFile using RewrittenPath (relative to HTML file) + var assetOutputFolder = (IModifiableFolder)await copiedFile.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/WacsdkBlogCommands.cs b/src/Commands/Blog/WacsdkBlogCommands.cs index bb8bbf0..60f90b9 100644 --- a/src/Commands/Blog/WacsdkBlogCommands.cs +++ b/src/Commands/Blog/WacsdkBlogCommands.cs @@ -19,8 +19,8 @@ public WacsdkBlogCommands() // Register Post/Page scenario AddCommand(new PostPageCommand()); - // Future: Register Pages scenario - // AddCommand(new PagesCommand()); + // Register Pages scenario + AddCommand(new PagesCommand()); // Future: Register Site scenario // AddCommand(new SiteCommand()); diff --git a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs new file mode 100644 index 0000000..27fdd1e --- /dev/null +++ b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs @@ -0,0 +1,245 @@ +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 + { + SourceFolder = _testSourceFolder + }, + InclusionStrategy = new ReferenceOnlyInclusionStrategy() + }; + } + + [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 + { + SourceFolder = _testSourceFolder + }; + + 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 InclusionStrategy_AppliesReferenceDecisions() + { + var strategy = new ReferenceOnlyInclusionStrategy(); + 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"); + } +} From 5e8b9ed77c717cc41b1c93c950b78d5504dc5159 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 22 Nov 2025 14:43:53 -0600 Subject: [PATCH 4/5] refactor: Remove SourceFolder dependency from IAssetResolver interface Simplifies asset resolver architecture: - Removed IFolder SourceFolder property from IAssetResolver interface - Removed SourceFolder property from RelativePathAssetResolver implementation - Asset resolution now fully stateless, relying only on markdown source context Updated PagesCommand implementation: - Simplified folder materialization logic with better variable naming - Changed --template-file option to --template-file-name for clarity - Removed debug logging statements - Used DepthFirstRecursiveFolder at pagesFolder level instead of per-page - Improved code formatting and inline documentation - Streamlined folder creation using CreateFoldersAlongRelativePathAsync pattern Restored HtmlTemplatedMarkdownPageFolder template asset yielding: - Re-added template folder enumeration and asset passthrough - Template HTML file exclusion logic restored - Enables PostPage scenario to continue working as expected Updated test setup: - Removed SourceFolder initialization from RelativePathAssetResolver instances - Tests now reflect stateless resolver design --- src/Blog/Assets/IAssetResolver.cs | 5 - src/Blog/Assets/RelativePathAssetResolver.cs | 3 - .../Page/HtmlTemplatedMarkdownPageFolder.cs | 33 ++++++- src/Commands/Blog/PostPage/PagesCommand.cs | 97 ++++++------------- ...reHtmlTemplatedMarkdownPagesFolderTests.cs | 10 +- 5 files changed, 60 insertions(+), 88 deletions(-) diff --git a/src/Blog/Assets/IAssetResolver.cs b/src/Blog/Assets/IAssetResolver.cs index fcb6dee..4feb542 100644 --- a/src/Blog/Assets/IAssetResolver.cs +++ b/src/Blog/Assets/IAssetResolver.cs @@ -7,11 +7,6 @@ namespace WindowsAppCommunity.Blog.Assets; /// public interface IAssetResolver { - /// - /// Root folder for relative path resolution. - /// - IFolder SourceFolder { get; init; } - /// /// Resolves a relative path string to an IFile instance. /// diff --git a/src/Blog/Assets/RelativePathAssetResolver.cs b/src/Blog/Assets/RelativePathAssetResolver.cs index f96232e..7feff2a 100644 --- a/src/Blog/Assets/RelativePathAssetResolver.cs +++ b/src/Blog/Assets/RelativePathAssetResolver.cs @@ -9,9 +9,6 @@ namespace WindowsAppCommunity.Blog.Assets; /// public sealed class RelativePathAssetResolver : IAssetResolver { - /// - public required IFolder SourceFolder { get; init; } - /// public async Task ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default) { diff --git a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs index 91eb787..64c56a7 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -72,9 +72,10 @@ public virtual async IAsyncEnumerable GetItemsAsync( StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Yield HtmlTemplatedMarkdownFile (virtual HTML file) only - // Template assets are NOT yielded here - they're detected as links in the template HTML - // and tracked in the HTML file's IncludedAssets collection for consumer materialization + // Resolve template file for exclusion and HtmlTemplatedMarkdownFile construction + var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName); + + // Yield HtmlTemplatedMarkdownFile (virtual HTML file) if (type == StorableType.All || type == StorableType.File) { var indexHtmlId = $"{Id}/index.html"; @@ -83,6 +84,32 @@ public virtual async IAsyncEnumerable GetItemsAsync( Name = "index.html" }; } + + // 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; + } + } + } } /// diff --git a/src/Commands/Blog/PostPage/PagesCommand.cs b/src/Commands/Blog/PostPage/PagesCommand.cs index 967b30f..7701419 100644 --- a/src/Commands/Blog/PostPage/PagesCommand.cs +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -46,7 +46,7 @@ public PagesCommand() }; var templateFileNameOption = new Option( - name: "--template-file", + name: "--template-file-name", description: "Template file name when --template is folder (optional, defaults to 'template.html')", getDefaultValue: () => null); @@ -75,87 +75,46 @@ private async Task ExecuteAsync( string outputPath, string? templateFileName) { - // Resolve markdown source folder (SystemFolder throws if doesn't exist) - var markdownSourceFolder = new SystemFolder(markdownFolderPath); - // 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); - } + IStorable templateSource = Directory.Exists(templatePath) + ? new SystemFolder(templatePath) + : new SystemFile(templatePath); - // Resolve output folder (SystemFolder throws if doesn't exist) - IModifiableFolder outputFolder = new SystemFolder(outputPath); + // Resolve markdown source and output folders (SystemFolder throws if doesn't exist) + var outputFolder = new SystemFolder(outputPath); + var markdownSourceFolder = new SystemFolder(markdownFolderPath); - // Create virtual AssetAwareHtmlTemplatedMarkdownPagesFolder (lazy generation - no I/O during construction) + // Create recursive markdown-to-webpage folder (lazy generation - no I/O during construction) + // Turns `.md` files into folders with an `index.html` holding asset metadata for output copy var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName) { LinkDetector = new RegexAssetLinkDetector(), - Resolver = new RelativePathAssetResolver - { - // MarkdownSource passed per-call in ResolveAsync (varies per page) - SourceFolder = markdownSourceFolder - }, + Resolver = new RelativePathAssetResolver(), InclusionStrategy = new ReferenceOnlyInclusionStrategy() }; - // Materialize virtual structure by iterating page folders, then files within each - // Pattern from PostPageCommand: Create output folder per page, copy files relative to it - await foreach (var item in pagesFolder.GetItemsAsync(StorableType.Folder)) + // Materialize virtual folderized markdown pages, then files within each markdown page folder. + await foreach (IChildFolder pageFolder in new DepthFirstRecursiveFolder(pagesFolder).GetItemsAsync(StorableType.Folder)) { - if (item is not IChildFolder pageFolder) - continue; - - Logger.LogInformation($"Processing page folder: {pageFolder.Name}"); - - // Create output folder for this page - var pageOutputFolder = await outputFolder.CreateFolderAsync(pageFolder.Name, overwrite: true); - - // Iterate files within this page folder recursively - var recursiveFolder = new DepthFirstRecursiveFolder(pageFolder); - await foreach (var fileItem in recursiveFolder.GetItemsAsync(StorableType.File)) + // Get path to markdown page folder (mirrors original source file without extension) + var relativePathToPagesPageFolder = await pagesFolder.GetRelativePathToAsync(pageFolder); + var pageOutputFolder = await outputFolder.CreateFoldersAlongRelativePathAsync(relativePathToPagesPageFolder, overwrite: true).LastAsync(); + + // Iterate/copy files within markdown page folder + await foreach (AssetAwareHtmlTemplatedMarkdownFile indexFile in pageFolder.GetItemsAsync(StorableType.File)) { - if (fileItem is not IChildFile file) - continue; - - Logger.LogInformation($" Yielded file: {file.Name} (Type: {file.GetType().Name})"); - // Get relative path from page folder (not pagesFolder root) - string relativePath = await pageFolder.GetRelativePathToAsync(file); - Logger.LogInformation($" Relative path: {relativePath}"); - - // Create folders relative to THIS page's output folder - var containingFolder = (IModifiableFolder)await pageOutputFolder.CreateFoldersAlongRelativePathAsync(relativePath, overwrite: false).LastAsync(); - Logger.LogInformation($" Containing folder: {containingFolder.Id}"); - - // Copy file - Logger.LogInformation($" About to copy - file.Id: {file.Id}, file.GetType(): {file.GetType().Name}"); - Logger.LogInformation($" Target folder: {containingFolder.Id}"); - var copiedFile = await containingFolder.CreateCopyOfAsync(file, overwrite: true); - Logger.LogInformation($" Copied to: {copiedFile.Id}"); - - // Check what else got created - Logger.LogInformation($" Checking folder contents after copy:"); - await foreach (var folderItem in containingFolder.GetItemsAsync()) - { - Logger.LogInformation($" Found: {folderItem.Name} (Type: {folderItem.GetType().Name})"); - } - - // Copy all assets referenced in content using RewrittenPath - if (file is AssetAwareHtmlTemplatedMarkdownFile htmlFile) + string pageFolderFileRelativePath = await pageFolder.GetRelativePathToAsync(indexFile); + + // Create folders relative to THIS page's output folder, then copy + var containingFolder = (IModifiableFolder)await pageOutputFolder.CreateFoldersAlongRelativePathAsync(pageFolderFileRelativePath, overwrite: false).LastAsync(); + var copiedIndexFile = await containingFolder.CreateCopyOfAsync(indexFile, overwrite: true); + + // Copy all assets referenced in index.html to the rewritten asset path + foreach (var asset in indexFile.IncludedAssets) { - foreach (var asset in htmlFile.IncludedAssets) - { - // Navigate FROM htmlFile using RewrittenPath (relative to HTML file) - var assetOutputFolder = (IModifiableFolder)await copiedFile.CreateFoldersAlongRelativePathAsync(asset.RewrittenPath, overwrite: false).LastAsync(); - await assetOutputFolder.CreateCopyOfAsync(asset.ResolvedFile, overwrite: true); - } + var assetOutputFolder = (IModifiableFolder)await copiedIndexFile.CreateFoldersAlongRelativePathAsync(asset.RewrittenPath, overwrite: false).LastAsync(); + await assetOutputFolder.CreateCopyOfAsync(asset.ResolvedFile, overwrite: true); } } } diff --git a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs index 27fdd1e..dab7a46 100644 --- a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs +++ b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs @@ -93,10 +93,7 @@ await templateWriter.WriteAsync(@" "index.html") { LinkDetector = new RegexAssetLinkDetector(), - Resolver = new RelativePathAssetResolver - { - SourceFolder = _testSourceFolder - }, + Resolver = new RelativePathAssetResolver(), InclusionStrategy = new ReferenceOnlyInclusionStrategy() }; } @@ -163,10 +160,7 @@ public async Task AssetLinkDetection_IdentifiesRelativeLinks() [TestMethod] public async Task AssetPathResolution_ResolvesValidPaths() { - var resolver = new RelativePathAssetResolver - { - SourceFolder = _testSourceFolder - }; + var resolver = new RelativePathAssetResolver(); var resolvedAsset = await resolver.ResolveAsync(_page1File, "images/logo.png"); From ebbd8cf223d844d6baaea1f79f644610ad9d4c7a Mon Sep 17 00:00:00 2001 From: Arlo Date: Mon, 24 Nov 2025 09:49:26 -0600 Subject: [PATCH 5/5] refactor: overhaul asset management system with extensible strategy pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major refactoring of the blog generation asset pipeline to improve flexibility and maintainability: **Core Interface Changes:** - Renamed `IAssetInclusionStrategy` → `IAssetStrategy` with nullable return type - Updated method signatures across asset interfaces for clarity and consistency - Renamed `ReferencedAsset` record → `PageAsset` to better reflect its purpose - Renamed `PostPageDataModel` → `HtmlMarkdownDataTemplateModel` for generic use **Asset Strategy System:** - Deleted `ReferenceOnlyInclusionStrategy.cs` (replaced with new architecture) - Added `KnownAssetStrategy.cs`: configurable strategy using known asset ID lists - Supports both included and referenced asset file ID sets - Includes fallback behavior options (Reference/Include/Drop) - Added `FaultStrategy.cs`: enum for unknown asset handling (None/LogWarn/LogError/Throw) **Asset Detection Improvements:** - Enhanced `RegexAssetLinkDetector` with improved path matching patterns - Added protocol scheme detection to filter out absolute URLs - Added standalone filename pattern detection - Fixed relative path resolution in `RelativePathAssetResolver` **Processing Pipeline Refactoring:** - Moved asset detection earlier in pipeline to include template file assets - Changed `AssetAwareHtmlTemplatedMarkdownFile` to scan both template and markdown - Updated post-processing to return nullable for dropped assets - Refactored `PagesCommand` to configure new asset strategy with separate ID sets **Command Structure:** - Deleted legacy `PostPageCommand.cs`, `PostPageFolder.cs`, `IndexHtmlFile.cs`, `PostPageAssetFolder.cs` - Added new `PageCommand.cs` for single-page generation - Updated `WacsdkBlogCommands` to use new command structure **Dependencies:** - Added `OwlCore.Extensions` package reference for enhanced functionality **Tests:** - Updated test references from `InclusionStrategy` → `AssetStrategy` - Created temporary `ReferenceOnlyAssetStrategy` for test compatibility This refactoring enables fine-grained control over asset handling, supporting scenarios like template-based asset inclusion vs markdown-based asset referencing, with configurable fallback behavior for unknown assets. --- src/Blog/Assets/FaultStrategy.cs | 28 ++ src/Blog/Assets/IAssetInclusionStrategy.cs | 15 +- src/Blog/Assets/IAssetLinkDetector.cs | 6 +- src/Blog/Assets/IAssetResolver.cs | 4 +- src/Blog/Assets/KnownAssetStrategy.cs | 92 ++++++ .../Assets/ReferenceOnlyInclusionStrategy.cs | 26 -- src/Blog/Assets/ReferencedAsset.cs | 6 +- src/Blog/Assets/RegexAssetLinkDetector.cs | 52 ++-- src/Blog/Assets/RelativePathAssetResolver.cs | 6 +- .../AssetAwareHtmlTemplatedMarkdownFile.cs | 82 +++-- ...setAwareHtmlTemplatedMarkdownPageFolder.cs | 30 +- .../HtmlMarkdownDataTemplateModel.cs} | 7 +- src/Blog/Page/HtmlTemplatedMarkdownFile.cs | 60 +--- .../Page/HtmlTemplatedMarkdownPageFolder.cs | 80 +---- ...etAwareHtmlTemplatedMarkdownPagesFolder.cs | 8 +- src/Blog/PostPage/IndexHtmlFile.cs | 282 ------------------ src/Blog/PostPage/PostPageAssetFolder.cs | 80 ----- src/Blog/PostPage/PostPageFolder.cs | 135 --------- src/Commands/Blog/PostPage/PageCommand.cs | 116 +++++++ src/Commands/Blog/PostPage/PagesCommand.cs | 28 +- src/Commands/Blog/PostPage/PostPageCommand.cs | 143 --------- src/Commands/Blog/WacsdkBlogCommands.cs | 2 +- src/WindowsAppCommunity.CommandLine.csproj | 1 + ...reHtmlTemplatedMarkdownPagesFolderTests.cs | 6 +- 24 files changed, 376 insertions(+), 919 deletions(-) create mode 100644 src/Blog/Assets/FaultStrategy.cs create mode 100644 src/Blog/Assets/KnownAssetStrategy.cs delete mode 100644 src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs rename src/Blog/{PostPage/PostPageDataModel.cs => Page/HtmlMarkdownDataTemplateModel.cs} (93%) delete mode 100644 src/Blog/PostPage/IndexHtmlFile.cs delete mode 100644 src/Blog/PostPage/PostPageAssetFolder.cs delete mode 100644 src/Blog/PostPage/PostPageFolder.cs create mode 100644 src/Commands/Blog/PostPage/PageCommand.cs delete mode 100644 src/Commands/Blog/PostPage/PostPageCommand.cs 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 index a7c086e..a93d8bc 100644 --- a/src/Blog/Assets/IAssetInclusionStrategy.cs +++ b/src/Blog/Assets/IAssetInclusionStrategy.cs @@ -6,25 +6,22 @@ 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 IAssetInclusionStrategy +public interface IAssetStrategy { /// /// Decides asset inclusion strategy by returning rewritten path. /// - /// The markdown file that references the asset. - /// The asset file being referenced. - /// The original relative path from markdown. + /// 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 referencingMarkdown, - IFile referencedAsset, - string originalPath, - CancellationToken ct = default); + 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 index d605248..9ca9343 100644 --- a/src/Blog/Assets/IAssetLinkDetector.cs +++ b/src/Blog/Assets/IAssetLinkDetector.cs @@ -10,8 +10,8 @@ public interface IAssetLinkDetector /// /// Detects relative asset link strings in rendered HTML output. /// - /// Virtual IFile representing rendered HTML output (in-memory representation). - /// Cancellation token. + /// File instance containing text to detect links from. + /// Cancellation token. /// Async enumerable of relative path strings. - IAsyncEnumerable DetectAsync(IFile htmlSource, CancellationToken ct = default); + 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 index 4feb542..f9ba7e2 100644 --- a/src/Blog/Assets/IAssetResolver.cs +++ b/src/Blog/Assets/IAssetResolver.cs @@ -10,9 +10,9 @@ public interface IAssetResolver /// /// Resolves a relative path string to an IFile instance. /// - /// Markdown file for relative path context (varies per page). + /// 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 markdownSource, string relativePath, CancellationToken ct = default); + 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/ReferenceOnlyInclusionStrategy.cs b/src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs deleted file mode 100644 index f28255a..0000000 --- a/src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs +++ /dev/null @@ -1,26 +0,0 @@ -using OwlCore.Storage; - -namespace WindowsAppCommunity.Blog.Assets; - -/// -/// Reference-only inclusion strategy: always rewrites paths to add one level of parent navigation, -/// treating all assets as externally referenced (not included in page folder). -/// -public sealed class ReferenceOnlyInclusionStrategy : IAssetInclusionStrategy -{ - /// - public Task DecideAsync( - IFile referencingMarkdown, - IFile referencedAsset, - string originalPath, - CancellationToken ct = default) - { - // Reference-only behavior: add one parent directory prefix to account for folderization - // Markdown file becomes folder/index.html, so links need one extra "../" to reach original location - if (string.IsNullOrWhiteSpace(originalPath)) - return Task.FromResult(originalPath); - - var rewrittenPath = "../" + originalPath; - return Task.FromResult(rewrittenPath); - } -} diff --git a/src/Blog/Assets/ReferencedAsset.cs b/src/Blog/Assets/ReferencedAsset.cs index 48f1511..27961c9 100644 --- a/src/Blog/Assets/ReferencedAsset.cs +++ b/src/Blog/Assets/ReferencedAsset.cs @@ -9,9 +9,5 @@ namespace WindowsAppCommunity.Blog.Assets /// Path detected in markdown (relative to source file) /// Path after inclusion strategy applied (include vs reference) /// Actual file instance for copy operations - public record ReferencedAsset( - string OriginalPath, - string RewrittenPath, - IFile ResolvedFile - ); + public record PageAsset(string OriginalPath, string RewrittenPath, IFile ResolvedFile); } diff --git a/src/Blog/Assets/RegexAssetLinkDetector.cs b/src/Blog/Assets/RegexAssetLinkDetector.cs index 22c4c02..0558752 100644 --- a/src/Blog/Assets/RegexAssetLinkDetector.cs +++ b/src/Blog/Assets/RegexAssetLinkDetector.cs @@ -1,11 +1,12 @@ using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using OwlCore.Diagnostics; using OwlCore.Storage; namespace WindowsAppCommunity.Blog.Assets; /// -/// Detects relative asset links in rendered HTML using path-pattern regex (no element parsing). +/// Detects relative asset links in rendered using path-pattern regex (no element parsing). /// public sealed partial class RegexAssetLinkDetector : IAssetLinkDetector { @@ -13,21 +14,24 @@ 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(@"(? + /// 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 htmlSource, [EnumeratorCancellation] CancellationToken ct = default) + public async IAsyncEnumerable DetectAsync(IFile source, [EnumeratorCancellation] CancellationToken ct = default) { - // Read HTML content - using var stream = await htmlSource.OpenStreamAsync(FileAccess.Read, ct); - using var reader = new StreamReader(stream); - var html = await reader.ReadToEndAsync(ct); - - // Find all matches - var matches = RelativePathPattern().Matches(html); + var text = await source.ReadTextAsync(ct); - foreach (Match match in matches) + foreach (Match match in RelativePathPattern().Matches(text)) { if (ct.IsCancellationRequested) yield break; @@ -38,18 +42,30 @@ public async IAsyncEnumerable DetectAsync(IFile htmlSource, [EnumeratorC if (string.IsNullOrWhiteSpace(path)) continue; - // Exclude absolute schemes - if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("//", StringComparison.Ordinal)) - 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 index 7feff2a..f06d07a 100644 --- a/src/Blog/Assets/RelativePathAssetResolver.cs +++ b/src/Blog/Assets/RelativePathAssetResolver.cs @@ -10,7 +10,7 @@ namespace WindowsAppCommunity.Blog.Assets; public sealed class RelativePathAssetResolver : IAssetResolver { /// - public async Task ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default) + public async Task ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(relativePath)) return null; @@ -20,9 +20,9 @@ public sealed class RelativePathAssetResolver : IAssetResolver // Normalize path separators to forward slash var normalizedPath = relativePath.Replace('\\', '/'); - // Resolve relative to markdown file's location (pre-folderization) + // Resolve relative to markdown file's containing location (pre-folderization) // The markdown file itself is the base for relative path resolution - var item = await markdownSource.GetItemByRelativePathAsync(normalizedPath, ct); + var item = await sourceFile.GetItemByRelativePathAsync($"../{normalizedPath}", ct); // Return only if it's a file return item as IFile; diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs index 12faef2..c0e5ccc 100644 --- a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using OwlCore.Diagnostics; using OwlCore.Storage; using WindowsAppCommunity.Blog.Assets; -using WindowsAppCommunity.Blog.PostPage; +using WindowsAppCommunity.Blog.Page; namespace WindowsAppCommunity.Blog.Page { @@ -15,9 +12,7 @@ namespace WindowsAppCommunity.Blog.Page /// public sealed class AssetAwareHtmlTemplatedMarkdownFile : HtmlTemplatedMarkdownFile { - private readonly List _includedAssets = new(); - private readonly IStorable _templateSource; - private readonly string? _templateFileName; + private readonly List _assets = new(); /// /// Creates asset-aware virtual HTML file with lazy markdown→HTML generation and asset management. @@ -27,16 +22,9 @@ public sealed class AssetAwareHtmlTemplatedMarkdownFile : HtmlTemplatedMarkdownF /// 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) + public AssetAwareHtmlTemplatedMarkdownFile(string id, IFile markdownSource, IStorable templateSource, string? templateFileName = null, IFolder? parent = null) : base(id, markdownSource, templateSource, templateFileName, parent) { - _templateSource = templateSource; - _templateFileName = templateFileName; } /// @@ -52,32 +40,48 @@ public AssetAwareHtmlTemplatedMarkdownFile( /// /// Inclusion strategy for deciding include vs reference via path rewriting. /// - public required IAssetInclusionStrategy InclusionStrategy { get; init; } + 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 IncludedAssets => _includedAssets.AsReadOnly(); + 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. /// - /// Rendered HTML from template + /// The resolved HTML template file. /// Data model used for rendering - /// Cancellation token + /// Cancellation token /// Post-processed HTML with rewritten links - protected override async Task PostProcessHtmlAsync(string html, PostPageDataModel model, CancellationToken ct) + protected override async Task RenderTemplateAsync(IFile templateFile, HtmlMarkdownDataTemplateModel model, CancellationToken cancellationToken) { // Clear included assets from any previous generation - _includedAssets.Clear(); + _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, ct)) + await foreach (var originalPath in LinkDetector.DetectAsync(MarkdownSource, cancellationToken)) { - html = await ProcessAssetLinkAsync(html, MarkdownSource, originalPath, ct); + var referencedAsset = await ProcessAssetLinkAsync(MarkdownSource, originalPath, cancellationToken); + if (referencedAsset is null) + continue; + + _assets.Add(referencedAsset); + html = html.Replace(referencedAsset.OriginalPath, referencedAsset.RewrittenPath); } return html; @@ -87,41 +91,29 @@ protected override async Task PostProcessHtmlAsync(string html, PostPage /// Process a single detected asset link through the asset pipeline. /// Shared logic for both markdown and template asset detection. /// - /// HTML content to update /// File providing resolution context (markdown or template) /// Original asset path as detected - /// Cancellation token + /// Cancellation token /// Updated HTML with rewritten link - private async Task ProcessAssetLinkAsync( - string html, - IFile contextFile, - string originalPath, - CancellationToken ct) + 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, ct); + var resolvedAsset = await Resolver.ResolveAsync(contextFile, originalPath, cancellationToken); - // Null resolver policy: Skip if not found (preserve broken link) + // Skip if not found if (resolvedAsset == null) - { - return html; - } + 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 InclusionStrategy.DecideAsync( - contextFile, - resolvedAsset, - originalPath, - ct); + var rewrittenPath = await AssetStrategy.DecideAsync(contextFile, resolvedAsset, originalPath, cancellationToken); + if (rewrittenPath is null) + return null; // Track all referenced assets for materialization - _includedAssets.Add(new ReferencedAsset(originalPath, rewrittenPath, resolvedAsset)); - - // Rewrite link in HTML (strategy determines path prefix) - return html.Replace(originalPath, rewrittenPath); + return new PageAsset(originalPath, rewrittenPath, resolvedAsset); } } } diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs index 031964f..7783cb0 100644 --- a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs @@ -21,10 +21,7 @@ public class AssetAwareHtmlTemplatedMarkdownPageFolder : HtmlTemplatedMarkdownPa /// 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) + public AssetAwareHtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateSource, string? templateFileName = null) : base(markdownSource, templateSource, templateFileName) { } @@ -42,43 +39,28 @@ public AssetAwareHtmlTemplatedMarkdownPageFolder( /// /// Inclusion strategy for deciding include vs reference per asset. /// - public required IAssetInclusionStrategy InclusionStrategy { get; init; } + public required IAssetStrategy AssetStrategy { get; init; } /// - public override async IAsyncEnumerable GetItemsAsync( - StorableType type = StorableType.All, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + public override async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - AssetAwareHtmlTemplatedMarkdownFile? assetAwareFile = null; - // 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 && assetAwareFile == null) + if (item is HtmlTemplatedMarkdownFile htmlFile) { // Create asset-aware variant with required properties set - assetAwareFile = new AssetAwareHtmlTemplatedMarkdownFile( - htmlFile.Id, - MarkdownSource, - TemplateSource, - TemplateFileName, - this) + yield return new AssetAwareHtmlTemplatedMarkdownFile(htmlFile.Id, MarkdownSource, TemplateSource, TemplateFileName, this) { Name = htmlFile.Name, Created = htmlFile.Created, Modified = htmlFile.Modified, LinkDetector = LinkDetector, Resolver = Resolver, - InclusionStrategy = InclusionStrategy + AssetStrategy = AssetStrategy }; - - yield return assetAwareFile; - continue; } - - // Pass through other items (template assets) - yield return item; } } } 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/Page/HtmlTemplatedMarkdownFile.cs b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs index 8362279..0d02aa8 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownFile.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs @@ -1,15 +1,9 @@ -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.PostPage; +using WindowsAppCommunity.Blog.Page; namespace WindowsAppCommunity.Blog.Page { @@ -68,7 +62,7 @@ public HtmlTemplatedMarkdownFile(string id, IFile markdownSource, IStorable temp /// Source markdown file being transformed. /// Exposed for derived class access (e.g., passing to asset strategies). /// - protected IFile MarkdownSource => _markdownSource; + public IFile MarkdownSource => _markdownSource; /// public Task GetParentAsync(CancellationToken cancellationToken = default) @@ -87,12 +81,12 @@ public async Task OpenStreamAsync(FileAccess accessMode, CancellationTok // 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; } @@ -115,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, @@ -125,16 +119,9 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken }; // Render template with model - var html = await RenderTemplateAsync(templateFile, model); - - // Post-process HTML (extensibility point for derived classes) - html = await PostProcessHtmlAsync(html, model, cancellationToken); - - return html; + return await RenderTemplateAsync(templateFile, model, cancellationToken); } - #region Protected Virtual Hooks - /// /// Extract YAML front-matter block from markdown file. /// Front-matter is delimited by "---" at start and end. @@ -145,7 +132,7 @@ private async Task GenerateHtmlAsync(CancellationToken cancellationToken protected virtual async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file) { var text = await file.ReadTextAsync(); - + // Check for front-matter delimiters if (!text.StartsWith("---")) { @@ -156,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() == "---") @@ -237,9 +224,7 @@ protected virtual Dictionary ParseFrontmatter(string yaml) /// Template as IFile or IFolder /// File name when source is IFolder (defaults to "template.html") /// Resolved template IFile - protected virtual async Task ResolveTemplateFileAsync( - IStorable templateSource, - string? templateFileName) + protected virtual async Task ResolveTemplateFileAsync(IStorable templateSource, string? templateFileName) { if (templateSource is IFile file) { @@ -272,13 +257,11 @@ protected virtual 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 - protected virtual 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) @@ -287,26 +270,7 @@ protected virtual async Task RenderTemplateAsync( throw new InvalidOperationException($"Template parsing failed:{Environment.NewLine}{errors}"); } - var html = template.Render(model); - - return html; + return template.Render(model); } - - /// - /// Post-process rendered HTML (extensibility point for derived classes). - /// Base implementation is pass-through - no modifications. - /// Derived classes can override to perform link rewriting, asset detection, etc. - /// - /// Rendered HTML from template - /// Data model used for rendering - /// Cancellation token - /// Post-processed HTML string - protected virtual Task PostProcessHtmlAsync(string html, PostPageDataModel model, CancellationToken ct) - { - // Base implementation: pass-through (no post-processing) - return Task.FromResult(html); - } - - #endregion } } \ No newline at end of file diff --git a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs index 64c56a7..6421995 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -1,12 +1,6 @@ -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.PostPage; namespace WindowsAppCommunity.Blog.Page { @@ -51,7 +45,7 @@ public HtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateS protected string? TemplateFileName => _templateFileName; /// - public string Id => _markdownSource.Id; + public required string Id { get; init; } /// public string Name => SanitizeFilename(_markdownSource.Name); @@ -68,48 +62,17 @@ public HtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateS } /// - public virtual async IAsyncEnumerable GetItemsAsync( - StorableType type = StorableType.All, - [EnumeratorCancellation] CancellationToken cancellationToken = default) + public virtual async IAsyncEnumerable GetItemsAsync(StorableType type = StorableType.All, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - // Resolve template file for exclusion and HtmlTemplatedMarkdownFile construction - var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName); - // Yield HtmlTemplatedMarkdownFile (virtual HTML file) if (type == StorableType.All || type == StorableType.File) { - var indexHtmlId = $"{Id}/index.html"; + var indexHtmlId = $"{$"{Id}-index.html".HashMD5Fast()}"; yield return new HtmlTemplatedMarkdownFile(indexHtmlId, _markdownSource, _templateSource, _templateFileName, this) { Name = "index.html" }; } - - // 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; - } - } - } } /// @@ -125,43 +88,10 @@ private string SanitizeFilename(string markdownFilename) // Replace invalid filename characters with underscore var invalidChars = Path.GetInvalidFileNameChars(); - var sanitized = string.Concat(nameWithoutExtension.Select(c => + 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)); - } } } \ No newline at end of file diff --git a/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs index 7be4819..7e7baef 100644 --- a/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs +++ b/src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs @@ -5,6 +5,7 @@ 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; @@ -52,7 +53,7 @@ public AssetAwareHtmlTemplatedMarkdownPagesFolder( /// /// Inclusion strategy for deciding include vs reference per asset. /// - public required IAssetInclusionStrategy InclusionStrategy { get; init; } + public required IAssetStrategy AssetStrategy { get; init; } /// public string Id => _markdownSourceFolder.Id; @@ -84,9 +85,10 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = { var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(file, _templateSource, _templateFileName) { + Id = $"{Id}-{file.Name}".HashMD5Fast(), LinkDetector = LinkDetector, Resolver = Resolver, - InclusionStrategy = InclusionStrategy, + AssetStrategy = AssetStrategy, Parent = this }; @@ -103,7 +105,7 @@ public async IAsyncEnumerable GetItemsAsync(StorableType type = { LinkDetector = LinkDetector, Resolver = Resolver, - InclusionStrategy = InclusionStrategy, + AssetStrategy = AssetStrategy, Parent = this }; diff --git a/src/Blog/PostPage/IndexHtmlFile.cs b/src/Blog/PostPage/IndexHtmlFile.cs deleted file mode 100644 index 0e4435f..0000000 --- a/src/Blog/PostPage/IndexHtmlFile.cs +++ /dev/null @@ -1,282 +0,0 @@ -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; - -namespace WindowsAppCommunity.Blog.PostPage -{ - /// - /// Virtual IChildFile representing index.html generated from markdown source. - /// Implements lazy generation - markdown→HTML transformation occurs on OpenStreamAsync. - /// Read-only - throws NotSupportedException for write operations. - /// - public sealed class IndexHtmlFile : IChildFile - { - private readonly string _id; - private readonly IFile _markdownSource; - private readonly IStorable _templateSource; - private readonly string? _templateFileName; - private readonly IFolder? _parent; - - /// - /// Creates virtual index.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) - { - _id = id ?? throw new ArgumentNullException(nameof(id)); - _markdownSource = markdownSource ?? throw new ArgumentNullException(nameof(markdownSource)); - _templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSource)); - _templateFileName = templateFileName; - _parent = parent; - } - - /// - public string Id => _id; - - /// - public string Name => "index.html"; - - /// - /// File creation timestamp from filesystem metadata. - /// - public DateTime? Created { get; set; } - - /// - /// File modification timestamp from filesystem metadata. - /// - public DateTime? Modified { get; set; } - - /// - public Task GetParentAsync(CancellationToken cancellationToken = default) - { - return Task.FromResult(_parent); - } - - /// - public async Task OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default) - { - // 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}"); - } - - // 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. - /// - private async Task GenerateHtmlAsync(CancellationToken cancellationToken) - { - // Parse markdown file (extract front-matter + content) - var (frontmatter, content) = await ParseMarkdownAsync(_markdownSource); - - // Transform markdown content to HTML body - var htmlBody = TransformMarkdownToHtml(content); - - // Parse front-matter YAML to dictionary - var frontmatterDict = ParseFrontmatter(frontmatter); - - // Resolve template file from IStorable source - var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName); - - // Create data model for template - var model = new PostPageDataModel - { - Body = htmlBody, - Frontmatter = frontmatterDict, - Filename = _markdownSource.Name, - Created = Created, - Modified = Modified - }; - - // Render template with model - var html = await RenderTemplateAsync(templateFile, model); - - return html; - } - - #region Transformation Helpers - - /// - /// Extract YAML front-matter block from markdown file. - /// Front-matter is delimited by "---" at start and end. - /// Handles files without front-matter (returns empty string for frontmatter). - /// - /// Markdown file to parse - /// Tuple of (frontmatter YAML string, content markdown string) - private async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file) - { - var text = await file.ReadTextAsync(); - - // Check for front-matter delimiters - if (!text.StartsWith("---")) - { - // No front-matter present - return (string.Empty, text); - } - - // 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() == "---") - { - closingDelimiterIndex = i; - break; - } - } - - if (closingDelimiterIndex == -1) - { - // No closing delimiter found - treat entire file as content - return (string.Empty, text); - } - - // Extract front-matter (lines between delimiters) - var frontmatterLines = lines.Skip(1).Take(closingDelimiterIndex - 1); - var frontmatter = string.Join(Environment.NewLine, frontmatterLines); - - // Extract content (everything after closing delimiter) - var contentLines = lines.Skip(closingDelimiterIndex + 1); - var content = string.Join(Environment.NewLine, contentLines); - - return (frontmatter, content); - } - - /// - /// Transform markdown content to HTML body using Markdig. - /// Returns HTML without wrapping elements - template controls structure. - /// Uses Advanced Extensions pipeline for full Markdown feature support. - /// - /// Markdown content string - /// HTML body content - private string TransformMarkdownToHtml(string markdown) - { - var pipeline = new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .UseSoftlineBreakAsHardlineBreak() - .Build(); - - return Markdown.ToHtml(markdown, pipeline); - } - - /// - /// Parse YAML front-matter string to arbitrary dictionary. - /// No schema enforcement - accepts any valid YAML structure. - /// Handles empty/missing front-matter gracefully. - /// - /// YAML string from front-matter - /// Dictionary with arbitrary keys and values - private Dictionary ParseFrontmatter(string yaml) - { - // Handle empty front-matter - if (string.IsNullOrWhiteSpace(yaml)) - { - return new Dictionary(); - } - - try - { - var deserializer = new DeserializerBuilder() - .Build(); - - var result = deserializer.Deserialize>(yaml); - return result ?? new Dictionary(); - } - catch (YamlDotNet.Core.YamlException ex) - { - throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex); - } - } - - /// - /// 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)); - } - - /// - /// Render Scriban template with data model to produce final HTML. - /// Template generates all HTML including meta tags from model.frontmatter. - /// Flow boundary: Generator provides data model, template generates HTML. - /// - /// Scriban template file - /// PostPageDataModel with body, frontmatter, metadata - /// Rendered HTML string - private async Task RenderTemplateAsync( - IFile templateFile, - PostPageDataModel model) - { - var templateContent = await templateFile.ReadTextAsync(); - - var template = Template.Parse(templateContent); - - if (template.HasErrors) - { - var errors = string.Join(Environment.NewLine, template.Messages); - throw new InvalidOperationException($"Template parsing failed:{Environment.NewLine}{errors}"); - } - - var html = template.Render(model); - - return html; - } - - #endregion - } -} diff --git a/src/Blog/PostPage/PostPageAssetFolder.cs b/src/Blog/PostPage/PostPageAssetFolder.cs deleted file mode 100644 index e35e52c..0000000 --- a/src/Blog/PostPage/PostPageAssetFolder.cs +++ /dev/null @@ -1,80 +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) - { - // 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; - } - } - } - } -} diff --git a/src/Blog/PostPage/PostPageFolder.cs b/src/Blog/PostPage/PostPageFolder.cs deleted file mode 100644 index 1f0d807..0000000 --- a/src/Blog/PostPage/PostPageFolder.cs +++ /dev/null @@ -1,135 +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 index 7701419..5ea0216 100644 --- a/src/Commands/Blog/PostPage/PagesCommand.cs +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -86,11 +86,19 @@ private async Task ExecuteAsync( // 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(), - InclusionStrategy = new ReferenceOnlyInclusionStrategy() + 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. @@ -98,22 +106,24 @@ private async Task ExecuteAsync( { // Get path to markdown page folder (mirrors original source file without extension) var relativePathToPagesPageFolder = await pagesFolder.GetRelativePathToAsync(pageFolder); - var pageOutputFolder = await outputFolder.CreateFoldersAlongRelativePathAsync(relativePathToPagesPageFolder, overwrite: true).LastAsync(); + 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)) { - // Get relative path from page folder (not pagesFolder root) - string pageFolderFileRelativePath = await pageFolder.GetRelativePathToAsync(indexFile); - // Create folders relative to THIS page's output folder, then copy - var containingFolder = (IModifiableFolder)await pageOutputFolder.CreateFoldersAlongRelativePathAsync(pageFolderFileRelativePath, overwrite: false).LastAsync(); - var copiedIndexFile = await containingFolder.CreateCopyOfAsync(indexFile, overwrite: true); + var copiedIndexFile = await pageOutputFolder.CreateCopyOfAsync(indexFile, overwrite: true); // Copy all assets referenced in index.html to the rewritten asset path - foreach (var asset in indexFile.IncludedAssets) + // Logger.LogInformation($"Included: {indexFile.Assets.Count}"); + foreach (var asset in indexFile.Assets) { - var assetOutputFolder = (IModifiableFolder)await copiedIndexFile.CreateFoldersAlongRelativePathAsync(asset.RewrittenPath, overwrite: false).LastAsync(); + 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); } } 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 60f90b9..c4b1a2f 100644 --- a/src/Commands/Blog/WacsdkBlogCommands.cs +++ b/src/Commands/Blog/WacsdkBlogCommands.cs @@ -17,7 +17,7 @@ public WacsdkBlogCommands() : base("blog", "Blog generation commands") { // Register Post/Page scenario - AddCommand(new PostPageCommand()); + AddCommand(new PageCommand()); // Register Pages scenario AddCommand(new PagesCommand()); 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 index dab7a46..b737d1e 100644 --- a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs +++ b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs @@ -94,7 +94,7 @@ await templateWriter.WriteAsync(@" { LinkDetector = new RegexAssetLinkDetector(), Resolver = new RelativePathAssetResolver(), - InclusionStrategy = new ReferenceOnlyInclusionStrategy() + AssetStrategy = new ReferenceOnlyAssetStrategy() }; } @@ -174,9 +174,9 @@ public async Task AssetPathResolution_ResolvesValidPaths() } [TestMethod] - public async Task InclusionStrategy_AppliesReferenceDecisions() + public async Task AssetStrategy_AppliesReferenceDecisions() { - var strategy = new ReferenceOnlyInclusionStrategy(); + var strategy = new ReferenceOnlyAssetStrategy(); Assert.IsNotNull(_page1File, "page1.md should exist"); Assert.IsNotNull(_logoFile, "logo.png should exist");