Skip to content

Commit ebbd8cf

Browse files
committed
refactor: overhaul asset management system with extensible strategy pattern
Major refactoring of the blog generation asset pipeline to improve flexibility and maintainability: **Core Interface Changes:** - Renamed `IAssetInclusionStrategy` → `IAssetStrategy` with nullable return type - Updated method signatures across asset interfaces for clarity and consistency - Renamed `ReferencedAsset` record → `PageAsset` to better reflect its purpose - Renamed `PostPageDataModel` → `HtmlMarkdownDataTemplateModel` for generic use **Asset Strategy System:** - Deleted `ReferenceOnlyInclusionStrategy.cs` (replaced with new architecture) - Added `KnownAssetStrategy.cs`: configurable strategy using known asset ID lists - Supports both included and referenced asset file ID sets - Includes fallback behavior options (Reference/Include/Drop) - Added `FaultStrategy.cs`: enum for unknown asset handling (None/LogWarn/LogError/Throw) **Asset Detection Improvements:** - Enhanced `RegexAssetLinkDetector` with improved path matching patterns - Added protocol scheme detection to filter out absolute URLs - Added standalone filename pattern detection - Fixed relative path resolution in `RelativePathAssetResolver` **Processing Pipeline Refactoring:** - Moved asset detection earlier in pipeline to include template file assets - Changed `AssetAwareHtmlTemplatedMarkdownFile` to scan both template and markdown - Updated post-processing to return nullable for dropped assets - Refactored `PagesCommand` to configure new asset strategy with separate ID sets **Command Structure:** - Deleted legacy `PostPageCommand.cs`, `PostPageFolder.cs`, `IndexHtmlFile.cs`, `PostPageAssetFolder.cs` - Added new `PageCommand.cs` for single-page generation - Updated `WacsdkBlogCommands` to use new command structure **Dependencies:** - Added `OwlCore.Extensions` package reference for enhanced functionality **Tests:** - Updated test references from `InclusionStrategy` → `AssetStrategy` - Created temporary `ReferenceOnlyAssetStrategy` for test compatibility This refactoring enables fine-grained control over asset handling, supporting scenarios like template-based asset inclusion vs markdown-based asset referencing, with configurable fallback behavior for unknown assets.
1 parent 5e8b9ed commit ebbd8cf

24 files changed

+376
-919
lines changed

src/Blog/Assets/FaultStrategy.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace WindowsAppCommunity.Blog.Assets;
2+
3+
/// <summary>
4+
/// The strategy to use when encountering an unknown asset.
5+
/// </summary>
6+
[Flags]
7+
public enum FaultStrategy
8+
{
9+
/// <summary>
10+
/// Nothing happens when an unknown asset it encountered. It is skipped without error or log.
11+
/// </summary>
12+
None,
13+
14+
/// <summary>
15+
/// Logs a warning if an unknown asset is encountered.
16+
/// </summary>
17+
LogWarn,
18+
19+
/// <summary>
20+
/// Logs an error without throwing if an unknown asset is encountered.
21+
/// </summary>
22+
LogError,
23+
24+
/// <summary>
25+
/// Throws if an unknown asset is encountered.
26+
/// </summary>
27+
Throw,
28+
}

src/Blog/Assets/IAssetInclusionStrategy.cs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,22 @@ namespace WindowsAppCommunity.Blog.Assets;
66
/// Provides decision logic for asset inclusion via path rewriting.
77
/// Strategy returns rewritten path - path structure determines Include vs Reference behavior.
88
/// </summary>
9-
public interface IAssetInclusionStrategy
9+
public interface IAssetStrategy
1010
{
1111
/// <summary>
1212
/// Decides asset inclusion strategy by returning rewritten path.
1313
/// </summary>
14-
/// <param name="referencingMarkdown">The markdown file that references the asset.</param>
15-
/// <param name="referencedAsset">The asset file being referenced.</param>
16-
/// <param name="originalPath">The original relative path from markdown.</param>
14+
/// <param name="referencingTextFile">The file that references the asset.</param>
15+
/// <param name="referencedAssetFile">The asset file being referenced.</param>
16+
/// <param name="originalPath">The original relative path from the file.</param>
1717
/// <param name="ct">Cancellation token.</param>
1818
/// <returns>
1919
/// Rewritten path string. Path structure determines behavior:
2020
/// <list type="bullet">
2121
/// <item><description>Child path (no ../ prefix): Asset included in page folder (self-contained)</description></item>
2222
/// <item><description>Parent path (../ prefix): Asset referenced externally (link rewritten to account for folderization)</description></item>
23+
/// <item><description>null: Asset has been dropped from output without inclusion or reference.</description></item>
2324
/// </list>
2425
/// </returns>
25-
Task<string> DecideAsync(
26-
IFile referencingMarkdown,
27-
IFile referencedAsset,
28-
string originalPath,
29-
CancellationToken ct = default);
26+
Task<string?> DecideAsync(IFile referencingTextFile, IFile referencedAssetFile, string originalPath, CancellationToken ct = default);
3027
}

