Skip to content

Commit ba22171

Browse files
committed
refactor: Decouple asset resolver from markdown source and implement ReferencedAsset tracking
Changes to asset resolution architecture: - Modified IAssetResolver to accept markdown source per-call instead of storing it as state - Updated RelativePathAssetResolver to be stateless, receiving context file in ResolveAsync() - Enables shared resolver instance across multiple pages without coupling Introduced ReferencedAsset record: - Captures complete asset reference info: original path, rewritten path, and resolved file - Facilitates materialization in consumer code (PagesCommand) - Replaces IFile collection with structured ReferencedAsset collection AssetAwareHtmlTemplatedMarkdownFile improvements: - Tracks all referenced assets (both included and referenced) via ReferencedAsset - Unified asset processing for markdown source and template file detection - Extracted ProcessAssetLinkAsync() method for shared pipeline logic Virtual structure simplification: - Removed automatic asset yielding from AssetAwareHtmlTemplatedMarkdownPageFolder - Removed template asset yielding from HtmlTemplatedMarkdownPageFolder base class - Assets now tracked in ReferencedAsset collection for explicit consumer materialization Implemented PagesCommand: - CLI command for multi-page blog generation using AssetAwareHtmlTemplatedMarkdownPagesFolder - Materializes virtual structure by iterating page folders and copying files - Uses ReferencedAsset.RewrittenPath for correct asset output placement Added comprehensive test coverage: - AssetAwareHtmlTemplatedMarkdownPagesFolderTests with 7 test cases - Covers markdown discovery, hierarchy preservation, asset resolution, and link rewriting Minor cleanup: - Removed debug logging from PostPageAssetFolder - Fixed formatting/whitespace in PostPageFolder and HtmlTemplatedMarkdownPageFolder - Changed return types to IChildFolder for Pages-related classes
1 parent f7663a3 commit ba22171

12 files changed

+504
-113
lines changed

src/Blog/Assets/IAssetResolver.cs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,12 @@ public interface IAssetResolver
1212
/// </summary>
1313
IFolder SourceFolder { get; init; }
1414

15-
/// <summary>
16-
/// Markdown file for relative path context.
17-
/// </summary>
18-
IFile MarkdownSource { get; init; }
19-
2015
/// <summary>
2116
/// Resolves a relative path string to an IFile instance.
2217
/// </summary>
18+
/// <param name="markdownSource">Markdown file for relative path context (varies per page).</param>
2319
/// <param name="relativePath">The relative path to resolve.</param>
2420
/// <param name="ct">Cancellation token.</param>
2521
/// <returns>The resolved IFile, or null if not found.</returns>
26-
Task<IFile?> ResolveAsync(string relativePath, CancellationToken ct = default);
22+
Task<IFile?> ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default);
2723
}

src/Blog/Assets/ReferencedAsset.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using OwlCore.Storage;
2+
3+
namespace WindowsAppCommunity.Blog.Assets
4+
{
5+
/// <summary>
6+
/// Captures complete asset reference information for materialization.
7+
/// Stores original detected path, rewritten path after strategy, and resolved file instance.
8+
/// </summary>
9+
/// <param name="OriginalPath">Path detected in markdown (relative to source file)</param>
10+
/// <param name="RewrittenPath">Path after inclusion strategy applied (include vs reference)</param>
11+
/// <param name="ResolvedFile">Actual file instance for copy operations</param>
12+
public record ReferencedAsset(
13+
string OriginalPath,
14+
string RewrittenPath,
15+
IFile ResolvedFile
16+
);
17+
}

