Spaces:
Paused
Paused
| /** | |
| * Config includes: $include directive for modular configs | |
| * | |
| * @example | |
| * ```json5 | |
| * { | |
| * "$include": "./base.json5", // single file | |
| * "$include": ["./a.json5", "./b.json5"] // merge multiple | |
| * } | |
| * ``` | |
| */ | |
| import JSON5 from "json5"; | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| export const INCLUDE_KEY = "$include"; | |
| export const MAX_INCLUDE_DEPTH = 10; | |
| // ============================================================================ | |
| // Types | |
| // ============================================================================ | |
| export type IncludeResolver = { | |
| readFile: (path: string) => string; | |
| parseJson: (raw: string) => unknown; | |
| }; | |
| // ============================================================================ | |
| // Errors | |
| // ============================================================================ | |
| export class ConfigIncludeError extends Error { | |
| constructor( | |
| message: string, | |
| public readonly includePath: string, | |
| public readonly cause?: Error, | |
| ) { | |
| super(message); | |
| this.name = "ConfigIncludeError"; | |
| } | |
| } | |
| export class CircularIncludeError extends ConfigIncludeError { | |
| constructor(public readonly chain: string[]) { | |
| super(`Circular include detected: ${chain.join(" -> ")}`, chain[chain.length - 1]); | |
| this.name = "CircularIncludeError"; | |
| } | |
| } | |
| // ============================================================================ | |
| // Utilities | |
| // ============================================================================ | |
| function isPlainObject(value: unknown): value is Record<string, unknown> { | |
| return ( | |
| typeof value === "object" && | |
| value !== null && | |
| !Array.isArray(value) && | |
| Object.prototype.toString.call(value) === "[object Object]" | |
| ); | |
| } | |
| /** Deep merge: arrays concatenate, objects merge recursively, primitives: source wins */ | |
| export function deepMerge(target: unknown, source: unknown): unknown { | |
| if (Array.isArray(target) && Array.isArray(source)) { | |
| return [...target, ...source]; | |
| } | |
| if (isPlainObject(target) && isPlainObject(source)) { | |
| const result: Record<string, unknown> = { ...target }; | |
| for (const key of Object.keys(source)) { | |
| result[key] = key in result ? deepMerge(result[key], source[key]) : source[key]; | |
| } | |
| return result; | |
| } | |
| return source; | |
| } | |
| // ============================================================================ | |
| // Include Resolver Class | |
| // ============================================================================ | |
| class IncludeProcessor { | |
| private visited = new Set<string>(); | |
| private depth = 0; | |
| constructor( | |
| private basePath: string, | |
| private resolver: IncludeResolver, | |
| ) { | |
| this.visited.add(path.normalize(basePath)); | |
| } | |
| process(obj: unknown): unknown { | |
| if (Array.isArray(obj)) { | |
| return obj.map((item) => this.process(item)); | |
| } | |
| if (!isPlainObject(obj)) { | |
| return obj; | |
| } | |
| if (!(INCLUDE_KEY in obj)) { | |
| return this.processObject(obj); | |
| } | |
| return this.processInclude(obj); | |
| } | |
| private processObject(obj: Record<string, unknown>): Record<string, unknown> { | |
| const result: Record<string, unknown> = {}; | |
| for (const [key, value] of Object.entries(obj)) { | |
| result[key] = this.process(value); | |
| } | |
| return result; | |
| } | |
| private processInclude(obj: Record<string, unknown>): unknown { | |
| const includeValue = obj[INCLUDE_KEY]; | |
| const otherKeys = Object.keys(obj).filter((k) => k !== INCLUDE_KEY); | |
| const included = this.resolveInclude(includeValue); | |
| if (otherKeys.length === 0) { | |
| return included; | |
| } | |
| if (!isPlainObject(included)) { | |
| throw new ConfigIncludeError( | |
| "Sibling keys require included content to be an object", | |
| typeof includeValue === "string" ? includeValue : INCLUDE_KEY, | |
| ); | |
| } | |
| // Merge included content with sibling keys | |
| const rest: Record<string, unknown> = {}; | |
| for (const key of otherKeys) { | |
| rest[key] = this.process(obj[key]); | |
| } | |
| return deepMerge(included, rest); | |
| } | |
| private resolveInclude(value: unknown): unknown { | |
| if (typeof value === "string") { | |
| return this.loadFile(value); | |
| } | |
| if (Array.isArray(value)) { | |
| return value.reduce<unknown>((merged, item) => { | |
| if (typeof item !== "string") { | |
| throw new ConfigIncludeError( | |
| `Invalid $include array item: expected string, got ${typeof item}`, | |
| String(item), | |
| ); | |
| } | |
| return deepMerge(merged, this.loadFile(item)); | |
| }, {}); | |
| } | |
| throw new ConfigIncludeError( | |
| `Invalid $include value: expected string or array of strings, got ${typeof value}`, | |
| String(value), | |
| ); | |
| } | |
| private loadFile(includePath: string): unknown { | |
| const resolvedPath = this.resolvePath(includePath); | |
| this.checkCircular(resolvedPath); | |
| this.checkDepth(includePath); | |
| const raw = this.readFile(includePath, resolvedPath); | |
| const parsed = this.parseFile(includePath, resolvedPath, raw); | |
| return this.processNested(resolvedPath, parsed); | |
| } | |
| private resolvePath(includePath: string): string { | |
| const resolved = path.isAbsolute(includePath) | |
| ? includePath | |
| : path.resolve(path.dirname(this.basePath), includePath); | |
| return path.normalize(resolved); | |
| } | |
| private checkCircular(resolvedPath: string): void { | |
| if (this.visited.has(resolvedPath)) { | |
| throw new CircularIncludeError([...this.visited, resolvedPath]); | |
| } | |
| } | |
| private checkDepth(includePath: string): void { | |
| if (this.depth >= MAX_INCLUDE_DEPTH) { | |
| throw new ConfigIncludeError( | |
| `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded at: ${includePath}`, | |
| includePath, | |
| ); | |
| } | |
| } | |
| private readFile(includePath: string, resolvedPath: string): string { | |
| try { | |
| return this.resolver.readFile(resolvedPath); | |
| } catch (err) { | |
| throw new ConfigIncludeError( | |
| `Failed to read include file: ${includePath} (resolved: ${resolvedPath})`, | |
| includePath, | |
| err instanceof Error ? err : undefined, | |
| ); | |
| } | |
| } | |
| private parseFile(includePath: string, resolvedPath: string, raw: string): unknown { | |
| try { | |
| return this.resolver.parseJson(raw); | |
| } catch (err) { | |
| throw new ConfigIncludeError( | |
| `Failed to parse include file: ${includePath} (resolved: ${resolvedPath})`, | |
| includePath, | |
| err instanceof Error ? err : undefined, | |
| ); | |
| } | |
| } | |
| private processNested(resolvedPath: string, parsed: unknown): unknown { | |
| const nested = new IncludeProcessor(resolvedPath, this.resolver); | |
| nested.visited = new Set([...this.visited, resolvedPath]); | |
| nested.depth = this.depth + 1; | |
| return nested.process(parsed); | |
| } | |
| } | |
| // ============================================================================ | |
| // Public API | |
| // ============================================================================ | |
| const defaultResolver: IncludeResolver = { | |
| readFile: (p) => fs.readFileSync(p, "utf-8"), | |
| parseJson: (raw) => JSON5.parse(raw), | |
| }; | |
| /** | |
| * Resolves all $include directives in a parsed config object. | |
| */ | |
| export function resolveConfigIncludes( | |
| obj: unknown, | |
| configPath: string, | |
| resolver: IncludeResolver = defaultResolver, | |
| ): unknown { | |
| return new IncludeProcessor(configPath, resolver).process(obj); | |
| } | |