/*
* This file is part of the xPack project (http://xpack.github.io).
* Copyright (c) 2021-2026 Liviu Ionescu. All rights reserved.
*
* Permission to use, copy, modify, and/or distribute this software
* for any purpose is hereby granted, under the terms of the MIT license.
*
* If a copy of the license was not distributed with this file, it can
* be obtained from https://opensource.org/license/mit.
*/
// ----------------------------------------------------------------------------
import assert from 'node:assert'
import { Context } from 'liquidjs'
import { Logger } from '@xpack/logger'
// ----------------------------------------------------------------------------
import { LiquidEngine } from '../classes/liquid-engine.js'
import { TemplateError } from '../classes/errors.js'
import {
LiquidPropertiesDrop,
LiquidMatrixDrop,
} from '../classes/liquid-drop.js'
import {
LiquidSubstitutionsVariables,
LiquidSubstitutionsStrings,
} from '../data/substitutions-variables.js'
// ============================================================================
// This limit prevents infinite loops from circular template references.
// Chosen to be high enough for deeply nested legitimate templates
// (typical nesting is < 10 levels) while catching pathological cases.
// In practice, most templates resolve in 2-5 iterations.
const PERFORM_SUBSTITUTION_MAX_ITERATIONS = 42 // Prevent infinite loops
// This limit prevents memory exhaustion from exponentially expanding
// templates. Set to 10MB to accommodate legitimate large outputs
// (e.g., generated code files, documentation) whilst catching
// pathological cases such as unbounded loops or recursive expansions.
// Typical template outputs are a few hundred bytes.
const PERFORM_SUBSTITUTION_MAX_OUTPUT_SIZE = 42 * 1024 // 42KB
/**
* Performs substitutions on an input string using Liquid.
*
* @remarks
* This function processes Liquid template syntax (variables and tags) by
* repeatedly rendering the input until no more substitutions are detected.
* The iterative approach supports nested substitutions where one property
* references another.
*
* Processing workflow:
*
*
* - Skip processing for empty strings to avoid unnecessary overhead.
* - Prepare Liquid context with substitution variables.
* - If
properties exist, wrap them in
* LiquidPropertiesDrop
* for lazy evaluation and nested substitution support.
* - If
matrix parameters exist, wrap them in
* LiquidMatrixDrop
* for template expansion variable access.
* - Iterate while Liquid syntax (
\{\{ or \{%)
* is present:
*
* - Parse and render the current string.
* - Break if no changes occur (safety check).
* - Continue with the substituted result.
*
*
* - Return the fully substituted string.
*
*
* The Drop pattern enables recursive property resolution: when a template
* accesses `{{ properties.foo }}` and `foo` contains `{{ properties.bar }}`,
* the next iteration resolves `bar`, and so on until no Liquid syntax
* remains.
*
* Error handling:
*
* Liquid rendering errors are caught, stripped of line
* number information (which can be misleading for nested templates), and
* re-thrown as {@link ConfigurationError}.
*
* @param log - The logger instance for output and diagnostics.
* @param engine - The Liquid engine used to render substitutions.
* @param input - The input string, possibly containing substitutions.
* @param substitutionsVariables - The variables available for substitution.
* @param maxIterations - Optional maximum number of substitution iterations
* to prevent infinite loops from circular references. Defaults to 420.
* @param maxOutputSize - Optional maximum output size in bytes to prevent
* memory exhaustion from exponentially expanding templates. Defaults to 43008
* bytes (42KB).
* @returns The fully substituted string.
*
* @throws {@link TemplateError}
* If Liquid rendering fails, iteration limit is exceeded, or output size
* limit is exceeded.
*/
export async function performSubstitutions({
engine,
input,
substitutionsVariables,
log,
maxIterations = PERFORM_SUBSTITUTION_MAX_ITERATIONS,
maxOutputSize = PERFORM_SUBSTITUTION_MAX_OUTPUT_SIZE,
}: {
engine: LiquidEngine
input: string
substitutionsVariables: LiquidSubstitutionsVariables
log: Logger
maxIterations?: number
maxOutputSize?: number
}): Promise {
assert(engine, 'engine is required')
assert(substitutionsVariables, 'substitutionsVariables is required')
assert(log, 'log is required')
assert(maxIterations > 0, 'maxIterations must be a positive integer')
assert(maxOutputSize > 0, 'maxOutputSize must be a positive integer')
if (input.trim() === '') {
// Spare it the trouble for empty strings.
return input
}
// Wrap properties into a liquid drop (a mechanism to process
// substitutions immediately).
let properties: LiquidSubstitutionsStrings | LiquidPropertiesDrop =
substitutionsVariables.properties
let matrix: LiquidSubstitutionsStrings | LiquidMatrixDrop | undefined =
substitutionsVariables.matrix
if (Object.keys(substitutionsVariables.properties).length > 0) {
properties = new LiquidPropertiesDrop({
log,
engine,
properties: substitutionsVariables.properties,
})
}
if (
substitutionsVariables.matrix &&
Object.keys(substitutionsVariables.matrix).length > 0
) {
matrix = new LiquidMatrixDrop({
log,
engine,
matrix: substitutionsVariables.matrix,
})
}
// Passing the engine options is important, otherwise unknown
// variables do not trigger exceptions.
const context = new Context(
{
...substitutionsVariables,
properties,
matrix,
},
engine.options,
{ sync: false }
)
log.trace(`performSubstitutions('${input}')`)
let current: string = input
let substituted: string = current
let count = 0
const LIQUID_SYNTAX_REGEX = /\{\{|\{%/
// Iterate until all substitutions are done.
while (LIQUID_SYNTAX_REGEX.test(current)) {
// Safety net: This limit prevents infinite loops from circular template
// references. In normal operation, templates resolve in a few iterations.
// The check is unlikely to trigger because:
// 1. Templates are validated during configuration loading
// 2. Liquid engine throws errors for most invalid references
// 3. The break below catches non-changing substitutions
// However, this protects against edge cases like deeply nested context
// references or malformed template logic that the engine doesn't catch.
/* c8 ignore start - safety net, normally should not get there. */
if (++count > maxIterations) {
throw new TemplateError(
`Substitution limit exceeded ` +
`(${String(maxIterations)} iterations). ` +
`Possible circular reference in template.`
)
}
/* c8 ignore stop */
// May throw.
try {
substituted = (await engine.parseAndRender(current, context)) as string
/* c8 ignore start - safety net. */
if (substituted.length > maxOutputSize) {
throw new TemplateError(
`Template expansion exceeded size limit ` +
`(${String(maxOutputSize)} bytes). ` +
`Output was ${String(substituted.length)} bytes.`
)
} /* c8 ignore stop */
// Safety net: This check detects when a substitution pass produces no
// changes despite template markers being present. This is unlikely
// because:
// 1. The while condition checks for markers ({{ or {%)
// 2. Liquid engine normally processes all markers or throws errors
// 3. Valid markers always resolve to something (even empty string)
// However, this catches edge cases like malformed markers that pass the
// simple includes() check but don't match Liquid's parser, preventing
// infinite loops when the iteration limit isn't reached.
/* c8 ignore start - safety net, normally errors throw. */
if (substituted === current) {
log.warn(
`performSubstitutions() step ${String(count)} => (`,
substituted,
') did not change'
)
break
} /* c8 ignore stop */
} catch (error) {
if (error instanceof Error) {
log.trace(`Liquid error: ${error.message}`)
const cleanMessage = error.message.replace(/, line:.*/g, '')
throw new TemplateError(cleanMessage)
// Safety net: This handles the unlikely case where something other than
// an Error is thrown. JavaScript/TypeScript allows throwing any value,
// but Liquid engine and Node.js fs operations consistently throw Error
// instances. This provides robust error handling for unexpected
// scenarios.
/* c8 ignore start - safety net, currently all are Errors */
} else {
throw new TemplateError(String(error))
}
/* c8 ignore stop */
}
log.trace(
`performSubstitutions() step ${String(count)} => (`,
substituted,
')'
)
current = substituted
}
return substituted
}
// ----------------------------------------------------------------------------