src/Blog/Assets/RelativePathAssetResolver.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,15 @@ namespace WindowsAppCommunity.Blog.Assets;
55
/// <summary>
66
/// Resolves relative paths to IFile instances using source folder and markdown file context.
77
/// Paths are resolved relative to the markdown file's location (pre-folderization).
8+
/// Stateless design - markdown source passed per-call to support shared resolver across pages.
89
/// </summary>
910
public sealed class RelativePathAssetResolver : IAssetResolver
1011
{
1112
/// <inheritdoc/>
1213
public required IFolder SourceFolder { get; init; }
1314

1415
/// <inheritdoc/>
15-
public required IFile MarkdownSource { get; init; }
16-
17-
/// <inheritdoc/>
18-
public async Task<IFile?> ResolveAsync(string relativePath, CancellationToken ct = default)
16+
public async Task<IFile?> ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default)
1917
{
2018
if (string.IsNullOrWhiteSpace(relativePath))
2119
return null;
@@ -27,7 +25,7 @@ public sealed class RelativePathAssetResolver : IAssetResolver
2725

2826
// Resolve relative to markdown file's location (pre-folderization)
2927
// The markdown file itself is the base for relative path resolution
30-
var item = await MarkdownSource.GetItemByRelativePathAsync(normalizedPath, ct);
28+
var item = await markdownSource.GetItemByRelativePathAsync(normalizedPath, ct);
3129

3230
// Return only if it's a file
3331
return item as IFile;

src/Blog/Page/AssetAwareHtmlTemplatedMarkdownFile.cs

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ namespace WindowsAppCommunity.Blog.Page
1515
/// </summary>
1616
public sealed class AssetAwareHtmlTemplatedMarkdownFile : HtmlTemplatedMarkdownFile
1717
{
18-
private readonly List<IFile> _includedAssets = new();
18+
private readonly List<ReferencedAsset> _includedAssets = new();
19+
private readonly IStorable _templateSource;
20+
private readonly string? _templateFileName;
1921

2022
/// <summary>
2123
/// Creates asset-aware virtual HTML file with lazy markdown→HTML generation and asset management.
@@ -33,6 +35,8 @@ public AssetAwareHtmlTemplatedMarkdownFile(
3335
IFolder? parent = null)
3436
: base(id, markdownSource, templateSource, templateFileName, parent)
3537
{
38+
_templateSource = templateSource;
39+
_templateFileName = templateFileName;
3640
}
3741

3842
/// <summary>
@@ -51,14 +55,15 @@ public AssetAwareHtmlTemplatedMarkdownFile(
5155
public required IAssetInclusionStrategy InclusionStrategy { get; init; }
5256

5357
/// <summary>
54-
/// Assets that were decided for inclusion (self-contained in page folder).
55-
/// Exposed to containing folder for yielding in virtual structure.
58+
/// All assets referenced by the markdown file (both included and referenced).
59+
/// Exposed to containing folder for materialization to output.
5660
/// </summary>
57-
public IReadOnlyCollection<IFile> IncludedAssets => _includedAssets.AsReadOnly();
61+
public IReadOnlyCollection<ReferencedAsset> IncludedAssets => _includedAssets.AsReadOnly();
5862

5963
/// <summary>
6064
/// Post-process HTML with asset management pipeline.
6165
/// Detects links → Resolves to files → Decides include/reference via path rewriting → Tracks included assets.
66+
/// Detects links from BOTH markdown source AND template file to unify asset handling.
6267
/// </summary>
6368
/// <param name="html">Rendered HTML from template</param>
6469
/// <param name="model">Data model used for rendering</param>
@@ -69,41 +74,54 @@ protected override async Task<string> PostProcessHtmlAsync(string html, PostPage
6974
// Clear included assets from any previous generation
7075
_includedAssets.Clear();
7176

72-
// Detect asset links in rendered HTML output (pass self as IFile)
73-
await foreach (var originalPath in LinkDetector.DetectAsync(this, ct))
77+
// Detect asset links from markdown source (content-referenced assets)
78+
await foreach (var originalPath in LinkDetector.DetectAsync(MarkdownSource, ct))
7479
{
75-
// Resolve path to IFile
76-
var resolvedAsset = await Resolver.ResolveAsync(originalPath, ct);
77-
78-
// Null resolver policy: Skip if not found (preserve broken link)
79-
if (resolvedAsset == null)
80-
{
81-
continue;
82-
}
80+
html = await ProcessAssetLinkAsync(html, MarkdownSource, originalPath, ct);
81+
}
8382

84-
// Strategy decides include vs reference by returning rewritten path
85-
// Path structure determines behavior:
86-
// - Child path (no ../ prefix): Include
87-
// - Parent path (../ prefix): Reference
88-
var rewrittenPath = await InclusionStrategy.DecideAsync(
89-
MarkdownSource, // Pass original markdown source (not virtual HTML)
90-
resolvedAsset,
91-
originalPath,
92-
ct);
83+
return html;
84+
}
9385

94-
// Implicit decision based on path structure
95-
if (!rewrittenPath.StartsWith("../"))
96-
{
97-
// Include: Add to tracked assets (will be yielded by containing folder)
98-
_includedAssets.Add(resolvedAsset);
99-
}
100-
// Reference: Asset not added to included list (stays external)
86+
/// <summary>
87+
/// Process a single detected asset link through the asset pipeline.
88+
/// Shared logic for both markdown and template asset detection.
89+
/// </summary>
90+
/// <param name="html">HTML content to update</param>
91+
/// <param name="contextFile">File providing resolution context (markdown or template)</param>
92+
/// <param name="originalPath">Original asset path as detected</param>
93+
/// <param name="ct">Cancellation token</param>
94+
/// <returns>Updated HTML with rewritten link</returns>
95+
private async Task<string> ProcessAssetLinkAsync(
96+
string html,
97+
IFile contextFile,
98+
string originalPath,
99+
CancellationToken ct)
100+
{
101+
// Resolve path to IFile (pass context file for resolution)
102+
var resolvedAsset = await Resolver.ResolveAsync(contextFile, originalPath, ct);
101103

102-
// Rewrite link in HTML (applies to both Include and Reference)
103-
html = html.Replace(originalPath, rewrittenPath);
104+
// Null resolver policy: Skip if not found (preserve broken link)
105+
if (resolvedAsset == null)
106+
{
107+
return html;
104108
}
105109

106-
return html;
110+
// Strategy decides include vs reference by returning rewritten path
111+
// Path structure determines behavior:
112+
// - Child path (no ../ prefix): Include
113+
// - Parent path (../ prefix): Reference
114+
var rewrittenPath = await InclusionStrategy.DecideAsync(
115+
contextFile,
116+
resolvedAsset,
117+
originalPath,
118+
ct);
119+
120+
// Track all referenced assets for materialization
121+
_includedAssets.Add(new ReferencedAsset(originalPath, rewrittenPath, resolvedAsset));
122+
123+
// Rewrite link in HTML (strategy determines path prefix)
124+
return html.Replace(originalPath, rewrittenPath);
107125
}
108126
}
109127
}

src/Blog/Page/AssetAwareHtmlTemplatedMarkdownPageFolder.cs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,6 @@ public override async IAsyncEnumerable<IStorableChild> GetItemsAsync(
8080
// Pass through other items (template assets)
8181
yield return item;
8282
}
83-
84-
// Yield markdown-referenced assets that were decided for inclusion
85-
if (assetAwareFile != null && (type == StorableType.All || type == StorableType.File))
86-
{
87-
foreach (var includedAsset in assetAwareFile.IncludedAssets)
88-
{
89-
yield return (IStorableChild)includedAsset;
90-
}
91-
}
9283
}
9384
}
9485
}

