/*
* 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 * as os from 'node:os'
import * as path from 'node:path'
import * as util from 'node:util'
// https://www.npmjs.com/package/liquidjs
import * as liquidjs from 'liquidjs'
// ----------------------------------------------------------------------------
import { isJsonObject } from '../functions/is-something.js'
import { PlatformDetector } from './platform-detector.js'
// ============================================================================
/**
* Liquid engine configured for xpm templates.
*
* @remarks
* This class extends the Liquid engine and registers custom filters
* for path manipulation, string formatting, and convenience helpers used across
* xpm templates.
*
* The engine is configured with strict parsing options to catch template
* errors early during development. Custom filters are organized into
* categories:
*
*
* - Path manipulation: Platform-specific and cross-platform path
* operations
* (
basename, dirname, join,
* relative, normalize) for default, POSIX, and
* Win32 paths.
* - String formatting: Utilities for printf-style formatting and
* filename
* sanitization.
* - Array/string conversion: Filters for joining and splitting
* lines.
* - Object introspection: Filters for extracting object keys.
*
*
* These filters enable templates to perform complex path manipulations and
* string transformations without requiring external dependencies or custom
* template tags.
*/
export class LiquidEngine extends liquidjs.Liquid {
// --------------------------------------------------------------------------
// Private Members.
/**
* The platform detector instance for platform-specific behaviour.
*/
private readonly platformDetector: PlatformDetector
// --------------------------------------------------------------------------
// Constructor.
/**
* Constructs a Liquid engine instance with xpm-specific settings and
* filters.
*
* @remarks
* The constructor configures strict parsing options and registers
* filters for path handling, formatting, and list operations.
*
* Configuration options:
*
*
* - strictFilters: Throw errors for undefined filters rather than
* silently ignoring them.
* - strictVariables: Throw errors for undefined variables rather
* than
* rendering empty strings.
* - trimTagLeft/Right: Preserve whitespace around template
* tags.
* - trimOutputLeft/Right: Preserve whitespace around output
* expressions.
* - greedy: Use non-greedy matching for better template
* compatibility.
* - lenientIf: Allow flexible truthiness in conditional
* expressions.
*
*
* Filter registration:
*
*
* - Platform-aware path filters (default, posix, win32): delegate to
* Node.js path module for consistent cross-platform behavior.
* - Custom filters (to_filename, join_lines, split_lines, keys):
* provide
* template-specific functionality not available in standard Liquid.
* - All filters are registered during construction for immediate
* availability in templates.
*
*
* @param platformDetector - The platform detector instance for
* platform-specific behaviour. Defaults to a new {@link PlatformDetector}
* instance.
*/
constructor({
platformDetector = new PlatformDetector(),
options = {},
}: {
platformDetector?: PlatformDetector
options?: liquidjs.LiquidOptions
} = {}) {
super({
strictFilters: true,
strictVariables: true,
trimTagLeft: false,
trimTagRight: false,
trimOutputLeft: false,
trimOutputRight: false,
greedy: false,
lenientIf: true,
cache: false,
...options, // Allow overriding defaults with provided options.
})
this.platformDetector = platformDetector
// https://liquidjs.com/api/classes/liquid_.liquid.html#registerFilter
// https://nodejs.org/dist/latest-v16.x/docs/api/path.html
// Add the main path manipulation functions.
this.registerFilter('path_basename', (p: string, ...arg) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
path.basename(p, ...arg)
)
this.registerFilter('path_dirname', (p: string) => path.dirname(p))
this.registerFilter('path_normalize', (p: string) => path.normalize(p))
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.registerFilter('path_join', (p, ...args) => path.join(p, ...args))
this.registerFilter('path_relative', (from: string, to: string) =>
path.relative(from, to)
)
this.registerFilter('path_posix_basename', (p: string, ...arg) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
path.posix.basename(p, ...arg)
)
this.registerFilter('path_posix_dirname', (p: string) =>
path.posix.dirname(p)
)
this.registerFilter('path_posix_normalize', (p: string) =>
path.posix.normalize(p)
)
this.registerFilter('path_posix_join', (p, ...args) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
path.posix.join(p, ...args)
)
this.registerFilter('path_posix_relative', (from: string, to: string) =>
path.posix.relative(from, to)
)
this.registerFilter('path_win32_basename', (p: string, ...arg) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
path.win32.basename(p, ...arg)
)
this.registerFilter('path_win32_dirname', (p: string) =>
path.win32.dirname(p)
)
this.registerFilter('path_win32_normalize', (p: string) =>
path.win32.normalize(p)
)
this.registerFilter('path_win32_join', (p, ...args) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
path.win32.join(p, ...args)
)
this.registerFilter('path_win32_relative', (from: string, to: string) =>
path.win32.relative(from, to)
)
// https://nodejs.org/dist/latest-v16.x/docs/api/util.html
this.registerFilter('util_format', (format, ...args) => {
// console.log([...args])
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return util.format(format, ...args)
})
// Custom action.
this.registerFilter(
'to_filename',
// Replace non alphanumeric chars with dashes to make the paths
// comply with filesystem names.
(input: string): string => {
/* c8 ignore start - windows specific code cannot be tested
on other platforms */
const fixed = this.platformDetector.isWindows()
? input.replace(/[^a-zA-Z0-9\\:]+/g, '-')
: input.replace(/[^a-zA-Z0-9/]+/g, '-')
/* c8 ignore stop */
return fixed.replace(/--/g, '-')
}
)
this.registerFilter('join_lines', (input: string[]): string => {
// Convert an array into a string with each element on a separate line.
if (Array.isArray(input)) {
return input.join(os.EOL)
}
return String(input)
})
// Convert a string with lines into an array.
this.registerFilter('split_lines', (input: string | string[]): string[] => {
if (Array.isArray(input)) {
// If already an array, first flatten it, then split it.
// This is needed in case any of the lines include EOLs.
return input.join(os.EOL).split(os.EOL)
}
return input.split(os.EOL)
})
this.registerFilter('keys', (input: unknown): string[] | string => {
if (isJsonObject(input)) {
const keys = Object.keys(input as object)
// console.log('input object', input)
// console.log('input keys', keys)
return keys
} else if (Array.isArray(input)) {
const keys = Object.keys(input)
return keys
} else {
return String(input)
}
})
}
}
// ----------------------------------------------------------------------------