From d23e7d4a8279d24a7d5c69a3b543e768afe0f456 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sat, 25 Oct 2025 14:50:23 -0500 Subject: [PATCH 1/5] feat: Add Blog generator file structure for Post/Page scenario - Add WacsdkBlogCommands.cs aggregator - Add PostPage/PostPageCommand.cs CLI command - Add PostPage/PostPageGenerator.cs transformation logic - Add PostPage/PostPageDataModel.cs Scriban data contract Nested structure isolates scenarios, prevents interdependencies. Implementation separate from commands for reusability by future scenarios. --- src/Blog/PostPage/PostPageDataModel.cs | 0 src/Blog/PostPage/PostPageGenerator.cs | 0 src/Commands/Blog/PostPage/PostPageCommand.cs | 0 src/Commands/Blog/WacsdkBlogCommands.cs | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/Blog/PostPage/PostPageDataModel.cs create mode 100644 src/Blog/PostPage/PostPageGenerator.cs create mode 100644 src/Commands/Blog/PostPage/PostPageCommand.cs create mode 100644 src/Commands/Blog/WacsdkBlogCommands.cs diff --git a/src/Blog/PostPage/PostPageDataModel.cs b/src/Blog/PostPage/PostPageDataModel.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Blog/PostPage/PostPageGenerator.cs b/src/Blog/PostPage/PostPageGenerator.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Commands/Blog/PostPage/PostPageCommand.cs b/src/Commands/Blog/PostPage/PostPageCommand.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Commands/Blog/WacsdkBlogCommands.cs b/src/Commands/Blog/WacsdkBlogCommands.cs new file mode 100644 index 0000000..e69de29 From 1cb1c85460676270550c4acba0a6a3129b8637b0 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 26 Oct 2025 01:12:46 -0500 Subject: [PATCH 2/5] Implement PostPageGenerator and PostPageCommand - Implemented PostPageGenerator with 3 partial files: - PostPageGenerator.Markdown.cs: ParseMarkdown, TransformMarkdown, ParseFrontmatter - PostPageGenerator.Template.cs: ResolveTemplate, CopyAssets, RenderTemplate - PostPageGenerator.cs: CreateDataModel, CreateOutputFolder, WriteIndexHtml, GenerateAsync orchestrator - Implemented PostPageCommand with CLI interface: - 4 options: --markdown, --template, --output, --template-file - Path resolution using SystemFile/SystemFolder constructors - Template type detection via Directory.Exists - Generator invocation with proper error handling - Applied all 12 gap resolutions from planning: - Markdig Advanced Extensions, YamlDotNet error handling, template conventions - DepthFirstRecursiveFolder for asset copying, path sanitization - Exception-based error handling, silent overwrite behavior - Ready for testing & validation --- src/Blog/PostPage/PostPageDataModel.cs | 48 ++++++ .../PostPage/PostPageGenerator.Markdown.cs | 113 +++++++++++++ .../PostPage/PostPageGenerator.Template.cs | 119 +++++++++++++ src/Blog/PostPage/PostPageGenerator.cs | 158 ++++++++++++++++++ src/Commands/Blog/PostPage/PostPageCommand.cs | 111 ++++++++++++ src/Commands/Blog/WacsdkBlogCommands.cs | 29 ++++ src/WindowsAppCommunity.CommandLine.csproj | 4 + 7 files changed, 582 insertions(+) create mode 100644 src/Blog/PostPage/PostPageGenerator.Markdown.cs create mode 100644 src/Blog/PostPage/PostPageGenerator.Template.cs diff --git a/src/Blog/PostPage/PostPageDataModel.cs b/src/Blog/PostPage/PostPageDataModel.cs index e69de29..8074e6e 100644 --- a/src/Blog/PostPage/PostPageDataModel.cs +++ b/src/Blog/PostPage/PostPageDataModel.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; + +namespace WindowsAppCommunity.Blog.PostPage +{ + /// + /// Data model for Scriban template rendering in Post/Page scenario. + /// Provides the data contract that templates can access via dot notation. + /// + public class PostPageDataModel + { + /// + /// Transformed HTML content from markdown body. + /// Generated via Markdig pipeline, ready to insert into template. + /// No wrapping elements - template controls structure. + /// + public string Body { get; set; } = string.Empty; + + /// + /// Arbitrary key-value pairs from YAML front-matter. + /// Keys are user-defined field names, values can be string, number, boolean, or structured data. + /// No required keys, no filtering - entirely user-defined. + /// Template accesses via frontmatter.key or frontmatter["key"] syntax. + /// + public Dictionary Frontmatter { get; set; } = new Dictionary(); + + /// + /// Original markdown filename without path or extension. + /// Useful for debugging, display, or conditional logic. + /// Null if not available or not provided. + /// + public string? Filename { get; set; } + + /// + /// File creation timestamp from filesystem metadata. + /// May not be available on all platforms. + /// Null if unavailable. + /// + public DateTime? Created { get; set; } + + /// + /// File modification timestamp from filesystem metadata. + /// More reliable than creation time. + /// Null if unavailable. + /// + public DateTime? Modified { get; set; } + } +} diff --git a/src/Blog/PostPage/PostPageGenerator.Markdown.cs b/src/Blog/PostPage/PostPageGenerator.Markdown.cs new file mode 100644 index 0000000..868abe4 --- /dev/null +++ b/src/Blog/PostPage/PostPageGenerator.Markdown.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Markdig; +using OwlCore.Storage; +using YamlDotNet.Serialization; + +namespace WindowsAppCommunity.Blog.PostPage +{ + /// + /// Markdown processing operations for PostPageGenerator. + /// Handles front-matter extraction, markdown transformation, and YAML parsing. + /// + public partial class PostPageGenerator + { + /// + /// 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(); + + // Gap #12 resolution: 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) + { + // Gap #1 resolution: Use Markdig Advanced Extensions pipeline + var pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .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(); + } + + // Gap #2 resolution: YamlDotNet with error handling + try + { + var deserializer = new DeserializerBuilder() + .Build(); + + var result = deserializer.Deserialize>(yaml); + return result ?? new Dictionary(); + } + catch (YamlDotNet.Core.YamlException ex) + { + // Gap #4 resolution: Exception-based error handling (no try-catch in caller) + throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex); + } + } + } +} diff --git a/src/Blog/PostPage/PostPageGenerator.Template.cs b/src/Blog/PostPage/PostPageGenerator.Template.cs new file mode 100644 index 0000000..9c97dab --- /dev/null +++ b/src/Blog/PostPage/PostPageGenerator.Template.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using OwlCore.Storage; +using Scriban; + +namespace WindowsAppCommunity.Blog.PostPage +{ + /// + /// Template processing operations for PostPageGenerator. + /// Handles template resolution, asset copying, and Scriban rendering. + /// + public partial class PostPageGenerator + { + /// + /// 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) + { + // Gap #8 resolution: Type detection using pattern matching + if (templateSource is IFile file) + { + // Direct file reference + return file; + } + + if (templateSource is IFolder folder) + { + // Gap #3 resolution: Convention-based template file lookup + 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)); + } + + /// + /// Recursively copy all assets from template folder to output folder. + /// Excludes template file itself to avoid duplication. + /// Preserves folder structure using OwlCore.Storage recursive operations. + /// + /// Source template folder + /// Destination output folder + /// Template file to exclude from copy + private async Task CopyTemplateAssetsAsync( + IFolder templateFolder, + IFolder outputFolder, + IFile templateFile) + { + // Gap #11 resolution: Use DepthFirstRecursiveFolder for recursive traversal + var recursiveFolder = new DepthFirstRecursiveFolder(templateFolder); + + // Gap #7 resolution: Filter files and exclude template file by ID comparison + await foreach (var item in recursiveFolder.GetItemsAsync(StorableType.File)) + { + if (item is not IFile file) + continue; + + // Exclude the template file itself + if (file.Id == templateFile.Id) + continue; + + // Copy asset to output folder (overwrite silently per Gap #6) + if (outputFolder is IModifiableFolder modifiableOutput) + { + await modifiableOutput.CreateCopyOfAsync(file, overwrite: true); + } + } + } + + /// + /// 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) + { + // Read template content + var templateContent = await templateFile.ReadTextAsync(); + + // Parse Scriban template + 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}"); + } + + // Render template with model + var html = template.Render(model); + + return html; + } + } +} diff --git a/src/Blog/PostPage/PostPageGenerator.cs b/src/Blog/PostPage/PostPageGenerator.cs index e69de29..a8cc73a 100644 --- a/src/Blog/PostPage/PostPageGenerator.cs +++ b/src/Blog/PostPage/PostPageGenerator.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using OwlCore.Storage; +using Markdig; +using Scriban; +using YamlDotNet.Serialization; + +namespace WindowsAppCommunity.Blog.PostPage +{ + /// + /// Transformation orchestrator for Post/Page scenario. + /// Orchestrates 6 implementation features + data model creation. + /// Flow: Markdown → Generator → Model → Template → HTML + /// Generator creates data model, template generates all HTML (including meta tags). + /// + public partial class PostPageGenerator + { + /// + /// Generate HTML output from markdown source using Scriban template. + /// Single entry point orchestrating complete transformation flow. + /// Flow: Markdown → Model → Template → HTML + /// + /// Source markdown file with optional YAML front-matter + /// Template file (IFile) or folder (IFolder) containing template + /// Output folder where [name]/index.html will be created + /// Template file name when templateSource is IFolder (optional, defaults to "template.html") + public async Task GenerateAsync( + IFile markdownFile, + IStorable templateSource, + IFolder destinationFolder, + string? templateFileName = null) + { + // 1. Parse markdown (front-matter + content) + var (frontmatter, content) = await ParseMarkdownAsync(markdownFile); + + // 2. Transform markdown to HTML body + var body = TransformMarkdownToHtml(content); + + // 3. Parse front-matter YAML + var frontmatterDict = ParseFrontmatter(frontmatter); + + // 4. Create data model + var model = CreateDataModel(body, frontmatterDict, markdownFile); + + // 5. Resolve template file + var templateFile = await ResolveTemplateFileAsync(templateSource, templateFileName); + + // 6. Copy template assets (if template source is folder) + if (templateSource is IFolder templateFolder) + { + // Create output folder first (needed for asset copying) + var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name); + var outputFolder = await CreateOutputFolderAsync(destinationFolder, outputFolderName); + + await CopyTemplateAssetsAsync(templateFolder, outputFolder, templateFile); + + // 7. Render template with model + var html = await RenderTemplateAsync(templateFile, model); + + // 8. Write index.html to output folder + await WriteIndexHtmlAsync(outputFolder, html); + } + else + { + // Template is single file (no assets to copy) + // Create output folder + var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name); + var outputFolder = await CreateOutputFolderAsync(destinationFolder, outputFolderName); + + // 7. Render template with model + var html = await RenderTemplateAsync(templateFile, model); + + // 8. Write index.html + await WriteIndexHtmlAsync(outputFolder, html); + } + } + + /// + /// Create PostPageDataModel from transformed content and metadata. + /// Populates model with body, frontmatter (as-is), and optional metadata. + /// Flow: Markdown → Model → Template → HTML + /// Generator provides data, template generates HTML (including meta tags). + /// + /// Transformed HTML body from markdown + /// Parsed front-matter dictionary (all keys included) + /// Source markdown file for metadata extraction + /// Complete data model for template rendering + private PostPageDataModel CreateDataModel( + string body, + Dictionary frontmatter, + IFile sourceFile) + { + return new PostPageDataModel + { + Body = body, + Frontmatter = frontmatter, + // Note: Timestamps and other metadata can be added here if IFile provides properties + // For now, template can extract filename and other data from frontmatter + }; + } + + /// + /// Create folderized output structure: [name]/index.html + /// Name derived from markdown filename, sanitized for filesystem safety. + /// + /// Parent destination folder + /// Name for output subfolder + /// Created output folder + private async Task CreateOutputFolderAsync( + IFolder destination, + string folderName) + { + // Gap #9 resolution: Sanitize folder name + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitizedName = string.Join("_", folderName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); + + if (string.IsNullOrWhiteSpace(sanitizedName)) + { + throw new ArgumentException("Folder name resulted in empty string after sanitization.", nameof(folderName)); + } + + // Gap #6 resolution: Overwrite silently + if (destination is IModifiableFolder modifiableDestination) + { + return await modifiableDestination.CreateFolderAsync(sanitizedName, overwrite: true); + } + + throw new ArgumentException( + $"Destination folder must be IModifiableFolder, got: {destination.GetType().Name}", + nameof(destination)); + } + + /// + /// Write rendered HTML to index.html in output folder. + /// Overwrites existing index.html if present. + /// + /// Output folder for index.html + /// Rendered HTML content + private async Task WriteIndexHtmlAsync(IFolder outputFolder, string html) + { + if (outputFolder is not IModifiableFolder modifiableFolder) + { + throw new ArgumentException( + $"Output folder must be IModifiableFolder, got: {outputFolder.GetType().Name}", + nameof(outputFolder)); + } + + // Create or overwrite index.html + var indexFile = await modifiableFolder.CreateFileAsync("index.html", overwrite: true); + + // Write HTML content using OwlCore.Storage extension + await indexFile.WriteTextAsync(html); + } + } +} diff --git a/src/Commands/Blog/PostPage/PostPageCommand.cs b/src/Commands/Blog/PostPage/PostPageCommand.cs index e69de29..366cfb2 100644 --- a/src/Commands/Blog/PostPage/PostPageCommand.cs +++ b/src/Commands/Blog/PostPage/PostPageCommand.cs @@ -0,0 +1,111 @@ +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 generator instance + var generator = new PostPageGenerator(); + + // 5. Invoke generator (exceptions bubble to System.CommandLine framework) + await generator.GenerateAsync(markdownFile, templateSource, outputFolder, templateFileName); + + // 6. 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 e69de29..bb8bbf0 100644 --- a/src/Commands/Blog/WacsdkBlogCommands.cs +++ b/src/Commands/Blog/WacsdkBlogCommands.cs @@ -0,0 +1,29 @@ +using System.CommandLine; +using WindowsAppCommunity.CommandLine.Blog.PostPage; + +namespace WindowsAppCommunity.CommandLine.Blog +{ + /// + /// Command aggregator for blog generation scenarios. + /// Registers Post/Page, Pages, and Site scenario commands. + /// + public class WacsdkBlogCommands : Command + { + /// + /// Initialize blog commands aggregator. + /// Registers all blog generation scenario subcommands. + /// + public WacsdkBlogCommands() + : base("blog", "Blog generation commands") + { + // Register Post/Page scenario + AddCommand(new PostPageCommand()); + + // Future: Register Pages scenario + // AddCommand(new PagesCommand()); + + // Future: Register Site scenario + // AddCommand(new SiteCommand()); + } + } +} diff --git a/src/WindowsAppCommunity.CommandLine.csproj b/src/WindowsAppCommunity.CommandLine.csproj index f8d4d2a..e13cbed 100644 --- a/src/WindowsAppCommunity.CommandLine.csproj +++ b/src/WindowsAppCommunity.CommandLine.csproj @@ -49,10 +49,14 @@ Initial release of WindowsAppCommunity.CommandLine. + + + + From fb9109dce94bec42cc682e184a224b7e754ced7b Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 26 Oct 2025 02:51:53 -0500 Subject: [PATCH 3/5] feat: Enhance PostPageGenerator to use relative path resolution for asset copying --- .../PostPage/PostPageGenerator.Template.cs | 24 ++++++++++++------- src/Program.cs | 6 +++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Blog/PostPage/PostPageGenerator.Template.cs b/src/Blog/PostPage/PostPageGenerator.Template.cs index 9c97dab..8072c07 100644 --- a/src/Blog/PostPage/PostPageGenerator.Template.cs +++ b/src/Blog/PostPage/PostPageGenerator.Template.cs @@ -55,7 +55,7 @@ private async Task ResolveTemplateFileAsync( /// /// Recursively copy all assets from template folder to output folder. /// Excludes template file itself to avoid duplication. - /// Preserves folder structure using OwlCore.Storage recursive operations. + /// Preserves folder structure using relative path resolution. /// /// Source template folder /// Destination output folder @@ -71,18 +71,26 @@ private async Task CopyTemplateAssetsAsync( // Gap #7 resolution: Filter files and exclude template file by ID comparison await foreach (var item in recursiveFolder.GetItemsAsync(StorableType.File)) { - if (item is not IFile file) + if (item is not IChildFile file) continue; // Exclude the template file itself if (file.Id == templateFile.Id) continue; - // Copy asset to output folder (overwrite silently per Gap #6) - if (outputFolder is IModifiableFolder modifiableOutput) - { - await modifiableOutput.CreateCopyOfAsync(file, overwrite: true); - } + // Get relative path from template folder to file (preserves folder structure) + var relativePath = await templateFolder.GetRelativePathToAsync(file); + + // Create file at relative path in output folder (creates necessary parent folders) + var targetStorable = await outputFolder.CreateByRelativePathAsync(relativePath, StorableType.File, overwrite: true); + + if (targetStorable is not IFile targetFile) + throw new InvalidOperationException($"Created item at '{relativePath}' is not a file."); + + // Copy file content + using var sourceStream = await file.OpenReadAsync(); + using var targetStream = await targetFile.OpenStreamAsync(FileAccess.Write); + await sourceStream.CopyToAsync(targetStream); } } @@ -116,4 +124,4 @@ private async Task RenderTemplateAsync( return html; } } -} +} \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 2dd6c94..f5033ee 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,4 +1,4 @@ -using OwlCore.Diagnostics; +using OwlCore.Diagnostics; using WindowsAppCommunity.CommandLine; using System.CommandLine; using OwlCore.Nomad.Kubo; @@ -8,6 +8,7 @@ using WindowsAppCommunity.CommandLine.User; using WindowsAppCommunity.CommandLine.Project; using WindowsAppCommunity.CommandLine.Publisher; +using WindowsAppCommunity.CommandLine.Blog; // Logging var startTime = DateTime.Now; @@ -85,5 +86,6 @@ void Logger_MessageReceived(object? sender, LoggerMessageEventArgs e) rootCommand.AddCommand(new WacsdkUserCommands(config, repoOption)); rootCommand.AddCommand(new WacsdkProjectCommands(config, repoOption)); rootCommand.AddCommand(new WacsdkPublisherCommands(config, repoOption)); +rootCommand.AddCommand(new WacsdkBlogCommands()); -await rootCommand.InvokeAsync(args); \ No newline at end of file +await rootCommand.InvokeAsync(args); From c934429cd4d6ce390fb1dd5fda960d47c51db7c0 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 26 Oct 2025 17:02:08 -0500 Subject: [PATCH 4/5] feat: Update PostPageGenerator to enhance markdown processing and template file handling --- src/Blog/PostPage/PostPageGenerator.Markdown.cs | 1 + src/Blog/PostPage/PostPageGenerator.Template.cs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Blog/PostPage/PostPageGenerator.Markdown.cs b/src/Blog/PostPage/PostPageGenerator.Markdown.cs index 868abe4..917c7b6 100644 --- a/src/Blog/PostPage/PostPageGenerator.Markdown.cs +++ b/src/Blog/PostPage/PostPageGenerator.Markdown.cs @@ -74,6 +74,7 @@ private string TransformMarkdownToHtml(string markdown) // Gap #1 resolution: Use Markdig Advanced Extensions pipeline var pipeline = new MarkdownPipelineBuilder() .UseAdvancedExtensions() + .UseSoftlineBreakAsHardlineBreak() .Build(); return Markdown.ToHtml(markdown, pipeline); diff --git a/src/Blog/PostPage/PostPageGenerator.Template.cs b/src/Blog/PostPage/PostPageGenerator.Template.cs index 8072c07..4fe1b6d 100644 --- a/src/Blog/PostPage/PostPageGenerator.Template.cs +++ b/src/Blog/PostPage/PostPageGenerator.Template.cs @@ -82,15 +82,15 @@ private async Task CopyTemplateAssetsAsync( var relativePath = await templateFolder.GetRelativePathToAsync(file); // Create file at relative path in output folder (creates necessary parent folders) - var targetStorable = await outputFolder.CreateByRelativePathAsync(relativePath, StorableType.File, overwrite: true); - + var targetStorable = await outputFolder.CreateByRelativePathAsync(relativePath, StorableType.File); if (targetStorable is not IFile targetFile) throw new InvalidOperationException($"Created item at '{relativePath}' is not a file."); // Copy file content using var sourceStream = await file.OpenReadAsync(); - using var targetStream = await targetFile.OpenStreamAsync(FileAccess.Write); - await sourceStream.CopyToAsync(targetStream); + using var destinationStream = await targetFile.OpenWriteAsync(); + await sourceStream.CopyToAsync(destinationStream); + await destinationStream.FlushAsync(); } } From d1f71023de3f089ea34f02fa9e70665a32ec4ef5 Mon Sep 17 00:00:00 2001 From: Arlo Date: Sun, 9 Nov 2025 00:59:24 -0600 Subject: [PATCH 5/5] Refactor PostPageGenerator to PostPageFolder virtual IFolder pattern - Replaced side-effect orchestrator with virtual IFolder implementation - Enables Multi-Page composition (prerequisite for PagesFolder) - Backward compatible: PostPageCommand behavior unchanged - Manual validation: Output matches pre-refactor baseline Components: - PostPageFolder: Virtual IFolder wrapping markdown source - IndexHtmlFile: Lazy generation on content access - PostPageAssetFolder: Recursive template asset mirroring Removed: - PostPageGenerator.cs (3 partials) Modified: - PostPageCommand.cs: Direct PostPageFolder integration Refs: - planning,post-page,refactor.md (requirements) - planning,post-page,refactor,execution.md (implementation tracking) --- src/Blog/PostPage/IndexHtmlFile.cs | 282 ++++++++++++++++++ src/Blog/PostPage/PostPageAssetFolder.cs | 84 ++++++ src/Blog/PostPage/PostPageFolder.cs | 139 +++++++++ .../PostPage/PostPageGenerator.Markdown.cs | 114 ------- .../PostPage/PostPageGenerator.Template.cs | 127 -------- src/Blog/PostPage/PostPageGenerator.cs | 158 ---------- src/Commands/Blog/PostPage/PostPageCommand.cs | 42 ++- 7 files changed, 542 insertions(+), 404 deletions(-) create mode 100644 src/Blog/PostPage/IndexHtmlFile.cs create mode 100644 src/Blog/PostPage/PostPageAssetFolder.cs create mode 100644 src/Blog/PostPage/PostPageFolder.cs delete mode 100644 src/Blog/PostPage/PostPageGenerator.Markdown.cs delete mode 100644 src/Blog/PostPage/PostPageGenerator.Template.cs delete mode 100644 src/Blog/PostPage/PostPageGenerator.cs diff --git a/src/Blog/PostPage/IndexHtmlFile.cs b/src/Blog/PostPage/IndexHtmlFile.cs new file mode 100644 index 0000000..0e4435f --- /dev/null +++ b/src/Blog/PostPage/IndexHtmlFile.cs @@ -0,0 +1,282 @@ +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 new file mode 100644 index 0000000..9771014 --- /dev/null +++ b/src/Blog/PostPage/PostPageAssetFolder.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using OwlCore.Storage; + +namespace WindowsAppCommunity.Blog.PostPage +{ + /// + /// Virtual IChildFolder that recursively wraps template asset folders. + /// Mirrors template folder structure with recursive PostPageAssetFolder wrapping. + /// Passes through files directly (preserves type identity for fastpath extension methods). + /// Propagates template file exclusion down hierarchy. + /// + public sealed class PostPageAssetFolder : IChildFolder + { + private readonly IFolder _wrappedFolder; + private readonly IFolder _parent; + private readonly IFile? _templateFileToExclude; + + /// + /// Creates virtual asset folder wrapping template folder structure. + /// + /// Template folder to mirror + /// Parent folder in virtual hierarchy + /// Template HTML file to exclude from enumeration + public PostPageAssetFolder(IFolder wrappedFolder, IFolder parent, IFile? templateFileToExclude) + { + _wrappedFolder = wrappedFolder ?? throw new ArgumentNullException(nameof(wrappedFolder)); + _parent = parent ?? throw new ArgumentNullException(nameof(parent)); + _templateFileToExclude = templateFileToExclude; + } + + /// + public string Id => _wrappedFolder.Id; + + /// + public string Name => _wrappedFolder.Name; + + /// + /// Parent folder in virtual hierarchy (not interface requirement, internal storage). + /// + public IFolder Parent => _parent; + + /// + public Task GetParentAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult(_parent); + } + + /// + public async IAsyncEnumerable GetItemsAsync( + StorableType type = StorableType.All, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync starting for: {_wrappedFolder.Id}"); + + // Enumerate wrapped folder items + await foreach (var item in _wrappedFolder.GetItemsAsync(type, cancellationToken)) + { + // Recursively wrap subfolders with this as parent + if (item is IFolder subfolder && (type == StorableType.All || type == StorableType.Folder)) + { + yield return new PostPageAssetFolder(subfolder, this, _templateFileToExclude); + continue; + } + + // Pass through files directly (preserves type identity) + if (item is IChildFile file && (type == StorableType.All || type == StorableType.File)) + { + // Exclude template HTML file if specified + if (_templateFileToExclude != null && file.Id == _templateFileToExclude.Id) + { + continue; + } + + yield return file; + } + } + + OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync complete for: {_wrappedFolder.Id}"); + } + } +} diff --git a/src/Blog/PostPage/PostPageFolder.cs b/src/Blog/PostPage/PostPageFolder.cs new file mode 100644 index 0000000..2ee7e28 --- /dev/null +++ b/src/Blog/PostPage/PostPageFolder.cs @@ -0,0 +1,139 @@ +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/Blog/PostPage/PostPageGenerator.Markdown.cs b/src/Blog/PostPage/PostPageGenerator.Markdown.cs deleted file mode 100644 index 917c7b6..0000000 --- a/src/Blog/PostPage/PostPageGenerator.Markdown.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Markdig; -using OwlCore.Storage; -using YamlDotNet.Serialization; - -namespace WindowsAppCommunity.Blog.PostPage -{ - /// - /// Markdown processing operations for PostPageGenerator. - /// Handles front-matter extraction, markdown transformation, and YAML parsing. - /// - public partial class PostPageGenerator - { - /// - /// 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(); - - // Gap #12 resolution: 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) - { - // Gap #1 resolution: Use Markdig Advanced Extensions pipeline - 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(); - } - - // Gap #2 resolution: YamlDotNet with error handling - try - { - var deserializer = new DeserializerBuilder() - .Build(); - - var result = deserializer.Deserialize>(yaml); - return result ?? new Dictionary(); - } - catch (YamlDotNet.Core.YamlException ex) - { - // Gap #4 resolution: Exception-based error handling (no try-catch in caller) - throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex); - } - } - } -} diff --git a/src/Blog/PostPage/PostPageGenerator.Template.cs b/src/Blog/PostPage/PostPageGenerator.Template.cs deleted file mode 100644 index 4fe1b6d..0000000 --- a/src/Blog/PostPage/PostPageGenerator.Template.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using OwlCore.Storage; -using Scriban; - -namespace WindowsAppCommunity.Blog.PostPage -{ - /// - /// Template processing operations for PostPageGenerator. - /// Handles template resolution, asset copying, and Scriban rendering. - /// - public partial class PostPageGenerator - { - /// - /// 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) - { - // Gap #8 resolution: Type detection using pattern matching - if (templateSource is IFile file) - { - // Direct file reference - return file; - } - - if (templateSource is IFolder folder) - { - // Gap #3 resolution: Convention-based template file lookup - 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)); - } - - /// - /// Recursively copy all assets from template folder to output folder. - /// Excludes template file itself to avoid duplication. - /// Preserves folder structure using relative path resolution. - /// - /// Source template folder - /// Destination output folder - /// Template file to exclude from copy - private async Task CopyTemplateAssetsAsync( - IFolder templateFolder, - IFolder outputFolder, - IFile templateFile) - { - // Gap #11 resolution: Use DepthFirstRecursiveFolder for recursive traversal - var recursiveFolder = new DepthFirstRecursiveFolder(templateFolder); - - // Gap #7 resolution: Filter files and exclude template file by ID comparison - await foreach (var item in recursiveFolder.GetItemsAsync(StorableType.File)) - { - if (item is not IChildFile file) - continue; - - // Exclude the template file itself - if (file.Id == templateFile.Id) - continue; - - // Get relative path from template folder to file (preserves folder structure) - var relativePath = await templateFolder.GetRelativePathToAsync(file); - - // Create file at relative path in output folder (creates necessary parent folders) - var targetStorable = await outputFolder.CreateByRelativePathAsync(relativePath, StorableType.File); - if (targetStorable is not IFile targetFile) - throw new InvalidOperationException($"Created item at '{relativePath}' is not a file."); - - // Copy file content - using var sourceStream = await file.OpenReadAsync(); - using var destinationStream = await targetFile.OpenWriteAsync(); - await sourceStream.CopyToAsync(destinationStream); - await destinationStream.FlushAsync(); - } - } - - /// - /// 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) - { - // Read template content - var templateContent = await templateFile.ReadTextAsync(); - - // Parse Scriban template - 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}"); - } - - // Render template with model - var html = template.Render(model); - - return html; - } - } -} \ No newline at end of file diff --git a/src/Blog/PostPage/PostPageGenerator.cs b/src/Blog/PostPage/PostPageGenerator.cs deleted file mode 100644 index a8cc73a..0000000 --- a/src/Blog/PostPage/PostPageGenerator.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using OwlCore.Storage; -using Markdig; -using Scriban; -using YamlDotNet.Serialization; - -namespace WindowsAppCommunity.Blog.PostPage -{ - /// - /// Transformation orchestrator for Post/Page scenario. - /// Orchestrates 6 implementation features + data model creation. - /// Flow: Markdown → Generator → Model → Template → HTML - /// Generator creates data model, template generates all HTML (including meta tags). - /// - public partial class PostPageGenerator - { - /// - /// Generate HTML output from markdown source using Scriban template. - /// Single entry point orchestrating complete transformation flow. - /// Flow: Markdown → Model → Template → HTML - /// - /// Source markdown file with optional YAML front-matter - /// Template file (IFile) or folder (IFolder) containing template - /// Output folder where [name]/index.html will be created - /// Template file name when templateSource is IFolder (optional, defaults to "template.html") - public async Task GenerateAsync( - IFile markdownFile, - IStorable templateSource, - IFolder destinationFolder, - string? templateFileName = null) - { - // 1. Parse markdown (front-matter + content) - var (frontmatter, content) = await ParseMarkdownAsync(markdownFile); - - // 2. Transform markdown to HTML body - var body = TransformMarkdownToHtml(content); - - // 3. Parse front-matter YAML - var frontmatterDict = ParseFrontmatter(frontmatter); - - // 4. Create data model - var model = CreateDataModel(body, frontmatterDict, markdownFile); - - // 5. Resolve template file - var templateFile = await ResolveTemplateFileAsync(templateSource, templateFileName); - - // 6. Copy template assets (if template source is folder) - if (templateSource is IFolder templateFolder) - { - // Create output folder first (needed for asset copying) - var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name); - var outputFolder = await CreateOutputFolderAsync(destinationFolder, outputFolderName); - - await CopyTemplateAssetsAsync(templateFolder, outputFolder, templateFile); - - // 7. Render template with model - var html = await RenderTemplateAsync(templateFile, model); - - // 8. Write index.html to output folder - await WriteIndexHtmlAsync(outputFolder, html); - } - else - { - // Template is single file (no assets to copy) - // Create output folder - var outputFolderName = Path.GetFileNameWithoutExtension(markdownFile.Name); - var outputFolder = await CreateOutputFolderAsync(destinationFolder, outputFolderName); - - // 7. Render template with model - var html = await RenderTemplateAsync(templateFile, model); - - // 8. Write index.html - await WriteIndexHtmlAsync(outputFolder, html); - } - } - - /// - /// Create PostPageDataModel from transformed content and metadata. - /// Populates model with body, frontmatter (as-is), and optional metadata. - /// Flow: Markdown → Model → Template → HTML - /// Generator provides data, template generates HTML (including meta tags). - /// - /// Transformed HTML body from markdown - /// Parsed front-matter dictionary (all keys included) - /// Source markdown file for metadata extraction - /// Complete data model for template rendering - private PostPageDataModel CreateDataModel( - string body, - Dictionary frontmatter, - IFile sourceFile) - { - return new PostPageDataModel - { - Body = body, - Frontmatter = frontmatter, - // Note: Timestamps and other metadata can be added here if IFile provides properties - // For now, template can extract filename and other data from frontmatter - }; - } - - /// - /// Create folderized output structure: [name]/index.html - /// Name derived from markdown filename, sanitized for filesystem safety. - /// - /// Parent destination folder - /// Name for output subfolder - /// Created output folder - private async Task CreateOutputFolderAsync( - IFolder destination, - string folderName) - { - // Gap #9 resolution: Sanitize folder name - var invalidChars = Path.GetInvalidFileNameChars(); - var sanitizedName = string.Join("_", folderName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)); - - if (string.IsNullOrWhiteSpace(sanitizedName)) - { - throw new ArgumentException("Folder name resulted in empty string after sanitization.", nameof(folderName)); - } - - // Gap #6 resolution: Overwrite silently - if (destination is IModifiableFolder modifiableDestination) - { - return await modifiableDestination.CreateFolderAsync(sanitizedName, overwrite: true); - } - - throw new ArgumentException( - $"Destination folder must be IModifiableFolder, got: {destination.GetType().Name}", - nameof(destination)); - } - - /// - /// Write rendered HTML to index.html in output folder. - /// Overwrites existing index.html if present. - /// - /// Output folder for index.html - /// Rendered HTML content - private async Task WriteIndexHtmlAsync(IFolder outputFolder, string html) - { - if (outputFolder is not IModifiableFolder modifiableFolder) - { - throw new ArgumentException( - $"Output folder must be IModifiableFolder, got: {outputFolder.GetType().Name}", - nameof(outputFolder)); - } - - // Create or overwrite index.html - var indexFile = await modifiableFolder.CreateFileAsync("index.html", overwrite: true); - - // Write HTML content using OwlCore.Storage extension - await indexFile.WriteTextAsync(html); - } - } -} diff --git a/src/Commands/Blog/PostPage/PostPageCommand.cs b/src/Commands/Blog/PostPage/PostPageCommand.cs index 366cfb2..832db6a 100644 --- a/src/Commands/Blog/PostPage/PostPageCommand.cs +++ b/src/Commands/Blog/PostPage/PostPageCommand.cs @@ -94,13 +94,45 @@ private async Task ExecuteAsync( // 3. Resolve output folder (SystemFolder throws if doesn't exist) IModifiableFolder outputFolder = new SystemFolder(outputPath); - // 4. Create generator instance - var generator = new PostPageGenerator(); + // 4. Create virtual PostPageFolder (lazy generation - no I/O during construction) + var postPageFolder = new PostPageFolder(markdownFile, templateSource, templateFileName); - // 5. Invoke generator (exceptions bubble to System.CommandLine framework) - await generator.GenerateAsync(markdownFile, templateSource, outputFolder, templateFileName); + // 5. Create output folder for this page + var pageOutputFolder = await outputFolder.CreateFolderAsync(postPageFolder.Name, overwrite: true); - // 6. Report success + // 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")}");