Configuration loader for .cire files Supports both JSON (.cire) and JSON5 (.cire.json5) formats



import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import JSON5 from "json5";
import { type CireConfig, ConfigError } from "../types";

export class ConfigLoader {
	public verbose: boolean = false;
	private defaultConfig: CireConfig = {
		name: "Cire Project",
		version: "1.0.0",
		description: "Static website generated with Cire",
		logLevel: "error",
		input: {
			root: "src",
			include: ["**/*.ts"],
			exclude: ["**/*.test.ts", "**/*.spec.ts", "node_modules/**"],
			language: "typescript",
		},
		output: {
			directory: "dist",
			baseUrl: "/",
		},
		lsp: {
			indexPath: undefined, // Path to LSIF/SCIP index file
			provider: "scip",
		},
		template: {
			layout: "default",
			templateDir: undefined, // Custom template directory path
			customCSS: undefined, // Custom CSS file path
		},
		features: {
			syntaxHighlighting: true,
			hoverDocumentation: true,
			definitionJumping: true,
			commentMarkdown: true,
			navigationIndex: false,
		},
	};
	

Load configuration from a .cire file and merge with defaults Supports both JSON (.cire) and JSON5 (.cire.json5) formats


	async loadConfig(configPath?: string): Promise<CireConfig> {
		// If no config path provided, return default config
		if (!configPath) {
			if (this.verbose) {
				console.log(
					"📄 No config file specified, using default configuration",
				);
			}
			return this.defaultConfig;
		}

		const fullPath = resolve(process.cwd(), configPath);
		const configDir = resolve(fullPath, "..");

		// If config file doesn't exist, return default config
		if (!existsSync(fullPath)) {
			if (this.verbose) {
				console.log(
					`📄 Config file not found: ${configPath}, using defaults`,
				);
			}
			return this.defaultConfig;
		}

		try {
			const configContent = readFileSync(fullPath, "utf-8");

			// Parse user configuration
			const userConfig: CireConfig = JSON5.parse(
				configContent,
			) as CireConfig;

			if (this.verbose) {
				console.log(`📄 Loaded config from: ${fullPath}`);
			}

			// Basic validation
			this.validateConfig(userConfig, fullPath);

			// Merge user config with defaults
			return this.mergeConfigs(this.defaultConfig, userConfig, configDir);
		} catch (error) {
			if (error instanceof ConfigError) {
				throw error;
			}

			if (error instanceof SyntaxError) {
				throw new ConfigError(
					`Invalid JSON5 in configuration file: ${error.message}`,
					fullPath,
				);
			}

			throw new ConfigError(
				`Failed to load configuration: ${error instanceof Error ? error.message : error}`,
				fullPath,
			);
		}
	}

	

Simple merge user configuration with default configuration


	private mergeConfigs(
		defaultConfig: CireConfig,
		userConfig: Partial<CireConfig>,
		configDir: string,
	): CireConfig {
		const resolvedConfig = {
			...defaultConfig,
			...userConfig,
			input: {
				...defaultConfig.input,
				...userConfig.input,
			},
			output: {
				...defaultConfig.output,
				...userConfig.output,
			},
			lsp: userConfig.lsp
				? {
						...defaultConfig.lsp,
						...userConfig.lsp,
					}
				: defaultConfig.lsp,
			template: userConfig.template
				? {
						...defaultConfig.template,
						...userConfig.template,
					}
				: defaultConfig.template,
			features: userConfig.features
				? {
						...defaultConfig.features,
						...userConfig.features,
					}
				: defaultConfig.features,
		};

		// Resolve paths relative to config file directory
		resolvedConfig.input.root = resolve(
			configDir,
			resolvedConfig.input.root,
		);
		resolvedConfig.output.directory = resolve(
			configDir,
			resolvedConfig.output.directory,
		);

		// Resolve other optional paths
		if (resolvedConfig.lsp?.indexPath) {
			resolvedConfig.lsp.indexPath = resolve(
				configDir,
				resolvedConfig.lsp.indexPath,
			);
		}

		if (resolvedConfig.template?.templateDir) {
			resolvedConfig.template.templateDir = resolve(
				configDir,
				resolvedConfig.template.templateDir,
			);
		}

		if (resolvedConfig.template?.customCSS) {
			resolvedConfig.template.customCSS = resolve(
				configDir,
				resolvedConfig.template.customCSS,
			);
		}

		return resolvedConfig;
	}

	

Basic configuration validation - TypeScript handles most type checking


	private validateConfig(config: CireConfig, configPath: string): void {
		// Only check for basic existence since TypeScript handles type safety
		if (!config) {
			throw new ConfigError(
				"Configuration is empty or invalid",
				configPath,
			);
		}

		// Basic sanity checks for critical fields
		if (!config.name?.trim()) {
			throw new ConfigError(
				'Configuration must have a non-empty "name" field',
				configPath,
			);
		}

		if (!config.input?.root?.trim()) {
			throw new ConfigError(
				'Configuration must have a non-empty "input.root" field',
				configPath,
			);
		}

		if (!config.output?.directory?.trim()) {
			throw new ConfigError(
				'Configuration must have a non-empty "output.directory" field',
				configPath,
			);
		}
	}

	

Create a sample .cire configuration file


	createSampleConfig(configPath: string): void {
		const configContent = JSON.stringify(this.defaultConfig, null, 2);

		try {
			require("node:fs").writeFileSync(
				configPath,
				configContent,
				"utf-8",
			);
		} catch (error) {
			throw new ConfigError(
				`Failed to create sample configuration: ${error instanceof Error ? error.message : error}`,
				configPath,
			);
		}
	}
}