/* * 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: * *
    *
  1. Skip processing for empty strings to avoid unnecessary overhead.
  2. *
  3. Prepare Liquid context with substitution variables.
  4. *
  5. If properties exist, wrap them in * LiquidPropertiesDrop * for lazy evaluation and nested substitution support.
  6. *
  7. If matrix parameters exist, wrap them in * LiquidMatrixDrop * for template expansion variable access.
  8. *
  9. Iterate while Liquid syntax (\{\{ or \{%) * is present: * *
  10. *
  11. Return the fully substituted string.
  12. *
* * 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 } // ----------------------------------------------------------------------------