UNPKG

cmte

Version:

Design by Committee™ except it's just you and LLMs

227 lines (206 loc) 12.3 kB
import { FileCollectionManager } from './file-collection-manager.js'; import { logger } from '../utils/logger.js'; // Import logger import { getNestedProperty } from '../utils/nested-property.js'; // Import helper /** * Renders templates with support for context variables and file collections. */ export class TemplateRenderer { /** * Create a new template renderer * @param {FileCollectionManager} fileCollectionManager - Shared instance * @param {OutputReferenceResolver} variableResolver - Optional variable resolver for nested variables * @param {WorkflowExecutor | null} workflowExecutor - Optional WorkflowExecutor instance (for dry run issue reporting) */ constructor(fileCollectionManager, variableResolver = null, workflowExecutor = null) { // Store the passed FileCollectionManager instance this.fileCollectionManager = fileCollectionManager; this.variableResolver = variableResolver; this.workflowExecutor = workflowExecutor; // Store the executor instance // Removed: this.context = context; // Removed: Internal collection manager creation } /** * Render a template string with context variables and collection references * @param {string} template - Template string to render * @param {Object} context - The context object containing variables for this specific render call * @param {boolean} isDryRun - Indicates whether the render is a dry run * @returns {Promise<string>} Rendered template */ async render(template, context, isDryRun = false) { // Ensure context is an object if (!context || typeof context !== 'object') { throw new Error('Invalid context provided for rendering.'); } // --- Handle Escaped Braces using Placeholders --- const escapedOpenPlaceholder = `__ESCAPED_OPEN_BRACE_${Date.now()}_${Math.random()}__`; // Temporarily replace \{{ with the placeholder template = template.replace(/\\\{\{/g, escapedOpenPlaceholder); // We only need to escape the opening {{, as }} without a preceding {{ is ignored. // --- End Escaped Braces Handling --- // Regex to find {{ variable.name }} or {{ files.collection:*.js }} const variableRegex = /\{\{\s*([\w.-]+(?:\.[\w.-]+)*|\[(?:this|\*)\]|files\.[\w-]+(?:\.[\w.-]+|\[.*\])*)\s*\}\}/g; let rendered = template; let match; while ((match = variableRegex.exec(template)) !== null) { const fullMatch = match[0]; // e.g., {{ variable.name }} const variableName = match[1]; // e.g., variable.name try { let resolvedValue; if (variableName.startsWith('files.')) { // Always handle as request for FORMATTED CONTENT const collectionName = variableName.substring(6); // ^ No special .path handling - treat "files.coll.path" as looking for collection "coll.path" if (!this.fileCollectionManager || !this.fileCollectionManager.hasCollection(collectionName)) { const issueMsg = `Template references file collection '${collectionName}' which was not found.`; logger.warn(issueMsg); if (this.workflowExecutor && this.workflowExecutor.isDryRun()) { this.workflowExecutor.addDryRunIssue('warning', issueMsg); } // Return empty string or placeholder if collection not found resolvedValue = '[File Collection Not Found]'; // Or just ''? } else { const filePaths = this.fileCollectionManager.getFiles(collectionName); if (filePaths.length === 0) { logger.warn(`Template reference '${variableName}' resolved to 0 files. Rendering empty string.`); resolvedValue = ''; } else { // Format and embed content logger.debug(`Template reference '${variableName}' embedding content for ${filePaths.length} files...`); const formattedContents = []; for (const filePath of filePaths) { try { const content = await this.fileCollectionManager.readFileRelative(filePath); // Consistent formatting (can be adjusted) const header = `#### ${filePath}\n`; // Assuming javascript default, might need refinement for other types formattedContents.push(`${header}\`\`\`javascript\n${content}\n\`\`\``); } catch (readError) { const errorMsg = `Failed to read file '${filePath}' for template reference '${variableName}'.`; logger.error(errorMsg, { error: readError.message }); // How to represent read error in output? formattedContents.push(`[Error reading file: ${filePath}]`); } } resolvedValue = formattedContents.join('\n\n'); // Add extra newlines before and after the embedded file content block if (resolvedValue) { // Avoid adding newlines if the content is empty resolvedValue = '\n\n\n\n' + resolvedValue + '\n\n\n\n'; } } } } else { // Handle regular variable/output reference (using resolveVariable) resolvedValue = this.resolveVariable(variableName, context, isDryRun); } // Ensure resolvedValue is a string for replacement let replacement = ''; // Default to empty string if (typeof resolvedValue === 'string') { replacement = resolvedValue; } else if (Array.isArray(resolvedValue)) { // Join array elements with newline for better template inclusion replacement = resolvedValue.join('\n'); } else if (resolvedValue !== null && resolvedValue !== undefined) { // Use JSON stringify for other non-null/undefined types (like objects) replacement = JSON.stringify(resolvedValue, null, 2); } // Otherwise, replacement remains '' for null/undefined rendered = rendered.replaceAll(fullMatch, replacement); } catch (error) { logger.error(`Error rendering template variable ${fullMatch}: ${error.message}`); // During rendering, unclear if we should throw or just replace with error placeholder // Let's re-throw for now, consistent with resolveVariable potentially throwing throw error; } } // --- Restore Escaped Braces --- // Replace the placeholder back with the literal {{ rendered = rendered.replace(new RegExp(escapedOpenPlaceholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g'), '{{'); // --- End Restore Escaped Braces --- return rendered; } resolveVariable(variableName, context, isDryRun) { logger.debug(`[RESOLVE_VAR] Starting for: '${variableName}'`); // Log start // Try resolving as an output reference first try { // Use the resolver instance passed in constructor const resolvedRef = this.variableResolver.resolveReference(variableName); logger.debug(`[RESOLVE_VAR] Resolved via OutputReferenceResolver for: '${variableName}'`); // Log success return resolvedRef; } catch (resolverError) { // --- Resolver failed, now try simple context lookup --- logger.debug(`[RESOLVE_VAR] Resolver failed for '${variableName}', trying context lookups. Error: ${resolverError.message}`); let value = undefined; let lookupMethod = 'none'; let keyExistsButUndefined = false; // Attempt all context lookups first... // 1. Direct if (context.hasOwnProperty(variableName)) { value = context[variableName]; if (value !== undefined) { lookupMethod = 'direct'; } else { // Key exists, but value is undefined. Track this. keyExistsButUndefined = true; } } // 2. item.* Fallback (only if value still undefined) if (value === undefined && variableName.startsWith('item.') && variableName !== 'item.key') { const fallbackRef = variableName.replace(/^item\./, 'item.value.'); value = getNestedProperty(context, fallbackRef); if (value !== undefined) { lookupMethod = 'fallback'; } } // 3. Nested Property (only if value still undefined and not found via direct undefined) if (value === undefined && lookupMethod !== 'direct' && !keyExistsButUndefined) { value = getNestedProperty(context, variableName); if (value !== undefined) { lookupMethod = 'nestedProperty'; } } // 4. Check if context lookup succeeded if (value !== undefined) { logger.debug(`[RESOLVE_VAR] Resolved '${variableName}' via context lookup (${lookupMethod}).`); return value; // Found in context, return it. } // --- Context Lookup Failed OR Resulted in Undefined --- logger.debug(`[RESOLVE_VAR] All context lookups failed or resulted in undefined for '${variableName}'.`); const isInvalidFormatError = resolverError.message.startsWith('Invalid output reference format'); let finalErrorMessage; let issueType; // Determine based on error origin if (keyExistsButUndefined) { // Case 3: Key existed but value was undefined (likely skipped prior_output) finalErrorMessage = `Variable '${variableName}' exists in context but its value is undefined. This might be due to an unresolved prior_output reference.`; // This is an expected dry run artifact, DO NOT log an issue, only log to console if needed. issueType = 'warning'; // Set type for logging, but we won't add issue if (isDryRun) { logger.warn(`[DRY RUN] ${finalErrorMessage}`, { originalResolverError: resolverError.message }); // *** DO NOT ADD ISSUE *** // if (this.workflowExecutor) this.workflowExecutor.addDryRunIssue(issueType, finalErrorMessage); return '[DRY_RUN_UNDEFINED_VARIABLE]'; } // If not dry run, this case shouldn't normally happen unless code is wrong. // Fall through to throw the error for non-dry run. } else if (isInvalidFormatError) { // Case 1: Didn't look like reference, AND truly not in context const availableKeys = Object.keys(context); finalErrorMessage = `Variable '${variableName}' could not be found. It is not a valid output reference format and does not exist as a context variable. Available top-level context keys: ${availableKeys.join(', ')}`; issueType = 'error'; // Template error } else { // Case 2: Looked like reference, failed lookup, AND not in context finalErrorMessage = `Output reference '${variableName}' could not be fully resolved (task likely skipped in dry run). It was also not found as a simple context variable.`; issueType = 'warning'; // Expected in dry run } // Handle logging and issue reporting based on dry run status if (isDryRun) { const consoleLogLevel = issueType === 'error' ? 'error' : 'warn'; logger[consoleLogLevel](`[DRY RUN] ${finalErrorMessage}`, { originalResolverError: resolverError.message }); if (this.workflowExecutor) this.workflowExecutor.addDryRunIssue(issueType, finalErrorMessage); // Return appropriate placeholder based on if it was a missing variable or just unresolved return issueType === 'error' ? '[DRY_RUN_MISSING_VARIABLE]' : '[DRY_RUN_UNRESOLVED_REFERENCE]'; } else { // Not dry run - always throw the error regardless of original type logger.error(finalErrorMessage, { originalResolverError: resolverError.message }); throw new Error(finalErrorMessage); } } } // Removed registerCollection - should be handled externally via FileCollectionManager // Removed readCollectionFile - should be handled externally via FileCollectionManager }