Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/Blog/Assets/FaultStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// The strategy to use when encountering an unknown asset.
/// </summary>
[Flags]
public enum FaultStrategy
{
/// <summary>
/// Nothing happens when an unknown asset it encountered. It is skipped without error or log.
/// </summary>
None,

/// <summary>
/// Logs a warning if an unknown asset is encountered.
/// </summary>
LogWarn,

/// <summary>
/// Logs an error without throwing if an unknown asset is encountered.
/// </summary>
LogError,

/// <summary>
/// Throws if an unknown asset is encountered.
/// </summary>
Throw,
}
27 changes: 27 additions & 0 deletions src/Blog/Assets/IAssetInclusionStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// Provides decision logic for asset inclusion via path rewriting.
/// Strategy returns rewritten path - path structure determines Include vs Reference behavior.
/// </summary>
public interface IAssetStrategy
{
/// <summary>
/// Decides asset inclusion strategy by returning rewritten path.
/// </summary>
/// <param name="referencingTextFile">The file that references the asset.</param>
/// <param name="referencedAssetFile">The asset file being referenced.</param>
/// <param name="originalPath">The original relative path from the file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// Rewritten path string. Path structure determines behavior:
/// <list type="bullet">
/// <item><description>Child path (no ../ prefix): Asset included in page folder (self-contained)</description></item>
/// <item><description>Parent path (../ prefix): Asset referenced externally (link rewritten to account for folderization)</description></item>
/// <item><description>null: Asset has been dropped from output without inclusion or reference.</description></item>
/// </list>
/// </returns>
Task<string?> DecideAsync(IFile referencingTextFile, IFile referencedAssetFile, string originalPath, CancellationToken ct = default);
}
17 changes: 17 additions & 0 deletions src/Blog/Assets/IAssetLinkDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// Detects relative asset links in rendered HTML output.
/// </summary>
public interface IAssetLinkDetector
{
/// <summary>
/// Detects relative asset link strings in rendered HTML output.
/// </summary>
/// <param name="sourceFile">File instance containing text to detect links from.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of relative path strings.</returns>
IAsyncEnumerable<string> DetectAsync(IFile sourceFile, CancellationToken cancellationToken = default);
}
18 changes: 18 additions & 0 deletions src/Blog/Assets/IAssetResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// Resolves relative path strings to IFile instances.
/// </summary>
public interface IAssetResolver
{
/// <summary>
/// Resolves a relative path string to an IFile instance.
/// </summary>
/// <param name="sourceFile">The file to get the relative path from.</param>
/// <param name="relativePath">The relative path to resolve.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The resolved IFile, or null if not found.</returns>
Task<IFile?> ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default);
}
92 changes: 92 additions & 0 deletions src/Blog/Assets/KnownAssetStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using OwlCore.Diagnostics;
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// Determines fallback asset behavior when the asset is not known to the strategy selector.
/// </summary>
public enum AssetFallbackBehavior
{
/// <summary>
/// The asset path is rewritten to support being referenced by the folderized markdown.
/// </summary>
Reference,

/// <summary>
/// The asset path is not rewritten and it is included in the output path.
/// </summary>
Include,

/// <summary>
/// The new asset path is returned as null and the asset is not included in the output.
/// </summary>
Drop,
}

