/*
* 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 * as path from 'node:path'
import * as os from 'node:os'
import { Logger } from '@xpack/logger'
// ----------------------------------------------------------------------------
import { LiquidEngine } from './liquid-engine.js'
import {
LiquidSubstitutionsVariables,
LiquidSubstitutionsStrings,
} from '../data/substitutions-variables.js'
import { filterPath } from '../functions/filter-paths.js'
import { isJsonObject, isString } from '../functions/is-something.js'
import { getErrorMessage, hasLiquidSyntax } from '../functions/utils.js'
import { performSubstitutions } from '../functions/perform-substitutions.js'
import {
JsonBuildConfigurations,
JsonBuildConfigurationTemplate,
JsonBuildConfiguration,
JsonBuildConfigurationContent,
JsonDependencies,
JsonBuildConfigurationInherits,
} from '../types/json.js'
import { Actions, Action } from './actions.js'
import { buildFolderRelativePathPropertyName } from './data-model.js'
import { ConfigurationError } from './errors.js'
import { TemplateExpander } from './template-expander.js'
// ============================================================================
/**
* Configuration parameters for constructing a build configurations collection.
*
* @remarks
* This interface defines the required configuration for creating an
* instance of {@link BuildConfigurations}. Most properties are mandatory
* except for the optional jsonBuildConfigurations, which can
* be undefined if there are no build configurations defined in the package.
*
* The parameters provide the collection with access to the Liquid templating
* engine, substitution variables hierarchy, build configuration definitions
* from the package manifest, and the logger for diagnostic output during
* configuration processing.
*/
export interface BuildConfigurationsConstructorParameters {
/**
* The Liquid templating engine for variable substitution.
*/
engine: LiquidEngine
/**
* The variables available for substitution in configuration definitions.
*/
substitutionsVariables: LiquidSubstitutionsVariables
/**
* The JSON build configurations definitions, or undefined if no build
* configurations are defined.
*/
jsonBuildConfigurations: JsonBuildConfigurations | undefined
/**
* The logger instance for output and diagnostics.
*/
log: Logger
}
/**
* A collection of xpm build configurations.
*
* @remarks
* This class manages build configurations defined in package metadata,
* including template expansion with matrix parameters and initialisation of
* derived configuration instances.
*
* Configuration lifecycle phases:
*
*
* - Construction: Basic setup without processing configurations.
* - Initialisation: Template name expansion without content
* evaluation.
* - Retrieval: On-demand instantiation when accessed via
*
get().
* - Configuration Initialisation: Full processing including
* inheritance, property resolution, dependency substitution, and
* action preparation.
*
*
* This lazy evaluation strategy ensures that only configurations actually
* used incur the cost of template evaluation, inheritance resolution, and
* variable substitution.
*/
export class BuildConfigurations {
// --------------------------------------------------------------------------
// Public Members.
/**
* The logger instance for output and diagnostics.
*
* @remarks
* This logger provides trace-level diagnostics throughout the build
* configuration lifecycle, including template expansion, inheritance
* resolution, property merging, and dependency substitution. It enables
* detailed debugging of complex build configuration hierarchies without
* impacting runtime performance when tracing is disabled.
*/
readonly log: Logger
/**
* The Liquid templating engine for variable substitution.
*
* @remarks
* This engine instance is shared across all build configurations and
* configured with custom filters for platform detection, path
* manipulation, and xpm-specific operations. It processes templates in
* configuration names, matrix parameters, properties, dependencies, and
* actions, ensuring consistent template evaluation throughout the
* configuration lifecycle.
*/
readonly engine: LiquidEngine
/**
* The variables available for substitution in configuration definitions.
*
* @remarks
* This comprehensive variable hierarchy provides the base context for all
* build configuration template evaluation, extended per-configuration with
* specific properties, dependencies, and matrix parameters.
*
* Base hierarchy includes:
*
*
* - Environment variables:
env namespace with system
* environment.
* - Platform detection:
os namespace with
* platform-specific values.
* - Path utilities:
path namespace with path
* manipulationfunctions.
* - Package metadata:
package namespace with
* name, version, dependencies.
*
*
* Individual configurations extend this with their own `properties`,
* `configuration`, and `matrix` namespaces during initialisation.
*/
readonly substitutionsVariables: LiquidSubstitutionsVariables
/**
* The JSON object containing build configuration definitions.
*
* @remarks
* This object holds raw build configuration definitions from the
* `package.json` `xpack.buildConfigurations` section. Configurations can be:
*
*
* - Regular configurations: Direct objects with properties,
* dependencies, actions, and inheritance.
* - Template configurations: Objects with
matrix
* and template
* properties for generating multiple configurations from a single
* definition.
*
*
* Template configuration names (containing `{{` markers) trigger matrix
* expansion during initialisation, creating concrete configurations from
* the Cartesian product of matrix parameter values. Each configuration
* can inherit from others, creating complex dependency hierarchies.
*/
readonly jsonBuildConfigurations: JsonBuildConfigurations
// --------------------------------------------------------------------------
// Protected Members.
/**
* Map of build configuration names to their corresponding instances.
*
* @remarks
* This map serves as the primary configuration registry, populated during
* collection initialisation with entries for all discovered configurations.
*
* Key characteristics:
*
*
* - Known only after
BuildConfigurations.initialise()
* completes.
* - Possibly empty if there are no build configurations defined.
* - Values can be
undefined to indicate a configuration
* exists but hasn't been instantiated yet (lazy loading).
* - For template configurations, contains one entry per expanded
* combination, not the original template definition.
*
*
* Configurations transition from `undefined` to instantiated when first
* accessed via {@link BuildConfigurations.get}, implementing the
* lazy evaluation pattern to avoid unnecessary processing.
*/
protected readonly _buildConfigurationsMap: Map<
string,
BuildConfiguration | undefined
> = new Map()
/**
* Map of expanded build configuration names to their JSON source names.
*
* @remarks
* This reverse mapping enables retrieving the original configuration
* definition from `jsonBuildConfigurations` when lazy-loading
* configuration instances.
*
* Mapping behavior:
*
*
* - For regular configurations: Maps configuration name to itself
* (identity mapping).
* - For template configurations: Maps each generated configuration
* name
* back to the original template name (e.g.,
release-x64 →
* release-\{\{ matrix.arch \}\}).
* - Known only after
BuildConfigurations.initialise()
* completes.
* - Enables
BuildConfigurations.get() to locate the
* correct JSON definition when instantiating a configuration on
* demand.
*
*
* This indirection is essential for lazy evaluation, allowing deferred
* instantiation while maintaining the connection to original definitions.
*/
protected readonly _jsonBuildConfigurationsNamesMap: Map =
new Map()
/**
* Set of all build configuration names for duplicate detection.
*
* @remarks
* This set provides O(1) existence checks for configuration names,
* enabling efficient validation during template expansion to prevent
* duplicate configurations.
*
* Duplicate scenarios detected:
*
*
* - Explicit duplicates in
package.json with identical
* names.
* - Template expansion conflicts where different templates generate the
* same concrete configuration name.
* - Conflicts between template-generated names and explicitly defined
* configuration names.
*
*
* Detection occurs during {@link BuildConfigurations.initialise},
* throwing {@link ConfigurationError} when duplicates are found to ensure
* configuration name uniqueness.
*/
protected readonly _namesSet: Set = new Set()
/**
* Flag indicating whether the collection has been initialised.
*
* @remarks
* This flag prevents redundant initialisation and ensures idempotent
* behavior when {@link BuildConfigurations.initialise} is called
* multiple times.
*
* State transitions:
*
*
* - Initially
false after construction.
* - Set to
true after successful template expansion
* and configuration
* name registration.
* - Checked at the beginning of
*
BuildConfigurations.initialise() to return early if
* already initialised.
*
*
* This pattern supports safe repeated calls during complex initialisation
* sequences without duplicating work or corrupting internal state.
*/
protected _isInitialised = false
/**
* Cached array of all build configuration names in the collection.
*
* @remarks
* This array provides O(1) access to configuration names without
* repeatedly creating new arrays from the map keys, improving performance
* when the names are accessed multiple times.
*
* Key characteristics:
*
*
* - Empty initially after construction.
* - Populated during
*
BuildConfigurations.initialise() after all
* configuration names are determined.
* - Contains all configuration names including those generated from
* templates.
* - Returned by the
names getter for efficient repeated
* access.
*
*
* This cached approach avoids the overhead of calling
* `Array.from(map.keys())` on every access whilst still
* providing a clean getter interface.
*/
protected _names: string[] = []
// --------------------------------------------------------------------------
// Constructor and async initialiser.
/**
* Constructs a build configurations collection.
*
* @remarks
* The constructor performs partial initialisation. Complete
* initialisation requires calling
* {@link BuildConfigurations.initialise}.
*
* @param engine - The Liquid templating engine for variable substitution.
* @param substitutionsVariables - The variables available for substitution.
* @param jsonBuildConfigurations - The JSON build configurations definitions,
* or undefined if no build configurations are defined.
* @param log - The logger instance for output and diagnostics.
*/
constructor({
engine,
substitutionsVariables,
jsonBuildConfigurations,
log,
}: BuildConfigurationsConstructorParameters) {
assert(log, 'log is required')
assert(engine, 'engine is required')
assert(substitutionsVariables, 'substitutionsVariables is required')
log.trace(`${BuildConfigurations.name}()`)
this.log = log
this.engine = engine
this.substitutionsVariables = substitutionsVariables
this.jsonBuildConfigurations = jsonBuildConfigurations ?? {}
// log.trace('substitutionsVariables => ', this.substitutionsVariables)
}
/**
* Completes the async initialisation of the build configurations collection.
*
* @remarks
* This method implements the first step of lazy evaluation. It processes
* all build configuration definitions by expanding template configuration
* names based on matrix parameters, but does not evaluate the configuration
* content or perform Liquid substitutions. The actual template evaluation
* and variable substitution occur later when individual configurations are
* initialised via {@link BuildConfiguration.initialise}, and only
* for configurations that are actually used. This approach avoids unnecessary
* operations on unused configurations.
*
* Processing steps:
*
*
* - Return early if already initialised (idempotent behaviour).
* - Iterate through all build configuration definitions from the JSON
* object.
* - For template configurations (names containing
\{\{):
*
* - Call
_processTemplate() to expand and register all
* generated configurations.
*
*
* - For regular configurations:
*
* - Validate uniqueness of the configuration name.
* - Register the configuration in internal maps with
*
undefined value (lazy loading).
*
*
* - Cache the array of all configuration names for efficient repeated
* access.
*
*
* @returns A promise that resolves to `true` if initialisation was performed,
* or `false` if already initialised.
*
* @throws {@link ConfigurationError}
* If duplicate names are detected or template expansion fails.
*/
async initialise(): Promise {
const log = this.log
if (this._isInitialised) {
log.trace(`${BuildConfigurations.name}.initialise() again`)
return false
}
log.trace(`${BuildConfigurations.name}.initialise()`)
for (const [
buildConfigurationName,
jsonBuildConfiguration,
] of Object.entries(this.jsonBuildConfigurations)) {
if (hasLiquidSyntax(buildConfigurationName)) {
await this._processTemplate({
buildConfigurationName,
jsonBuildConfigurationTemplate:
jsonBuildConfiguration as JsonBuildConfigurationTemplate,
})
} else {
if (this._namesSet.has(buildConfigurationName)) {
throw new ConfigurationError(
`build configuration name ` +
`"${buildConfigurationName}" already defined`
)
} else {
this._buildConfigurationsMap.set(buildConfigurationName, undefined)
this._jsonBuildConfigurationsNamesMap.set(
buildConfigurationName,
buildConfigurationName
)
this._namesSet.add(buildConfigurationName)
}
}
}
const names = Array.from(this._buildConfigurationsMap.keys())
this._names = names
log.trace(`${BuildConfigurations.name}.initialise() =>`, names)
this._isInitialised = true
return true
}
// --------------------------------------------------------------------------
// Public Methods.
/**
* The number of build configurations in the collection.
*
* @remarks
* This value is known only after `initialise()`.
*
* This getter provides direct access to the collection size, enabling
* callers to check for emptiness or iterate with knowledge of the
* collection's extent.
*
* @returns The number of build configurations in the collection.
*/
get size(): number {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing size'
)
return this._buildConfigurationsMap.size
}
/**
* Indicates whether the collection is empty.
*
* @remarks
* This value is known only after `initialise()`.
*
* @returns `true` if there are no build configurations, `false` otherwise.
*/
get isEmpty(): boolean {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing isEmpty'
)
return this._buildConfigurationsMap.size === 0
}
/**
* The names of all build configurations.
*
* @remarks
* This value is known only after `initialise()`.
*
* This getter returns the cached array of configuration names for
* efficient repeated access without recreating the array.
*
* @returns An array of build configuration names.
*/
get names(): string[] {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing names'
)
return this._names
}
/**
* Retrieves the JSON configuration name for a build configuration.
*
* @param buildConfigurationName - The build configuration name to resolve.
* @returns The JSON configuration name associated with the given build
* configuration name.
*
* @remarks
* For template-generated configurations, this returns the template
* name.
*
* @throws {@link InputError}
* If the build configuration does not exist.
*/
getJsonName(buildConfigurationName: string): string {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing getJsonName()'
)
const name = this._jsonBuildConfigurationsNamesMap.get(
buildConfigurationName
)
if (name === undefined) {
throw new ConfigurationError(
`build configuration "${buildConfigurationName}" does not exist`
)
}
return name
}
/**
* Determines whether a JSON definition exists for a build configuration.
*
* @param buildConfigurationName - The build configuration name to check.
* @returns `true` if a JSON definition exists, `false` otherwise.
*/
hasJson(buildConfigurationName: string): boolean {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing hasJson()'
)
return this._jsonBuildConfigurationsNamesMap.has(buildConfigurationName)
}
/**
* Retrieves the JSON build configuration definition.
*
* @param buildConfigurationName - The build configuration name to resolve.
* @returns The JSON build configuration definition.
*/
getJson(buildConfigurationName: string): JsonBuildConfiguration {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing getJson()'
)
return this.jsonBuildConfigurations[
this.getJsonName(buildConfigurationName)
]
}
/**
* Determines whether a build configuration is hidden.
*
* @param buildConfigurationName - The build configuration name to check.
* @returns `true` if the configuration is hidden, `false` otherwise.
*/
isHidden(buildConfigurationName: string): boolean {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing isHidden()'
)
const jsonBuildConfigurationName = this.getJsonName(buildConfigurationName)
if (jsonBuildConfigurationName.includes('{{')) {
const jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate =
this.jsonBuildConfigurations[
jsonBuildConfigurationName
] as JsonBuildConfigurationTemplate
return jsonBuildConfigurationTemplate.template.hidden ?? false
}
const jsonBuildConfigurationContent: JsonBuildConfigurationContent = this
.jsonBuildConfigurations[
jsonBuildConfigurationName
] as JsonBuildConfigurationContent
return jsonBuildConfigurationContent.hidden ?? false
}
/**
* Determines whether a build configuration exists in the collection.
*
* @param buildConfigurationName - The build configuration name to check.
* @returns `true` if the configuration exists, `false` otherwise.
*/
has(buildConfigurationName: string): boolean {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing has()'
)
return this._buildConfigurationsMap.has(buildConfigurationName)
}
/**
* Retrieves a build configuration by name, creating it if required.
*
* @remarks
* This method implements lazy evaluation to avoid unnecessary
* operations. Build configurations are instantiated on demand but
* remain uninitialised until actually used.
*
* Retrieval process:
*
*
* - Check if the configuration already exists in the internal map.
* - If found and already instantiated, return the existing instance.
* - If the configuration name is unknown (not in JSON name mapping),
* throw
InputError.
* - For known but not yet instantiated configurations:
*
* - Resolve the original JSON configuration name (handles both
* regular and template-generated configurations).
* - Retrieve the JSON configuration definition.
* - Create a new
BuildConfiguration instance.
* - Store the instance in the map for future access.
*
*
* - Return the configuration instance (still uninitialised).
*
*
* The two-step lazy evaluation process:
*
*
* - During collection initialisation
* (
BuildConfigurations.initialise()), only the
* matrix of options is evaluated for each template, expanding
* configuration names without processing their content.
* - Later, when a configuration is accessed via this method and
* subsequently initialised
* (
BuildConfiguration.initialise()), the template
* is fully evaluated and Liquid substitutions are performed on
* all properties.
*
*
* This approach ensures that only build configurations that are
* actually used incur the cost of template evaluation and variable
* substitution.
*
* @param buildConfigurationName - The build configuration name to retrieve.
* @returns The build configuration instance.
*
* @throws {@link InputError}
* If a configuration with the specified name does not exist.
*/
get(buildConfigurationName: string): BuildConfiguration {
assert(
this._isInitialised,
'BuildConfigurations collection must be initialised before ' +
'accessing get()'
)
const log = this.log
log.trace(`${BuildConfigurations.name}.get(${buildConfigurationName})`)
let buildConfiguration = this._buildConfigurationsMap.get(
buildConfigurationName
)
if (buildConfiguration === undefined) {
// This will throw InputError if the configuration doesn't exist
const jsonBuildConfigurationName: string = this.getJsonName(
buildConfigurationName
)
// Safety net: This fallback to empty object is defensive programming.
// The jsonBuildConfigurations[jsonBuildConfigurationName] should always
// be defined because getJsonName() throws if the configuration doesn't
// exist. The ?? {} provides protection against unexpected inconsistencies
// between the names map and the configurations object.
/* c8 ignore start - safety net, they are always defined */
const jsonBuildConfiguration: JsonBuildConfigurationContent = (this
.jsonBuildConfigurations[jsonBuildConfigurationName] ??
{}) as JsonBuildConfigurationContent
/* c8 ignore stop */
buildConfiguration = new BuildConfiguration({
buildConfigurationName,
jsonBuildConfiguration,
parentBuildConfigurations: this,
})
this._buildConfigurationsMap.set(
buildConfigurationName,
buildConfiguration
)
}
// await buildConfiguration.initialise()
return buildConfiguration
}
// --------------------------------------------------------------------------
// Private Methods.
/**
* Processes a template build configuration by expanding it and registering
* the generated configurations.
*
* @remarks
* This helper method is called during collection initialisation for each
* build configuration whose name contains template syntax
* (\{\{ markers).
*
* Processing steps:
*
*
* - Calls
_expandTemplateBuildConfigurations() to generate
* all configuration instances from the template's matrix parameters.
* - Validates that each expanded configuration name is unique and does
* not conflict with existing configurations.
* - Registers each expanded configuration in the internal maps:
*
* _buildConfigurationsMap: Maps name to configuration
* instance.
* _jsonBuildConfigurationsNamesMap: Maps expanded name
* back to original template name.
* _namesSet: Tracks all registered
* names for duplicate detection.
*
*
*
*
* @param buildConfigurationName - The template configuration name
* containing Liquid variables.
* @param jsonBuildConfiguration - The JSON template definition containing
* matrix parameters and a configuration template.
* @returns A promise that resolves when processing is complete.
*
* @throws {@link ConfigurationError}
* If duplicate configuration names are detected during expansion or if
* template expansion fails.
*/
protected async _processTemplate({
buildConfigurationName,
jsonBuildConfigurationTemplate,
}: {
buildConfigurationName: string
jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate
}): Promise {
// Expand templates and generate multiple build configurations.
try {
const expandedBuildConfigurationsMap =
await this._expandTemplateBuildConfigurations({
buildConfigurationName,
jsonBuildConfigurationTemplate,
})
for (const [
expandedBuildConfigurationName,
expandedBuildConfiguration,
] of expandedBuildConfigurationsMap) {
if (this._namesSet.has(expandedBuildConfigurationName)) {
throw new ConfigurationError(
`duplicate build configuration name ` +
`"${expandedBuildConfigurationName}" ` +
`could not be generated from template.`
)
} else {
this._buildConfigurationsMap.set(
expandedBuildConfigurationName,
expandedBuildConfiguration
)
this._jsonBuildConfigurationsNamesMap.set(
expandedBuildConfigurationName,
buildConfigurationName
)
this._namesSet.add(expandedBuildConfigurationName)
}
}
} catch (error) {
const message =
getErrorMessage(error) +
` in buildConfiguration "${buildConfigurationName}"`
throw new ConfigurationError(message)
}
}
/**
* Expands a template build configuration into multiple configurations.
*
* @remarks
* This method uses the {@link TemplateExpander} to compute the Cartesian
* product of matrix parameter values and creates a configuration for each
* combination, substituting matrix values into both the configuration name
* and content.
*
* Processing steps:
*
*
* - Validates matrix and template structure.
* - Delegates to
TemplateExpander for matrix processing and
* name expansion.
* - Creates configuration instances via factory callback for each
* combination.
*
*
* Matrix variables are scoped to individual configurations and accessible
* via the `matrix` namespace during property, dependency, and action
* evaluation.
*
* @param buildConfigurationName - The template configuration name containing
* Liquid variables.
* @param jsonBuildConfigurationTemplate - The template definition containing
* matrix parameters and a configuration template.
* @returns A promise that resolves to a map of expanded configuration names
* to their corresponding instances.
*
* @throws {@link ConfigurationError}
* If the matrix structure is invalid or substitution fails.
*/
protected async _expandTemplateBuildConfigurations({
buildConfigurationName,
jsonBuildConfigurationTemplate,
}: {
buildConfigurationName: string
jsonBuildConfigurationTemplate: JsonBuildConfigurationTemplate
}): Promise