src/Blog/Assets/IAssetLinkDetector.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ public interface IAssetLinkDetector
1010
/// <summary>
1111
/// Detects relative asset link strings in rendered HTML output.
1212
/// </summary>
13-
/// <param name="htmlSource">Virtual IFile representing rendered HTML output (in-memory representation).</param>
14-
/// <param name="ct">Cancellation token.</param>
13+
/// <param name="sourceFile">File instance containing text to detect links from.</param>
14+
/// <param name="cancellationToken">Cancellation token.</param>
1515
/// <returns>Async enumerable of relative path strings.</returns>
16-
IAsyncEnumerable<string> DetectAsync(IFile htmlSource, CancellationToken ct = default);
16+
IAsyncEnumerable<string> DetectAsync(IFile sourceFile, CancellationToken cancellationToken = default);
1717
}

src/Blog/Assets/IAssetResolver.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ public interface IAssetResolver
1010
/// <summary>
1111
/// Resolves a relative path string to an IFile instance.
1212
/// </summary>
13-
/// <param name="markdownSource">Markdown file for relative path context (varies per page).</param>
13+
/// <param name="sourceFile">The file to get the relative path from.</param>
1414
/// <param name="relativePath">The relative path to resolve.</param>
1515
/// <param name="ct">Cancellation token.</param>
1616
/// <returns>The resolved IFile, or null if not found.</returns>
17-
Task<IFile?> ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default);
17+
Task<IFile?> ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default);
1818
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using OwlCore.Diagnostics;
2+
using OwlCore.Storage;
3+
4+
namespace WindowsAppCommunity.Blog.Assets;
5+
6+
/// <summary>
7+
/// Determines fallback asset behavior when the asset is not known to the strategy selector.
8+
/// </summary>
9+
public enum AssetFallbackBehavior
10+
{
11+
/// <summary>
12+
/// The asset path is rewritten to support being referenced by the folderized markdown.
13+
/// </summary>
14+
Reference,
15+
16+
/// <summary>
17+
/// The asset path is not rewritten and it is included in the output path.
18+
/// </summary>
19+
Include,
20+
21+
/// <summary>
22+
/// The new asset path is returned as null and the asset is not included in the output.
23+
/// </summary>
24+
Drop,
25+
}
26+
27+
/// <summary>
28+
/// Uses a known list of files to decide between asset inclusion (child path) vs asset reference (parented path).
29+
/// </summary>
30+
public sealed class KnownAssetStrategy : IAssetStrategy
31+
{
32+
/// <summary>
33+
/// A list of known file IDs to rewrite to an included asset.
34+
/// </summary>
35+
public HashSet<string> IncludedAssetFileIds { get; set; } = new();
36+
37+
/// <summary>
38+
/// A list of known file IDs rewrite as a referenced asset.
39+
/// </summary>
40+
public HashSet<string> ReferencedAssetFileIds { get; set; } = new();
41+
42+
/// <summary>
43+
/// The strategy to use when encountering an unknown asset.
44+
/// </summary>
45+
public FaultStrategy UnknownAssetFaultStrategy { get; set; }
46+
47+
/// <summary>
48+
/// Gets or sets the fallback used when the asset is unknown but <see cref="UnknownAssetFaultStrategy"/> does not have <see cref="FaultStrategy.Throw"/>.
49+
/// </summary>
50+
public AssetFallbackBehavior UnknownAssetFallbackStrategy { get; set; }
51+
52+
/// <inheritdoc/>
53+
public async Task<string?> DecideAsync(IFile referencingMarkdown, IFile referencedAsset, string originalPath, CancellationToken ct = default)
54+
{
55+
if (string.IsNullOrWhiteSpace(originalPath))
56+
return originalPath;
57+
58+
var isReferenced = ReferencedAssetFileIds.Contains(referencedAsset.Id);
59+
var isIncluded = IncludedAssetFileIds.Contains(referencedAsset.Id);
60+
61+
if (isReferenced)
62+
return $"../{originalPath}";
63+
64+
if (isIncluded)
65+
return originalPath;
66+
67+
// Handle as unknown
68+
HandleUnknownAsset(referencedAsset);
69+
70+
return UnknownAssetFallbackStrategy switch
71+
{
72+
AssetFallbackBehavior.Reference => $"../{originalPath}",
73+
AssetFallbackBehavior.Include => originalPath,
74+
AssetFallbackBehavior.Drop => null,
75+
_ => throw new ArgumentOutOfRangeException(nameof(UnknownAssetFallbackStrategy)),
76+
};
77+
}
78+
79+
private void HandleUnknownAsset(IFile referencedAsset)
80+
{
81+
var faultMessage = $"Unknown asset encountered: {nameof(referencedAsset.Name)} {referencedAsset.Name}, {nameof(referencedAsset.Id)} {referencedAsset.Id}. Please add this ID to either {nameof(IncludedAssetFileIds)} or {nameof(ReferencedAssetFileIds)}.";
82+
83+
if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.LogWarn))
84+
Logger.LogWarning(faultMessage);
85+
86+
if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.LogError))
87+
Logger.LogError(faultMessage);
88+
89+
if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.Throw))
90+
throw new InvalidOperationException(faultMessage);
91+
}
92+
}

