Skip to content

Commit 3c765e2

Browse files
authored
Merge pull request #4 from WindowsAppCommunity/feature/blog-generator-postpage
Add blog generator feature with storage-first virtual IFolder pattern
2 parents 10970af + d1f7102 commit 3c765e2

File tree

8 files changed

+733
-2
lines changed

8 files changed

+733
-2
lines changed

src/Blog/PostPage/IndexHtmlFile.cs

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Text;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Markdig;
9+
using OwlCore.Storage;
10+
using Scriban;
11+
using YamlDotNet.Serialization;
12+
13+
namespace WindowsAppCommunity.Blog.PostPage
14+
{
15+
/// <summary>
16+
/// Virtual IChildFile representing index.html generated from markdown source.
17+
/// Implements lazy generation - markdown→HTML transformation occurs on OpenStreamAsync.
18+
/// Read-only - throws NotSupportedException for write operations.
19+
/// </summary>
20+
public sealed class IndexHtmlFile : IChildFile
21+
{
22+
private readonly string _id;
23+
private readonly IFile _markdownSource;
24+
private readonly IStorable _templateSource;
25+
private readonly string? _templateFileName;
26+
private readonly IFolder? _parent;
27+
28+
/// <summary>
29+
/// Creates virtual index.html file with lazy markdown→HTML generation.
30+
/// </summary>
31+
/// <param name="id">Unique identifier for this file (parent-derived)</param>
32+
/// <param name="markdownSource">Source markdown file to transform</param>
33+
/// <param name="templateSource">Template as IFile or IFolder</param>
34+
/// <param name="templateFileName">Template file name when source is IFolder (defaults to "template.html")</param>
35+
/// <param name="parent">Parent folder in virtual hierarchy (optional)</param>
36+
public IndexHtmlFile(string id, IFile markdownSource, IStorable templateSource, string? templateFileName, IFolder? parent = null)
37+
{
38+
_id = id ?? throw new ArgumentNullException(nameof(id));
39+
_markdownSource = markdownSource ?? throw new ArgumentNullException(nameof(markdownSource));
40+
_templateSource = templateSource ?? throw new ArgumentNullException(nameof(templateSource));
41+
_templateFileName = templateFileName;
42+
_parent = parent;
43+
}
44+
45+
/// <inheritdoc />
46+
public string Id => _id;
47+
48+
/// <inheritdoc />
49+
public string Name => "index.html";
50+
51+
/// <summary>
52+
/// File creation timestamp from filesystem metadata.
53+
/// </summary>
54+
public DateTime? Created { get; set; }
55+
56+
/// <summary>
57+
/// File modification timestamp from filesystem metadata.
58+
/// </summary>
59+
public DateTime? Modified { get; set; }
60+
61+
/// <inheritdoc />
62+
public Task<IFolder?> GetParentAsync(CancellationToken cancellationToken = default)
63+
{
64+
return Task.FromResult(_parent);
65+
}
66+
67+
/// <inheritdoc />
68+
public async Task<Stream> OpenStreamAsync(FileAccess accessMode, CancellationToken cancellationToken = default)
69+
{
70+
// Read-only file - reject write operations
71+
if (accessMode == FileAccess.Write || accessMode == FileAccess.ReadWrite)
72+
{
73+
throw new NotSupportedException($"IndexHtmlFile is read-only. Cannot open with access mode: {accessMode}");
74+
}
75+
76+
// Lazy generation: Transform markdown→HTML on every call (no caching)
77+
var html = await GenerateHtmlAsync(cancellationToken);
78+
79+
// Convert HTML string to UTF-8 byte stream
80+
var bytes = Encoding.UTF8.GetBytes(html);
81+
var stream = new MemoryStream(bytes);
82+
stream.Position = 0;
83+
84+
return stream;
85+
}
86+
87+
/// <summary>
88+
/// Generate HTML by transforming markdown source with template.
89+
/// Orchestrates: Parse markdown → Transform to HTML → Render template.
90+
/// </summary>
91+
private async Task<string> GenerateHtmlAsync(CancellationToken cancellationToken)
92+
{
93+
// Parse markdown file (extract front-matter + content)
94+
var (frontmatter, content) = await ParseMarkdownAsync(_markdownSource);
95+
96+
// Transform markdown content to HTML body
97+
var htmlBody = TransformMarkdownToHtml(content);
98+
99+
// Parse front-matter YAML to dictionary
100+
var frontmatterDict = ParseFrontmatter(frontmatter);
101+
102+
// Resolve template file from IStorable source
103+
var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName);
104+
105+
// Create data model for template
106+
var model = new PostPageDataModel
107+
{
108+
Body = htmlBody,
109+
Frontmatter = frontmatterDict,
110+
Filename = _markdownSource.Name,
111+
Created = Created,
112+
Modified = Modified
113+
};
114+
115+
// Render template with model
116+
var html = await RenderTemplateAsync(templateFile, model);
117+
118+
return html;
119+
}
120+
121+
#region Transformation Helpers
122+
123+
/// <summary>
124+
/// Extract YAML front-matter block from markdown file.
125+
/// Front-matter is delimited by "---" at start and end.
126+
/// Handles files without front-matter (returns empty string for frontmatter).
127+
/// </summary>
128+
/// <param name="file">Markdown file to parse</param>
129+
/// <returns>Tuple of (frontmatter YAML string, content markdown string)</returns>
130+
private async Task<(string frontmatter, string content)> ParseMarkdownAsync(IFile file)
131+
{
132+
var text = await file.ReadTextAsync();
133+
134+
// Check for front-matter delimiters
135+
if (!text.StartsWith("---"))
136+
{
137+
// No front-matter present
138+
return (string.Empty, text);
139+
}
140+
141+
// Find the closing delimiter
142+
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.None);
143+
var closingDelimiterIndex = -1;
144+
145+
for (int i = 1; i < lines.Length; i++)
146+
{
147+
if (lines[i].Trim() == "---")
148+
{
149+
closingDelimiterIndex = i;
150+
break;
151+
}
152+
}
153+
154+
if (closingDelimiterIndex == -1)
155+
{
156+
// No closing delimiter found - treat entire file as content
157+
return (string.Empty, text);
158+
}
159+
160+
// Extract front-matter (lines between delimiters)
161+
var frontmatterLines = lines.Skip(1).Take(closingDelimiterIndex - 1);
162+
var frontmatter = string.Join(Environment.NewLine, frontmatterLines);
163+
164+
// Extract content (everything after closing delimiter)
165+
var contentLines = lines.Skip(closingDelimiterIndex + 1);
166+
var content = string.Join(Environment.NewLine, contentLines);
167+
168+
return (frontmatter, content);
169+
}
170+
171+
/// <summary>
172+
/// Transform markdown content to HTML body using Markdig.
173+
/// Returns HTML without wrapping elements - template controls structure.
174+
/// Uses Advanced Extensions pipeline for full Markdown feature support.
175+
/// </summary>
176+
/// <param name="markdown">Markdown content string</param>
177+
/// <returns>HTML body content</returns>
178+
private string TransformMarkdownToHtml(string markdown)
179+
{
180+
var pipeline = new MarkdownPipelineBuilder()
181+
.UseAdvancedExtensions()
182+
.UseSoftlineBreakAsHardlineBreak()
183+
.Build();
184+
185+
return Markdown.ToHtml(markdown, pipeline);
186+
}
187+
188+
/// <summary>
189+
/// Parse YAML front-matter string to arbitrary dictionary.
190+
/// No schema enforcement - accepts any valid YAML structure.
191+
/// Handles empty/missing front-matter gracefully.
192+
/// </summary>
193+
/// <param name="yaml">YAML string from front-matter</param>
194+
/// <returns>Dictionary with arbitrary keys and values</returns>
195+
private Dictionary<string, object> ParseFrontmatter(string yaml)
196+
{
197+
// Handle empty front-matter
198+
if (string.IsNullOrWhiteSpace(yaml))
199+
{
200+
return new Dictionary<string, object>();
201+
}
202+
203+
try
204+
{
205+
var deserializer = new DeserializerBuilder()
206+
.Build();
207+
208+
var result = deserializer.Deserialize<Dictionary<string, object>>(yaml);
209+
return result ?? new Dictionary<string, object>();
210+
}
211+
catch (YamlDotNet.Core.YamlException ex)
212+
{
213+
throw new InvalidOperationException($"Failed to parse YAML front-matter: {ex.Message}", ex);
214+
}
215+
}
216+
217+
/// <summary>
218+
/// Resolve template file from IStorable source.
219+
/// Handles both IFile (single template) and IFolder (template + assets).
220+
/// Uses convention-based lookup ("template.html") when source is folder.
221+
/// </summary>
222+
/// <param name="templateSource">Template as IFile or IFolder</param>
223+
/// <param name="templateFileName">File name when source is IFolder (defaults to "template.html")</param>
224+
/// <returns>Resolved template IFile</returns>
225+
private async Task<IFile> ResolveTemplateFileAsync(
226+
IStorable templateSource,
227+
string? templateFileName)
228+
{
229+
if (templateSource is IFile file)
230+
{
231+
return file;
232+
}
233+
234+
if (templateSource is IFolder folder)
235+
{
236+
var fileName = templateFileName ?? "template.html";
237+
var templateFile = await folder.GetFirstByNameAsync(fileName);
238+
239+
if (templateFile is not IFile resolvedFile)
240+
{
241+
throw new FileNotFoundException(
242+
$"Template file '{fileName}' not found in folder '{folder.Name}'.");
243+
}
244+
245+
return resolvedFile;
246+
}
247+
248+
throw new ArgumentException(
249+
$"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}",
250+
nameof(templateSource));
251+
}
252+
253+
/// <summary>
254+
/// Render Scriban template with data model to produce final HTML.
255+
/// Template generates all HTML including meta tags from model.frontmatter.
256+
/// Flow boundary: Generator provides data model, template generates HTML.
257+
/// </summary>
258+
/// <param name="templateFile">Scriban template file</param>
259+
/// <param name="model">PostPageDataModel with body, frontmatter, metadata</param>
260+
/// <returns>Rendered HTML string</returns>
261+
private async Task<string> RenderTemplateAsync(
262+
IFile templateFile,
263+
PostPageDataModel model)
264+
{
265+
var templateContent = await templateFile.ReadTextAsync();
266+
267+
var template = Template.Parse(templateContent);
268+
269+
if (template.HasErrors)
270+
{
271+
var errors = string.Join(Environment.NewLine, template.Messages);
272+
throw new InvalidOperationException($"Template parsing failed:{Environment.NewLine}{errors}");
273+
}
274+
275+
var html = template.Render(model);
276+
277+
return html;
278+
}
279+
280+
#endregion
281+
}
282+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Runtime.CompilerServices;
4+
using System.Threading;
5+
using OwlCore.Storage;
6+
7+
namespace WindowsAppCommunity.Blog.PostPage
8+
{
9+
/// <summary>
10+
/// Virtual IChildFolder that recursively wraps template asset folders.
11+
/// Mirrors template folder structure with recursive PostPageAssetFolder wrapping.
12+
/// Passes through files directly (preserves type identity for fastpath extension methods).
13+
/// Propagates template file exclusion down hierarchy.
14+
/// </summary>
15+
public sealed class PostPageAssetFolder : IChildFolder
16+
{
17+
private readonly IFolder _wrappedFolder;
18+
private readonly IFolder _parent;
19+
private readonly IFile? _templateFileToExclude;
20+
21+
/// <summary>
22+
/// Creates virtual asset folder wrapping template folder structure.
23+
/// </summary>
24+
/// <param name="wrappedFolder">Template folder to mirror</param>
25+
/// <param name="parent">Parent folder in virtual hierarchy</param>
26+
/// <param name="templateFileToExclude">Template HTML file to exclude from enumeration</param>
27+
public PostPageAssetFolder(IFolder wrappedFolder, IFolder parent, IFile? templateFileToExclude)
28+
{
29+
_wrappedFolder = wrappedFolder ?? throw new ArgumentNullException(nameof(wrappedFolder));
30+
_parent = parent ?? throw new ArgumentNullException(nameof(parent));
31+
_templateFileToExclude = templateFileToExclude;
32+
}
33+
34+
/// <inheritdoc />
35+
public string Id => _wrappedFolder.Id;
36+
37+
/// <inheritdoc />
38+
public string Name => _wrappedFolder.Name;
39+
40+
/// <summary>
41+
/// Parent folder in virtual hierarchy (not interface requirement, internal storage).
42+
/// </summary>
43+
public IFolder Parent => _parent;
44+
45+
/// <inheritdoc />
46+
public Task<IFolder?> GetParentAsync(CancellationToken cancellationToken = default)
47+
{
48+
return Task.FromResult<IFolder?>(_parent);
49+
}
50+
51+
/// <inheritdoc />
52+
public async IAsyncEnumerable<IStorableChild> GetItemsAsync(
53+
StorableType type = StorableType.All,
54+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
55+
{
56+
OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync starting for: {_wrappedFolder.Id}");
57+
58+
// Enumerate wrapped folder items
59+
await foreach (var item in _wrappedFolder.GetItemsAsync(type, cancellationToken))
60+
{
61+
// Recursively wrap subfolders with this as parent
62+
if (item is IFolder subfolder && (type == StorableType.All || type == StorableType.Folder))
63+
{
64+
yield return new PostPageAssetFolder(subfolder, this, _templateFileToExclude);
65+
continue;
66+
}
67+
68+
// Pass through files directly (preserves type identity)
69+
if (item is IChildFile file && (type == StorableType.All || type == StorableType.File))
70+
{
71+
// Exclude template HTML file if specified
72+
if (_templateFileToExclude != null && file.Id == _templateFileToExclude.Id)
73+
{
74+
continue;
75+
}
76+
77+
yield return file;
78+
}
79+
}
80+
81+
OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync complete for: {_wrappedFolder.Id}");
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)