From 17371682a26ea75ad2455f3a9bf94a88f2ccbd8a Mon Sep 17 00:00:00 2001 From: Steph Ango <10565871+kepano@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:14:40 -0700 Subject: [PATCH 1/2] Preserve typed values through filter chains --- CHANGELOG.md | 5 ++ src/docs/filter-docs.ts | 12 +-- src/filters/calc.ts | 39 +++++++--- src/filters/first.ts | 20 ++--- src/filters/index.ts | 5 +- src/filters/last.ts | 21 ++---- src/filters/length.ts | 27 ++----- src/filters/nth.ts | 111 ++++++++++++++-------------- src/filters/round.ts | 76 +++++++++++++------ src/filters/sum.ts | 9 +-- src/filters/value_utils.ts | 38 +++++++++- tests/filters/calc.test.ts | 56 +++++++++++--- tests/filters/first.test.ts | 40 ++++++++-- tests/filters/last.test.ts | 16 +++- tests/filters/length.test.ts | 36 +++++++-- tests/filters/nth.test.ts | 38 ++++++---- tests/filters/round.test.ts | 96 ++++++++++++++++++++++-- tests/filters/sort.test.ts | 3 + tests/filters/yaml_property.test.ts | 25 ++++++- 19 files changed, 462 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4762e4..b32a85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## 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 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 diff --git a/src/docs/filter-docs.ts b/src/docs/filter-docs.ts index 1ab10f1..395f9ef 100644 --- a/src/docs/filter-docs.ts +++ b/src/docs/filter-docs.ts @@ -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.'], @@ -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')] }, diff --git a/src/filters/calc.ts b/src/filters/calc.ts index 8e6f282..7bd8d59 100644 --- a/src/filters/calc.ts +++ b/src/filters/calc.ts @@ -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) { @@ -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 @@ -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; @@ -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; } }; diff --git a/src/filters/first.ts b/src/filters/first.ts index 3aa6156..b82e488 100644 --- a/src/filters/first.ts +++ b/src/filters/first.ts @@ -1,16 +1,8 @@ -export const first = (str: string): string => { - // Return empty string as-is without attempting to parse - if (str === '') { - return str; - } +import type { FilterContext, TemplateValue } from '../types'; +import { arrayInputValue } from './value_utils'; - try { - const array = JSON.parse(str); - if (Array.isArray(array) && array.length > 0) { - return array[0] == null ? '' : array[0].toString(); - } - } catch { - // Plain strings are valid pass-through values. - } - return str; +export const first = (str: string, _param?: string, context?: FilterContext): TemplateValue => { + const value = arrayInputValue(str, context); + if (!Array.isArray(value)) return value; + return value.length > 0 ? value[0] : null; }; diff --git a/src/filters/index.ts b/src/filters/index.ts index 89833b0..d90342b 100644 --- a/src/filters/index.ts +++ b/src/filters/index.ts @@ -402,7 +402,6 @@ export function applyFiltersWithRegistry( processedValue = output; } - return typeof processedValue === 'string' - ? processedValue - : JSON.stringify(processedValue) ?? ''; + if (processedValue === undefined || processedValue === null) return ''; + return typeof processedValue === 'string' ? processedValue : JSON.stringify(processedValue) ?? ''; } diff --git a/src/filters/last.ts b/src/filters/last.ts index a0419ca..7b036e9 100644 --- a/src/filters/last.ts +++ b/src/filters/last.ts @@ -1,17 +1,8 @@ -export const last = (str: string): string => { - // Return empty string as-is without attempting to parse - if (str === '') { - return str; - } +import type { FilterContext, TemplateValue } from '../types'; +import { arrayInputValue } from './value_utils'; - try { - const array = JSON.parse(str); - if (Array.isArray(array) && array.length > 0) { - const value = array[array.length - 1]; - return value == null ? '' : value.toString(); - } - } catch { - // Plain strings are valid pass-through values. - } - return str; +export const last = (str: string, _param?: string, context?: FilterContext): TemplateValue => { + const value = arrayInputValue(str, context); + if (!Array.isArray(value)) return value; + return value.length > 0 ? value[value.length - 1] : null; }; diff --git a/src/filters/length.ts b/src/filters/length.ts index 3acfb0c..81f9f55 100644 --- a/src/filters/length.ts +++ b/src/filters/length.ts @@ -1,20 +1,9 @@ -export const length = (str: string): string => { - try { - // Try to parse as JSON first - const parsed = JSON.parse(str); +import type { FilterContext } from '../types'; +import { inputValue, isPlainObject } from './value_utils'; - if (Array.isArray(parsed)) { - // For arrays, return the number of items - return parsed.length.toString(); - } else if (typeof parsed === 'object' && parsed !== null) { - // For objects, return the number of keys - return Object.keys(parsed).length.toString(); - } - // If parsing succeeds but it's not an array or object, - // treat it as a string - return str.length.toString(); - } catch (error) { - // If parsing fails, treat as a string and return its length - return str.length.toString(); - } -}; \ No newline at end of file +export const length = (str: string, _param?: string, context?: FilterContext): number => { + const value = inputValue(str, context, true); + if (Array.isArray(value)) return value.length; + if (isPlainObject(value)) return Object.keys(value).length; + return str.length; +}; diff --git a/src/filters/nth.ts b/src/filters/nth.ts index 15242a9..876f306 100644 --- a/src/filters/nth.ts +++ b/src/filters/nth.ts @@ -1,6 +1,7 @@ import type { ParamValidationResult } from '../filters'; -import type { FilterContext } from '../types'; +import type { FilterContext, TemplateValue } from '../types'; import { reportFilterWarning } from './warnings'; +import { arrayInputValue } from './value_utils'; export const validateNthParams = (param: string | undefined): ParamValidationResult => { // Param is optional - no param means return all items @@ -43,69 +44,65 @@ export const validateNthParams = (param: string | undefined): ParamValidationRes return { valid: false, error: 'invalid syntax. Use number (2), multiplier (5n), offset (n+7), or basis (1,2:5)' }; }; -export const nth = (str: string, params?: string, context?: FilterContext): string => { - // Handle empty or invalid input - if (!str || str === 'undefined' || str === 'null') { - return str; - } - - try { - const data = JSON.parse(str); - if (!Array.isArray(data)) { - return str; - } - - // Default to keeping every item if no params - if (!params) { - return JSON.stringify(data); +export const nth = (str: string, params?: string, context?: FilterContext): TemplateValue => { + const data = arrayInputValue(str, context); + if (!Array.isArray(data)) { + if (typeof data === 'string' && data && data !== 'undefined' && data !== 'null') { + try { + JSON.parse(str); + } catch { + reportFilterWarning(context, 'Could not parse value as a JSON array', 'INVALID_FILTER_INPUT'); + } } + return data; + } - // Check if we have a basis pattern (e.g., "1,2,3:7") - if (params.includes(':')) { - const [positions, basis] = params.split(':').map(p => p.trim()); - const nthValues = positions.split(',').map(n => parseInt(n.trim(), 10)) - .filter(n => !isNaN(n) && n > 0); - const basisSize = parseInt(basis, 10); - - return JSON.stringify(data.filter((_, index) => { - const positionInGroup = (index % basisSize) + 1; - return nthValues.includes(positionInGroup); - })); - } + // Default to keeping every item if no params + if (!params) { + return data; + } - // Parse CSS-style nth expressions - const nthExpression = params.trim(); + // Check if we have a basis pattern (e.g., "1,2,3:7") + if (params.includes(':')) { + const [positions, basis] = params.split(':').map(p => p.trim()); + const nthValues = positions.split(',').map(n => parseInt(n.trim(), 10)) + .filter(n => !isNaN(n) && n > 0); + const basisSize = parseInt(basis, 10); - // Handle simple number (e.g., "7") - if (/^\d+$/.test(nthExpression)) { - const position = parseInt(nthExpression, 10); - return JSON.stringify(data.filter((_, index) => index + 1 === position)); - } + return data.filter((_, index) => { + const positionInGroup = (index % basisSize) + 1; + return nthValues.includes(positionInGroup); + }); + } - // Handle "n" multiplier (e.g., "5n") - if (/^\d+n$/.test(nthExpression)) { - const multiplier = parseInt(nthExpression, 10); - return JSON.stringify(data.filter((_, index) => { - const position = index + 1; - return position % multiplier === 0; - })); - } + // Parse CSS-style nth expressions + const nthExpression = params.trim(); - // Handle "n+b" format (e.g., "n+7") - const nPlusBMatch = nthExpression.match(/^n\+(\d+)$/); - if (nPlusBMatch) { - const offset = parseInt(nPlusBMatch[1], 10); - return JSON.stringify(data.filter((_, index) => { - const position = index + 1; - return position >= offset; - })); - } + // Handle simple number (e.g., "7") + if (/^\d+$/.test(nthExpression)) { + const position = parseInt(nthExpression, 10); + return data.filter((_, index) => index + 1 === position); + } - // Invalid syntax - return str; + // Handle "n" multiplier (e.g., "5n") + if (/^\d+n$/.test(nthExpression)) { + const multiplier = parseInt(nthExpression, 10); + return data.filter((_, index) => { + const position = index + 1; + return position % multiplier === 0; + }); + } - } catch { - reportFilterWarning(context, 'Could not parse value as a JSON array', 'INVALID_FILTER_INPUT'); - return str; + // Handle "n+b" format (e.g., "n+7") + const nPlusBMatch = nthExpression.match(/^n\+(\d+)$/); + if (nPlusBMatch) { + const offset = parseInt(nPlusBMatch[1], 10); + return data.filter((_, index) => { + const position = index + 1; + return position >= offset; + }); } + + // Invalid syntax + return data; }; diff --git a/src/filters/round.ts b/src/filters/round.ts index 46a0966..b23a06a 100644 --- a/src/filters/round.ts +++ b/src/filters/round.ts @@ -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 { collectionInputValue, finiteNumber, isPlainObject } from './value_utils'; export const validateRoundParams = (param: string | undefined): ParamValidationResult => { // Param is optional - no param means round to integer @@ -21,27 +22,45 @@ export const validateRoundParams = (param: string | undefined): ParamValidationR return { valid: true }; }; -export const round = (input: string, param?: string, context?: FilterContext): string => { - const roundNumber = (num: number, decimalPlaces?: number): number => { +export const round = (input: string, param?: string, context?: FilterContext): TemplateValue => { + const roundNumber = (num: number, decimalPlaces?: number): number | undefined => { if (decimalPlaces === undefined) { - return Math.round(num); + const result = Math.round(num); + return Number.isFinite(result) ? result : undefined; } const factor = Math.pow(10, decimalPlaces); - return Math.round(num * factor) / factor; + const result = Math.round(num * factor) / factor; + return Number.isFinite(result) ? result : undefined; }; - const processValue = (value: any, decimalPlaces?: number): any => { + let encounteredNonFinite = false; + const processCollectionValue = (value: TemplateValue, decimalPlaces?: number): TemplateValue => { if (typeof value === 'number') { - return roundNumber(value, decimalPlaces); + if (!Number.isFinite(value)) { + encounteredNonFinite = true; + return value; + } + const result = roundNumber(value, decimalPlaces); + if (result === undefined) { + encounteredNonFinite = true; + return value; + } + return result; } else if (typeof value === 'string') { - const num = parseFloat(value); - return isNaN(num) ? value : roundNumber(num, decimalPlaces).toString(); + const num = finiteNumber(value); + if (num === undefined) return value; + const result = roundNumber(num, decimalPlaces); + if (result === undefined) { + encounteredNonFinite = true; + return value; + } + return result; } else if (Array.isArray(value)) { - return value.map(item => processValue(item, decimalPlaces)); - } else if (typeof value === 'object' && value !== null) { - const result: {[key: string]: any} = {}; + return value.map(item => processCollectionValue(item, decimalPlaces)); + } else if (isPlainObject(value)) { + const result: Record = {}; for (const [key, val] of Object.entries(value)) { - result[key] = processValue(val, decimalPlaces); + result[key] = processCollectionValue(val, decimalPlaces); } return result; } @@ -52,21 +71,32 @@ export const round = (input: string, param?: string, context?: FilterContext): s const cleanParam = cleanScalarParam(param); const decimalPlaces = cleanParam ? parseInt(cleanParam, 10) : undefined; if (cleanParam !== undefined && isNaN(Number(cleanParam))) { - return input; // Return the original input if the parameter is not a valid number + return collectionInputValue(input, context, true); } - let parsedInput: any; - try { - parsedInput = JSON.parse(input); - } catch { - // If JSON parsing fails, treat input as a single value - parsedInput = input; + const value = collectionInputValue(input, context, true); + if (value === null || value === undefined) return value; + if (!Array.isArray(value) && !isPlainObject(value)) { + const num = finiteNumber(value); + if (num === undefined) { + reportFilterWarning(context, `Could not parse "${input}" as a number`, 'INVALID_FILTER_INPUT'); + return value; + } + const result = roundNumber(num, decimalPlaces); + if (result === undefined) { + reportFilterWarning(context, `Rounding produced a non-finite result for "${input}"`, 'INVALID_FILTER_INPUT'); + return value; + } + return result; } - const result = processValue(parsedInput, decimalPlaces); - return typeof result === 'string' ? result : JSON.stringify(result); + const result = processCollectionValue(value, decimalPlaces); + if (encounteredNonFinite) { + reportFilterWarning(context, 'Could not round one or more non-finite collection values', 'INVALID_FILTER_INPUT'); + } + return result; } catch (error) { reportFilterWarning(context, `Could not round value: ${errorMessage(error)}`); - return input; // Return original input if any unexpected error occurs + return collectionInputValue(input, context, true); } }; diff --git a/src/filters/sum.ts b/src/filters/sum.ts index 22630d3..128fcd0 100644 --- a/src/filters/sum.ts +++ b/src/filters/sum.ts @@ -1,7 +1,7 @@ import { parseTypedParams, splitParams, unquoteParamToken, unwrapParamList } from '../parser-utils'; import type { FilterContext, ParamValidationResult, TemplateValue } from '../types'; import { ownPropertyAtPath } from './property_utils'; -import { collectionInputValue } from './value_utils'; +import { collectionInputValue, finiteNumber } from './value_utils'; export const validateSumParams = (param: string | undefined): ParamValidationResult => { if (!param) return { valid: true }; @@ -15,13 +15,6 @@ export const validateSumParams = (param: string | undefined): ParamValidationRes return { valid: true }; }; -function finiteNumber(value: TemplateValue): number | undefined { - if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; - if (typeof value !== 'string' || value.trim() === '') return undefined; - const number = Number(value.trim()); - return Number.isFinite(number) ? number : undefined; -} - export const sum = ( value: string, param?: string, diff --git a/src/filters/value_utils.ts b/src/filters/value_utils.ts index 82a70bc..7e5d699 100644 --- a/src/filters/value_utils.ts +++ b/src/filters/value_utils.ts @@ -2,6 +2,13 @@ import type { FilterContext, TemplateValue } from '../types'; export type StringFormatter = (value: string) => string; +export function finiteNumber(value: TemplateValue): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value !== 'string' || value.trim() === '') return undefined; + const number = Number(value.trim()); + return Number.isFinite(number) ? number : undefined; +} + export function isPlainObject(value: TemplateValue): value is Record { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); @@ -11,6 +18,7 @@ export function isPlainObject(value: TemplateValue): value is Record mapStringValues(item, formatter)); diff --git a/tests/filters/calc.test.ts b/tests/filters/calc.test.ts index c265316..25091b7 100644 --- a/tests/filters/calc.test.ts +++ b/tests/filters/calc.test.ts @@ -7,27 +7,27 @@ const engine = createEngine({ filters: standardFilters }); describe('calc filter', () => { test('addition', () => { - expect(calc('5', '+10')).toBe('15'); + expect(calc('5', '+10')).toBe(15); }); test('subtraction', () => { - expect(calc('10', '-3')).toBe('7'); + expect(calc('10', '-3')).toBe(7); }); test('multiplication', () => { - expect(calc('5', '*3')).toBe('15'); + expect(calc('5', '*3')).toBe(15); }); test('division', () => { - expect(calc('10', '/2')).toBe('5'); + expect(calc('10', '/2')).toBe(5); }); test('exponentiation with **', () => { - expect(calc('2', '**3')).toBe('8'); + expect(calc('2', '**3')).toBe(8); }); test('exponentiation with ^', () => { - expect(calc('2', '^3')).toBe('8'); + expect(calc('2', '^3')).toBe(8); }); test('returns original for non-numbers', () => { @@ -35,15 +35,15 @@ describe('calc filter', () => { }); test('handles decimal numbers', () => { - expect(calc('5.5', '+2.5')).toBe('8'); + expect(calc('5.5', '+2.5')).toBe(8); }); test('handles negative numbers', () => { - expect(calc('-5', '+10')).toBe('5'); + expect(calc('-5', '+10')).toBe(5); }); test('trims spaces inside quoted operations', async () => { - expect(calc('5', '" +1 "')).toBe('6'); + expect(calc('5', '" +1 "')).toBe(6); await expect(engine.renderOrThrow('{{ value | calc:" +1 " }}', { variables: { value: 5 }, })).resolves.toBe('6'); @@ -52,6 +52,44 @@ describe('calc filter', () => { test('returns original without params', () => { expect(calc('5')).toBe('5'); }); + + test('uses strict numeric parsing and warns while preserving invalid input', async () => { + for (const value of ['', ' ', '42abc', 'abc']) { + const result = await engine.render('{{ value | calc:"+10" }}', { variables: { value } }); + expect(result.output).toBe(value); + expect(result.warnings).toMatchObject([{ code: 'INVALID_FILTER_INPUT', filter: 'calc' }]); + } + }); + + test('passes through missing and null values without warnings', async () => { + for (const variables of [{}, { value: null }]) { + const result = await engine.render('{{ value | calc:"+10" }}', { variables }); + expect(result.output).toBe(''); + expect(result.warnings).toEqual([]); + } + }); + + test('accepts numeric text using the shared full-string parser', () => { + expect(calc('0x1A', '+1')).toBe(27); + }); + + test('keeps ten-decimal floating-point cleanup on numeric results', () => { + expect(calc('0.1', '+0.2')).toBe(0.3); + }); + + test('retains singleton primitive unwrapping for scalar arithmetic', async () => { + await expect(engine.renderOrThrow('{{ values | calc:"+1" }}', { + variables: { values: [42] }, + })).resolves.toBe('43'); + }); + + test('warns and preserves the original typed value for non-finite results', async () => { + const result = await engine.render('{{ value | calc:"/0" | yaml_property:"v" }}', { + variables: { value: 42.567 }, + }); + expect(result.output).toBe('v: 42.567'); + expect(result.warnings).toMatchObject([{ code: 'INVALID_FILTER_INPUT', filter: 'calc' }]); + }); }); describe('calc param validation', () => { diff --git a/tests/filters/first.test.ts b/tests/filters/first.test.ts index e25a4d5..1b3d88f 100644 --- a/tests/filters/first.test.ts +++ b/tests/filters/first.test.ts @@ -1,9 +1,14 @@ import { describe, test, expect } from 'vitest'; +import { createEngine } from '../../src/engine'; +import { standardFilters } from '../../src/filters'; import { first } from '../../src/filters/first'; +const engine = createEngine({ filters: standardFilters }); + describe('first filter', () => { test('returns first element of array', () => { expect(first('["a","b","c"]')).toBe('a'); + expect(first(' ["a","b","c"]')).toBe('a'); }); test('returns input if not array', () => { @@ -15,21 +20,42 @@ describe('first filter', () => { }); test('handles array of numbers', () => { - expect(first('[1,2,3]')).toBe('1'); + expect(first('[1,2,3]')).toBe(1); }); test('handles empty array', () => { - // Empty array returns the input string as-is - expect(first('[]')).toBe('[]'); + expect(first('[]')).toBeNull(); }); test('handles array of objects', () => { - // Object.toString() returns "[object Object]" const result = first('[{"a":1},{"b":2}]'); - expect(result).toBe('[object Object]'); + expect(result).toEqual({ a: 1 }); + }); + + test('preserves a selected null value', () => { + expect(first('[null,"x"]')).toBeNull(); + }); + + test('preserves nested array shape', () => { + expect(first('[[1,2],[3]]')).toEqual([1, 2]); + }); + + test('uses raw singleton collection shape', () => { + expect(first('42', undefined, { variables: {}, rawValue: [42] })).toBe(42); + expect(first('', undefined, { variables: {}, rawValue: [''] })).toBe(''); + }); + + test('passes through non-array typed values', () => { + const value = { a: 1 }; + expect(first('{"a":1}', undefined, { variables: {}, rawValue: value })).toBe(value); }); - test('renders a selected null as empty text', () => { - expect(first('[null,"x"]')).toBe(''); + test('renders an empty selection as empty text and serializes it as null', async () => { + await expect(engine.renderOrThrow('{{ items | first }}', { + variables: { items: [] }, + })).resolves.toBe(''); + await expect(engine.renderOrThrow('{{ items | first | yaml_property:"v" }}', { + variables: { items: [] }, + })).resolves.toBe('v: null'); }); }); diff --git a/tests/filters/last.test.ts b/tests/filters/last.test.ts index 7fd072e..59ea0eb 100644 --- a/tests/filters/last.test.ts +++ b/tests/filters/last.test.ts @@ -15,15 +15,23 @@ describe('last filter', () => { }); test('handles array of numbers', () => { - expect(last('[1,2,3]')).toBe('3'); + expect(last('[1,2,3]')).toBe(3); }); test('handles empty array', () => { - // Empty array returns the input string as-is - expect(last('[]')).toBe('[]'); + expect(last('[]')).toBeNull(); }); test('renders a selected null as empty text', () => { - expect(last('["x",null]')).toBe(''); + expect(last('["x",null]')).toBeNull(); + }); + + test('preserves nested array shape', () => { + expect(last('[[1,2],[3]]')).toEqual([3]); + }); + + test('uses raw singleton collection shape', () => { + expect(last('42', undefined, { variables: {}, rawValue: [42] })).toBe(42); + expect(last('', undefined, { variables: {}, rawValue: [''] })).toBe(''); }); }); diff --git a/tests/filters/length.test.ts b/tests/filters/length.test.ts index 3dcc79e..39a158f 100644 --- a/tests/filters/length.test.ts +++ b/tests/filters/length.test.ts @@ -1,32 +1,54 @@ import { describe, test, expect } from 'vitest'; +import { createEngine } from '../../src/engine'; +import { standardFilters } from '../../src/filters'; import { length } from '../../src/filters/length'; +const engine = createEngine({ filters: standardFilters }); + describe('length filter', () => { test('returns string length', () => { - expect(length('hello')).toBe('5'); + expect(length('hello')).toBe(5); }); test('returns array length', () => { - expect(length('["a","b","c"]')).toBe('3'); + expect(length('["a","b","c"]')).toBe(3); + expect(length(' ["a","b","c"]')).toBe(3); }); test('returns object keys count', () => { - expect(length('{"a":1,"b":2}')).toBe('2'); + expect(length('{"a":1,"b":2}')).toBe(2); }); test('handles empty string', () => { - expect(length('')).toBe('0'); + expect(length('')).toBe(0); }); test('handles empty array', () => { - expect(length('[]')).toBe('0'); + expect(length('[]')).toBe(0); }); test('handles empty object', () => { - expect(length('{}')).toBe('0'); + expect(length('{}')).toBe(0); }); test('counts unicode characters correctly', () => { - expect(length('hello')).toBe('5'); + expect(length('hello')).toBe(5); + }); + + test('uses raw singleton collection shape', () => { + expect(length('42', undefined, { variables: {}, rawValue: [42] })).toBe(1); + }); + + test('keeps rendered-text semantics for other typed scalars', () => { + expect(length('42', undefined, { variables: {}, rawValue: 42 })).toBe(2); + expect(length('true', undefined, { variables: {}, rawValue: true })).toBe(4); + expect(length('', undefined, { variables: {}, rawValue: null })).toBe(0); + }); + + test('retains numeric comparison behavior', async () => { + await expect(engine.renderOrThrow( + '{% set count = items | length %}{% if count > 9 %}YES{% else %}NO{% endif %}', + { variables: { items: Array.from({ length: 10 }) } }, + )).resolves.toBe('YES'); }); }); diff --git a/tests/filters/nth.test.ts b/tests/filters/nth.test.ts index 408d0bb..dd06d81 100644 --- a/tests/filters/nth.test.ts +++ b/tests/filters/nth.test.ts @@ -8,36 +8,38 @@ const engine = createEngine({ filters: standardFilters }); describe('nth filter', () => { test('keeps nth element (1-based)', () => { - const result = nth('["a","b","c","d","e"]', '3'); - const parsed = JSON.parse(result); - expect(parsed).toEqual(['c']); + expect(nth('["a","b","c","d","e"]', '3')).toEqual(['c']); }); test('keeps every nth element', () => { - const result = nth('["a","b","c","d","e","f"]', '2n'); - const parsed = JSON.parse(result); - expect(parsed).toEqual(['b', 'd', 'f']); + expect(nth('["a","b","c","d","e","f"]', '2n')).toEqual(['b', 'd', 'f']); }); test('keeps nth and following (n+offset)', () => { - const result = nth('["a","b","c","d","e"]', 'n+3'); - const parsed = JSON.parse(result); - expect(parsed).toEqual(['c', 'd', 'e']); + expect(nth('["a","b","c","d","e"]', 'n+3')).toEqual(['c', 'd', 'e']); }); test('handles group pattern', () => { - const result = nth('[1,2,3,4,5,6,7,8,9,10]', '1,2,3:5'); - const parsed = JSON.parse(result); - expect(parsed).toEqual([1, 2, 3, 6, 7, 8]); + expect(nth('[1,2,3,4,5,6,7,8,9,10]', '1,2,3:5')).toEqual([1, 2, 3, 6, 7, 8]); }); test('handles empty array', () => { const result = nth('[]', '3'); - expect(result).toBe('[]'); + expect(result).toEqual([]); }); test('preserves null in its existing array return shape', () => { - expect(nth('[null,"x"]', '1')).toBe('[null]'); + expect(nth('[null,"x"]', '1')).toEqual([null]); + }); + + test('uses raw singleton collection shape and preserves element types', () => { + expect(nth('42', '1', { variables: {}, rawValue: [42] })).toEqual([42]); + expect(nth('', '2', { variables: {}, rawValue: [''] })).toEqual([]); + }); + + test('preserves selected element types', () => { + const values = ['42', 42, true, null, [1], { a: 1 }]; + expect(nth(JSON.stringify(values), '1,2,3,4,5,6:6')).toEqual(values); }); test('returns original for non-JSON', () => { @@ -131,7 +133,13 @@ describe('nth filter via renderer', () => { variables: { msg: '["a","b","c","d","e"]' }, }); expect(result.errors).toHaveLength(0); - expect(result.output).toBe('["b"]'); + expect(result.output).toBe('b'); + }); + + test('passes a singleton selection to downstream text filters using renderer unwrapping', async () => { + await expect(engine.renderOrThrow('{{msg|nth:2|upper}}', { + variables: { msg: ['a', 'b'] }, + })).resolves.toBe('B'); }); test('nth:2n gets every 2nd element through template', async () => { diff --git a/tests/filters/round.test.ts b/tests/filters/round.test.ts index 6dde91a..eeff608 100644 --- a/tests/filters/round.test.ts +++ b/tests/filters/round.test.ts @@ -1,26 +1,30 @@ import { describe, test, expect } from 'vitest'; +import { createEngine } from '../../src/engine'; +import { standardFilters } from '../../src/filters'; import { round, validateRoundParams } from '../../src/filters/round'; +const engine = createEngine({ filters: standardFilters }); + describe('round filter', () => { test('rounds to nearest integer by default', () => { - expect(round('3.7')).toBe('4'); - expect(round('3.2')).toBe('3'); + expect(round('3.7')).toBe(4); + expect(round('3.2')).toBe(3); }); test('rounds to specified decimal places', () => { - expect(round('3.14159', '2')).toBe('3.14'); + expect(round('3.14159', '2')).toBe(3.14); }); test('rounds up at midpoint', () => { - expect(round('3.5')).toBe('4'); + expect(round('3.5')).toBe(4); }); test('handles negative numbers', () => { - expect(round('-3.7')).toBe('-4'); + expect(round('-3.7')).toBe(-4); }); test('handles integers', () => { - expect(round('5')).toBe('5'); + expect(round('5')).toBe(5); }); test('returns original for non-numbers', () => { @@ -28,7 +32,85 @@ describe('round filter', () => { }); test('handles zero decimal places', () => { - expect(round('3.14159', '0')).toBe('3'); + expect(round('3.14159', '0')).toBe(3); + }); + + test('accepts surrounding whitespace and JavaScript numeric forms', () => { + expect(round(' 42.5 ', '1')).toBe(42.5); + expect(round('0x1A')).toBe(26); + }); + + test('uses strict numeric parsing and warns while preserving invalid scalar input', async () => { + for (const value of ['', ' ', '42abc', 'abc']) { + const result = await engine.render('{{ value | round:1 }}', { variables: { value } }); + expect(result.output).toBe(value); + expect(result.warnings).toMatchObject([{ code: 'INVALID_FILTER_INPUT', filter: 'round' }]); + } + }); + + test('passes through missing and null values without warnings', async () => { + for (const variables of [{}, { value: null }]) { + const result = await engine.render('{{ value | round }}', { variables }); + expect(result.output).toBe(''); + expect(result.warnings).toEqual([]); + } + }); + + test('rounds numeric collection members while preserving their types', () => { + expect(round('{"price":"42.567","title":"Neo","loose":"42abc"}', '2')).toEqual({ + price: 42.57, + title: 'Neo', + loose: '42abc', + }); + }); + + test('recognizes serialized collections with leading whitespace', () => { + expect(round(' [1.23,"2.34"]', '1')).toEqual([1.2, 2.3]); + }); + + test('supports precision greater than ten without adding floating artifacts', () => { + expect(round('1.2345678901234', '12')).toBe(1.234567890123); + }); + + test('warns and preserves scalar input when rounding would overflow', async () => { + const result = await engine.render('{{ value | round:10 | yaml_property:"v" }}', { + variables: { value: 1e308 }, + }); + expect(result.output).toBe('v: 1e+308'); + expect(result.warnings).toMatchObject([{ code: 'INVALID_FILTER_INPUT', filter: 'round' }]); + }); + + test('preserves overflowing collection members, rounds valid members, and emits one warning', () => { + const warnings: unknown[] = []; + const values = [1.234567890123, '42abc', 1e308]; + expect(round(JSON.stringify(values), '10', { + variables: {}, rawValue: values, reportWarning: warning => warnings.push(warning), + })).toEqual([1.2345678901, '42abc', 1e308]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ code: 'INVALID_FILTER_INPUT' }); + }); + + test('preserves an existing non-finite collection member in the aggregate warning', () => { + const warnings: unknown[] = []; + const values = [1.234, Infinity]; + expect(round('', '2', { + variables: {}, rawValue: values, reportWarning: warning => warnings.push(warning), + })).toEqual([1.23, Infinity]); + expect(warnings).toHaveLength(1); + }); + + test('preserves singleton collection shape for downstream typed filters', async () => { + await expect(engine.renderOrThrow('{{ items | round | yaml_property:"v" }}', { + variables: { items: [42.5] }, + })).resolves.toBe('v:\n - 43'); + }); + + test('does not warn for non-numeric collection members', async () => { + const result = await engine.render('{{ item | round:1 }}', { + variables: { item: { name: 'Neo', note: '42abc' } }, + }); + expect(result.output).toBe('{"name":"Neo","note":"42abc"}'); + expect(result.warnings).toEqual([]); }); }); diff --git a/tests/filters/sort.test.ts b/tests/filters/sort.test.ts index 3b8cbf3..cc2d8eb 100644 --- a/tests/filters/sort.test.ts +++ b/tests/filters/sort.test.ts @@ -32,6 +32,9 @@ describe('sort filter', () => { await expect(engine.renderOrThrow('{{ value | sort }}', { variables: { value: '3' }, })).resolves.toBe('3'); + await expect(engine.renderOrThrow('{{ value | sort }}', { + variables: { value: ' [3,1,2]' }, + })).resolves.toBe(' [3,1,2]'); }); test('validates sort direction', () => { diff --git a/tests/filters/yaml_property.test.ts b/tests/filters/yaml_property.test.ts index 3fbd135..d9f3939 100644 --- a/tests/filters/yaml_property.test.ts +++ b/tests/filters/yaml_property.test.ts @@ -35,16 +35,35 @@ describe('yaml_property filter', () => { })).resolves.toBe(expected); }); - test('preserves the type returned by preceding filters', () => { + test('preserves the typed value returned by preceding filters', () => { expect(applyFiltersWithRegistry( [2, 3], 'sum | yaml_property:"sum"', standardFilters, { variables: {} }, )).toBe('sum: 5'); expect(applyFiltersWithRegistry( 42.567, 'round:2 | yaml_property:"price"', standardFilters, { variables: {} }, - )).toBe('price: "42.57"'); + )).toBe('price: 42.57'); expect(applyFiltersWithRegistry( - [2, 3], 'length | parse_json | yaml_property:"count"', standardFilters, { variables: {} }, + [2, 3], 'length | yaml_property:"count"', standardFilters, { variables: {} }, )).toBe('count: 2'); + expect(applyFiltersWithRegistry( + 1234.5, 'number_format:2 | yaml_property:"display"', standardFilters, { variables: {} }, + )).toBe('display: "1,234.50"'); + }); + + test('preserves computed types through assignments', async () => { + await expect(engine.renderOrThrow( + '{% set p = price | round:2 %}{{ p | yaml_property:"price" }}', + { variables: { price: 42.567 } }, + )).resolves.toBe('price: 42.57'); + }); + + test('preserves selected primitive types through YAML serialization', async () => { + await expect(engine.renderOrThrow('{{ items | first | yaml_property:"v" }}', { + variables: { items: [42] }, + })).resolves.toBe('v: 42'); + await expect(engine.renderOrThrow('{{ items | first | yaml_property:"v" }}', { + variables: { items: ['42'] }, + })).resolves.toBe('v: "42"'); }); test('nests arrays and objects with two-space indentation', async () => { From 1ddf70b25cc476e19bdef43a28046bf8b13a268c Mon Sep 17 00:00:00 2001 From: Steph Ango <10565871+kepano@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:00:38 -0700 Subject: [PATCH 2/2] Clarify parse_json documentation --- CHANGELOG.md | 2 +- src/docs/filter-docs.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b32a85f..afaefa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 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. diff --git a/src/docs/filter-docs.ts b/src/docs/filter-docs.ts index 395f9ef..4d8ce27 100644 --- a/src/docs/filter-docs.ts +++ b/src/docs/filter-docs.ts @@ -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'), @@ -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'),