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/PostPageDataModel.cs b/src/Blog/PostPage/PostPageDataModel.cs new file mode 100644 index 0000000..8074e6e --- /dev/null +++ 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/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/Commands/Blog/PostPage/PostPageCommand.cs b/src/Commands/Blog/PostPage/PostPageCommand.cs new file mode 100644 index 0000000..832db6a --- /dev/null +++ b/src/Commands/Blog/PostPage/PostPageCommand.cs @@ -0,0 +1,143 @@ +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 new file mode 100644 index 0000000..bb8bbf0 --- /dev/null +++ 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/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); 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. + + + +