src/Blog/Page/HtmlTemplatedMarkdownPageFolder.cs

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace WindowsAppCommunity.Blog.Page
1515
/// Base class - wraps markdown source file and template to provide virtual {filename}/index.html + assets structure.
1616
/// Implements lazy generation - no file system operations during construction.
1717
/// </summary>
18-
public class HtmlTemplatedMarkdownPageFolder : IFolder
18+
public class HtmlTemplatedMarkdownPageFolder : IChildFolder
1919
{
2020
private readonly IFile _markdownSource;
2121
private readonly IStorable _templateSource;
@@ -72,10 +72,9 @@ public virtual async IAsyncEnumerable<IStorableChild> GetItemsAsync(
7272
StorableType type = StorableType.All,
7373
[EnumeratorCancellation] CancellationToken cancellationToken = default)
7474
{
75-
// Resolve template file for exclusion and HtmlTemplatedMarkdownFile construction
76-
var templateFile = await ResolveTemplateFileAsync(_templateSource, _templateFileName);
77-
78-
// Yield HtmlTemplatedMarkdownFile (virtual HTML file)
75+
// Yield HtmlTemplatedMarkdownFile (virtual HTML file) only
76+
// Template assets are NOT yielded here - they're detected as links in the template HTML
77+
// and tracked in the HTML file's IncludedAssets collection for consumer materialization
7978
if (type == StorableType.All || type == StorableType.File)
8079
{
8180
var indexHtmlId = $"{Id}/index.html";
@@ -84,32 +83,6 @@ public virtual async IAsyncEnumerable<IStorableChild> GetItemsAsync(
8483
Name = "index.html"
8584
};
8685
}
87-
88-
// If template is folder, yield wrapped asset structure
89-
if (_templateSource is IFolder templateFolder)
90-
{
91-
await foreach (var item in templateFolder.GetItemsAsync(StorableType.All, cancellationToken))
92-
{
93-
// Wrap subfolders as PostPageAssetFolder
94-
if (item is IFolder subfolder && (type == StorableType.All || type == StorableType.Folder))
95-
{
96-
yield return new PostPageAssetFolder(subfolder, this, templateFile);
97-
continue;
98-
}
99-
100-
// Pass through files directly (excluding template HTML file)
101-
if (item is IChildFile file && (type == StorableType.All || type == StorableType.File))
102-
{
103-
// Exclude template HTML file (already rendered as index.html)
104-
if (file.Id == templateFile.Id)
105-
{
106-
continue;
107-
}
108-
109-
yield return file;
110-
}
111-
}
112-
}
11386
}
11487

11588
/// <summary>
@@ -155,16 +128,13 @@ private async Task<IFile> ResolveTemplateFileAsync(
155128

156129
if (templateFile is not IFile resolvedFile)
157130
{
158-
throw new FileNotFoundException(
159-
$"Template file '{fileName}' not found in folder '{folder.Name}'.");
131+
throw new FileNotFoundException($"Template file '{fileName}' not found in folder '{folder.Name}'.");
160132
}
161133

162134
return resolvedFile;
163135
}
164136

165-
throw new ArgumentException(
166-
$"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}",
167-
nameof(templateSource));
137+
throw new ArgumentException($"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}", nameof(templateSource));
168138
}
169139
}
170140
}

