UNPKG

simple-blog-engine

Version:

Современный легковесный генератор статического блога с поддержкой Markdown

459 lines (398 loc) 13.9 kB
/** * Configuration Manager Module * Handles loading and validating configuration for the static site generator */ const path = require('path'); const { readFile } = require('./fileHandler'); // Version of the configuration schema const CONFIG_VERSION = '1.0.0'; // Configuration schema for validation const CONFIG_SCHEMA = { _version: { type: 'string', required: true }, site: { title: { type: 'string', required: true }, description: { type: 'string', required: true }, language: { type: 'string', required: true } }, content: { postsPerPage: { type: 'number', required: true, min: 1 } }, paths: { contentDir: { type: 'string', required: true }, templatesDir: { type: 'string', required: true }, outputDir: { type: 'string', required: true }, cssDir: { type: 'string', required: true } } }; // Default configuration const DEFAULT_CONFIG = { _version: CONFIG_VERSION, site: { title: 'Markdown Blog', description: 'A static blog built with simple-blog-engine', language: 'en' }, content: { postsPerPage: 10 }, paths: { contentDir: './blog/content', templatesDir: './blog/templates', outputDir: './dist', cssDir: './blog/css' } }; // Cache for loaded configuration let configCache = null; /** * Merge existing config with default config preserving user settings * @param {Object} existingConfig - User's existing configuration * @param {Object} defaultConfig - Default configuration to merge with * @returns {Object} Merged configuration */ function mergeConfigs(existingConfig, defaultConfig = DEFAULT_CONFIG) { // Helper function to check if an object is a plain object (not array, null, etc) const isPlainObject = (obj) => obj && typeof obj === 'object' && !Array.isArray(obj); // Deep merge objects preserving user settings const deepMerge = (target, source) => { const output = { ...target }; if (isPlainObject(target) && isPlainObject(source)) { for (const key in source) { if (isPlainObject(source[key])) { if (key in target) { output[key] = deepMerge(target[key], source[key]); } else { output[key] = { ...source[key] }; } } else { // For non-object values, prefer target (user) values over source (default) values output[key] = key in target ? target[key] : source[key]; } } } return output; }; // Preserve user settings that we always want to keep const userSettings = { site: { title: existingConfig.site?.title, description: existingConfig.site?.description, language: existingConfig.site?.language, url: existingConfig.site?.url, author: existingConfig.site?.author }, appearance: existingConfig.appearance, // Preserve custom paths if they exist paths: { ...existingConfig.paths } }; // Start with default config and merge with existing, then ensure user settings const merged = deepMerge(defaultConfig, existingConfig); // Ensure user settings are preserved if (userSettings.site) { merged.site = { ...merged.site, ...userSettings.site }; } if (userSettings.appearance) { merged.appearance = { ...merged.appearance, ...userSettings.appearance }; } if (userSettings.paths) { merged.paths = { ...merged.paths, ...userSettings.paths }; } // Always update version to latest merged._version = CONFIG_VERSION; return merged; } /** * Deep merge two objects * @param {Object} target - Target object * @param {Object} source - Source object to merge * @returns {Object} - New merged object */ function deepMerge(target, source) { const output = { ...target }; for (const key in source) { if (source.hasOwnProperty(key)) { if ( typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key]) ) { // If property exists in target and is an object, merge recursively if (target[key] && typeof target[key] === 'object') { output[key] = deepMerge(target[key], source[key]); } else { // Otherwise just copy from source output[key] = { ...source[key] }; } } else { // For non-objects, just copy the value output[key] = source[key]; } } } return output; } /** * Validate a config value against schema * @param {any} value - Config value to validate * @param {Object} schema - Schema definition for the value * @param {string} path - Path in the config for error reporting * @returns {Array} - Array of validation errors, empty if valid */ function validateValue(value, schema, path) { const errors = []; // Check required if (schema.required && (value === undefined || value === null)) { errors.push(`${path} is required`); return errors; } // Skip further validation if value is not provided if (value === undefined || value === null) { return errors; } // Check type if (schema.type) { const actualType = Array.isArray(value) ? 'array' : typeof value; if (actualType !== schema.type) { errors.push(`${path} should be of type ${schema.type}, got ${actualType}`); } } // Check min value for numbers if (schema.type === 'number' && schema.min !== undefined && value < schema.min) { errors.push(`${path} should be at least ${schema.min}`); } // Check min length for strings and arrays if ((schema.type === 'string' || schema.type === 'array') && schema.minLength !== undefined && value.length < schema.minLength) { errors.push(`${path} should have length of at least ${schema.minLength}`); } return errors; } /** * Validate configuration against schema * @param {Object} config - Configuration to validate * @returns {Object} - Validation result {valid: boolean, errors: string[]} */ function validateConfig(config) { const errors = []; // Helper function to validate section recursively function validateSection(sectionConfig, sectionSchema, path) { for (const key in sectionSchema) { const propertyPath = path ? `${path}.${key}` : key; const schema = sectionSchema[key]; // If schema is a nested object with properties if (typeof schema === 'object' && !schema.type) { // Create section if it doesn't exist if (!sectionConfig[key]) { sectionConfig[key] = {}; } validateSection(sectionConfig[key], schema, propertyPath); } else { // Validate leaf property const propertyErrors = validateValue(sectionConfig[key], schema, propertyPath); errors.push(...propertyErrors); // Set default value if needed if ( schema.required && (sectionConfig[key] === undefined || sectionConfig[key] === null) && DEFAULT_CONFIG[path] && DEFAULT_CONFIG[path][key] !== undefined ) { sectionConfig[key] = DEFAULT_CONFIG[path][key]; console.log(`Warning: ${propertyPath} not specified, using default: ${sectionConfig[key]}`); } } } } // Validate each section for (const section in CONFIG_SCHEMA) { if (!config[section]) { config[section] = {}; } validateSection(config[section], CONFIG_SCHEMA[section], section); } return { valid: errors.length === 0, errors }; } /** * Add derived properties to config * @param {Object} config - Configuration object * @returns {Object} - Configuration with derived properties */ function addDerivedProperties(config) { const newConfig = { ...config }; // Add path-related properties if (newConfig.paths) { newConfig.paths = { ...newConfig.paths, postsDir: path.join(newConfig.paths.contentDir, 'posts'), aboutDir: path.join(newConfig.paths.contentDir, 'about') }; } return newConfig; } /** * Migrate config from one version to another * @param {Object} config - Config object * @param {string} fromVersion - Current version * @param {string} toVersion - Target version * @returns {Object} - Migrated config */ function migrateConfig(config, fromVersion, toVersion) { // No migrations yet, but this allows for future version changes if (fromVersion === toVersion) { return config; } console.log(`Migrating configuration from version ${fromVersion} to ${toVersion}`); // Add migration logic here when needed for future versions // Example: // if (fromVersion === '1.0.0' && compareVersions(toVersion, '1.1.0') >= 0) { // config = migrate_1_0_0_to_1_1_0(config); // fromVersion = '1.1.0'; // } // Always set the new version config._version = toVersion; return config; } /** * Resolves paths in config to be absolute or relative to cwd * @param {Object} config - Config object with paths * @param {string} basePath - Base path for resolving relative paths * @returns {Object} - Config with resolved paths */ function resolvePaths(config, basePath) { const result = { ...config }; // Resolve paths.contentDir and paths.outputDir if they exist if (result.paths) { if (result.paths.contentDir && !path.isAbsolute(result.paths.contentDir)) { // Check if path already contains the base directory to avoid duplication const contentPath = path.resolve(basePath, result.paths.contentDir); // If contentDir is something like './blog/content' but basePath already ends with 'blog', // we want to avoid creating paths like 'blog/blog/content' if (path.basename(basePath) === 'blog' && result.paths.contentDir.startsWith('./blog/')) { result.paths.contentDir = path.resolve(path.dirname(basePath), result.paths.contentDir); } else { result.paths.contentDir = contentPath; } } if (result.paths.outputDir && !path.isAbsolute(result.paths.outputDir)) { result.paths.outputDir = path.resolve(basePath, result.paths.outputDir); } if (result.paths.templatesDir && !path.isAbsolute(result.paths.templatesDir)) { // Apply same logic to templatesDir to prevent duplication const templatesPath = path.resolve(basePath, result.paths.templatesDir); if (path.basename(basePath) === 'blog' && result.paths.templatesDir.startsWith('./blog/')) { result.paths.templatesDir = path.resolve(path.dirname(basePath), result.paths.templatesDir); } else { result.paths.templatesDir = templatesPath; } } if (result.paths.cssDir && !path.isAbsolute(result.paths.cssDir)) { // Apply same logic to cssDir to prevent duplication const cssPath = path.resolve(basePath, result.paths.cssDir); if (path.basename(basePath) === 'blog' && result.paths.cssDir.startsWith('./blog/')) { result.paths.cssDir = path.resolve(path.dirname(basePath), result.paths.cssDir); } else { result.paths.cssDir = cssPath; } } } return result; } /** * Load configuration from file * @param {Object} options - Options for loading config * @param {string} options.configPath - Path to config file * @param {boolean} options.useCache - Whether to use cached config * @param {string} options.outputDir - Override for output directory * @returns {Promise<Object>} - Loaded and validated config */ async function loadConfig(options = {}) { const { configPath = path.join(process.cwd(), 'blog/config.json'), useCache = true, outputDir } = typeof options === 'string' ? { configPath: options } : options; // Use cache if available and requested if (useCache && configCache) { return configCache; } try { // Start with default config let config = { ...DEFAULT_CONFIG }; // Load user config if available try { const configData = await readFile(configPath); const userConfig = JSON.parse(configData); // Check if user config has version const userVersion = userConfig._version || '1.0.0'; // Migrate if necessary const migratedConfig = migrateConfig(userConfig, userVersion, CONFIG_VERSION); // Merge with defaults config = mergeConfigs(migratedConfig); } catch (error) { console.warn(`Could not load config from ${configPath}:`, error.message); console.warn('Using default configuration'); } // Resolve paths relative to config file location const basePath = path.dirname(configPath); config = resolvePaths(config, basePath); // Override output directory if specified if (outputDir) { config.paths.outputDir = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir); } // Add computed properties config = addDerivedProperties(config); // Validate config const { valid, errors } = validateConfig(config); if (!valid) { console.error('Configuration validation errors:'); errors.forEach(error => console.error(`- ${error}`)); throw new Error('Invalid configuration'); } // Cache valid config configCache = config; return config; } catch (error) { console.error('Error loading configuration:', error); throw error; } } /** * Get the current configuration * @param {boolean} useCache - Whether to return cached config or throw if not loaded * @returns {Object} - Current configuration */ function getConfig(useCache = true) { if (configCache === null && !useCache) { throw new Error('Configuration not loaded. Call loadConfig() first.'); } return configCache || DEFAULT_CONFIG; } /** * Clear configuration cache */ function clearConfigCache() { configCache = null; } module.exports = { loadConfig, getConfig, clearConfigCache, validateConfig, CONFIG_VERSION, mergeConfigs };