UNPKG

cmte

Version:

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

191 lines (172 loc) 6.98 kB
import Mustache from 'mustache'; import path from 'path'; import { readFile } from "./fs.js"; import { logger } from "./logger.js"; import yaml from 'yaml'; /** * Loads and renders a template file with the given context * @param templatePath Path to the template file * @param context Object containing values for template variables * @returns Rendered template as string */ export async function renderTemplateFile(templatePath, context) { try { logger.debug(`Loading template: ${templatePath}`); const template = await readFile(templatePath); return renderTemplate(template, context); } catch (error) { logger.error(`Failed to render template file: ${templatePath}`, { error }); throw new Error(`Unable to render template at ${templatePath}: ${error.message}`); } } /** * Renders a template string with the given context * @param template Template string with Mustache tags * @param context Object containing values for template variables * @param options Optional rendering options * @returns Rendered template as string */ export function renderTemplate(template, context, options = {}) { try { // Disable HTML escaping as we're working with markdown Mustache.escape = text => text; // Split template into frontmatter and content if it exists const frontmatterMatch = template.match(/^(---\n[\s\S]+?\n---\n)([\s\S]*)$/); if (frontmatterMatch) { const [, frontmatter, content] = frontmatterMatch; // Parse frontmatter const frontmatterContent = frontmatter.replace(/^---\n/, '').replace(/\n---\n$/, ''); const metadata = yaml.parse(frontmatterContent); // Create safe context for content rendering const safeContext = createSafeContext(context, options.preserveTemplates); // Handle thinking output placement based on frontmatter config let contentToRender = content; if (context.thinking && metadata.thinking) { const thinkingIntro = metadata.thinking_introduction || 'Previous analysis:'; const formattedThinking = `${thinkingIntro}\n\n${context.thinking}`; contentToRender = metadata.thinking_position === 'prepend' ? `${formattedThinking}\n\n${content}` : `${content}\n\n${formattedThinking}`; } // Render frontmatter and content separately const renderedFrontmatter = Mustache.render(frontmatter, context); const renderedContent = options.isLLMResponse ? escapeMustacheTags(Mustache.render(contentToRender, safeContext)) : Mustache.render(contentToRender, safeContext); return renderedFrontmatter + renderedContent; } // No frontmatter - render entire template const safeContext = createSafeContext(context, options.preserveTemplates); return options.isLLMResponse ? escapeMustacheTags(Mustache.render(template, safeContext)) : Mustache.render(template, safeContext); } catch (error) { logger.error('Failed to render template', { error }); throw new Error(`Template rendering failed: ${error.message}`); } } /** * Creates a safe context for template rendering * @param {Object} context Original context * @param {Array<string>} preserveTemplates Template variables to preserve * @returns {Object} Safe context */ function createSafeContext(context, preserveTemplates = []) { const safeContext = {}; for (const [key, value] of Object.entries(context)) { if (preserveTemplates.includes(key)) { safeContext[key] = value; } else { safeContext[key] = typeof value === 'string' ? value.replace(/\{\{/g, '\\{{').replace(/\}\}/g, '\\}}') : value; } } return safeContext; } /** * Escapes any remaining Mustache tags in rendered content * @param {string} content Rendered content * @returns {string} Content with escaped Mustache tags */ function escapeMustacheTags(content) { return content.replace(/\{\{/g, '\\{{').replace(/\}\}/g, '\\}}'); } /** * Loads a template file from the template directory * @param templateName Template name within templates directory * @returns Template content as string */ export async function loadTemplate(templateName) { const templateDir = process.env.TEMPLATE_DIR || 'templates'; const templatePath = path.join(process.cwd(), templateDir, templateName); try { logger.debug(`Loading template: ${templatePath}`); return await readFile(templatePath); } catch (error) { logger.error(`Failed to load template: ${templatePath}`, { error }); throw new Error(`Unable to load template ${templateName}: ${error.message}`); } } /** * Resolves variables in a template string that match a file collection * @param template Template string * @param collections Map of collection names to file paths * @param contentLoader Function to load file content * @returns Template with file collections expanded */ export async function resolveFileCollections(template, collections, contentLoader) { // Find all variable references in the template const variableRegex = /\{\{([^}:]+)(?::([^}]+))?\}\}/g; const variables = [...template.matchAll(variableRegex)].map(match => ({ full: match[0], name: match[1], filter: match[2] })); let resolvedTemplate = template; // Process each variable that might be a file collection for (const variable of variables) { const { name, filter } = variable; // Check if this variable refers to a file collection if (collections[name]) { logger.debug(`Resolving file collection: ${name}`); // Get the matching files (apply filter if any) const filePaths = filter ? collections[name].filter(path => path.includes(filter)) : collections[name]; if (filePaths.length === 0) { const replacement = `<!-- No matching files for ${name}${filter ? ': ' + filter : ''} -->`; resolvedTemplate = resolvedTemplate.replace(variable.full, replacement); continue; } // Build XML content from matching files const contentParts = []; contentParts.push(`<${name}>`); for (const filePath of filePaths) { try { const content = await contentLoader(filePath); contentParts.push(` <${filePath}>`); contentParts.push(content); contentParts.push(` </${filePath}>`); } catch (error) { logger.warn(`Failed to load file for collection: ${filePath}`, { error }); contentParts.push(` <${filePath}>`); contentParts.push(` <!-- Error loading file: ${error.message} -->`); contentParts.push(` </${filePath}>`); } } contentParts.push(`</${name}>`); const replacement = contentParts.join('\n'); // Replace the variable with the collection content resolvedTemplate = resolvedTemplate.replace(variable.full, replacement); } } return resolvedTemplate; }