Skip to content
Draft
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);
}
71 changes: 71 additions & 0 deletions src/Blog/Assets/RegexAssetLinkDetector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using OwlCore.Diagnostics;
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

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

/// <summary>
/// Regex pattern to detect protocol schemes (e.g., http://, custom://, drive://).
/// </summary>
[GeneratedRegex(@"[A-Za-z][A-Za-z0-9+\-\.]*://", RegexOptions.Compiled)]
private static partial Regex ProtocolSchemePattern();

[GeneratedRegex(@"\b[A-Za-z0-9_\-]+\.[A-Za-z0-9]+\b", RegexOptions.Compiled)]
private static partial Regex FilenamePattern();

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

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

var path = match.Value;

// Filter out non-relative patterns
if (string.IsNullOrWhiteSpace(path))
continue;

// Exclude absolute root paths (optional - treating these as non-relative)
if (path.StartsWith('/') || path.StartsWith('\\'))
continue;

// Check if this path is preceded by a protocol scheme (e.g., custom://path/to/file)
// Look back to see if there's a protocol before this match
var startIndex = match.Index;
if (startIndex > 0)
{
// Check up to 50 characters before the match for a protocol scheme
var lookbackLength = Math.Min(50, startIndex);
var precedingText = text.Substring(startIndex - lookbackLength, lookbackLength);

// If the preceding text ends with a protocol scheme (e.g., "custom://"), skip this match
if (ProtocolSchemePattern().IsMatch(precedingText) && precedingText.TrimEnd().EndsWith("://"))
continue;
}

yield return path;
}

foreach (Match match in FilenamePattern().Matches(text))
{
yield return match.Value;
}
}
}
36 changes: 36 additions & 0 deletions src/Blog/Assets/RelativePathAssetResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using OwlCore.Storage;

namespace WindowsAppCommunity.Blog.Assets;

/// <summary>
/// Resolves relative paths to IFile instances using source folder and markdown file context.
/// Paths are resolved relative to the markdown file's location (pre-folderization).
/// Stateless design - markdown source passed per-call to support shared resolver across pages.
/// </summary>
public sealed class RelativePathAssetResolver : IAssetResolver
{
/// <inheritdoc/>
public async Task<IFile?> ResolveAsync(IFile sourceFile, string relativePath, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(relativePath))
return null;

try
{
// Normalize path separators to forward slash
var normalizedPath = relativePath.Replace('\\', '/');

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

// Return only if it's a file
return item as IFile;
}
catch
{
// Path resolution failed (invalid path, not found, etc.)
return null;
}
}
}
Loading
Loading