Skip to content
Merged
4 changes: 0 additions & 4 deletions context7.json

This file was deleted.

32 changes: 29 additions & 3 deletions docs/wiki/Template-Expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,22 @@ The `??` operator provides a fallback when the left side is null or missing:

### Filters

Filters transform values using the pipe (`|`) syntax. Filters can take an optional argument after a colon:
Filters transform values using the pipe (`|`) syntax. Filters support a positional argument, named parameters (`key:value`), and boolean flags:

```yaml
{{value | filterName}}
{{value | filterName:argument}}
{{value | filterName:positional key1:value1 key2:'string' flag}}
{{value | filterName key1:value1 flag}}
```

**Three modes:**
- Positional only: `{{value | truncate:30}}`
- Named only: `{{value | truncate length:30 suffix:'…'}}`
- Mixed: `{{value | truncate:30 suffix:'…' fromEnd}}`

Named parameters use `key:value` syntax. Boolean flags are just the key name without a value.

#### Built-in Filters

All 8 built-in filters are enabled by default. Use `WithoutDefaultFilters()` on `FlexRenderBuilder` to disable them if needed.
Expand All @@ -186,7 +195,7 @@ All 8 built-in filters are enabled by default. Use `WithoutDefaultFilters()` on
| `upper` | -- | Convert string to uppercase | `{{name \| upper}}` -> `"JOHN"` |
| `lower` | -- | Convert string to lowercase | `{{name \| lower}}` -> `"john"` |
| `trim` | -- | Remove leading/trailing whitespace | `{{input \| trim}}` |
| `truncate` | max length (default: 50) | Truncate string with "..." suffix | `{{desc \| truncate:20}}` |
| `truncate` | `length` (positional, default: 50), `suffix` (default: "..."), `fromEnd` (flag) | Truncate string with configurable suffix and direction | `{{desc \| truncate:20}}`, `{{path \| truncate:20 fromEnd suffix:'…'}}` |
| `format` | format string | Format number or date with .NET format string | `{{date \| format:"dd.MM.yyyy"}}` |
| `currencySymbol` | -- | Convert ISO 4217 currency code (alphabetic or numeric) to symbol | `{{currency \| currencySymbol}}` -> `"$"`, `{{840 \| currencySymbol}}` -> `"$"` |

Expand Down Expand Up @@ -214,6 +223,18 @@ All 8 built-in filters are enabled by default. Use `WithoutDefaultFilters()` on
# Truncated description
- type: text
content: "{{product.description | truncate:50}}"

# Truncated description with custom suffix
- type: text
content: "{{product.description | truncate:30 suffix:'…'}}"

# Keep last 20 chars of file path
- type: text
content: "{{file.path | truncate:20 fromEnd}}"

# Truncate with all named parameters
- type: text
content: "{{text | truncate length:25 suffix:'...' fromEnd}}"
```

### Expression Precedence
Expand Down Expand Up @@ -267,10 +288,15 @@ Custom filters implement `ITemplateFilter`:
public interface ITemplateFilter
{
string Name { get; }
TemplateValue Apply(TemplateValue input, TemplateValue? argument);
TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture);
}
```

The `FilterArguments` class provides:
- `Positional` -- the first (unnamed) argument, or null
- `GetNamed(name, defaultValue)` -- get a named parameter by key
- `HasFlag(name)` -- check if a boolean flag is present

---

## Text Blocks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public sealed class CurrencyFilter : ITemplateFilter
public string Name => "currency";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is NumberValue num)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public sealed class CurrencySymbolFilter : ITemplateFilter
public string Name => "currencySymbol";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
Expand Down
4 changes: 2 additions & 2 deletions src/FlexRender.Core/TemplateEngine/Filters/FormatFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ public sealed class FormatFilter : ITemplateFilter
public string Name => "format";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (argument is not StringValue formatStr || string.IsNullOrEmpty(formatStr.Value))
if (arguments.Positional is not StringValue formatStr || string.IsNullOrEmpty(formatStr.Value))
{
return input;
}
Expand Down
2 changes: 1 addition & 1 deletion src/FlexRender.Core/TemplateEngine/Filters/LowerFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class LowerFilter : ITemplateFilter
public string Name => "lower";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
Expand Down
4 changes: 2 additions & 2 deletions src/FlexRender.Core/TemplateEngine/Filters/NumberFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ public sealed class NumberFilter : ITemplateFilter
public string Name => "number";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is not NumberValue num)
{
return NullValue.Instance;
}

