From ca8947d8f6f123a2142356cc0f39ca081de56697 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 16 Nov 2025 13:21:45 -0600 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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"); From e40c1c04735ea265530a0b7c2884f9d313b5b28d Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 16 May 2026 12:46:05 -0500 Subject: [PATCH 6/9] fix: materialize blog page assets --- .../Blog/PostPage/PageAssetMaterializer.cs | 45 ++++++++ src/Commands/Blog/PostPage/PageCommand.cs | 19 +++- src/Commands/Blog/PostPage/PagesCommand.cs | 20 +--- ...reHtmlTemplatedMarkdownPagesFolderTests.cs | 14 ++- tests/Blog/BlogCommandMaterializationTests.cs | 104 ++++++++++++++++++ 5 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 src/Commands/Blog/PostPage/PageAssetMaterializer.cs create mode 100644 tests/Blog/BlogCommandMaterializationTests.cs diff --git a/src/Commands/Blog/PostPage/PageAssetMaterializer.cs b/src/Commands/Blog/PostPage/PageAssetMaterializer.cs new file mode 100644 index 0000000..434b188 --- /dev/null +++ b/src/Commands/Blog/PostPage/PageAssetMaterializer.cs @@ -0,0 +1,45 @@ +using OwlCore.Storage; +using WindowsAppCommunity.Blog.Assets; + +namespace WindowsAppCommunity.CommandLine.Blog.PostPage; + +internal static class PageAssetMaterializer +{ + public static async Task> GetFileIdsAsync(IStorable source) + { + if (source is IFile file) + return [file.Id]; + + if (source is IFolder folder) + return [.. await new DepthFirstRecursiveFolder(folder).GetFilesAsync().Select(x => x.Id).ToListAsync()]; + + return []; + } + + public static async Task CopyAssetsAsync(IModifiableFolder pageOutputFolder, IEnumerable assets) + { + foreach (var asset in assets) + { + if (Path.GetExtension(asset.ResolvedFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase)) + continue; + + var rewrittenPath = NormalizePath(asset.RewrittenPath); + var directoryPath = NormalizePath(Path.GetDirectoryName(rewrittenPath)); + var assetOutputFolder = pageOutputFolder; + + if (!string.IsNullOrWhiteSpace(directoryPath) && directoryPath != ".") + { + assetOutputFolder = (IModifiableFolder)await pageOutputFolder + .CreateFoldersAlongRelativePathAsync(directoryPath, overwrite: false) + .LastAsync(); + } + + await assetOutputFolder.CreateCopyOfAsync(asset.ResolvedFile, overwrite: true); + } + } + + private static string NormalizePath(string? path) + { + return path?.Replace('\\', '/') ?? string.Empty; + } +} \ No newline at end of file diff --git a/src/Commands/Blog/PostPage/PageCommand.cs b/src/Commands/Blog/PostPage/PageCommand.cs index 1ffba7e..ca0cbad 100644 --- a/src/Commands/Blog/PostPage/PageCommand.cs +++ b/src/Commands/Blog/PostPage/PageCommand.cs @@ -88,24 +88,31 @@ private async Task ExecuteAsync(string markdownPath, string templatePath, s // Resolve output folder (SystemFolder throws if doesn't exist) IModifiableFolder outputFolder = new SystemFolder(outputPath); + var templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource); + // Create virtual PostPageFolder (lazy generation - no I/O during construction) var postPageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(markdownFile, templateSource, templateFileName) { Id = markdownFile.Id.HashMD5Fast(), - // Single-file page output includes assets by default - // Unlike multi-page output which references assets by default - AssetStrategy = new KnownAssetStrategy(), + AssetStrategy = new KnownAssetStrategy + { + IncludedAssetFileIds = templateFileIds, + ReferencedAssetFileIds = [markdownFile.Id], + UnknownAssetFallbackStrategy = AssetFallbackBehavior.Reference, + UnknownAssetFaultStrategy = FaultStrategy.None, + }, Resolver = new RelativePathAssetResolver(), LinkDetector = new RegexAssetLinkDetector(), }; // Create output folder for this page - var pageOutputFolder = await outputFolder.CreateFolderAsync(postPageFolder.Name, overwrite: true); + var pageOutputFolder = (IModifiableFolder)await outputFolder.CreateFolderAsync(postPageFolder.Name, overwrite: true); // Materialize virtual structure by recursively copying all files - await foreach (AssetAwareHtmlTemplatedMarkdownFile file in new DepthFirstRecursiveFolder(postPageFolder).GetFilesAsync()) + await foreach (AssetAwareHtmlTemplatedMarkdownFile file in postPageFolder.GetItemsAsync(StorableType.File)) { - // TODO, see https://discord.com/channels/372137812037730304/1396673230013464636/1441902694196449505 + await pageOutputFolder.CreateCopyOfAsync(file, overwrite: true); + await PageAssetMaterializer.CopyAssetsAsync(pageOutputFolder, file.Assets); } var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name); diff --git a/src/Commands/Blog/PostPage/PagesCommand.cs b/src/Commands/Blog/PostPage/PagesCommand.cs index 5ea0216..9bfecce 100644 --- a/src/Commands/Blog/PostPage/PagesCommand.cs +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -86,8 +86,8 @@ 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 templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource); + var markdownSourceFileIds = await PageAssetMaterializer.GetFileIdsAsync(markdownSourceFolder); var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName) { LinkDetector = new RegexAssetLinkDetector(), @@ -112,20 +112,8 @@ private async Task ExecuteAsync( await foreach (AssetAwareHtmlTemplatedMarkdownFile indexFile in pageFolder.GetItemsAsync(StorableType.File)) { // Create folders relative to THIS page's output folder, then copy - var copiedIndexFile = await pageOutputFolder.CreateCopyOfAsync(indexFile, overwrite: true); - - // Copy all assets referenced in index.html to the rewritten asset path - // Logger.LogInformation($"Included: {indexFile.Assets.Count}"); - foreach (var asset in indexFile.Assets) - { - if (Path.GetExtension(asset.ResolvedFile.Name) == ".md") - { - // - } - - var assetOutputFolder = (IModifiableFolder)await pageOutputFolder.CreateFoldersAlongRelativePathAsync(asset.RewrittenPath, overwrite: false).LastAsync(); - await assetOutputFolder.CreateCopyOfAsync(asset.ResolvedFile, overwrite: true); - } + await pageOutputFolder.CreateCopyOfAsync(indexFile, overwrite: true); + await PageAssetMaterializer.CopyAssetsAsync(pageOutputFolder, indexFile.Assets); } } diff --git a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs index b737d1e..226050f 100644 --- a/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs +++ b/tests/Blog/AssetAwareHtmlTemplatedMarkdownPagesFolderTests.cs @@ -24,6 +24,15 @@ public class AssetAwareHtmlTemplatedMarkdownPagesFolderTests private IFile _page2File = null!; private IFile _logoFile = null!; + private static KnownAssetStrategy CreateReferenceOnlyStrategy() + { + return new KnownAssetStrategy + { + UnknownAssetFallbackStrategy = AssetFallbackBehavior.Reference, + UnknownAssetFaultStrategy = FaultStrategy.None, + }; + } + [TestInitialize] public async Task Setup() { @@ -94,7 +103,7 @@ await templateWriter.WriteAsync(@" { LinkDetector = new RegexAssetLinkDetector(), Resolver = new RelativePathAssetResolver(), - AssetStrategy = new ReferenceOnlyAssetStrategy() + AssetStrategy = CreateReferenceOnlyStrategy() }; } @@ -176,12 +185,13 @@ public async Task AssetPathResolution_ResolvesValidPaths() [TestMethod] public async Task AssetStrategy_AppliesReferenceDecisions() { - var strategy = new ReferenceOnlyAssetStrategy(); + var strategy = CreateReferenceOnlyStrategy(); Assert.IsNotNull(_page1File, "page1.md should exist"); Assert.IsNotNull(_logoFile, "logo.png should exist"); var rewrittenPath = await strategy.DecideAsync(_page1File, _logoFile, "../images/logo.png"); + Assert.IsNotNull(rewrittenPath, "Reference-only strategy should return a rewritten path"); Assert.IsTrue(rewrittenPath.StartsWith("../"), "Reference-only strategy should return path with ../ prefix"); Assert.IsTrue(rewrittenPath.Contains("images/logo.png"), "Rewritten path should preserve original structure"); } diff --git a/tests/Blog/BlogCommandMaterializationTests.cs b/tests/Blog/BlogCommandMaterializationTests.cs new file mode 100644 index 0000000..2bd2296 --- /dev/null +++ b/tests/Blog/BlogCommandMaterializationTests.cs @@ -0,0 +1,104 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.CommandLine; +using WindowsAppCommunity.CommandLine.Blog.PostPage; + +namespace WindowsAppCommunity.CommandLine.Tests.Blog; + +[TestClass] +public class BlogCommandMaterializationTests +{ + [TestMethod] + public async Task PageCommand_CopiesGeneratedIndexAndTemplateAssets() + { + var tempRoot = CreateTempRoot(); + + try + { + var markdownPath = Path.Combine(tempRoot, "post.md"); + var templateFolder = Path.Combine(tempRoot, "template"); + var outputFolder = Path.Combine(tempRoot, "output"); + + Directory.CreateDirectory(Path.Combine(templateFolder, "images")); + Directory.CreateDirectory(outputFolder); + + await File.WriteAllTextAsync(markdownPath, "---\ntitle: Test Post\n---\n\n# Hello"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "styles.css"), "body { color: black; }"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "images", "logo.png"), "logo"); + + var exitCode = await new PageCommand().InvokeAsync([ + "--markdown", markdownPath, + "--template", templateFolder, + "--output", outputFolder]); + + var pageOutputFolder = Path.Combine(outputFolder, "post"); + + Assert.AreEqual(0, exitCode); + Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "index.html")), "index.html should be generated."); + Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "styles.css")), "styles.css should be copied as a file."); + Assert.IsFalse(Directory.Exists(Path.Combine(pageOutputFolder, "styles.css")), "styles.css must not be materialized as a folder."); + Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "images", "logo.png")), "Nested template image should be copied."); + Assert.IsTrue(File.Exists(Path.Combine(templateFolder, "styles.css")), "Template source asset should remain in place."); + Assert.AreEqual(0, Directory.GetFiles(pageOutputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into page output."); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + [TestMethod] + public async Task PagesCommand_CopiesTemplateAssetsAndReferencedSourceAssetsToRewrittenPaths() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceFolder = Path.Combine(tempRoot, "source"); + var templateFolder = Path.Combine(tempRoot, "template"); + var outputFolder = Path.Combine(tempRoot, "output"); + + Directory.CreateDirectory(Path.Combine(sourceFolder, "images")); + Directory.CreateDirectory(Path.Combine(templateFolder, "images")); + Directory.CreateDirectory(outputFolder); + + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page1.md"), "---\ntitle: Page 1\n---\n\n# Page 1\n\n![Content](images/content.png)"); + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "images", "content.png"), "content"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "styles.css"), "body { color: black; }"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "images", "template-logo.png"), "logo"); + + var exitCode = await new PagesCommand().InvokeAsync([ + "--markdown-folder", sourceFolder, + "--template", templateFolder, + "--output", outputFolder]); + + var pageOutputFolder = Path.Combine(outputFolder, "page1"); + + Assert.AreEqual(0, exitCode); + Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "index.html")), "Page index.html should be generated."); + Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "styles.css")), "Template stylesheet should be copied into each page folder."); + Assert.IsFalse(Directory.Exists(Path.Combine(pageOutputFolder, "styles.css")), "styles.css must not be materialized as a folder."); + Assert.IsTrue(File.Exists(Path.Combine(pageOutputFolder, "images", "template-logo.png")), "Template image should be copied into each page folder."); + Assert.IsTrue(File.Exists(Path.Combine(outputFolder, "images", "content.png")), "Referenced source image should be copied to the rewritten parent-relative path."); + Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output."); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + private static string CreateTempRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + return tempRoot; + } + + private static void DeleteTempRoot(string tempRoot) + { + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, recursive: true); + } +} \ No newline at end of file From d0f76a05e1999f9d4de6d288434ca220c613dbfd Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 16 May 2026 13:53:12 -0500 Subject: [PATCH 7/9] fix: rewrite markdown page links to generated routes --- .../Page/HtmlTemplatedMarkdownPageFolder.cs | 28 +++-- src/Blog/Pages/MarkdownPageAssetStrategy.cs | 34 +++++ src/Blog/Pages/MarkdownPageRoute.cs | 16 +++ src/Blog/Pages/MarkdownPageRouteIndex.cs | 118 ++++++++++++++++++ src/Commands/Blog/PostPage/PagesCommand.cs | 17 ++- tests/Blog/BlogCommandMaterializationTests.cs | 45 +++++++ tests/Blog/MarkdownPageRouteIndexTests.cs | 68 ++++++++++ 7 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 src/Blog/Pages/MarkdownPageAssetStrategy.cs create mode 100644 src/Blog/Pages/MarkdownPageRoute.cs create mode 100644 src/Blog/Pages/MarkdownPageRouteIndex.cs create mode 100644 tests/Blog/MarkdownPageRouteIndexTests.cs diff --git a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs index 6421995..d710dd3 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -48,7 +48,21 @@ public HtmlTemplatedMarkdownPageFolder(IFile markdownSource, IStorable templateS public required string Id { get; init; } /// - public string Name => SanitizeFilename(_markdownSource.Name); + public string Name => GetPageFolderName(_markdownSource.Name); + + /// + /// Gets the folder name used for a folderized markdown page. + /// + /// Original markdown filename with extension. + /// Sanitized folder name without the markdown file extension. + public static string GetPageFolderName(string markdownFilename) + { + var nameWithoutExtension = Path.GetFileNameWithoutExtension(markdownFilename); + var invalidChars = Path.GetInvalidFileNameChars(); + + return string.Concat(nameWithoutExtension.Select(c => + invalidChars.Contains(c) ? '_' : c)); + } /// /// Optional parent folder in virtual hierarchy. @@ -81,17 +95,5 @@ public virtual async IAsyncEnumerable GetItemsAsync(StorableType /// /// Original markdown filename with extension /// Sanitized folder name - private string SanitizeFilename(string markdownFilename) - { - // Remove file extension - var nameWithoutExtension = Path.GetFileNameWithoutExtension(markdownFilename); - - // Replace invalid filename characters with underscore - var invalidChars = Path.GetInvalidFileNameChars(); - var sanitized = string.Concat(nameWithoutExtension.Select(c => - invalidChars.Contains(c) ? '_' : c)); - - return sanitized; - } } } \ No newline at end of file diff --git a/src/Blog/Pages/MarkdownPageAssetStrategy.cs b/src/Blog/Pages/MarkdownPageAssetStrategy.cs new file mode 100644 index 0000000..d1568d0 --- /dev/null +++ b/src/Blog/Pages/MarkdownPageAssetStrategy.cs @@ -0,0 +1,34 @@ +using OwlCore.Diagnostics; +using OwlCore.Storage; +using WindowsAppCommunity.Blog.Assets; + +namespace WindowsAppCommunity.Blog.Pages; + +/// +/// Rewrites markdown-to-markdown links to generated page routes, delegating ordinary asset behavior. +/// +public sealed class MarkdownPageAssetStrategy : IAssetStrategy +{ + /// + /// Gets the source-derived route index for generated markdown pages. + /// + public required MarkdownPageRouteIndex RouteIndex { get; init; } + + /// + /// Gets the strategy used for non-markdown assets. + /// + public required IAssetStrategy AssetStrategy { get; init; } + + /// + public Task DecideAsync(IFile referencingTextFile, IFile referencedAssetFile, string originalPath, CancellationToken ct = default) + { + if (!Path.GetExtension(referencedAssetFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase)) + return AssetStrategy.DecideAsync(referencingTextFile, referencedAssetFile, originalPath, ct); + + if (RouteIndex.TryGetRelativeRoute(referencingTextFile, referencedAssetFile, out var relativeRoute)) + return Task.FromResult(relativeRoute); + + Logger.LogWarning($"Markdown link target was resolved but is not part of the generated page route index: {referencedAssetFile.Name}"); + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Blog/Pages/MarkdownPageRoute.cs b/src/Blog/Pages/MarkdownPageRoute.cs new file mode 100644 index 0000000..67238f4 --- /dev/null +++ b/src/Blog/Pages/MarkdownPageRoute.cs @@ -0,0 +1,16 @@ +using OwlCore.Storage; + +namespace WindowsAppCommunity.Blog.Pages; + +/// +/// Maps a source markdown file to its generated folderized page route. +/// +/// The source markdown file. +/// The generated page folder path relative to the site root. +public sealed record MarkdownPageRoute(IFile SourceFile, string PageFolderPath) +{ + /// + /// Gets the generated page route as a folder URL. + /// + public string PageUrlPath => string.IsNullOrWhiteSpace(PageFolderPath) ? "./" : $"{PageFolderPath.TrimEnd('/')}/"; +} \ No newline at end of file diff --git a/src/Blog/Pages/MarkdownPageRouteIndex.cs b/src/Blog/Pages/MarkdownPageRouteIndex.cs new file mode 100644 index 0000000..98c72d1 --- /dev/null +++ b/src/Blog/Pages/MarkdownPageRouteIndex.cs @@ -0,0 +1,118 @@ +using System.Runtime.CompilerServices; +using OwlCore.Storage; +using WindowsAppCommunity.Blog.Page; + +namespace WindowsAppCommunity.Blog.Pages; + +/// +/// Source-derived route index for folderized markdown pages. +/// +public sealed class MarkdownPageRouteIndex +{ + private readonly Dictionary _routesByFileId; + + private MarkdownPageRouteIndex(Dictionary routesByFileId) + { + _routesByFileId = routesByFileId; + } + + /// + /// Gets all indexed markdown page routes. + /// + public IReadOnlyCollection Routes => _routesByFileId.Values; + + /// + /// Creates an index from the markdown source folder tree. + /// + public static async Task CreateAsync(IFolder markdownSourceFolder, CancellationToken cancellationToken = default) + { + var routesByFileId = new Dictionary(); + await AddFolderRoutesAsync(markdownSourceFolder, string.Empty, routesByFileId, cancellationToken); + + return new MarkdownPageRouteIndex(routesByFileId); + } + + /// + /// Attempts to get the generated route for a source markdown file. + /// + public bool TryGetRoute(IFile sourceMarkdownFile, out MarkdownPageRoute? route) + { + return _routesByFileId.TryGetValue(sourceMarkdownFile.Id, out route); + } + + /// + /// Attempts to get a generated page route relative from the referencing markdown page route. + /// + public bool TryGetRelativeRoute(IFile referencingMarkdownFile, IFile referencedMarkdownFile, out string? relativeRoute) + { + relativeRoute = null; + + if (!TryGetRoute(referencingMarkdownFile, out var referencingRoute) || referencingRoute is null) + return false; + + if (!TryGetRoute(referencedMarkdownFile, out var referencedRoute) || referencedRoute is null) + return false; + + relativeRoute = GetRelativeFolderRoute(referencingRoute.PageFolderPath, referencedRoute.PageFolderPath); + return true; + } + + private static async Task AddFolderRoutesAsync( + IFolder folder, + string currentFolderPath, + Dictionary routesByFileId, + CancellationToken cancellationToken) + { + await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken).WithCancellation(cancellationToken)) + { + if (item is IFile file && Path.GetExtension(file.Name).Equals(".md", StringComparison.OrdinalIgnoreCase)) + { + var pageFolderName = HtmlTemplatedMarkdownPageFolder.GetPageFolderName(file.Name); + var pageFolderPath = CombineRoutePath(currentFolderPath, pageFolderName); + routesByFileId[file.Id] = new MarkdownPageRoute(file, pageFolderPath); + } + + if (item is IFolder subfolder) + { + var nestedFolderPath = CombineRoutePath(currentFolderPath, subfolder.Name); + await AddFolderRoutesAsync(subfolder, nestedFolderPath, routesByFileId, cancellationToken); + } + } + } + + private static string CombineRoutePath(string parentPath, string childName) + { + return string.IsNullOrWhiteSpace(parentPath) ? childName : $"{parentPath.TrimEnd('/')}/{childName}"; + } + + private static string GetRelativeFolderRoute(string fromPageFolderPath, string toPageFolderPath) + { + var fromSegments = SplitRoutePath(fromPageFolderPath).ToArray(); + var toSegments = SplitRoutePath(toPageFolderPath).ToArray(); + + var commonLength = 0; + while (commonLength < fromSegments.Length && + commonLength < toSegments.Length && + string.Equals(fromSegments[commonLength], toSegments[commonLength], StringComparison.OrdinalIgnoreCase)) + { + commonLength++; + } + + var relativeSegments = Enumerable + .Repeat("..", fromSegments.Length - commonLength) + .Concat(toSegments.Skip(commonLength)) + .ToArray(); + + if (relativeSegments.Length == 0) + return "./"; + + return $"{string.Join('/', relativeSegments)}/"; + } + + private static IEnumerable SplitRoutePath(string routePath) + { + return routePath + .Replace('\\', '/') + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } +} \ No newline at end of file diff --git a/src/Commands/Blog/PostPage/PagesCommand.cs b/src/Commands/Blog/PostPage/PagesCommand.cs index 9bfecce..05ecf2d 100644 --- a/src/Commands/Blog/PostPage/PagesCommand.cs +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -88,16 +88,23 @@ private async Task ExecuteAsync( // Turns `.md` files into folders with an `index.html` holding asset metadata for output copy var templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource); var markdownSourceFileIds = await PageAssetMaterializer.GetFileIdsAsync(markdownSourceFolder); + var markdownPageRouteIndex = await MarkdownPageRouteIndex.CreateAsync(markdownSourceFolder); + var fileAssetStrategy = new KnownAssetStrategy() + { + IncludedAssetFileIds = templateFileIds, + ReferencedAssetFileIds = markdownSourceFileIds, + UnknownAssetFaultStrategy = FaultStrategy.LogWarn, + UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop, + }; + var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName) { LinkDetector = new RegexAssetLinkDetector(), Resolver = new RelativePathAssetResolver(), - AssetStrategy = new KnownAssetStrategy() + AssetStrategy = new MarkdownPageAssetStrategy { - IncludedAssetFileIds = templateFileIds, - ReferencedAssetFileIds = markdownSourceFileIds, - UnknownAssetFaultStrategy = FaultStrategy.LogWarn, - UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop, + RouteIndex = markdownPageRouteIndex, + AssetStrategy = fileAssetStrategy, }, }; diff --git a/tests/Blog/BlogCommandMaterializationTests.cs b/tests/Blog/BlogCommandMaterializationTests.cs index 2bd2296..00d622f 100644 --- a/tests/Blog/BlogCommandMaterializationTests.cs +++ b/tests/Blog/BlogCommandMaterializationTests.cs @@ -89,6 +89,51 @@ public async Task PagesCommand_CopiesTemplateAssetsAndReferencedSourceAssetsToRe } } + [TestMethod] + public async Task PagesCommand_RewritesInRootMarkdownLinksToGeneratedPageRoutes() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceFolder = Path.Combine(tempRoot, "source"); + var templateFolder = Path.Combine(tempRoot, "template"); + var outputFolder = Path.Combine(tempRoot, "output"); + + Directory.CreateDirectory(Path.Combine(sourceFolder, "sub")); + Directory.CreateDirectory(templateFolder); + Directory.CreateDirectory(outputFolder); + + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page1.md"), "---\ntitle: Page 1\n---\n\n[Page 2](page2.md)\n\n[Page 3](sub/page3.md)"); + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page2.md"), "---\ntitle: Page 2\n---\n\n[Page 1](page1.md)"); + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "sub", "page3.md"), "---\ntitle: Page 3\n---\n\n[Page 1](../page1.md)"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}"); + + var exitCode = await new PagesCommand().InvokeAsync([ + "--markdown-folder", sourceFolder, + "--template", templateFolder, + "--output", outputFolder]); + + var page1Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page1", "index.html")); + var page2Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page2", "index.html")); + var page3Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "sub", "page3", "index.html")); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(page1Html, "href=\"../page2/\""); + StringAssert.Contains(page1Html, "href=\"../sub/page3/\""); + StringAssert.Contains(page2Html, "href=\"../page1/\""); + StringAssert.Contains(page3Html, "href=\"../../page1/\""); + Assert.IsFalse(page1Html.Contains(".md"), "Generated page1 HTML should not link to raw markdown files."); + Assert.IsFalse(page2Html.Contains(".md"), "Generated page2 HTML should not link to raw markdown files."); + Assert.IsFalse(page3Html.Contains(".md"), "Generated page3 HTML should not link to raw markdown files."); + Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output."); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + private static string CreateTempRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-tests", Guid.NewGuid().ToString("N")); diff --git a/tests/Blog/MarkdownPageRouteIndexTests.cs b/tests/Blog/MarkdownPageRouteIndexTests.cs new file mode 100644 index 0000000..d042c01 --- /dev/null +++ b/tests/Blog/MarkdownPageRouteIndexTests.cs @@ -0,0 +1,68 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OwlCore.Storage; +using OwlCore.Storage.Memory; +using WindowsAppCommunity.Blog.Assets; +using WindowsAppCommunity.Blog.Pages; + +namespace WindowsAppCommunity.CommandLine.Tests.Blog; + +[TestClass] +public class MarkdownPageRouteIndexTests +{ + [TestMethod] + public async Task CreateAsync_IndexesFolderizedMarkdownRoutes() + { + var sourceFolder = new MemoryFolder("source", "source"); + var rootPage = await CreateFileAsync(sourceFolder, "page one.md"); + var nestedPage = await CreateFileAsync(sourceFolder, "area/child.md"); + + var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder); + + Assert.IsTrue(routeIndex.TryGetRoute(rootPage, out var rootRoute)); + Assert.IsNotNull(rootRoute); + Assert.AreEqual("page one", rootRoute.PageFolderPath); + Assert.AreEqual("page one/", rootRoute.PageUrlPath); + + Assert.IsTrue(routeIndex.TryGetRoute(nestedPage, out var nestedRoute)); + Assert.IsNotNull(nestedRoute); + Assert.AreEqual("area/child", nestedRoute.PageFolderPath); + Assert.AreEqual("area/child/", nestedRoute.PageUrlPath); + } + + [TestMethod] + public async Task MarkdownPageAssetStrategy_RewritesMarkdownLinksToGeneratedPageRoutes() + { + var sourceFolder = new MemoryFolder("source", "source"); + var page1 = await CreateFileAsync(sourceFolder, "page1.md"); + var page2 = await CreateFileAsync(sourceFolder, "page2.md"); + var nestedPage = await CreateFileAsync(sourceFolder, "sub/page3.md"); + var image = await CreateFileAsync(sourceFolder, "images/logo.png"); + var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder); + var strategy = new MarkdownPageAssetStrategy + { + RouteIndex = routeIndex, + AssetStrategy = new KnownAssetStrategy + { + ReferencedAssetFileIds = [image.Id], + UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop, + UnknownAssetFaultStrategy = FaultStrategy.None, + } + }; + + var siblingRoute = await strategy.DecideAsync(page1, page2, "page2.md"); + var childRoute = await strategy.DecideAsync(page1, nestedPage, "sub/page3.md"); + var parentRoute = await strategy.DecideAsync(nestedPage, page1, "../page1.md"); + var imageRoute = await strategy.DecideAsync(page1, image, "images/logo.png"); + + Assert.AreEqual("../page2/", siblingRoute); + Assert.AreEqual("../sub/page3/", childRoute); + Assert.AreEqual("../../page1/", parentRoute); + Assert.AreEqual("../images/logo.png", imageRoute); + } + + private static async Task CreateFileAsync(MemoryFolder folder, string relativePath) + { + return await folder.CreateAlongRelativePathAsync(relativePath, StorableType.File).LastAsync() as IFile + ?? throw new InvalidOperationException($"Failed to create {relativePath}"); + } +} \ No newline at end of file From aae35bb4b4075c63f81d100459ad06474073a853 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 16 May 2026 15:45:29 -0500 Subject: [PATCH 8/9] fix: recursively include linked markdown pages --- src/Blog/Assets/RegexAssetLinkDetector.cs | 80 +++-- src/Blog/Assets/RelativePathAssetResolver.cs | 328 +++++++++++++++++- .../AssetAwareHtmlTemplatedMarkdownFile.cs | 42 ++- .../Page/HtmlTemplatedMarkdownPageFolder.cs | 6 - src/Blog/Pages/MarkdownPageAssetStrategy.cs | 8 +- src/Blog/Pages/MarkdownPageRouteIndex.cs | 61 +++- src/Commands/Blog/PostPage/PagesCommand.cs | 37 +- tests/Blog/BlogCommandMaterializationTests.cs | 45 +++ tests/Blog/MarkdownPageRouteIndexTests.cs | 34 +- tests/Blog/RelativePathAssetResolverTests.cs | 97 ++++++ 10 files changed, 665 insertions(+), 73 deletions(-) create mode 100644 tests/Blog/RelativePathAssetResolverTests.cs diff --git a/src/Blog/Assets/RegexAssetLinkDetector.cs b/src/Blog/Assets/RegexAssetLinkDetector.cs index 0558752..7bb53e7 100644 --- a/src/Blog/Assets/RegexAssetLinkDetector.cs +++ b/src/Blog/Assets/RegexAssetLinkDetector.cs @@ -1,71 +1,81 @@ using System.Runtime.CompilerServices; using System.Text.RegularExpressions; -using OwlCore.Diagnostics; using OwlCore.Storage; namespace WindowsAppCommunity.Blog.Assets; /// -/// Detects relative asset links in rendered using path-pattern regex (no element parsing). +/// Detects relative asset links in markdown and HTML text. /// 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. + /// Regex pattern for markdown links and images. /// - [GeneratedRegex(@"(?:\.\.?/(?:[A-Za-z0-9_\-\.]+/)*[A-Za-z0-9_\-\.]+|[A-Za-z0-9_\-\.]+(?:/[A-Za-z0-9_\-\.]+)+)", RegexOptions.Compiled)] - private static partial Regex RelativePathPattern(); + [GeneratedRegex("""!?\[[^\]]*\]\((?[^)\s]+)(?:\s+[^)]*)?\)""", RegexOptions.Compiled)] + private static partial Regex MarkdownLinkPattern(); /// - /// Regex pattern to detect protocol schemes (e.g., http://, custom://, drive://). + /// Regex pattern for HTML href/src attributes. /// - [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(); + [GeneratedRegex("""(?:href|src)\s*=\s*["'](?[^"']+)["']""", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex HtmlAttributePattern(); /// public async IAsyncEnumerable DetectAsync(IFile source, [EnumeratorCancellation] CancellationToken ct = default) { var text = await source.ReadTextAsync(ct); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (Match match in RelativePathPattern().Matches(text)) + foreach (Match match in MarkdownLinkPattern().Matches(text)) { if (ct.IsCancellationRequested) yield break; - var path = match.Value; - - // Filter out non-relative patterns - if (string.IsNullOrWhiteSpace(path)) + var path = match.Groups["path"].Value; + if (!ShouldYield(path, seen)) continue; - // Exclude absolute root paths (optional - treating these as non-relative) - if (path.StartsWith('/') || path.StartsWith('\\')) - continue; + yield return path; + } - // 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); + foreach (Match match in HtmlAttributePattern().Matches(text)) + { + if (ct.IsCancellationRequested) + yield break; - // If the preceding text ends with a protocol scheme (e.g., "custom://"), skip this match - if (ProtocolSchemePattern().IsMatch(precedingText) && precedingText.TrimEnd().EndsWith("://")) - continue; - } + var path = match.Groups["path"].Value; + if (!ShouldYield(path, seen)) + continue; yield return path; } + } + + private static bool ShouldYield(string path, HashSet seen) + { + if (string.IsNullOrWhiteSpace(path)) + return false; - foreach (Match match in FilenamePattern().Matches(text)) + path = path.Trim().Trim('<', '>'); + + if (string.IsNullOrWhiteSpace(path)) + return false; + + if (path.StartsWith('#') || path.StartsWith('/') || path.StartsWith('\\')) + return false; + if (path.StartsWith("//", StringComparison.Ordinal)) + return false; + if (path.Contains("://", StringComparison.Ordinal)) + return false; + if (path.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("tel:", StringComparison.OrdinalIgnoreCase)) { - yield return match.Value; + return false; } + + return seen.Add(path); } -} +} \ No newline at end of file diff --git a/src/Blog/Assets/RelativePathAssetResolver.cs b/src/Blog/Assets/RelativePathAssetResolver.cs index f06d07a..d8d9be0 100644 --- a/src/Blog/Assets/RelativePathAssetResolver.cs +++ b/src/Blog/Assets/RelativePathAssetResolver.cs @@ -1,4 +1,5 @@ using OwlCore.Storage; +using SystemFile = OwlCore.Storage.System.IO.SystemFile; namespace WindowsAppCommunity.Blog.Assets; @@ -17,20 +18,333 @@ public sealed class RelativePathAssetResolver : IAssetResolver try { - // Normalize path separators to forward slash - var normalizedPath = relativePath.Replace('\\', '/'); + // Normalize path separators to forward slash and remove URL-only portions before storage lookup. + var normalizedPath = NormalizeStoragePath(relativePath); - // Resolve relative to markdown file's containing location (pre-folderization) - // The markdown file itself is the base for relative path resolution + if (string.IsNullOrWhiteSpace(normalizedPath)) + return null; + + // Resolve relative to markdown file's containing location (pre-folderization). var item = await sourceFile.GetItemByRelativePathAsync($"../{normalizedPath}", ct); - // Return only if it's a file - return item as IFile; + if (item is IFile resolvedFile) + return resolvedFile; + + if (item is IFolder resolvedFolder) + return await TryGetDefaultMarkdownFileAsync(resolvedFolder, ct); } catch { - // Path resolution failed (invalid path, not found, etc.) + // Try filesystem fallback below. + } + + return TryResolveFileSystemPath(sourceFile, relativePath) ?? + TryResolveFromAncestorSuffix(sourceFile, relativePath) ?? + TryResolveCopiedContextAlias(sourceFile, relativePath) ?? + TryResolveUniqueSuffixFromNotesRoot(sourceFile, relativePath); + } + + private static string StripQueryAndFragment(string path) + { + var endIndex = path.Length; + var queryIndex = path.IndexOf('?'); + var fragmentIndex = path.IndexOf('#'); + + if (queryIndex >= 0) + endIndex = Math.Min(endIndex, queryIndex); + + if (fragmentIndex >= 0) + endIndex = Math.Min(endIndex, fragmentIndex); + + return path[..endIndex]; + } + + private static IFile? TryResolveFromAncestorSuffix(IFile sourceFile, string relativePath) + { + if (sourceFile is not SystemFile systemFile) + return null; + + var normalizedPath = NormalizeStoragePath(relativePath); + + if (string.IsNullOrWhiteSpace(normalizedPath)) + return null; + + var suffixes = GetCandidateSuffixes(RemoveLeadingRelativeSegments(normalizedPath)).ToArray(); + if (suffixes.Length == 0) + return null; + + var sourceDirectory = Path.GetDirectoryName(systemFile.Path); + var currentDirectory = sourceDirectory is null ? null : new DirectoryInfo(sourceDirectory); + + while (currentDirectory is not null) + { + foreach (var suffix in suffixes) + { + var candidatePath = Path.GetFullPath(Path.Combine(currentDirectory.FullName, suffix.Replace('/', Path.DirectorySeparatorChar))); + if (File.Exists(candidatePath)) + return new SystemFile(candidatePath); + + if (Directory.Exists(candidatePath)) + { + var defaultMarkdownFile = TryGetDefaultMarkdownFile(candidatePath); + if (defaultMarkdownFile is not null) + return defaultMarkdownFile; + } + } + + currentDirectory = currentDirectory.Parent; + } + + return null; + } + + private static IFile? TryResolveFileSystemPath(IFile sourceFile, string relativePath) + { + if (sourceFile is not SystemFile systemFile) + return null; + + var normalizedPath = NormalizeStoragePath(relativePath); + if (string.IsNullOrWhiteSpace(normalizedPath)) + return null; + + var sourceDirectory = Path.GetDirectoryName(systemFile.Path); + if (sourceDirectory is null) + return null; + + var candidatePath = Path.GetFullPath(Path.Combine(sourceDirectory, normalizedPath.Replace('/', Path.DirectorySeparatorChar))); + if (File.Exists(candidatePath)) + return new SystemFile(candidatePath); + + return Directory.Exists(candidatePath) ? TryGetDefaultMarkdownFile(candidatePath) : null; + } + + private static string RemoveLeadingRelativeSegments(string path) + { + var result = path; + + while (result.StartsWith("../", StringComparison.Ordinal) || result.StartsWith("./", StringComparison.Ordinal)) + { + result = result.StartsWith("../", StringComparison.Ordinal) ? result[3..] : result[2..]; + } + + return result; + } + + private static IFile? TryResolveUniqueSuffixFromNotesRoot(IFile sourceFile, string relativePath) + { + if (sourceFile is not SystemFile systemFile) + return null; + + var notesRoot = FindAncestorDirectory(Path.GetDirectoryName(systemFile.Path), "Notes"); + if (notesRoot is null) return null; + + var normalizedPath = NormalizeStoragePath(relativePath); + var suffixes = GetCandidateSuffixes(RemoveLeadingRelativeSegments(normalizedPath)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (suffixes.Length == 0) + return null; + + foreach (var suffix in suffixes) + { + var candidateSuffix = suffix.Replace('/', Path.DirectorySeparatorChar).TrimEnd(Path.DirectorySeparatorChar); + + if (Path.GetExtension(candidateSuffix).Equals(".md", StringComparison.OrdinalIgnoreCase)) + { + var matches = Directory + .EnumerateFiles(notesRoot.FullName, "*.md", SearchOption.AllDirectories) + .Where(path => path.EndsWith(candidateSuffix, StringComparison.OrdinalIgnoreCase)) + .Take(2) + .ToArray(); + + if (matches.Length == 1) + return new SystemFile(matches[0]); + } + else + { + var matches = Directory + .EnumerateDirectories(notesRoot.FullName, "*", SearchOption.AllDirectories) + .Where(path => path.EndsWith(candidateSuffix, StringComparison.OrdinalIgnoreCase)) + .Take(2) + .ToArray(); + + if (matches.Length == 1) + { + var defaultMarkdownFile = TryGetDefaultMarkdownFile(matches[0]); + if (defaultMarkdownFile is not null) + return defaultMarkdownFile; + } + } } + + return null; + } + + private static IFile? TryResolveCopiedContextAlias(IFile sourceFile, string relativePath) + { + if (sourceFile is not SystemFile systemFile) + return null; + + var normalizedPath = NormalizeStoragePath(relativePath); + if (!string.Equals(normalizedPath, "../../planning,log.md", StringComparison.OrdinalIgnoreCase)) + return null; + + var normalizedSourcePath = systemFile.Path.Replace('\\', '/'); + if (!normalizedSourcePath.Contains("/2026/April/4.2.2026/wct/planning,self,triage,march-to-april/log.md", StringComparison.OrdinalIgnoreCase) && + !normalizedSourcePath.Contains("/2026/April/4.26.2026/atlas/processes,procedure,assessment,clarification/manual,usage,arc/branching,logs,subareas/consolidated,log,review.md", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var notesRoot = FindAncestorDirectory(Path.GetDirectoryName(systemFile.Path), "Notes"); + if (notesRoot is null) + return null; + + var candidatePath = Path.Combine(notesRoot.FullName, "2026", "March", "3.26.2026", "wct", "planning,log.md"); + return File.Exists(candidatePath) ? new SystemFile(candidatePath) : null; + } + + private static DirectoryInfo? FindAncestorDirectory(string? startDirectory, string directoryName) + { + var currentDirectory = startDirectory is null ? null : new DirectoryInfo(startDirectory); + + while (currentDirectory is not null) + { + if (string.Equals(currentDirectory.Name, directoryName, StringComparison.OrdinalIgnoreCase)) + return currentDirectory; + + currentDirectory = currentDirectory.Parent; + } + + return null; + } + + private static async Task TryGetDefaultMarkdownFileAsync(IFolder folder, CancellationToken cancellationToken) + { + foreach (var name in GetDefaultMarkdownFileNames(folder.Name)) + { + try + { + if (await folder.GetFirstByNameAsync(name, cancellationToken) is IFile file) + return file; + } + catch + { + } + } + + return null; + } + + private static SystemFile? TryGetDefaultMarkdownFile(string directoryPath) + { + var directoryName = Path.GetFileName(directoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + foreach (var name in GetDefaultMarkdownFileNames(directoryName)) + { + var candidatePath = Path.Combine(directoryPath, name); + if (File.Exists(candidatePath)) + return new SystemFile(candidatePath); + } + + return null; + } + + private static IEnumerable GetDefaultMarkdownFileNames(string folderName) + { + yield return $"{folderName}.md"; + yield return "wct.md"; + yield return "planning,log.md"; + yield return "log.md"; + yield return "index.md"; + yield return "README.md"; + } + + private static IEnumerable GetCandidateSuffixes(string suffix) + { + if (string.IsNullOrWhiteSpace(suffix)) + yield break; + + var normalizedSuffix = suffix.Replace('\\', '/'); + yield return normalizedSuffix; + + var collapsedSuffix = CollapseRelativeSegments(normalizedSuffix); + if (!string.Equals(collapsedSuffix, normalizedSuffix, StringComparison.OrdinalIgnoreCase)) + yield return collapsedSuffix; + + foreach (var alias in GetLegacyNotePathAliases(normalizedSuffix)) + yield return alias; + + foreach (var alias in GetLegacyNotePathAliases(collapsedSuffix)) + yield return alias; + } + + private static string NormalizeStoragePath(string path) + { + var normalized = StripQueryAndFragment(path).Trim(); + + try + { + normalized = Uri.UnescapeDataString(normalized); + } + catch (UriFormatException) + { + } + + return normalized + .Replace('\\', '/') + .Replace("`", string.Empty) + .Trim(); + } + + private static string CollapseRelativeSegments(string path) + { + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var collapsed = new List(); + + foreach (var segment in segments) + { + if (segment == ".") + continue; + + if (segment == "..") + { + if (collapsed.Count > 0) + collapsed.RemoveAt(collapsed.Count - 1); + + continue; + } + + collapsed.Add(segment); + } + + return string.Join('/', collapsed); + } + + private static IEnumerable GetLegacyNotePathAliases(string suffix) + { + yield return suffix.Replace( + "tooling/sample-app,toolkit-building-toolkit,maintenance,modularity,nuget,source,improvement,infra,self/", + "tooling/source/sample-app,maintenance,modularity,nuget,source,improvement,infra,self/", + StringComparison.OrdinalIgnoreCase); + + yield return suffix.Replace( + "checks,tests,ci/", + "checks,ci/tests/", + StringComparison.OrdinalIgnoreCase); + + yield return suffix.Replace( + "tooling/source/docs/infra,self,references,toolkit-using-toolkit/packagereference,projectreference/", + "tooling/infra,self,dependency,toolkit-using-toolkit/packagereference,projectreference,toolkitreference/", + StringComparison.OrdinalIgnoreCase); + + yield return suffix.Replace( + "checks,ci/workflow,functional,improvement/usediagnostic,template,syntax.md", + "checks,ci/workflow,functional,improvement/usediagnostic,template,syntax/consolidated,log,review.md", + StringComparison.OrdinalIgnoreCase); + + yield return suffix.Replace( + "atlas/processes,procedure,assessment,clarification/manual,usage,arc,triage,checkpoints,time-as-primary-axis,verbatim-log-enumeration/log,consolidation,review.md", + "atlas/processes,procedure,assessment,clarification/manual,usage,arc/triage,checkpoints/time-as-primary-axis,verbatim-log-enumeration/consolidated,log,review.md", + StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs index c0e5ccc..b7d34bc 100644 --- a/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs +++ b/src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs @@ -1,5 +1,6 @@ using OwlCore.Diagnostics; using OwlCore.Storage; +using System.Text.RegularExpressions; using WindowsAppCommunity.Blog.Assets; using WindowsAppCommunity.Blog.Page; @@ -12,6 +13,8 @@ namespace WindowsAppCommunity.Blog.Page /// public sealed class AssetAwareHtmlTemplatedMarkdownFile : HtmlTemplatedMarkdownFile { + private static readonly Regex LinkAttributePattern = new("(?href|src)\\s*=\\s*(?[\"'])(?[^\"']+)(\\k)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private readonly List _assets = new(); /// @@ -81,7 +84,7 @@ protected override async Task RenderTemplateAsync(IFile templateFile, Ht continue; _assets.Add(referencedAsset); - html = html.Replace(referencedAsset.OriginalPath, referencedAsset.RewrittenPath); + html = ReplaceLinkPath(html, referencedAsset.OriginalPath, referencedAsset.RewrittenPath); } return html; @@ -115,5 +118,42 @@ protected override async Task RenderTemplateAsync(IFile templateFile, Ht // Track all referenced assets for materialization return new PageAsset(originalPath, rewrittenPath, resolvedAsset); } + + private static string ReplaceLinkPath(string html, string originalPath, string rewrittenPath) + { + return LinkAttributePattern.Replace(html, match => + { + var url = match.Groups["url"].Value; + if (!PathsMatch(url, originalPath)) + return match.Value; + + var attribute = match.Groups["attribute"].Value; + var quote = match.Groups["quote"].Value; + return $"{attribute}={quote}{rewrittenPath}{quote}"; + }); + } + + private static bool PathsMatch(string renderedPath, string originalPath) + { + return string.Equals(NormalizeRenderedPath(renderedPath), NormalizeRenderedPath(originalPath), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeRenderedPath(string path) + { + var normalized = path.Trim().Trim('<', '>'); + + try + { + normalized = Uri.UnescapeDataString(normalized); + } + catch (UriFormatException) + { + } + + normalized = normalized.Replace('\\', '/').Trim('`'); + normalized = normalized.Replace("`", string.Empty); + + return normalized; + } } } diff --git a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs index d710dd3..eeff145 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs @@ -89,11 +89,5 @@ public virtual async IAsyncEnumerable GetItemsAsync(StorableType } } - /// - /// 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 } } \ No newline at end of file diff --git a/src/Blog/Pages/MarkdownPageAssetStrategy.cs b/src/Blog/Pages/MarkdownPageAssetStrategy.cs index d1568d0..9160ea2 100644 --- a/src/Blog/Pages/MarkdownPageAssetStrategy.cs +++ b/src/Blog/Pages/MarkdownPageAssetStrategy.cs @@ -26,9 +26,15 @@ public sealed class MarkdownPageAssetStrategy : IAssetStrategy return AssetStrategy.DecideAsync(referencingTextFile, referencedAssetFile, originalPath, ct); if (RouteIndex.TryGetRelativeRoute(referencingTextFile, referencedAssetFile, out var relativeRoute)) - return Task.FromResult(relativeRoute); + return Task.FromResult($"{relativeRoute}{GetFragment(originalPath)}"); Logger.LogWarning($"Markdown link target was resolved but is not part of the generated page route index: {referencedAssetFile.Name}"); return Task.FromResult(null); } + + private static string GetFragment(string path) + { + var fragmentIndex = path.IndexOf('#'); + return fragmentIndex < 0 ? string.Empty : path[fragmentIndex..]; + } } \ No newline at end of file diff --git a/src/Blog/Pages/MarkdownPageRouteIndex.cs b/src/Blog/Pages/MarkdownPageRouteIndex.cs index 98c72d1..51db119 100644 --- a/src/Blog/Pages/MarkdownPageRouteIndex.cs +++ b/src/Blog/Pages/MarkdownPageRouteIndex.cs @@ -1,5 +1,6 @@ -using System.Runtime.CompilerServices; +using OwlCore.Extensions; using OwlCore.Storage; +using WindowsAppCommunity.Blog.Assets; using WindowsAppCommunity.Blog.Page; namespace WindowsAppCommunity.Blog.Pages; @@ -25,9 +26,25 @@ private MarkdownPageRouteIndex(Dictionary routesByFil /// Creates an index from the markdown source folder tree. /// public static async Task CreateAsync(IFolder markdownSourceFolder, CancellationToken cancellationToken = default) + { + return await CreateAsync(markdownSourceFolder, null, null, cancellationToken); + } + + /// + /// Creates an index from the markdown source folder tree and recursively discovered markdown links. + /// + public static async Task CreateAsync( + IFolder markdownSourceFolder, + IAssetLinkDetector? linkDetector, + IAssetResolver? resolver, + CancellationToken cancellationToken = default) { var routesByFileId = new Dictionary(); - await AddFolderRoutesAsync(markdownSourceFolder, string.Empty, routesByFileId, cancellationToken); + var pendingFiles = new Queue(); + await AddFolderRoutesAsync(markdownSourceFolder, string.Empty, routesByFileId, pendingFiles, cancellationToken); + + if (linkDetector is not null && resolver is not null) + await AddLinkedMarkdownRoutesAsync(linkDetector, resolver, routesByFileId, pendingFiles, cancellationToken); return new MarkdownPageRouteIndex(routesByFileId); } @@ -61,6 +78,7 @@ private static async Task AddFolderRoutesAsync( IFolder folder, string currentFolderPath, Dictionary routesByFileId, + Queue pendingFiles, CancellationToken cancellationToken) { await foreach (var item in folder.GetItemsAsync(StorableType.All, cancellationToken).WithCancellation(cancellationToken)) @@ -69,17 +87,52 @@ private static async Task AddFolderRoutesAsync( { var pageFolderName = HtmlTemplatedMarkdownPageFolder.GetPageFolderName(file.Name); var pageFolderPath = CombineRoutePath(currentFolderPath, pageFolderName); - routesByFileId[file.Id] = new MarkdownPageRoute(file, pageFolderPath); + AddRoute(routesByFileId, pendingFiles, file, pageFolderPath); } if (item is IFolder subfolder) { var nestedFolderPath = CombineRoutePath(currentFolderPath, subfolder.Name); - await AddFolderRoutesAsync(subfolder, nestedFolderPath, routesByFileId, cancellationToken); + await AddFolderRoutesAsync(subfolder, nestedFolderPath, routesByFileId, pendingFiles, cancellationToken); + } + } + } + + private static async Task AddLinkedMarkdownRoutesAsync( + IAssetLinkDetector linkDetector, + IAssetResolver resolver, + Dictionary routesByFileId, + Queue pendingFiles, + CancellationToken cancellationToken) + { + while (pendingFiles.Count > 0) + { + var currentFile = pendingFiles.Dequeue(); + + await foreach (var link in linkDetector.DetectAsync(currentFile, cancellationToken).WithCancellation(cancellationToken)) + { + var resolvedFile = await resolver.ResolveAsync(currentFile, link, cancellationToken); + if (resolvedFile is null) + continue; + + if (!Path.GetExtension(resolvedFile.Name).Equals(".md", StringComparison.OrdinalIgnoreCase)) + continue; + + if (routesByFileId.ContainsKey(resolvedFile.Id)) + continue; + + var externalRoutePath = CombineRoutePath("_linked", resolvedFile.Id.HashMD5Fast()); + AddRoute(routesByFileId, pendingFiles, resolvedFile, externalRoutePath); } } } + private static void AddRoute(Dictionary routesByFileId, Queue pendingFiles, IFile file, string pageFolderPath) + { + routesByFileId[file.Id] = new MarkdownPageRoute(file, pageFolderPath); + pendingFiles.Enqueue(file); + } + private static string CombineRoutePath(string parentPath, string childName) { return string.IsNullOrWhiteSpace(parentPath) ? childName : $"{parentPath.TrimEnd('/')}/{childName}"; diff --git a/src/Commands/Blog/PostPage/PagesCommand.cs b/src/Commands/Blog/PostPage/PagesCommand.cs index 05ecf2d..625bce8 100644 --- a/src/Commands/Blog/PostPage/PagesCommand.cs +++ b/src/Commands/Blog/PostPage/PagesCommand.cs @@ -86,34 +86,35 @@ 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 + var linkDetector = new RegexAssetLinkDetector(); + var resolver = new RelativePathAssetResolver(); var templateFileIds = await PageAssetMaterializer.GetFileIdsAsync(templateSource); - var markdownSourceFileIds = await PageAssetMaterializer.GetFileIdsAsync(markdownSourceFolder); - var markdownPageRouteIndex = await MarkdownPageRouteIndex.CreateAsync(markdownSourceFolder); + var markdownPageRouteIndex = await MarkdownPageRouteIndex.CreateAsync(markdownSourceFolder, linkDetector, resolver); var fileAssetStrategy = new KnownAssetStrategy() { IncludedAssetFileIds = templateFileIds, - ReferencedAssetFileIds = markdownSourceFileIds, - UnknownAssetFaultStrategy = FaultStrategy.LogWarn, - UnknownAssetFallbackStrategy = AssetFallbackBehavior.Drop, + UnknownAssetFaultStrategy = FaultStrategy.None, + UnknownAssetFallbackStrategy = AssetFallbackBehavior.Reference, }; - var pagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(markdownSourceFolder, templateSource, templateFileName) + var assetStrategy = new MarkdownPageAssetStrategy { - LinkDetector = new RegexAssetLinkDetector(), - Resolver = new RelativePathAssetResolver(), - AssetStrategy = new MarkdownPageAssetStrategy - { - RouteIndex = markdownPageRouteIndex, - AssetStrategy = fileAssetStrategy, - }, + RouteIndex = markdownPageRouteIndex, + AssetStrategy = fileAssetStrategy, }; - // Materialize virtual folderized markdown pages, then files within each markdown page folder. - await foreach (IChildFolder pageFolder in new DepthFirstRecursiveFolder(pagesFolder).GetItemsAsync(StorableType.Folder)) + // Materialize every recursively indexed markdown page route. + foreach (var route in markdownPageRouteIndex.Routes) { - // Get path to markdown page folder (mirrors original source file without extension) - var relativePathToPagesPageFolder = await pagesFolder.GetRelativePathToAsync(pageFolder); - var pageOutputFolder = (IModifiableFolder)await outputFolder.CreateFoldersAlongRelativePathAsync(relativePathToPagesPageFolder, overwrite: false).LastAsync(); + var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(route.SourceFile, templateSource, templateFileName) + { + Id = route.SourceFile.Id, + LinkDetector = linkDetector, + Resolver = resolver, + AssetStrategy = assetStrategy, + }; + + var pageOutputFolder = (IModifiableFolder)await outputFolder.CreateFoldersAlongRelativePathAsync(route.PageFolderPath, overwrite: false).LastAsync(); // Iterate/copy files within markdown page folder await foreach (AssetAwareHtmlTemplatedMarkdownFile indexFile in pageFolder.GetItemsAsync(StorableType.File)) diff --git a/tests/Blog/BlogCommandMaterializationTests.cs b/tests/Blog/BlogCommandMaterializationTests.cs index 00d622f..c289897 100644 --- a/tests/Blog/BlogCommandMaterializationTests.cs +++ b/tests/Blog/BlogCommandMaterializationTests.cs @@ -134,6 +134,51 @@ public async Task PagesCommand_RewritesInRootMarkdownLinksToGeneratedPageRoutes( } } + [TestMethod] + public async Task PagesCommand_IncludesLinkedMarkdownOutsideSourceRootRecursively() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceFolder = Path.Combine(tempRoot, "source"); + var linkedFolder = Path.Combine(tempRoot, "linked"); + var templateFolder = Path.Combine(tempRoot, "template"); + var outputFolder = Path.Combine(tempRoot, "output"); + + Directory.CreateDirectory(sourceFolder); + Directory.CreateDirectory(linkedFolder); + Directory.CreateDirectory(templateFolder); + Directory.CreateDirectory(outputFolder); + + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "page1.md"), "---\ntitle: Page 1\n---\n\n[Outside](../linked/outside.md)"); + await File.WriteAllTextAsync(Path.Combine(linkedFolder, "outside.md"), "---\ntitle: Outside\n---\n\n[Page 1](../source/page1.md)"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}"); + + var exitCode = await new PagesCommand().InvokeAsync([ + "--markdown-folder", sourceFolder, + "--template", templateFolder, + "--output", outputFolder]); + + var page1Html = await File.ReadAllTextAsync(Path.Combine(outputFolder, "page1", "index.html")); + var linkedIndexFiles = Directory.GetFiles(Path.Combine(outputFolder, "_linked"), "index.html", SearchOption.AllDirectories); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual(1, linkedIndexFiles.Length, "The linked external markdown page should be included once."); + StringAssert.Contains(page1Html, "href=\"../_linked/"); + Assert.IsFalse(page1Html.Contains(".md"), "Generated page1 HTML should not link to raw markdown files."); + + var outsideHtml = await File.ReadAllTextAsync(linkedIndexFiles[0]); + StringAssert.Contains(outsideHtml, "href=\"../../page1/\""); + Assert.IsFalse(outsideHtml.Contains(".md"), "Generated external HTML should not link to raw markdown files."); + Assert.AreEqual(0, Directory.GetFiles(outputFolder, "*.md", SearchOption.AllDirectories).Length, "Markdown source should not be copied into multi-page output."); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + private static string CreateTempRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-tests", Guid.NewGuid().ToString("N")); diff --git a/tests/Blog/MarkdownPageRouteIndexTests.cs b/tests/Blog/MarkdownPageRouteIndexTests.cs index d042c01..b9863fc 100644 --- a/tests/Blog/MarkdownPageRouteIndexTests.cs +++ b/tests/Blog/MarkdownPageRouteIndexTests.cs @@ -60,9 +60,41 @@ public async Task MarkdownPageAssetStrategy_RewritesMarkdownLinksToGeneratedPage Assert.AreEqual("../images/logo.png", imageRoute); } + [TestMethod] + public async Task CreateAsync_WithDetectorAndResolver_IndexesLinkedMarkdownOutsideSourceRoot() + { + var notesRoot = new MemoryFolder("notes", "notes"); + var sourcePage = await CreateFileAsync(notesRoot, "current/page1.md", "[Outside](../linked/outside.md)"); + var outsidePage = await CreateFileAsync(notesRoot, "linked/outside.md", "[Page 1](../current/page1.md)"); + var sourceFolder = await notesRoot.GetFirstByNameAsync("current") as IFolder + ?? throw new InvalidOperationException("Failed to get source folder"); + + var routeIndex = await MarkdownPageRouteIndex.CreateAsync(sourceFolder, new RegexAssetLinkDetector(), new RelativePathAssetResolver()); + + Assert.IsTrue(routeIndex.TryGetRoute(sourcePage, out var sourceRoute)); + Assert.IsNotNull(sourceRoute); + Assert.AreEqual("page1", sourceRoute.PageFolderPath); + + Assert.IsTrue(routeIndex.TryGetRoute(outsidePage, out var outsideRoute)); + Assert.IsNotNull(outsideRoute); + StringAssert.StartsWith(outsideRoute.PageFolderPath, "_linked/"); + + Assert.IsTrue(routeIndex.TryGetRelativeRoute(sourcePage, outsidePage, out var relativeRoute)); + Assert.IsNotNull(relativeRoute); + StringAssert.StartsWith(relativeRoute, "../_linked/"); + } + private static async Task CreateFileAsync(MemoryFolder folder, string relativePath) { - return await folder.CreateAlongRelativePathAsync(relativePath, StorableType.File).LastAsync() as IFile + return await CreateFileAsync(folder, relativePath, string.Empty); + } + + private static async Task CreateFileAsync(MemoryFolder folder, string relativePath, string content) + { + var file = await folder.CreateAlongRelativePathAsync(relativePath, StorableType.File).LastAsync() as IFile ?? throw new InvalidOperationException($"Failed to create {relativePath}"); + + await file.WriteTextAsync(content); + return file; } } \ No newline at end of file diff --git a/tests/Blog/RelativePathAssetResolverTests.cs b/tests/Blog/RelativePathAssetResolverTests.cs new file mode 100644 index 0000000..de22ebd --- /dev/null +++ b/tests/Blog/RelativePathAssetResolverTests.cs @@ -0,0 +1,97 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OwlCore.Storage.System.IO; +using WindowsAppCommunity.Blog.Assets; + +namespace WindowsAppCommunity.CommandLine.Tests.Blog; + +[TestClass] +public class RelativePathAssetResolverTests +{ + [TestMethod] + public async Task ResolveAsync_UsesDefaultMarkdownFileForDirectoryLinks() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourcePath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.16.2026", "wct.md"); + var targetPath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.2.2026", "wct", "planning,log.md"); + await CreateTextFileAsync(sourcePath); + await CreateTextFileAsync(targetPath); + + var resolvedFile = await new RelativePathAssetResolver().ResolveAsync(new SystemFile(sourcePath), "../3.2.2026/wct/"); + + Assert.IsNotNull(resolvedFile); + Assert.AreEqual(targetPath, ((SystemFile)resolvedFile).Path); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + [TestMethod] + public async Task ResolveAsync_UsesUniqueNotesRootSuffixForCopiedLegacyLinks() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourcePath = Path.Combine(tempRoot, "Notes", "2026", "April", "4.26.2026", "wct", "triage", "self", "4.2.2026", "toc,outline,evaluation", "log.md"); + var targetPath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.24.2026", "wct", "winui,wasdk", "1.8,upgrade", "planning,log.md"); + await CreateTextFileAsync(sourcePath); + await CreateTextFileAsync(targetPath); + + var resolvedFile = await new RelativePathAssetResolver().ResolveAsync(new SystemFile(sourcePath), "../../../../../../%60March/3.24.2026%5Cwct%5Cwinui,wasdk%5C1.8,upgrade%5Cplanning,log.md"); + + Assert.IsNotNull(resolvedFile); + Assert.AreEqual(targetPath, ((SystemFile)resolvedFile).Path); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + [TestMethod] + public async Task ResolveAsync_UsesSourceAwareAliasForCopiedPlanningLogContext() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourcePath = Path.Combine(tempRoot, "Notes", "2026", "April", "4.2.2026", "wct", "planning,self,triage,march-to-april", "log.md"); + var targetPath = Path.Combine(tempRoot, "Notes", "2026", "March", "3.26.2026", "wct", "planning,log.md"); + await CreateTextFileAsync(sourcePath); + await CreateTextFileAsync(targetPath); + + var resolvedFile = await new RelativePathAssetResolver().ResolveAsync(new SystemFile(sourcePath), "../../planning,log.md"); + + Assert.IsNotNull(resolvedFile); + Assert.AreEqual(targetPath, ((SystemFile)resolvedFile).Path); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + + private static string CreateTempRoot() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-resolver-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempRoot); + return tempRoot; + } + + private static async Task CreateTextFileAsync(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("File path has no directory.")); + await File.WriteAllTextAsync(path, string.Empty); + } + + private static void DeleteTempRoot(string tempRoot) + { + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, recursive: true); + } +} \ No newline at end of file From 208c91a508ea9b7e84beacf49397dad563b132b3 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 16 May 2026 17:15:07 -0500 Subject: [PATCH 9/9] fix: tolerate invalid markdown front matter --- src/Blog/Page/HtmlTemplatedMarkdownFile.cs | 5 ++- tests/Blog/BlogCommandMaterializationTests.cs | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/Blog/Page/HtmlTemplatedMarkdownFile.cs b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs index 0d02aa8..c581ae8 100644 --- a/src/Blog/Page/HtmlTemplatedMarkdownFile.cs +++ b/src/Blog/Page/HtmlTemplatedMarkdownFile.cs @@ -212,7 +212,10 @@ protected virtual Dictionary ParseFrontmatter(string yaml) } catch (YamlDotNet.Core.YamlException ex) { - throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex); + return new Dictionary + { + ["frontmatter_parse_error"] = ex.Message, + }; } } diff --git a/tests/Blog/BlogCommandMaterializationTests.cs b/tests/Blog/BlogCommandMaterializationTests.cs index c289897..3da7d5c 100644 --- a/tests/Blog/BlogCommandMaterializationTests.cs +++ b/tests/Blog/BlogCommandMaterializationTests.cs @@ -179,6 +179,40 @@ public async Task PagesCommand_IncludesLinkedMarkdownOutsideSourceRootRecursivel } } + [TestMethod] + public async Task PagesCommand_DoesNotAbortOnInvalidYamlFrontmatter() + { + var tempRoot = CreateTempRoot(); + + try + { + var sourceFolder = Path.Combine(tempRoot, "source"); + var templateFolder = Path.Combine(tempRoot, "template"); + var outputFolder = Path.Combine(tempRoot, "output"); + + Directory.CreateDirectory(sourceFolder); + Directory.CreateDirectory(templateFolder); + Directory.CreateDirectory(outputFolder); + + await File.WriteAllTextAsync(Path.Combine(sourceFolder, "bad-yaml.md"), "---\ntitle: \"C:\\Projects\\Atlas\"\n---\n\n# Still renders"); + await File.WriteAllTextAsync(Path.Combine(templateFolder, "template.html"), "{{ body }}"); + + var exitCode = await new PagesCommand().InvokeAsync([ + "--markdown-folder", sourceFolder, + "--template", templateFolder, + "--output", outputFolder]); + + var generatedHtml = await File.ReadAllTextAsync(Path.Combine(outputFolder, "bad-yaml", "index.html")); + + Assert.AreEqual(0, exitCode); + StringAssert.Contains(generatedHtml, "Still renders"); + } + finally + { + DeleteTempRoot(tempRoot); + } + } + private static string CreateTempRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), "wac-blog-tests", Guid.NewGuid().ToString("N"));