import { path, FileSystem, type NowConfig } from '@servicenow/sdk-build-core' /** * Context required by the config value resolver to look up property values. * * @property config - The parsed {@link NowConfig} for the current build. * @property rootDir - Absolute path to the project root directory. * @property fs - The filesystem handle used to read auxiliary files (e.g. `aiux.json`). */ export type ResolverContext = { config: NowConfig rootDir: string fs: FileSystem } /** * Resolves pseudo property tokens in a string value. * Tokens use the format `[$config.]` and are replaced with their resolved values. * * Supported tokens: * - `[$config.]` — resolves a NowConfig property by dot-delimited path (e.g. `[$config.scope]`, `[$config.staticContent.buildDir]`) * - `[$config.aiux.]` — resolves a property from `aiux.json` at the project root (e.g. `[$config.aiux.basename]`) * * Deprecated flat keys (e.g. `[$config.staticContentDir]`) remain supported via {@link LEGACY_CONFIG_TOKEN_ALIASES}. */ export function resolveConfigValue(value: string, context: ResolverContext): string { return value.replace(/\[\$config\.([^\]]+)\]/g, (_match, propertyPath: string) => { const resolved = resolveProperty(propertyPath, context) if (resolved === undefined) { throw new Error(`Unable to resolve config pseudo property: [$config.${propertyPath}]`) } return resolved }) } /** * Maps deprecated flat config token names to their nested replacements so that * existing `[$config.]` tokens keep resolving after the key was nested. */ const LEGACY_CONFIG_TOKEN_ALIASES: globalThis.Record = { staticContentDir: 'staticContent.buildDir', } /** * Resolves a single dot-delimited property path against the config or an auxiliary file. * * @param propertyPath - The portion after `config.` (e.g. `"scope"`, `"staticContent.buildDir"`, or `"aiux.basename"`). * @param context - Resolver context providing config, rootDir, and filesystem. * @returns The resolved string value, or `undefined` if the property cannot be found. */ function resolveProperty(propertyPath: string, context: ResolverContext): string | undefined { if (propertyPath.startsWith('aiux.')) { const aiuxConfig = readAiuxJson(context) if (!aiuxConfig) { return undefined } const key = propertyPath.slice('aiux.'.length) const value = aiuxConfig[key] return typeof value === 'string' ? value : undefined } const normalizedPath = LEGACY_CONFIG_TOKEN_ALIASES[propertyPath] ?? propertyPath let current: unknown = context.config for (const segment of normalizedPath.split('.')) { if (current === null || typeof current !== 'object') { return undefined } current = (current as globalThis.Record)[segment] } return typeof current === 'string' ? current : undefined } /** WeakMap cache keyed on the FileSystem instance so parsed results are shared within a build. */ const aiuxJsonCache = new WeakMap | null>() /** * Reads and parses `aiux.json` from the project root, caching the result per {@link FileSystem}. * * @param context - Resolver context providing rootDir and filesystem. * @returns The parsed JSON object, or `undefined` if the file does not exist or is malformed. */ function readAiuxJson(context: ResolverContext): globalThis.Record | undefined { const cached = aiuxJsonCache.get(context.fs) if (cached !== undefined) { return cached ?? undefined } const aiuxPath = path.join(context.rootDir, 'aiux.json') if (!FileSystem.existsSync(context.fs, aiuxPath)) { aiuxJsonCache.set(context.fs, null) return undefined } try { const content = context.fs.readFileSync(aiuxPath, { encoding: 'utf-8' }).toString() const parsed = JSON.parse(content) as globalThis.Record aiuxJsonCache.set(context.fs, parsed) return parsed } catch { aiuxJsonCache.set(context.fs, null) return undefined } }