UNPKG

astro-photostream

Version:

An Astro integration for creating photo galleries with AI metadata generation and geolocation features

414 lines (365 loc) 11.9 kB
import fs from 'fs/promises'; import path from 'path'; import { z } from 'zod'; import { integrationOptionsSchema, type IntegrationOptions } from '../types.js'; /** * Configuration Manager for Astro Photo Stream * Handles loading configuration from files, environment variables, and defaults */ // Configuration file schema (for astro-photostream.config.js) const configFileSchema = z.object({ // Core configuration enabled: z.boolean().default(true), // Photo processing options photos: z .object({ directory: z.string().default('src/content/photos'), // Directory for photo markdown (.md) files assetsDirectory: z.string().default('src/assets/photos'), formats: z .array(z.enum(['jpg', 'jpeg', 'png', 'webp', 'avif'])) .default(['jpg', 'jpeg', 'png', 'webp']), maxWidth: z.number().default(1920), maxHeight: z.number().default(1080), quality: z.number().min(1).max(100).default(85), }) .default({}), // AI metadata generation ai: z .object({ enabled: z.boolean().default(false), provider: z.enum(['claude', 'openai', 'custom']).default('claude'), apiKey: z.string().optional(), model: z.string().optional(), prompt: z.string().optional(), maxTokens: z.number().default(400), temperature: z.number().min(0).max(2).default(0.9), }) .default({}), // Geolocation processing geolocation: z .object({ enabled: z.boolean().default(true), apiKey: z.string().optional(), // OpenCage API key privacy: z .object({ enabled: z.boolean().default(true), radius: z.number().default(1000), // meters method: z.enum(['blur', 'offset', 'disable']).default('blur'), }) .default({}), }) .default({}), // Gallery display options gallery: z .object({ itemsPerPage: z.number().default(20), gridCols: z .object({ mobile: z.number().default(2), tablet: z.number().default(3), desktop: z.number().default(4), }) .default({}), enableMap: z.boolean().default(true), enableTags: z.boolean().default(true), enableSearch: z.boolean().default(false), }) .default({}), // SEO and social seo: z .object({ generateOpenGraph: z.boolean().default(true), siteName: z.string().optional(), twitterHandle: z.string().optional(), }) .default({}), }); export type ConfigFile = z.infer<typeof configFileSchema>; /** * Configuration Manager Class */ export class ConfigManager { private static instance: ConfigManager; private config: IntegrationOptions | null = null; private constructor() {} static getInstance(): ConfigManager { if (!ConfigManager.instance) { ConfigManager.instance = new ConfigManager(); } return ConfigManager.instance; } /** * Load configuration from multiple sources in priority order: * 1. Provided options (highest priority) * 2. astro-photostream.config.js file * 3. Environment variables * 4. Defaults (lowest priority) */ async loadConfig( providedOptions?: Partial<IntegrationOptions>, cwd?: string ): Promise<IntegrationOptions> { if (this.config && !providedOptions) { return this.config; } const workingDir = cwd || process.cwd(); // Start with defaults let config: Record<string, unknown> = { enabled: true, photos: { directory: 'src/content/photos', formats: ['jpg', 'jpeg', 'png', 'webp'], maxWidth: 1920, maxHeight: 1080, quality: 85, }, ai: { enabled: false, provider: 'claude', model: 'claude-3-haiku-20240307', maxTokens: 400, temperature: 0.9, }, geolocation: { enabled: true, privacy: { enabled: true, radius: 1000, method: 'blur', }, }, gallery: { itemsPerPage: 20, gridCols: { mobile: 2, tablet: 3, desktop: 4, }, enableMap: true, enableTags: true, enableSearch: false, }, seo: { generateOpenGraph: true, }, }; // 1. Load from config file if it exists const configFromFile = await this.loadConfigFile(workingDir); if (configFromFile) { config = this.deepMerge(config, configFromFile); } // 2. Override with environment variables const configFromEnv = this.loadConfigFromEnv(); config = this.deepMerge(config, configFromEnv); // 3. Override with provided options (highest priority) if (providedOptions) { config = this.deepMerge(config, providedOptions); } // Validate final configuration const validatedConfig = integrationOptionsSchema.parse(config); this.config = validatedConfig; return validatedConfig; } /** * Load configuration from astro-photostream.config.js file */ private async loadConfigFile( cwd: string ): Promise<Partial<ConfigFile> | null> { const possiblePaths = [ 'astro-photostream.config.js', 'astro-photostream.config.mjs', 'astro-photostream.config.ts', ]; for (const configPath of possiblePaths) { const fullPath = path.join(cwd, configPath); try { await fs.access(fullPath); // Dynamic import to load the config file const configModule = await import(`file://${fullPath}`); const rawConfig = configModule.default || configModule; // Validate the config file structure const validatedConfig = configFileSchema.parse(rawConfig); console.log(`✅ Loaded configuration from ${configPath}`); return validatedConfig; } catch (error) { // File doesn't exist or has errors, continue to next if ( error instanceof Error && 'code' in error && error.code !== 'ENOENT' ) { console.warn( `⚠️ Error loading config file ${configPath}:`, error.message ); } continue; } } return null; } /** * Load configuration from environment variables */ private loadConfigFromEnv(): Partial<IntegrationOptions> { const envConfig: Partial<IntegrationOptions> = {} as Partial<IntegrationOptions>; // AI configuration from environment if (process.env.ANTHROPIC_API_KEY) { envConfig.ai = { enabled: true, provider: 'claude', apiKey: process.env.ANTHROPIC_API_KEY, model: process.env.ANTHROPIC_MODEL, prompt: process.env.ANTHROPIC_PROMPT, }; } if (process.env.OPENAI_API_KEY) { envConfig.ai = { ...envConfig.ai, enabled: true, provider: 'openai', apiKey: process.env.OPENAI_API_KEY, model: process.env.OPENAI_MODEL || 'gpt-4-vision-preview', }; } // Geolocation configuration from environment if (process.env.OPENCAGE_API_KEY) { envConfig.geolocation = { enabled: true, apiKey: process.env.OPENCAGE_API_KEY, privacy: { enabled: true, radius: 1000, method: 'blur', }, }; } // Directory configuration from environment if (process.env.CONTENT_DIRECTORY) { if (!envConfig.photos) envConfig.photos = {} as any; (envConfig.photos as any).directory = process.env.CONTENT_DIRECTORY; } if (process.env.PHOTOS_DIRECTORY) { if (!envConfig.photos) envConfig.photos = {} as any; (envConfig.photos as any).assetsDirectory = process.env.PHOTOS_DIRECTORY; } return envConfig; } /** * Deep merge two objects */ private deepMerge( target: Record<string, unknown>, source: Record<string, unknown> ): Record<string, unknown> { const result = { ...target }; for (const key in source) { if (source[key] === null || source[key] === undefined) { continue; } if (typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = this.deepMerge( (result[key] as Record<string, unknown>) || {}, source[key] as Record<string, unknown> ); } else { result[key] = source[key]; } } return result; } /** * Get current configuration */ getConfig(): IntegrationOptions { if (!this.config) { throw new Error('Configuration not loaded. Call loadConfig() first.'); } return this.config; } /** * Generate example configuration file */ generateExampleConfig(): string { return `// astro-photostream.config.js export default { // Enable/disable the integration enabled: true, // Photo processing configuration photos: { directory: 'src/content/photos', // Directory for photo markdown (.md) files assetsDirectory: 'src/assets/photos', // Photo assets directory formats: ['jpg', 'jpeg', 'png', 'webp'], // Supported formats maxWidth: 1920, // Max width for processing maxHeight: 1080, // Max height for processing quality: 85 // JPEG quality (1-100) }, // AI metadata generation ai: { enabled: false, // Enable AI analysis provider: 'claude', // 'claude' | 'openai' | 'custom' apiKey: process.env.ANTHROPIC_API_KEY, // API key (use env var) model: 'claude-3-haiku-20240307', // Model to use prompt: undefined, // Custom prompt (optional) maxTokens: 400, // Max tokens for response temperature: 0.9 // Creativity level (0-2) }, // Geolocation processing geolocation: { enabled: true, // Enable location processing apiKey: process.env.OPENCAGE_API_KEY, // OpenCage API key privacy: { enabled: true, // Enable privacy protection radius: 1000, // Privacy radius in meters method: 'blur' // 'blur' | 'offset' | 'disable' } }, // Gallery display options gallery: { itemsPerPage: 20, // Photos per page gridCols: { mobile: 2, // Grid columns on mobile tablet: 3, // Grid columns on tablet desktop: 4 // Grid columns on desktop }, enableMap: true, // Show location maps enableTags: true, // Enable tag filtering enableSearch: false // Enable search (future) }, // SEO and social media seo: { generateOpenGraph: true, // Generate OG images siteName: 'My Photo Gallery', // Site name for OG twitterHandle: '@myhandle' // Twitter handle } };`; } /** * Write example configuration file to disk */ async writeExampleConfig(filePath?: string): Promise<void> { const configPath = filePath || 'astro-photostream.config.js'; const content = this.generateExampleConfig(); await fs.writeFile(configPath, content, 'utf8'); console.log(`✅ Created example configuration file: ${configPath}`); } } // Export convenience functions export const configManager = ConfigManager.getInstance(); /** * Load configuration with smart defaults */ export async function loadConfig( providedOptions?: Partial<IntegrationOptions>, cwd?: string ): Promise<IntegrationOptions> { return configManager.loadConfig(providedOptions, cwd); } /** * Get current configuration */ export function getConfig(): IntegrationOptions { return configManager.getConfig(); }