Skip to content
Merged
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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

## Unreleased

- Preserve string types in `yaml` and `yaml_property`, quoting scalar strings such as `"null"`, `"true"`, and `"123"` instead of emitting them as YAML nulls, booleans, or numbers. Typed scalar values remain unquoted, and `parse_json` can be used for explicit coercion.
- Preserve string types in `yaml` and `yaml_property`, quoting scalar strings such as `"null"`, `"true"`, and `"123"` instead of emitting them as YAML nulls, booleans, or numbers. Typed scalar values remain unquoted, and `parse_json` can explicitly decode JSON text before serialization.
- Preserve typed values through filter chains: `calc`, `round`, and `length` now return numbers; `first` and `last` return the selected value; and `nth` returns a typed subset array. `number_format` remains text.
- Reject blank or partially numeric arithmetic input, and preserve invalid or non-finite `calc` and `round` values with a warning instead of silently coercing them or emitting `Infinity` or `null`.
- Count singleton arrays as collections in `length`, and return `null` from `first` and `last` when an array is empty.
- Render typed selector results consistently: singleton `nth` results unwrap during interpolation and downstream text filters, while objects and nested arrays selected by `first` or `last` render as JSON. Numeric strings rounded inside collections become numbers.
- Align the exported `applyFiltersWithRegistry` helper with template rendering by emitting empty text, rather than `"null"`, when the final filtered value is `null`.

## 0.5.1