src/Blog/Assets/ReferenceOnlyInclusionStrategy.cs

Lines changed: 0 additions & 26 deletions
This file was deleted.

src/Blog/Assets/ReferencedAsset.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,5 @@ namespace WindowsAppCommunity.Blog.Assets
99
/// <param name="OriginalPath">Path detected in markdown (relative to source file)</param>
1010
/// <param name="RewrittenPath">Path after inclusion strategy applied (include vs reference)</param>
1111
/// <param name="ResolvedFile">Actual file instance for copy operations</param>
12-
public record ReferencedAsset(
13-
string OriginalPath,
14-
string RewrittenPath,
15-
IFile ResolvedFile
16-
);
12+
public record PageAsset(string OriginalPath, string RewrittenPath, IFile ResolvedFile);
1713
}
Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,37 @@
11
using System.Runtime.CompilerServices;
22
using System.Text.RegularExpressions;
3+
using OwlCore.Diagnostics;
34
using OwlCore.Storage;
45

56
namespace WindowsAppCommunity.Blog.Assets;
67

78
/// <summary>
8-
/// Detects relative asset links in rendered HTML using path-pattern regex (no element parsing).
9+
/// Detects relative asset links in rendered using path-pattern regex (no element parsing).
910
/// </summary>
1011
public sealed partial class RegexAssetLinkDetector : IAssetLinkDetector
1112
{
1213
/// <summary>
1314
/// Regex pattern for relative path segments: alphanumerics, underscore, hyphen, dot.
1415
/// Matches paths with optional ./ or ../ prefixes and / or \ separators.
1516
/// </summary>
16-
[GeneratedRegex(@"(?<![A-Za-z0-9_\-\.])(?:(?:\./)|(?:\.\./)+)?[A-Za-z0-9_\-\.]+(?:[\/\\][A-Za-z0-9_\-\.]+)*(?![A-Za-z0-9_\-\.])", RegexOptions.Compiled)]
17+
[GeneratedRegex(@"(?:\.\.?/(?:[A-Za-z0-9_\-\.]+/)*[A-Za-z0-9_\-\.]+|[A-Za-z0-9_\-\.]+(?:/[A-Za-z0-9_\-\.]+)+)", RegexOptions.Compiled)]
1718
private static partial Regex RelativePathPattern();
1819

20+
/// <summary>
21+
/// Regex pattern to detect protocol schemes (e.g., http://, custom://, drive://).
22+
/// </summary>
23+
[GeneratedRegex(@"[A-Za-z][A-Za-z0-9+\-\.]*://", RegexOptions.Compiled)]
24+
private static partial Regex ProtocolSchemePattern();
25+
26+
[GeneratedRegex(@"\b[A-Za-z0-9_\-]+\.[A-Za-z0-9]+\b", RegexOptions.Compiled)]
27+
private static partial Regex FilenamePattern();
28+
1929
/// <inheritdoc/>
20-
public async IAsyncEnumerable<string> DetectAsync(IFile htmlSource, [EnumeratorCancellation] CancellationToken ct = default)
30+
public async IAsyncEnumerable<string> DetectAsync(IFile source, [EnumeratorCancellation] CancellationToken ct = default)
2131
{
22-
// Read HTML content
23-
using var stream = await htmlSource.OpenStreamAsync(FileAccess.Read, ct);
24-
using var reader = new StreamReader(stream);
25-
var html = await reader.ReadToEndAsync(ct);
26-
27-
// Find all matches
28-
var matches = RelativePathPattern().Matches(html);
32+
var text = await source.ReadTextAsync(ct);
2933

30-
foreach (Match match in matches)
34+
foreach (Match match in RelativePathPattern().Matches(text))
3135
{
3236
if (ct.IsCancellationRequested)
3337
yield break;
@@ -38,18 +42,30 @@ public async IAsyncEnumerable<string> DetectAsync(IFile htmlSource, [EnumeratorC
3842
if (string.IsNullOrWhiteSpace(path))
3943
continue;
4044

41-
// Exclude absolute schemes
42-
if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
43-
path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
44-
path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
45-
path.StartsWith("//", StringComparison.Ordinal))
46-
continue;
47-
4845
// Exclude absolute root paths (optional - treating these as non-relative)
4946
if (path.StartsWith('/') || path.StartsWith('\\'))
5047
continue;
5148

49+
// Check if this path is preceded by a protocol scheme (e.g., custom://path/to/file)
50+
// Look back to see if there's a protocol before this match
51+
var startIndex = match.Index;
52+
if (startIndex > 0)
53+
{
54+
// Check up to 50 characters before the match for a protocol scheme
55+
var lookbackLength = Math.Min(50, startIndex);
56+
var precedingText = text.Substring(startIndex - lookbackLength, lookbackLength);
57+
58+
// If the preceding text ends with a protocol scheme (e.g., "custom://"), skip this match
59+
if (ProtocolSchemePattern().IsMatch(precedingText) && precedingText.TrimEnd().EndsWith("://"))
60+
continue;
61+
}
62+
5263
yield return path;
5364
}
65+
66+
foreach (Match match in FilenamePattern().Matches(text))
67+
{
68+
yield return match.Value;
69+
}
5470
}
5571
}

src/Blog/Assets/RelativePathAssetResolver.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace WindowsAppCommunity.Blog.Assets;
1010
public sealed class RelativePathAssetResolver : IAssetResolver
1111
{
1212
/// <inheritdoc/>
13-
public async Task<IFile?> ResolveAsync(IFile markdownSource, string relativePath, CancellationToken ct = default)
13+
public async Task<IFile?> ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default)
1414
{
1515
if (string.IsNullOrWhiteSpace(relativePath))
1616
return null;
@@ -20,9 +20,9 @@ public sealed class RelativePathAssetResolver : IAssetResolver
2020
// Normalize path separators to forward slash
2121
var normalizedPath = relativePath.Replace('\\', '/');
2222

23-
// Resolve relative to markdown file's location (pre-folderization)
23+
// Resolve relative to markdown file's containing location (pre-folderization)
2424
// The markdown file itself is the base for relative path resolution
25-
var item = await markdownSource.GetItemByRelativePathAsync(normalizedPath, ct);
25+
var item = await sourceFile.GetItemByRelativePathAsync($"../{normalizedPath}", ct);
2626

2727
// Return only if it's a file
2828
return item as IFile;

0 commit comments

Comments
 (0)