Skip to content

Commit 1cb1c85

Browse files
committed
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
1 parent d23e7d4 commit 1cb1c85

File tree

7 files changed

+582
-0
lines changed

7 files changed

+582
-0
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace WindowsAppCommunity.Blog.PostPage
5+
{
6+
/// <summary>
7+
/// Data model for Scriban template rendering in Post/Page scenario.
8+
/// Provides the data contract that templates can access via dot notation.
9+
/// </summary>
10+
public class PostPageDataModel
11+
{
12+
/// <summary>
13+
/// Transformed HTML content from markdown body.
14+
/// Generated via Markdig pipeline, ready to insert into template.
15+
/// No wrapping elements - template controls structure.
16+
/// </summary>
17+
public string Body { get; set; } = string.Empty;
18+
19+
/// <summary>
20+
/// Arbitrary key-value pairs from YAML front-matter.
21+
/// Keys are user-defined field names, values can be string, number, boolean, or structured data.
22+
/// No required keys, no filtering - entirely user-defined.
23+
/// Template accesses via frontmatter.key or frontmatter["key"] syntax.
24+
/// </summary>
25+
public Dictionary<string, object> Frontmatter { get; set; } = new Dictionary<string, object>();
26+
27+
/// <summary>
28+
/// Original markdown filename without path or extension.
29+
/// Useful for debugging, display, or conditional logic.
30+
/// Null if not available or not provided.
31+
/// </summary>
32+
public string? Filename { get; set; }
33+
34+
/// <summary>
35+
/// File creation timestamp from filesystem metadata.
36+
/// May not be available on all platforms.
37+
/// Null if unavailable.
38+
/// </summary>
39+
public DateTime? Created { get; set; }
40+
41+
/// <summary>
42+
/// File modification timestamp from filesystem metadata.
43+
/// More reliable than creation time.
44+
/// Null if unavailable.
45+
/// </summary>
46+
public DateTime? Modified { get; set; }
47+
}
48+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Markdig;
6+
using OwlCore.Storage;
7+
using YamlDotNet.Serialization;
8+
9+
namespace WindowsAppCommunity.Blog.PostPage
10+
{
11+
/// <summary>
12+
/// Markdown processing operations for PostPageGenerator.
13+
/// Handles front-matter extraction, markdown transformation, and YAML parsing.
14+
/// </summary>
15+
public partial class PostPageGenerator
16+
{
17+
/// <summary>
18+
/// Extract YAML front-matter block from markdown file.
19+
/// Front-matter is delimited by "---" at start and end.
20+
/// Handles files without front-matter (returns empty string for frontmatter).
21+
/// </summary>
22+
/// <param name="file">Markdown file to parse</param>
23+
/// <returns>Tuple of (frontmatter YAML string, content markdown string)</returns>
24+
private async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file)
25+
{
26+
var text = await file.ReadTextAsync();
27+
28+
// Gap #12 resolution: Check for front-matter delimiters
29+
if (!text.StartsWith("---"))
30+
{
31+
// No front-matter present
32+
return (string.Empty, text);
33+
}
34+
35+
// Find the closing delimiter
36+
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None);
37+
var closingDelimiterIndex = -1;
38+
39+
for (int i = 1; i < lines.Length; i++)
40+
{
41+
if (lines[i].Trim() == "---")
42+
{
43+
closingDelimiterIndex = i;
44+
break;
45+
}
46+
}
47+
48+
if (closingDelimiterIndex == -1)
49+
{
50+
// No closing delimiter found - treat entire file as content
51+
return (string.Empty, text);
52+
}
53+
54+
// Extract front-matter (lines between delimiters)
55+
var frontmatterLines = lines.Skip(1).Take(closingDelimiterIndex - 1);
56+
var frontmatter = string.Join(Environment.NewLine, frontmatterLines);
57+
58+
// Extract content (everything after closing delimiter)
59+
var contentLines = lines.Skip(closingDelimiterIndex + 1);
60+
var content = string.Join(Environment.NewLine, contentLines);
61+
62+
return (frontmatter, content);
63+
}
64+
65+
/// <summary>
66+
/// Transform markdown content to HTML body using Markdig.
67+
/// Returns HTML without wrapping elements - template controls structure.
68+
/// Uses Advanced Extensions pipeline for full Markdown feature support.
69+
/// </summary>
70+
/// <param name="markdown">Markdown content string</param>
71+
/// <returns>HTML body content</returns>
72+
private string TransformMarkdownToHtml(string markdown)
73+
{
74+
// Gap #1 resolution: Use Markdig Advanced Extensions pipeline
75+
var pipeline = new MarkdownPipelineBuilder()
76+
.UseAdvancedExtensions()
77+
.Build();
78+
79+
return Markdown.ToHtml(markdown, pipeline);
80+
}
81+
82+
/// <summary>
83+
/// Parse YAML front-matter string to arbitrary dictionary.
84+
/// No schema enforcement - accepts any valid YAML structure.
85+
/// Handles empty/missing front-matter gracefully.
86+
/// </summary>
87+
/// <param name="yaml">YAML string from front-matter</param>
88+
/// <returns>Dictionary with arbitrary keys and values</returns>
89+
private Dictionary<string, object> ParseFrontmatter(string yaml)
90+
{
91+
// Handle empty front-matter
92+
if (string.IsNullOrWhiteSpace(yaml))
93+
{
94+
return new Dictionary<string, object>();
95+
}
96+
97+
// Gap #2 resolution: YamlDotNet with error handling
98+
try
99+
{
100+
var deserializer = new DeserializerBuilder()
101+
.Build();
102+
103+
var result = deserializer.Deserialize<Dictionary<string, object>>(yaml);
104+
return result ?? new Dictionary<string, object>();
105+
}
106+
catch (YamlDotNet.Core.YamlException ex)
107+
{
108+
// Gap #4 resolution: Exception-based error handling (no try-catch in caller)
109+
throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex);
110+
}
111+
}
112+
}
113+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using OwlCore.Storage;
6+
using Scriban;
7+
8+
namespace WindowsAppCommunity.Blog.PostPage
9+
{
10+
/// <summary>
11+
/// Template processing operations for PostPageGenerator.
12+
/// Handles template resolution, asset copying, and Scriban rendering.
13+
/// </summary>
14+
public partial class PostPageGenerator
15+
{
16+
/// <summary>
17+
/// Resolve template file from IStorable source.
18+
/// Handles both IFile (single template) and IFolder (template + assets).
19+
/// Uses convention-based lookup ("template.html") when source is folder.
20+
/// </summary>
21+
/// <param name="templateSource">Template as IFile or IFolder</param>
22+
/// <param name="templateFileName">File name when source is IFolder (defaults to "template.html")</param>
23+
/// <returns>Resolved template IFile</returns>
24+
private async Task<IFile> ResolveTemplateFileAsync(
25+
IStorable templateSource,
26+
string? templateFileName)
27+
{
28+
// Gap #8 resolution: Type detection using pattern matching
29+
if (templateSource is IFile file)
30+
{
31+
// Direct file reference
32+
return file;
33+
}
34+
35+
if (templateSource is IFolder folder)
36+
{
37+
// Gap #3 resolution: Convention-based template file lookup
38+
var fileName = templateFileName ?? "template.html";
39+
var templateFile = await folder.GetFirstByNameAsync(fileName);
40+
41+
if (templateFile is not IFile resolvedFile)
42+
{
43+
throw new FileNotFoundException(
44+
$"Template file '{fileName}' not found in folder '{folder.Name}'.");
45+
}
46+
47+
return resolvedFile;
48+
}
49+
50+
throw new ArgumentException(
51+
$"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}",
52+
nameof(templateSource));
53+
}
54+
55+
/// <summary>
56+
/// Recursively copy all assets from template folder to output folder.
57+
/// Excludes template file itself to avoid duplication.
58+
/// Preserves folder structure using OwlCore.Storage recursive operations.
59+
/// </summary>
60+
/// <param name="templateFolder">Source template folder</param>
61+
/// <param name="outputFolder">Destination output folder</param>
62+
/// <param name="templateFile">Template file to exclude from copy</param>
63+
private async Task CopyTemplateAssetsAsync(
64+
IFolder templateFolder,
65+
IFolder outputFolder,
66+
IFile templateFile)
67+
{
68+
// Gap #11 resolution: Use DepthFirstRecursiveFolder for recursive traversal
69+
var recursiveFolder = new DepthFirstRecursiveFolder(templateFolder);
70+
71+
// Gap #7 resolution: Filter files and exclude template file by ID comparison
72+
await foreach (var item in recursiveFolder.GetItemsAsync(StorableType.File))
73+
{
74+
if (item is not IFile file)
75+
continue;
76+
77+
// Exclude the template file itself
78+
if (file.Id == templateFile.Id)
79+
continue;
80+
81+
// Copy asset to output folder (overwrite silently per Gap #6)
82+
if (outputFolder is IModifiableFolder modifiableOutput)
83+
{
84+
await modifiableOutput.CreateCopyOfAsync(file, overwrite: true);
85+
}
86+
}
87+
}
88+
89+
/// <summary>
90+
/// Render Scriban template with data model to produce final HTML.
91+
/// Template generates all HTML including meta tags from model.frontmatter.
92+
/// Flow boundary: Generator provides data model, template generates HTML.
93+
/// </summary>
94+
/// <param name="templateFile">Scriban template file</param>
95+
/// <param name="model">PostPageDataModel with body, frontmatter, metadata</param>
96+
/// <returns>Rendered HTML string</returns>
97+
private async Task<string> RenderTemplateAsync(
98+
IFile templateFile,
99+
PostPageDataModel model)
100+
{
101+
// Read template content
102+
var templateContent = await templateFile.ReadTextAsync();
103+
104+
// Parse Scriban template
105+
var template = Template.Parse(templateContent);
106+
107+
if (template.HasErrors)
108+
{
109+
var errors = string.Join(Environment.NewLine, template.Messages);
110+
throw new InvalidOperationException($"Template parsing failed:{Environment.NewLine}{errors}");
111+
}
112+
113+
// Render template with model
114+
var html = template.Render(model);
115+
116+
return html;
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)