var decimals = 0;
if (argument is StringValue argStr &&
if (arguments.Positional is StringValue argStr &&
int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
decimals = Math.Clamp(parsed, 0, MaxDecimalPlaces);
Expand Down
2 changes: 1 addition & 1 deletion src/FlexRender.Core/TemplateEngine/Filters/TrimFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class TrimFilter : ITemplateFilter
public string Name => "trim";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
Expand Down
91 changes: 75 additions & 16 deletions src/FlexRender.Core/TemplateEngine/Filters/TruncateFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,25 @@
namespace FlexRender.TemplateEngine.Filters;

/// <summary>
/// Truncates a string to a maximum length, appending "..." if truncated.
/// Not affected by culture settings.
/// Truncates a string to a maximum length with a configurable suffix.
/// Supports truncation from start or end of string.
/// </summary>
/// <remarks>
/// <para>Parameters:</para>
/// <list type="bullet">
/// <item><c>length</c> (positional, default 50): Maximum length of the result including suffix.</item>
/// <item><c>suffix</c> (named, default "..."): The suffix/prefix to add when truncating.</item>
/// <item><c>fromEnd</c> (flag): When present, keeps the end of the string and adds suffix as prefix.</item>
/// </list>
/// <para>
/// Non-string input is converted: <see cref="NumberValue"/> and <see cref="BoolValue"/>
/// become strings; <see cref="NullValue"/> becomes empty string;
/// <see cref="ArrayValue"/> and <see cref="ObjectValue"/> are returned unchanged.
/// </para>
/// </remarks>
/// <example>
/// <c>{{desc | truncate:10}}</c> with desc="Hello World!" produces <c>Hello W...</c>.
/// <c>{{desc | truncate:10}}</c> produces <c>Hello W...</c>.
/// <c>{{path | truncate:20 fromEnd suffix:'...'}}</c> produces <c>...ts/SkiaLayout/src</c>.
/// </example>
public sealed class TruncateFilter : ITemplateFilter
{
Expand All @@ -17,38 +31,83 @@ public sealed class TruncateFilter : ITemplateFilter
private const int MaxLength = 10000;

/// <summary>
/// Suffix appended when a string is truncated.
/// Maximum allowed suffix length to prevent misuse.
/// </summary>
private const string Ellipsis = "...";
private const int MaxSuffixLength = 100;

/// <summary>
/// Default suffix appended or prepended when a string is truncated.
/// </summary>
private const string DefaultSuffix = "...";

/// <inheritdoc />
public string Name => "truncate";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is not StringValue str)
// Convert non-string input to string
var text = input switch
{
StringValue sv => sv.Value,
NumberValue nv => nv.Value.ToString("G", culture),
BoolValue bv => bv.Value ? "true" : "false",
NullValue => "",
_ => (string?)null
};

if (text is null)
{
return input;
return input; // ArrayValue, ObjectValue — return as-is
}

var maxLen = 50; // default
if (argument is StringValue argStr &&
int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
// Resolve length: named "length" overrides positional
var maxLen = 50;
var lengthSource = arguments.GetNamed("length", NullValue.Instance);
if (lengthSource is StringValue lenStr &&
int.TryParse(lenStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var lenParsed))
{
maxLen = Math.Clamp(lenParsed, 0, MaxLength);
}
else if (arguments.Positional is StringValue argStr &&
int.TryParse(argStr.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
maxLen = Math.Clamp(parsed, 0, MaxLength);
}

if (str.Value.Length <= maxLen)
if (text.Length <= maxLen)
{
return str;
return new StringValue(text);
}

if (maxLen <= Ellipsis.Length)
// Resolve suffix
var suffix = DefaultSuffix;
var suffixValue = arguments.GetNamed("suffix", NullValue.Instance);
if (suffixValue is StringValue suffixStr)
{
suffix = suffixStr.Value;
if (suffix.Length > MaxSuffixLength)
{
suffix = suffix[..MaxSuffixLength];
}
}

// Resolve direction
var fromEnd = arguments.HasFlag("fromEnd");

// Edge case: length <= suffix length
if (maxLen <= suffix.Length)
{
return new StringValue(suffix[..maxLen]);
}

var contentLen = maxLen - suffix.Length;

if (fromEnd)
{
return new StringValue(Ellipsis[..maxLen]);
return new StringValue(string.Concat(suffix, text.AsSpan(text.Length - contentLen, contentLen)));
}

return new StringValue(string.Concat(str.Value.AsSpan(0, maxLen - Ellipsis.Length), Ellipsis));
return new StringValue(string.Concat(text.AsSpan(0, contentLen), suffix));
}
}
2 changes: 1 addition & 1 deletion src/FlexRender.Core/TemplateEngine/Filters/UpperFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public sealed class UpperFilter : ITemplateFilter
public string Name => "upper";

/// <inheritdoc />
public TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture)
public TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture)
{
if (input is StringValue str)
{
Expand Down
71 changes: 69 additions & 2 deletions src/FlexRender.Core/TemplateEngine/ITemplateFilter.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,74 @@
using System.Collections.ObjectModel;
using System.Globalization;

namespace FlexRender.TemplateEngine;

/// <summary>
/// Holds parsed filter arguments — an optional positional argument plus named key:value pairs and flags.
/// </summary>
public sealed class FilterArguments
{
private static readonly IReadOnlyDictionary<string, TemplateValue?> EmptyNamed =
new ReadOnlyDictionary<string, TemplateValue?>(new Dictionary<string, TemplateValue?>());

/// <summary>
/// Empty arguments instance. Used when a filter has no arguments.
/// </summary>
public static readonly FilterArguments Empty = new(null, EmptyNamed);

/// <summary>
/// Gets a shared empty named-arguments dictionary for reuse when no named arguments are present.
/// </summary>
internal static IReadOnlyDictionary<string, TemplateValue?> EmptyNamedDictionary => EmptyNamed;

private readonly IReadOnlyDictionary<string, TemplateValue?> _named;

/// <summary>
/// Initializes a new instance with positional and named arguments.
/// </summary>
/// <param name="positional">The positional argument, or null if absent.</param>
/// <param name="named">Named arguments. A null value indicates a boolean flag.</param>
public FilterArguments(TemplateValue? positional, IReadOnlyDictionary<string, TemplateValue?> named)
{
ArgumentNullException.ThrowIfNull(named);
Positional = positional;
_named = named;
}

/// <summary>
/// Gets the positional (first, unnamed) argument, or null if not provided.
/// </summary>
public TemplateValue? Positional { get; }

/// <summary>
/// Gets a named argument by key, returning <paramref name="defaultValue"/> if absent.
/// </summary>
/// <param name="name">The name of the argument to retrieve.</param>
/// <param name="defaultValue">The value to return if the named argument is not found.</param>
/// <returns>The named argument value if present and non-null; otherwise, <paramref name="defaultValue"/>.</returns>
public TemplateValue GetNamed(string name, TemplateValue defaultValue)
{
ArgumentNullException.ThrowIfNull(name);
if (_named.TryGetValue(name, out var value) && value is not null)
{
return value;
}

return defaultValue;
}

/// <summary>
/// Returns true if the given flag is present (a named key with no value).
/// </summary>
/// <param name="name">The flag name to check.</param>
/// <returns>True if the flag is present (key exists with null value); otherwise, false.</returns>
public bool HasFlag(string name)
{
ArgumentNullException.ThrowIfNull(name);
return _named.TryGetValue(name, out var value) && value is null;
}
}

/// <summary>
/// Interface for template filters that transform values in filter pipe expressions.
/// </summary>
Expand Down Expand Up @@ -29,8 +96,8 @@ public interface ITemplateFilter
/// Applies the filter to the input value.
/// </summary>
/// <param name="input">The value to transform.</param>
/// <param name="argument">An optional argument from the filter syntax (e.g., <c>truncate:30</c> passes <c>"30"</c>).</param>
/// <param name="arguments">The filter arguments (positional, named, and flags).</param>
/// <param name="culture">The culture to use for formatting operations.</param>
/// <returns>The transformed value.</returns>
TemplateValue Apply(TemplateValue input, TemplateValue? argument, CultureInfo culture);
TemplateValue Apply(TemplateValue input, FilterArguments arguments, CultureInfo culture);
}
20 changes: 17 additions & 3 deletions src/FlexRender.Core/TemplateEngine/InlineExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,27 @@ public sealed record ArithmeticExpression(InlineExpression Left, ArithmeticOpera
public sealed record CoalesceExpression(InlineExpression Left, InlineExpression Right) : InlineExpression;

/// <summary>
/// A filter pipe expression (e.g., <c>price | currency</c>, <c>name | truncate:30</c>).
/// A named argument for a filter (e.g., <c>suffix:'...'</c>). A null <paramref name="Value"/>
/// indicates a boolean flag (e.g., <c>fromEnd</c>).
/// </summary>
/// <param name="Name">The argument name.</param>
/// <param name="Value">The argument value, or null for boolean flags.</param>
public sealed record FilterNamedArgument(string Name, string? Value);

/// <summary>
/// A filter pipe expression (e.g., <c>price | currency</c>, <c>name | truncate:30 suffix:'...' fromEnd</c>).
/// Applies a named filter to the input expression.
/// </summary>
/// <param name="Input">The expression whose result is passed to the filter.</param>
/// <param name="FilterName">The name of the filter to apply.</param>
/// <param name="Argument">An optional string argument to the filter (after the colon).</param>
public sealed record FilterExpression(InlineExpression Input, string FilterName, string? Argument) : InlineExpression;
/// <param name="Argument">An optional positional string argument (after the colon).</param>
/// <param name="NamedArguments">Optional named arguments and flags.</param>
public sealed record FilterExpression(
InlineExpression Input,
string FilterName,
string? Argument,
IReadOnlyList<FilterNamedArgument>? NamedArguments = null
) : InlineExpression;

/// <summary>
/// A unary negation expression (e.g., <c>-price</c>).
Expand Down
Loading