src/Blog/Pages/AssetAwareHtmlTemplatedMarkdownPagesFolder.cs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ namespace WindowsAppCommunity.Blog.Pages
1616
/// Asset-aware only variant (no non-asset-aware needed for multi-page scenario).
1717
/// Implements lazy generation - no file system operations during construction.
1818
/// </summary>
19-
public class AssetAwareHtmlTemplatedMarkdownPagesFolder : IFolder
19+
public class AssetAwareHtmlTemplatedMarkdownPagesFolder : IChildFolder
2020
{
2121
private readonly IFolder _markdownSourceFolder;
2222
private readonly IStorable _templateSource;
@@ -82,18 +82,15 @@ public async IAsyncEnumerable<IStorableChild> GetItemsAsync(StorableType type =
8282
{
8383
if (type == StorableType.All || type == StorableType.Folder)
8484
{
85-
var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(
86-
file,
87-
_templateSource,
88-
_templateFileName)
85+
var pageFolder = new AssetAwareHtmlTemplatedMarkdownPageFolder(file, _templateSource, _templateFileName)
8986
{
9087
LinkDetector = LinkDetector,
9188
Resolver = Resolver,
9289
InclusionStrategy = InclusionStrategy,
9390
Parent = this
9491
};
9592

96-
yield return (IStorableChild)pageFolder;
93+
yield return pageFolder;
9794
}
9895
}
9996

@@ -102,18 +99,15 @@ public async IAsyncEnumerable<IStorableChild> GetItemsAsync(StorableType type =
10299
{
103100
if (type == StorableType.All || type == StorableType.Folder)
104101
{
105-
var nestedPagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(
106-
subfolder,
107-
_templateSource,
108-
_templateFileName)
102+
var nestedPagesFolder = new AssetAwareHtmlTemplatedMarkdownPagesFolder(subfolder, _templateSource, _templateFileName)
109103
{
110104
LinkDetector = LinkDetector,
111105
Resolver = Resolver,
112106
InclusionStrategy = InclusionStrategy,
113107
Parent = this
114108
};
115109

116-
yield return (IStorableChild)nestedPagesFolder;
110+
yield return nestedPagesFolder;
117111
}
118112
}
119113
}

src/Blog/PostPage/PostPageAssetFolder.cs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@ public async IAsyncEnumerable<IStorableChild> GetItemsAsync(
5353
StorableType type = StorableType.All,
5454
[EnumeratorCancellation] CancellationToken cancellationToken = default)
5555
{
56-
OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync starting for: {_wrappedFolder.Id}");
57-
5856
// Enumerate wrapped folder items
5957
await foreach (var item in _wrappedFolder.GetItemsAsync(type, cancellationToken))
6058
{
@@ -77,8 +75,6 @@ public async IAsyncEnumerable<IStorableChild> GetItemsAsync(
7775
yield return file;
7876
}
7977
}
80-
81-
OwlCore.Diagnostics.Logger.LogInformation($"PostPageAssetFolder.GetItemsAsync complete for: {_wrappedFolder.Id}");
8278
}
8379
}
8480
}

src/Blog/PostPage/PostPageFolder.cs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ private string SanitizeFilename(string markdownFilename)
9494

9595
// Replace invalid filename characters with underscore
9696
var invalidChars = Path.GetInvalidFileNameChars();
97-
var sanitized = string.Concat(nameWithoutExtension.Select(c =>
98-
invalidChars.Contains(c) ? '_' : c));
97+
var sanitized = string.Concat(nameWithoutExtension.Select(c => invalidChars.Contains(c) ? '_' : c));
9998

10099
return sanitized;
101100
}
@@ -124,16 +123,13 @@ private async Task<IFile> ResolveTemplateFileAsync(
124123

125124
if (templateFile is not IFile resolvedFile)
126125
{
127-
throw new FileNotFoundException(
128-
$"Template file '{fileName}' not found in folder '{folder.Name}'.");
126+
throw new FileNotFoundException($"Template file '{fileName}' not found in folder '{folder.Name}'.");
129127
}
130128

131129
return resolvedFile;
132130
}
133131

134-
throw new ArgumentException(
135-
$"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}",
136-
nameof(templateSource));
132+
throw new ArgumentException($"Template source must be IFile or IFolder, got: {templateSource.GetType().Name}", nameof(templateSource));
137133
}
138134
}
139135
}

0 commit comments

Comments
 (0)