cmte
Version:
Design by Committee™ except it's just you and LLMs
227 lines (206 loc) • 12.3 kB
JavaScript
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
}