Skip to content

Commit f75a7c5

Browse files
committed
refactor: unify resource loading through IResourceLoader chain
- ContentSourceResolver now delegates all URI resolution to loaders - Base64ResourceLoader handles data:, data://, base64:, base64:// schemes - FileResourceLoader uses generic URI scheme detection instead of blacklist - FileResourceLoader supports file:, file://, file:/// prefixes - base64: is a shorthand alias for data:application/octet-stream;base64,... - Cookbook: add CLI commands and image source recipes
1 parent 440a0a4 commit f75a7c5

9 files changed

Lines changed: 208 additions & 202 deletions

File tree

docs/wiki/API-Reference.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,8 @@ var data = new ObjectValue { ["receipt"] = new BytesValue(rawBytes) };
140140
// From Stream
141141
var data = new ObjectValue { ["receipt"] = BytesValue.FromStream(fileStream) };
142142

143-
// From base64 in YAML source
144-
// source: "base64:SGVsbG8gV29ybGQ="
143+
// From data URI in YAML source
144+
// source: "data:application/octet-stream;base64,SGVsbG8gV29ybGQ="
145145
```
146146

147147
`BytesValue` wraps `ReadOnlyMemory<byte>` and is passed directly to `IBinaryContentParser` without encoding conversion. When only `IContentParser` is registered, binary data is decoded as UTF-8.

docs/wiki/Cookbook.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -559,10 +559,10 @@ var render = new FlexRenderBuilder()
559559
.Build();
560560
```
561561

