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