Expand Down
16 changes: 8 additions & 8 deletions src/docs/filter-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ const docs: FilterDoc[] = [
slug: 'yaml', name: 'yaml', searchTerms: ['frontmatter', 'properties', 'block list', 'flow'], category: 'Markdown', summary: 'Serialize a value as YAML.', syntax: ['yaml', 'yaml:flow'],
parameterValues: ['flow'],
parameters: ['Arrays and objects use block style with two-space nesting by default. Pass `flow` for compact inline collections.'],
notes: ['Accepts typed arrays and objects or serialized JSON collections, including output from filters such as `wikilink`.', 'Strings stay quoted while numbers, booleans, and null retain their types, both as standalone values and within collections. Empty arrays and objects use `[]` and `{}` in either style.', 'Filter chains preserve the type returned by the preceding filter, so formatting filters that return numeric-looking text still produce quoted strings. Use `parse_json` before this filter to explicitly convert JSON scalar text such as `"42"`, `"true"`, or `"null"` to its corresponding type. Multiline strings use escaped newlines.', 'Output has no trailing newline or frontmatter delimiters. Place block collections on the line below a property and use `indent:2` to indent them, or use `yaml_property` to format a complete property.'], related: ['yaml_property', 'parse_json', 'indent', 'wikilink'],
notes: ['Accepts typed arrays and objects or serialized JSON collections, including output from filters such as `wikilink`.', 'Strings stay quoted while numbers, booleans, and null retain their types, both as standalone values and within collections. Empty arrays and objects use `[]` and `{}` in either style.', 'Filter chains preserve the type returned by the preceding filter, so formatting filters that return numeric-looking text still produce quoted strings. Use `parse_json` to decode JSON text before serialization. Multiline strings use escaped newlines.', 'Output has no trailing newline or frontmatter delimiters. Place block collections on the line below a property and use `indent:2` to indent them, or use `yaml_property` to format a complete property.'], related: ['yaml_property', 'parse_json', 'indent', 'wikilink'],
examples: [
example({ title: 'A: value #1' }, '{{ title | yaml }}', '"A: value #1"', 'Scalar'),
example({ genres: ['Action', 'Sci-fi'] }, 'genre:\n{{ genres | yaml | indent:2 }}', 'genre:\n - "Action"\n - "Sci-fi"', 'Block list'),
Expand All @@ -333,7 +333,7 @@ const docs: FilterDoc[] = [
{
slug: 'yaml-property', name: 'yaml_property', searchTerms: ['frontmatter', 'properties', 'yaml key'], category: 'Markdown', summary: 'Serialize a complete YAML property.', syntax: ['yaml_property:"name"'],
parameters: ['One non-empty property name is required. Quote names that contain spaces or punctuation.'],
notes: ['Scalars appear beside the key. Non-empty arrays and objects use block style beneath the key with two-space indentation; empty collections stay inline as `[]` or `{}`.', 'Uses the same value serialization as `yaml` and quotes keys when needed. Strings stay quoted while typed numbers, booleans, and null remain unquoted.', 'Filter chains preserve the type returned by the preceding filter, so formatting filters that return numeric-looking text still produce quoted strings. Apply `parse_json` before this filter when explicit JSON coercion is wanted.', 'Apply transformations such as `wikilink` before this filter. No separate `yaml` or `indent` filter is needed.', 'Output has no trailing newline or frontmatter delimiters.'], related: ['yaml', 'parse_json', 'wikilink', 'indent'],
notes: ['Scalars appear beside the key. Non-empty arrays and objects use block style beneath the key with two-space indentation; empty collections stay inline as `[]` or `{}`.', 'Uses the same value serialization as `yaml` and quotes keys when needed. Strings stay quoted while typed numbers, booleans, and null remain unquoted.', 'Filter chains preserve the type returned by the preceding filter, so formatting filters that return numeric-looking text still produce quoted strings.', 'Apply transformations such as `wikilink` before this filter. No separate `yaml` or `indent` filter is needed.', 'Output has no trailing newline or frontmatter delimiters.'], related: ['yaml', 'wikilink', 'indent'],
examples: [
example({ year: 1999 }, '{{ year | yaml_property:"year" }}', 'year: 1999', 'Scalar'),
example({ directors: ['Lana Wachowski', 'Lilly Wachowski'] }, '{{ directors | wikilink | yaml_property:"director" }}', 'director:\n - "[[Lana Wachowski]]"\n - "[[Lilly Wachowski]]"', 'Wikilink list'),
Expand All @@ -342,7 +342,7 @@ const docs: FilterDoc[] = [
],
},

{ slug: 'calc', name: 'calc', category: 'Numbers', summary: 'Apply a simple arithmetic operation to a number.', syntax: ['calc:"+10"', 'calc:"*2"', 'calc:"**3"'], parameters: ['Supported operators are `+`, `-`, `*`, `/`, `**`, and `^`.'], notes: ['Non-numeric data is returned unchanged.'], related: ['round', 'number_format'], examples: [example({ count: 5 }, '{{ count | calc:"+10" }}', '15', 'Addition'), example({ count: 2 }, '{{ count | calc:"**3" }}', '8', 'Exponentiation')] },
{ slug: 'calc', name: 'calc', category: 'Numbers', summary: 'Apply a simple arithmetic operation to a number.', syntax: ['calc:"+10"', 'calc:"*2"', 'calc:"**3"'], parameters: ['Supported operators are `+`, `-`, `*`, `/`, `**`, and `^`.'], notes: ['Successful arithmetic returns a number, including when the input is numeric text.', 'Blank, non-numeric, and non-finite input or results are returned unchanged with a warning. Missing and `null` values pass through silently.'], related: ['round', 'number_format'], examples: [example({ count: 5 }, '{{ count | calc:"+10" }}', '15', 'Addition'), example({ count: 2 }, '{{ count | calc:"**3" }}', '8', 'Exponentiation')] },
{
slug: 'number-format', name: 'number_format', category: 'Numbers', summary: 'Add thousands separators and optional decimal places.', syntax: ['number_format', 'number_format:2', 'number_format:(2, ",", ".")'],
parameters: ['Parameters set decimal places, the decimal separator, and the thousands separator, in that order.'],
Expand All @@ -353,16 +353,16 @@ const docs: FilterDoc[] = [
example({ amounts: [1200, 3500.5] }, '{{ amounts | number_format:2 }}', '["1,200.00","3,500.50"]', 'Array data'),
],
},
{ slug: 'round', name: 'round', category: 'Numbers', summary: 'Round a number to an optional number of decimal places.', syntax: ['round', 'round:2'], parameters: ['The optional non-negative parameter sets decimal places.'], related: ['calc', 'number_format'], examples: [example({ value: 3.14159 }, '{{ value | round:2 }}', '3.14')] },
{ slug: 'round', name: 'round', category: 'Numbers', summary: 'Round a number to an optional number of decimal places.', syntax: ['round', 'round:2'], parameters: ['The optional non-negative parameter sets decimal places.'], notes: ['Successful rounding returns a number and preserves array or object structure for collection input.', 'Invalid scalar input and non-finite values are preserved with a warning. Missing, `null`, and ordinary non-numeric collection values pass through silently.'], related: ['calc', 'number_format'], examples: [example({ value: 3.14159 }, '{{ value | round:2 }}', '3.14')] },

{ slug: 'first', name: 'first', category: 'Collections', summary: 'Return the first item in an array.', syntax: ['first'], related: ['last', 'slice'], examples: [example({ items: ['a', 'b', 'c'] }, '{{ items | first }}', 'a')] },
{ slug: 'first', name: 'first', category: 'Collections', summary: 'Return the first item in an array.', syntax: ['first'], notes: ['The selected value keeps its type. Non-array input passes through unchanged, and an empty array returns `null`.'], related: ['last', 'slice'], examples: [example({ items: ['a', 'b', 'c'] }, '{{ items | first }}', 'a')] },
{ slug: 'compact', name: 'compact', category: 'Collections', summary: 'Remove null and empty-string values from a collection.', syntax: ['compact'], notes: ['Whitespace-only strings are empty. The values `0` and `false` are preserved.', 'For objects, matching properties are removed. Nested collections are preserved.', 'Serialized arrays and objects produced by another filter are recognized automatically.'], related: ['unique'], examples: [example({ items: [null, '', 'one', 0, false, 'two'] }, '{{ items | compact }}', '["one",0,false,"two"]', 'Array'), example({ values: { empty: '', missing: null, count: 0, enabled: false } }, '{{ values | compact }}', '{"count":0,"enabled":false}', 'Object')] },
{ slug: 'join', name: 'join', category: 'Collections', summary: 'Join array items with an optional separator.', syntax: ['join', 'join:", "'], parameters: ['The default separator is a comma. Escaped newlines are supported.'], related: ['split'], examples: [example({ tags: ['notes', 'ideas', 'books'] }, '{{ tags | join:", " }}', 'notes, ideas, books')] },
{ slug: 'last', name: 'last', category: 'Collections', summary: 'Return the last item in an array.', syntax: ['last'], related: ['first', 'slice'], examples: [example({ items: ['a', 'b', 'c'] }, '{{ items | last }}', 'c')] },
{ slug: 'length', name: 'length', category: 'Collections', summary: 'Count string characters, array items, or object keys.', syntax: ['length'], examples: [example({ tags: ['notes', 'ideas', 'books'] }, '{{ tags | length }}', '3', 'Array length'), example({ title: 'Knap' }, '{{ title | length }}', '4', 'String length'), example({ author: { name: 'Ada', role: 'Writer' } }, '{{ author | length }}', '2', 'Object length')] },
{ slug: 'last', name: 'last', category: 'Collections', summary: 'Return the last item in an array.', syntax: ['last'], notes: ['The selected value keeps its type. Non-array input passes through unchanged, and an empty array returns `null`.'], related: ['first', 'slice'], examples: [example({ items: ['a', 'b', 'c'] }, '{{ items | last }}', 'c')] },
{ slug: 'length', name: 'length', category: 'Collections', summary: 'Count string characters, array items, or object keys.', syntax: ['length'], notes: ['Returns a number. Singleton arrays retain their collection shape when counted.'], examples: [example({ tags: ['notes', 'ideas', 'books'] }, '{{ tags | length }}', '3', 'Array length'), example({ title: 'Knap' }, '{{ title | length }}', '4', 'String length'), example({ author: { name: 'Ada', role: 'Writer' } }, '{{ author | length }}', '2', 'Object length')] },
{ slug: 'map', name: 'map', category: 'Collections', summary: 'Select a property from each array item or map items with an expression.', syntax: ['map:"name"', 'map:"author.name"', 'map:item => item.name', 'map:item => ({name: item.name})', 'map:item => "prefix/${item}"'], parameters: ['Pass a quoted property path for simple selection. Dotted paths select nested own properties.', 'Arrow expressions can select properties, construct a small object, or interpolate an item into a string literal.'], notes: ['Property selection preserves value types and produces `null` when a path is missing.', 'Built-in filters cannot be called inside an arrow expression. Chain `template` after `map` when you need to render the mapped results.'], related: ['template', 'where'], examples: [example({ people: [{ name: 'Ada' }, { name: 'Lin' }] }, '{{ people | map:"name" }}', '["Ada","Lin"]', 'Property shorthand'), example({ people: [{ author: { name: 'Ada' } }, { author: { name: 'Lin' } }] }, '{{ people | map:"author.name" }}', '["Ada","Lin"]', 'Nested property'), example({ people: [{ name: 'Ada', role: 'Engineer' }, { name: 'Lin', role: 'Designer' }] }, '{{ people | map:person => ({name: person.name, role: person.role}) }}', '[{"name":"Ada","role":"Engineer"},{"name":"Lin","role":"Designer"}]', 'Construct objects'), example({ genres: ['rock', 'pop'] }, '{{ genres | map:item => "genres/${item}" }}', '["genres/rock","genres/pop"]', 'Build strings')] },
{ slug: 'merge', name: 'merge', category: 'Collections', summary: 'Append one or more values to an array.', syntax: ['merge:"value"', 'merge:("a", "b")'], notes: ['Quoted values may contain commas.'], related: ['unique'], examples: [example({ tags: ['notes', 'ideas'] }, '{{ tags | merge:"books" }}', '["notes","ideas","books"]', 'Append one value'), example({ tags: ['notes', 'drafts'] }, '{{ tags | merge:("ideas", "books") }}', '["notes","drafts","ideas","books"]', 'Append multiple values')] },
{ slug: 'nth', name: 'nth', category: 'Collections', summary: 'Select positions from an array with nth-pattern syntax.', syntax: ['nth:3', 'nth:2n', 'nth:n+3', 'nth:2,3:4'], parameters: ['Indexes are one-based. Patterns support a single position, every nth item, an offset, or positions within repeating groups.'], related: ['first', 'last', 'slice'], examples: [example({ items: ['a', 'b', 'c', 'd', 'e', 'f'] }, '{{ items | nth:2n }}', '["b","d","f"]', 'Every nth item'), example({ items: [1, 2, 3, 4, 5, 6, 7, 8] }, '{{ items | nth:2,3:4 }}', '[2,3,6,7]', 'Repeating groups')] },
{ slug: 'nth', name: 'nth', category: 'Collections', summary: 'Select positions from an array with nth-pattern syntax.', syntax: ['nth:3', 'nth:2n', 'nth:n+3', 'nth:2,3:4'], parameters: ['Indexes are one-based. Patterns support a single position, every nth item, an offset, or positions within repeating groups.'], notes: ['Returns an array whose selected elements keep their types. A singleton primitive array renders as its element in interpolation and text-only filter chains.'], related: ['first', 'last', 'slice'], examples: [example({ items: ['a', 'b', 'c', 'd', 'e', 'f'] }, '{{ items | nth:2n }}', '["b","d","f"]', 'Every nth item'), example({ items: [1, 2, 3, 4, 5, 6, 7, 8] }, '{{ items | nth:2,3:4 }}', '[2,3,6,7]', 'Repeating groups')] },
{ slug: 'object', name: 'object', category: 'Collections', summary: 'Convert an object to keys, values, or key-value pairs.', syntax: ['object:"keys"', 'object:"values"', 'object:"array"'], parameters: ['A mode parameter is required: `keys`, `values`, or `array`. Bare and parenthesized spellings remain supported.'], examples: [example({ person: { name: 'Ada', role: 'Writer' } }, '{{ person | object:"keys" }}', '["name","role"]', 'Keys'), example({ person: { name: 'Ada', role: 'Writer' } }, '{{ person | object:"values" }}', '["Ada","Writer"]', 'Values'), example({ person: { name: 'Ada', role: 'Writer' } }, '{{ person | object:"array" }}', '[["name","Ada"],["role","Writer"]]', 'Key-value pairs')] },
{ slug: 'parse-json', name: 'parse_json', searchTerms: ['decode json', 'json array', 'json object'], category: 'Collections', summary: 'Parse JSON text into a typed template value.', syntax: ['parse_json'], notes: ['Invalid JSON is returned unchanged with a nonfatal warning.', 'Values that are already typed are returned unchanged.', 'Collection-aware filters recognize serialized arrays and objects automatically, so ordinary collection chains do not require `parse_json`.'], related: ['object'], examples: [example({ value: '["one","two"]' }, '{{ value | parse_json | join:", " }}', 'one, two')] },
{ slug: 'reverse', name: 'reverse', category: 'Collections', summary: 'Reverse a string, array, or object entry order.', syntax: ['reverse'], related: ['slice'], examples: [example({ word: 'abc' }, '{{ word | reverse }}', 'cba', 'String'), example({ items: ['a', 'b', 'c'] }, '{{ items | reverse }}', '["c","b","a"]', 'Array'), example({ values: { first: 1, second: 2 } }, '{{ values | reverse }}', '{"second":2,"first":1}', 'Object')] },
Expand Down
39 changes: 27 additions & 12 deletions src/filters/calc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ParamValidationResult } from '../filters';
import type { FilterContext } from '../types';
import type { FilterContext, TemplateValue } from '../types';
import { cleanScalarParam } from '../parser-utils';
import { errorMessage, reportFilterWarning } from './warnings';
import { finiteNumber } from './value_utils';

export const validateCalcParams = (param: string | undefined): ParamValidationResult => {
if (!param) {
Expand Down Expand Up @@ -32,17 +33,26 @@ export const validateCalcParams = (param: string | undefined): ParamValidationRe
return { valid: true };
};

export const calc = (str: string, param?: string, context?: FilterContext): string => {
export const calc = (str: string, param?: string, context?: FilterContext): TemplateValue => {
const hasRawValue = Boolean(context && Object.prototype.hasOwnProperty.call(context, 'rawValue'));
const originalValue = hasRawValue
? context?.rawValue
: str;

if (!param) {
return str;
return originalValue;
}
if (hasRawValue && (originalValue === null || originalValue === undefined)) {
return originalValue;
}

try {
// Convert input to number
const num = Number(str);
if (isNaN(num)) {
// calc remains a scalar filter, so use the renderer's string input. This
// preserves the deliberate singleton-primitive array unwrapping behavior.
const num = finiteNumber(str);
if (num === undefined) {
reportFilterWarning(context, `Could not parse "${str}" as a number`, 'INVALID_FILTER_INPUT');
return str;
return originalValue;
}

// Remove outer quotes if present
Expand All @@ -53,7 +63,7 @@ export const calc = (str: string, param?: string, context?: FilterContext): stri
const value = Number(operation.slice(operator === '**' ? 2 : 1));

if (isNaN(value)) {
return str;
return originalValue;
}

let result: number;
Expand All @@ -75,13 +85,18 @@ export const calc = (str: string, param?: string, context?: FilterContext): stri
result = Math.pow(num, value);
break;
default:
return str;
return originalValue;
}

if (!Number.isFinite(result)) {
reportFilterWarning(context, `Calculation produced a non-finite result for "${str}"`, 'INVALID_FILTER_INPUT');
return originalValue;
}

// Convert to string and remove trailing zeros after decimal
return Number(result.toFixed(10)).toString();
// Keep the existing floating-point cleanup, but preserve the numeric type.
return Number(result.toFixed(10));
} catch (error) {
reportFilterWarning(context, `Could not calculate value: ${errorMessage(error)}`);
return str;
return originalValue;
}
};
Loading