562-
> **Note:** NDC receipts use binary data (`BytesValue`), so they must be rendered through the C# API. The CLI does not support binary data inputs. For text-based NDC content, you can use `base64:` prefix in the JSON data:
562+
> **Note:** NDC receipts use binary data (`BytesValue`), so they must be rendered through the C# API. The CLI does not support binary data inputs. For text-based NDC content, you can use `data:` URI format in the JSON data:
563563
>
564564
> ```json
565-
> { "receiptData": "base64:PFN0YXJ0PjxOREMgZGF0YT4..." }
565+
> { "receiptData": "data:application/octet-stream;base64,PFN0YXJ0PjxOREMgZGF0YT4..." }
566566
> ```
567567
>
568568
> ```bash
@@ -1313,7 +1313,7 @@ FlexRender supports four ways to load images in `type: image` elements. All sour
13131313
| Base64 data URL | `src: "data:image/png;base64,iVBOR..."` | Default (Base64ResourceLoader) |
13141314
| Embedded resource | `src: "embedded://MyApp.Assets.logo.png"` | `.WithEmbeddedLoader(assembly)` |
13151315

1316-
> **Important:** For `data:` URIs, the MIME type is **required** (e.g., `data:image/png;base64,...`). For the `base64:` prefix in content sources, MIME type is **not required** (e.g., `base64:SGVsbG8=`).
1316+
> **Important:** For image `data:` URIs, the MIME type is **required** (e.g., `data:image/png;base64,...`). For content source `data:` URIs, the MIME type is **optional** (e.g., `data:;base64,SGVsbG8=` or `data:application/octet-stream;base64,SGVsbG8=`).
13171317
13181318
---
13191319

docs/wiki/Element-Reference.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,7 +1451,7 @@ Renders tabular data with configurable columns, optional header row, and support
14511451

14521452
## Content Element (Control Flow)
14531453

1454-
Embeds dynamically formatted content (Markdown, HTML, NDC binary data, etc.) from template data. The `source` supports multiple input types: plain text, `base64:`-encoded binary data, `file:` URIs, `text:` prefixed strings, and template variables bound to `string` or `byte[]` (`BytesValue`). The source is parsed at render time into a subtree of FlexRender elements using pluggable content parsers.
1454+
Embeds dynamically formatted content (Markdown, HTML, NDC binary data, etc.) from template data. The `source` supports multiple input types: plain text, `data:` URI-encoded binary data, `file:` URIs, `text:` prefixed strings, and template variables bound to `string` or `byte[]` (`BytesValue`). The source is parsed at render time into a subtree of FlexRender elements using pluggable content parsers.
14551455

14561456
This is a **control-flow element** — like `each` and `if`, it is expanded during template processing and does not appear in the final render tree.
14571457

@@ -1465,7 +1465,7 @@ This is a **control-flow element** — like `each` and `if`, it is expanded duri
14651465

14661466
| Property | YAML Name | Type | Default | Valid Values | Expression | Description |
14671467
|----------|-----------|------|---------|--------------|-----------|-------------|
1468-
| Source | `source` | string | `""` | Any string, typically `{{variable}}` | Yes | The content to parse. Supports plain text, `base64:` binary, `file:` URIs, `text:` prefix, and `{{variable}}` expressions resolving to `string` or `BytesValue` (`byte[]`). See [Content Source Resolution](#content-source-resolution) below. |
1468+
| Source | `source` | string | `""` | Any string, typically `{{variable}}` | Yes | The content to parse. Supports plain text, `data:` URI binary, `file:` URIs, `text:` prefix, and `{{variable}}` expressions resolving to `string` or `BytesValue` (`byte[]`). See [Content Source Resolution](#content-source-resolution) below. |
14691469
| Format | `format` | string | `""` | `markdown`, `html`, or any registered parser name | Yes | The content format. Must match a registered `IContentParser.FormatName`. |
14701470
| Options | `options` | dict? | `null` | Key-value dictionary | No | Parser-specific options (e.g., NDC `columns`, `charsets`). Passed to the content parser. |
14711471

@@ -1476,7 +1476,7 @@ The `source` property is resolved at render time through `ContentSourceResolver`
14761476
| Source Format | Example | Resolved As | Description |
14771477
|---------------|---------|-------------|-------------|
14781478
| Template variable (`BytesValue`) | `source: "{{rawData}}"` | Binary (`byte[]`) | When `{{variable}}` resolves to `BytesValue` in the data context, binary data is passed directly to `IBinaryContentParser`. No string encoding overhead. |
1479-
| `base64:` prefix | `source: "base64:SGVsbG8="` | Binary (`byte[]`) | Base64-encoded payload decoded into bytes. Useful for embedding binary data in JSON/YAML. |
1479+
| `data:` URI | `source: "data:;base64,SGVsbG8="` | Binary (`byte[]`) | Data URI with base64-encoded payload decoded into bytes. MIME type is optional (e.g., `data:application/octet-stream;base64,...`). Useful for embedding binary data in JSON/YAML. |
14801480
| `file:` scheme | `source: "file:receipt.bin"` | Binary (`byte[]`) | Loads content via registered resource loaders (file system, HTTP, embedded). Also supports `file:///` URIs. Throws if file not found. |
14811481
| `text:` prefix | `source: "text:# Hello"` | Text (`string`) | Forces text interpretation, skipping file path detection. |
14821482
| File path heuristic | `source: "receipt.md"` | Binary (`byte[]`) | If source looks like a file path (contains `/`, `\`, or a file extension), tries resource loaders first. Falls back to text if no loader matches. |
@@ -1585,10 +1585,10 @@ layout:
15851585
font_style: bold
15861586
```
15871587

1588-
Data (JSON with base64-encoded NDC binary):
1588+
Data (JSON with data URI-encoded NDC binary):
15891589
```json
15901590
{
1591-
"receiptData": "base64:G1sxfjQwHSgxHQ=="
1591+
"receiptData": "data:application/octet-stream;base64,G1sxfjQwHSgxHQ=="
15921592
}
15931593
```
15941594

src/FlexRender.Core/Loaders/Base64ResourceLoader.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ namespace FlexRender.Loaders;
1313
public sealed class Base64ResourceLoader : IResourceLoader
1414
{
1515
private const string DataPrefix = "data:";
16+
private const string DataUriPrefix = "data://";
17+
private const string Base64Shorthand = "base64:";
18+
private const string Base64UriPrefix = "base64://";
1619

1720
private readonly FlexRenderOptions _options;
1821

@@ -36,7 +39,8 @@ public Base64ResourceLoader(FlexRenderOptions options)
3639

3740
/// <inheritdoc />
3841
/// <remarks>
39-
/// Returns <c>true</c> for URIs that start with "data:".
42+
/// Returns <c>true</c> for URIs that start with "data:", "data://", "base64:", or "base64://".
43+
/// All shorthand forms are normalized to standard "data:" URI format internally.
4044
/// </remarks>
4145
public bool CanHandle(string uri)
4246
{
@@ -45,7 +49,8 @@ public bool CanHandle(string uri)
4549
return false;
4650
}
4751

48-
return uri.StartsWith(DataPrefix, StringComparison.OrdinalIgnoreCase);
52+
return uri.StartsWith(DataPrefix, StringComparison.OrdinalIgnoreCase)
53+
|| uri.StartsWith(Base64Shorthand, StringComparison.Ordinal);
4954
}
5055

5156
/// <inheritdoc />
@@ -64,6 +69,21 @@ public bool CanHandle(string uri)
6469

6570
cancellationToken.ThrowIfCancellationRequested();
6671

72+
// Normalize all variants to standard "data:" URI format
73+
if (uri.StartsWith(Base64UriPrefix, StringComparison.Ordinal))
74+
{
75+
uri = "data:application/octet-stream;base64," + uri[Base64UriPrefix.Length..];
76+
}
77+
else if (uri.StartsWith(Base64Shorthand, StringComparison.Ordinal))
78+
{
79+
uri = "data:application/octet-stream;base64," + uri[Base64Shorthand.Length..];
80+
}
81+
else if (uri.StartsWith(DataUriPrefix, StringComparison.OrdinalIgnoreCase))
82+
{
83+
// "data://mime;base64,payload" → "data:mime;base64,payload"
84+
uri = DataPrefix + uri[DataUriPrefix.Length..];
85+
}
86+
6787
var base64Data = ExtractBase64Data(uri);
6888
ValidateDataSize(base64Data);
6989

src/FlexRender.Core/Loaders/FileResourceLoader.cs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ namespace FlexRender.Loaders;
1313
/// </remarks>
1414
public sealed class FileResourceLoader : IResourceLoader
1515
{
16-
private static readonly string[] UrlPrefixes = ["http://", "https://", "data:", "embedded://"];
17-
1816
private readonly FlexRenderOptions _options;
1917

2018
/// <summary>
@@ -37,7 +35,8 @@ public FileResourceLoader(FlexRenderOptions options)
3735

3836
/// <inheritdoc />
3937
/// <remarks>
40-
/// Returns <c>true</c> for URIs that do not start with http://, https://, data:, or embedded://.
38+
/// Returns <c>true</c> for URIs that look like file paths or use the "file:" / "file://" scheme.
39+
/// Rejects anything with other URI schemes (e.g., "http://", "data:", "base64:", "embedded://").
4140
/// </remarks>
4241
public bool CanHandle(string uri)
4342
{
@@ -46,9 +45,41 @@ public bool CanHandle(string uri)
4645
return false;
4746
}
4847

49-
foreach (var prefix in UrlPrefixes)
48+
// Allow "file:" and "file://" scheme — this loader handles local files
49+
if (uri.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
50+
{
51+
return true;
52+
}
53+
54+
// Reject any URI with a "://" scheme (e.g., "http://", "data://", "custom://")
55+
if (uri.Contains("://", StringComparison.Ordinal))
5056
{
51-
if (uri.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
57+
return false;
58+
}
59+
60+
// Reject scheme-like prefixes without "://" (e.g., "data:", "base64:")
61+
// A URI scheme is [a-zA-Z][a-zA-Z0-9+.-]*: (min 2 chars to exclude Windows drive letters like "C:")
62+
var colonIndex = uri.IndexOf(':');
63+
if (colonIndex > 1 && colonIndex < 20)
64+
{
65+
var scheme = uri.AsSpan(0, colonIndex);
66+
if (char.IsLetter(scheme[0]) && IsValidScheme(scheme))
67+
{
68+
return false;
69+
}
70+
}
71+
72+
return true;
73+
}
74+
75+
/// <summary>
76+
/// Checks whether a span represents a valid URI scheme (letters, digits, +, ., -).
77+
/// </summary>
78+
private static bool IsValidScheme(ReadOnlySpan<char> scheme)
79+
{
80+
foreach (var c in scheme)
81+
{
82+
if (!char.IsLetterOrDigit(c) && c != '+' && c != '.' && c != '-')
5283
{
5384
return false;
5485
}
@@ -70,9 +101,19 @@ public bool CanHandle(string uri)
70101
return Task.FromResult<Stream?>(null);
71102
}
72103

73-
ValidatePathSecurity(uri);
104+
// Strip file: scheme prefix if present
105+
// Supports: "file:///path" (RFC 8089), "file://path", "file:path"
106+
var path = uri;
107+
if (path.StartsWith("file:///", StringComparison.OrdinalIgnoreCase))
108+
path = path["file:///".Length..];
109+
else if (path.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
110+
path = path["file://".Length..];
111+
else if (path.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
112+
path = path["file:".Length..];
113+
114+
ValidatePathSecurity(path);
74115

75-
var fullPath = ResolvePath(uri);
116+
var fullPath = ResolvePath(path);
76117

77118
if (!File.Exists(fullPath))
78119
{

0 commit comments

Comments
 (0)