/// <summary>
/// Uses a known list of files to decide between asset inclusion (child path) vs asset reference (parented path).
/// </summary>
public sealed class KnownAssetStrategy : IAssetStrategy
{
/// <summary>
/// A list of known file IDs to rewrite to an included asset.
/// </summary>
public HashSet<string> IncludedAssetFileIds { get; set; } = new();

/// <summary>
/// A list of known file IDs rewrite as a referenced asset.
/// </summary>
public HashSet<string> ReferencedAssetFileIds { get; set; } = new();

/// <summary>
/// The strategy to use when encountering an unknown asset.
/// </summary>
public FaultStrategy UnknownAssetFaultStrategy { get; set; }

/// <summary>
/// Gets or sets the fallback used when the asset is unknown but <see cref="UnknownAssetFaultStrategy"/> does not have <see cref="FaultStrategy.Throw"/>.
/// </summary>
public AssetFallbackBehavior UnknownAssetFallbackStrategy { get; set; }

/// <inheritdoc/>
public async Task<string?> DecideAsync(IFile referencingMarkdown, IFile referencedAsset, string originalPath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(originalPath))
return originalPath;

var isReferenced = ReferencedAssetFileIds.Contains(referencedAsset.Id);
var isIncluded = IncludedAssetFileIds.Contains(referencedAsset.Id);

if (isReferenced)
return $"../{originalPath}";

if (isIncluded)
return originalPath;

// Handle as unknown
HandleUnknownAsset(referencedAsset);

return UnknownAssetFallbackStrategy switch
{
AssetFallbackBehavior.Reference => $"../{originalPath}",
AssetFallbackBehavior.Include => originalPath,
AssetFallbackBehavior.Drop => null,
_ => throw new ArgumentOutOfRangeException(nameof(UnknownAssetFallbackStrategy)),
};
}

private void HandleUnknownAsset(IFile referencedAsset)
{
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)}.";

if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.LogWarn))
Logger.LogWarning(faultMessage);

if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.LogError))
Logger.LogError(faultMessage);

if (UnknownAssetFaultStrategy.HasFlag(FaultStrategy.Throw))
throw new InvalidOperationException(faultMessage);
}
}
13 changes: 13 additions & 0 deletions src/Blog/Assets/ReferencedAsset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets
{
/// <summary>
/// Captures complete asset reference information for materialization.
/// Stores original detected path, rewritten path after strategy, and resolved file instance.
/// </summary>
/// <param name="OriginalPath">Path detected in markdown (relative to source file)</param>
/// <param name="RewrittenPath">Path after inclusion strategy applied (include vs reference)</param>
/// <param name="ResolvedFile">Actual file instance for copy operations</param>
public record PageAsset(string OriginalPath, string RewrittenPath, IFile ResolvedFile);
}
81 changes: 81 additions & 0 deletions src/Blog/Assets/RegexAssetLinkDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// Detects relative asset links in markdown and HTML text.
/// </summary>
public sealed partial class RegexAssetLinkDetector : IAssetLinkDetector
{
/// <summary>
/// Regex pattern for markdown links and images.
/// </summary>
[GeneratedRegex("""!?\[[^\]]*\]\((?<path>[^)\s]+)(?:\s+[^)]*)?\)""", RegexOptions.Compiled)]
private static partial Regex MarkdownLinkPattern();

/// <summary>
/// Regex pattern for HTML href/src attributes.
/// </summary>
[GeneratedRegex("""(?:href|src)\s*=\s*["'](?<path>[^"']+)["']""", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex HtmlAttributePattern();

/// <inheritdoc/>
public async IAsyncEnumerable<string> DetectAsync(IFile source, [EnumeratorCancellation] CancellationToken ct = default)
{
var text = await source.ReadTextAsync(ct);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

foreach (Match match in MarkdownLinkPattern().Matches(text))
{
if (ct.IsCancellationRequested)
yield break;

var path = match.Groups["path"].Value;
if (!ShouldYield(path, seen))
continue;

yield return path;
}

foreach (Match match in HtmlAttributePattern().Matches(text))
{
if (ct.IsCancellationRequested)
yield break;

var path = match.Groups["path"].Value;
if (!ShouldYield(path, seen))
continue;

yield return path;
}
}

private static bool ShouldYield(string path, HashSet<string> seen)
{
if (string.IsNullOrWhiteSpace(path))
return false;

path = path.Trim().Trim('<', '>');

if (string.IsNullOrWhiteSpace(path))
return false;

if (path.StartsWith('#') || path.StartsWith('/') || path.StartsWith('\\'))
return false;
if (path.StartsWith("//", StringComparison.Ordinal))
return false;
if (path.Contains("://", StringComparison.Ordinal))
return false;
if (path.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("tel:", StringComparison.OrdinalIgnoreCase))
{
return false;
}

return seen.Add(path);
}
}
Loading
Loading