UNPKG

@rhofkens/mcp-quotes-server-claude-code

Version:

Model Context Protocol (MCP) server for managing and serving quotes

427 lines 16.3 kB
/** * MCP Quotes Server - Template Renderer * * Renders quote templates with variable substitution and formatting */ import { OutputFormat } from '../../../types/templates.js'; import { logger } from '../../../utils/logger.js'; import { TemplateValidator } from '../validators/templateValidator.js'; /** * Template renderer class */ export class TemplateRenderer { /** * Render a template with given context */ static async render(template, context) { const warnings = []; try { // Validate required variables const validationResult = this.validateContext(template, context); if (!validationResult.isValid) { throw new Error(`Template validation failed: ${validationResult.errors.join(', ')}`); } warnings.push(...validationResult.warnings); // Apply default values const variables = this.applyDefaults(template, context.variables); // Render template content let output = this.substituteVariables(template.content, variables, context.options?.formatters); // Apply components if (template.components) { output = await this.applyComponents(output, template.components, variables); } // Apply post-processors if (template.postProcessors) { output = await this.applyPostProcessors(output, template.postProcessors, template); } // Format output const format = context.options?.outputFormat || template.outputFormat.format; output = this.formatOutput(output, format, template.outputFormat.options); // Build result const result = { output, format, warnings: warnings.length > 0 ? warnings : [], }; // Include metadata if requested if (context.options?.includeMetadata) { result.metadata = { templateId: template.metadata.id, templateName: template.metadata.name, templateVersion: template.metadata.version, renderedAt: new Date().toISOString(), variablesUsed: Object.keys(variables), }; } return result; } catch (error) { logger.error('Template rendering failed', { templateId: template.metadata.id, error: error instanceof Error ? error.message : String(error), }); throw error; } } /** * Validate rendering context */ static validateContext(template, context) { const errors = []; const warnings = []; // Check required variables template.variables.forEach((variable) => { const value = context.variables[variable.name]; if (variable.required && (value === undefined || value === null)) { if (!variable.defaultValue) { errors.push(`Required variable "${variable.name}" is missing`); } } // Validate variable value if provided if (value !== undefined && value !== null) { const validation = TemplateValidator.validateVariableWithRules(variable, value); if (!validation.isValid) { errors.push(`Variable "${variable.name}": ${validation.error}`); } } }); // Warn about extra variables const definedVariables = new Set(template.variables.map((v) => v.name)); Object.keys(context.variables).forEach((varName) => { if (!definedVariables.has(varName)) { warnings.push(`Unknown variable "${varName}" provided`); } }); return { isValid: errors.length === 0, errors, warnings, }; } /** * Apply default values for missing variables */ static applyDefaults(template, providedVariables) { const variables = { ...providedVariables }; template.variables.forEach((variable) => { if ((variables[variable.name] === undefined || variables[variable.name] === null) && variable.defaultValue !== undefined) { variables[variable.name] = variable.defaultValue; } }); return variables; } /** * Substitute variables in template content */ static substituteVariables(content, variables, formatters) { return content.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)(?::([a-zA-Z][a-zA-Z0-9_]*))?\}/g, (match, varName, formatterName) => { const value = variables[varName]; if (value === undefined) { return match; // Keep placeholder if variable not found } // Apply formatter if specified if (formatterName && formatters?.[formatterName]) { return formatters[formatterName](value); } // Default formatting return this.formatValue(value); }); } /** * Format a value for string substitution */ static formatValue(value) { if (value === null || value === undefined) { return ''; } if (typeof value === 'string') { return value; } if (typeof value === 'object') { if (value instanceof Date) { return value.toISOString(); } if (Array.isArray(value)) { return value.map((v) => this.formatValue(v)).join(', '); } return JSON.stringify(value); } return String(value); } /** * Apply template components */ static async applyComponents(content, components, variables) { if (!components || components.length === 0) { return content; } // Sort components by order const sortedComponents = [...components].sort((a, b) => (a.order || 0) - (b.order || 0)); let result = content; for (const component of sortedComponents) { // Check condition for conditional components if (component.type === 'conditional' && component.condition) { const conditionMet = this.evaluateCondition(component.condition, variables); if (!conditionMet) { continue; } } // Apply component switch (component.type) { case 'prefix': result = component.content + result; break; case 'suffix': result = result + component.content; break; case 'wrapper': // Wrapper content should contain {content} placeholder result = component.content.replace('{content}', result); break; case 'conditional': // Already checked condition above result = this.substituteVariables(component.content, variables); break; } } return result; } /** * Evaluate a simple condition */ static evaluateCondition(condition, variables) { try { // Simple condition evaluation (e.g., "topic !== null", "numberOfQuotes > 5") // This is a basic implementation - could be enhanced with a proper expression evaluator const conditionFunction = new Function(...Object.keys(variables), `return ${condition}`); return conditionFunction(...Object.values(variables)); } catch (error) { logger.warn('Failed to evaluate condition', { condition, error }); return false; } } /** * Apply post-processors */ static async applyPostProcessors(content, processors, template) { // Sort processors by order const sortedProcessors = [...processors].sort((a, b) => (a.order || 0) - (b.order || 0)); let result = content; for (const processor of sortedProcessors) { result = await this.applyPostProcessor(result, processor, template); } return result; } /** * Apply a single post-processor */ static async applyPostProcessor(content, processor, template) { switch (processor.type) { case 'formatter': return this.applyFormatter(content, processor); case 'validator': // Validators don't modify content, just validate await this.applyValidator(content, processor); return content; case 'transformer': return this.applyTransformer(content, processor); case 'enricher': return this.applyEnricher(content, processor, template); default: logger.warn('Unknown post-processor type', { type: processor.type }); return content; } } /** * Apply formatter post-processor */ static applyFormatter(content, processor) { const { name, options } = processor; switch (name) { case 'trim': return content.trim(); case 'uppercase': return content.toUpperCase(); case 'lowercase': return content.toLowerCase(); case 'capitalize': return content.charAt(0).toUpperCase() + content.slice(1); case 'truncate': const maxLength = options?.['maxLength'] || 100; const ellipsis = options?.['ellipsis'] || '...'; return content.length > maxLength ? content.substring(0, maxLength - ellipsis.length) + ellipsis : content; default: return content; } } /** * Apply validator post-processor */ static async applyValidator(content, processor) { const { name, options } = processor; switch (name) { case 'minLength': if (content.length < (options?.['min'] || 0)) { throw new Error(`Content length ${content.length} is below minimum ${options?.['min']}`); } break; case 'maxLength': if (content.length > (options?.['max'] || Infinity)) { throw new Error(`Content length ${content.length} exceeds maximum ${options?.['max']}`); } break; case 'pattern': if (options?.['pattern']) { const regex = new RegExp(options['pattern']); if (!regex.test(content)) { throw new Error(`Content does not match required pattern`); } } break; } } /** * Apply transformer post-processor */ static applyTransformer(content, processor) { const { name, options } = processor; switch (name) { case 'replace': if (options?.['search'] && options?.['replace'] !== undefined) { const flags = options['flags'] || 'g'; const regex = new RegExp(options['search'], flags); return content.replace(regex, options['replace']); } return content; case 'split-lines': const separator = options?.['separator'] || '\n'; const splitLines = content.split('\n'); return splitLines.join(separator); case 'number-lines': const startNum = options?.['start'] || 1; const numberLines = content.split('\n'); return numberLines .map((line, index) => `${startNum + index}. ${line}`) .join('\n'); default: return content; } } /** * Apply enricher post-processor */ static applyEnricher(content, processor, template) { const { name, options } = processor; switch (name) { case 'add-metadata': const metadata = [ `Template: ${template.metadata.name}`, `Category: ${template.metadata.category}`, `Generated: ${new Date().toISOString()}`, ].join('\n'); return options?.['position'] === 'top' ? `${metadata}\n\n${content}` : `${content}\n\n${metadata}`; case 'add-attribution': const attribution = options?.['text'] || `Generated using ${template.metadata.name} template`; return `${content}\n\n--- ${attribution} ---`; default: return content; } } /** * Format output based on format type */ static formatOutput(content, format, options) { switch (format) { case OutputFormat.TEXT: return content; case OutputFormat.JSON: return JSON.stringify({ content, timestamp: new Date().toISOString(), ...options, }, null, 2); case OutputFormat.MARKDOWN: return this.formatAsMarkdown(content, options); case OutputFormat.HTML: return this.formatAsHTML(content, options); case OutputFormat.CSV: return this.formatAsCSV(content, options); case OutputFormat.XML: return this.formatAsXML(content, options); default: return content; } } /** * Format content as Markdown */ static formatAsMarkdown(content, options) { const title = options?.['title'] || 'Quote'; const includeHeader = options?.['includeHeader'] !== false; if (includeHeader) { return `# ${title}\n\n${content}`; } return content; } /** * Format content as HTML */ static formatAsHTML(content, options) { const title = options?.['title'] || 'Quote'; const cssClass = options?.['cssClass'] || 'quote-content'; const escapedContent = content .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;') .replace(/\n/g, '<br>'); return ` <div class="${cssClass}"> <h1>${title}</h1> <div class="content"> ${escapedContent} </div> </div> `.trim(); } /** * Format content as CSV */ static formatAsCSV(content, options) { // For quotes, assume each line is a separate quote const quotes = content.split('\n').filter((line) => line.trim()); const headers = options?.['headers'] || ['Quote', 'Timestamp']; const timestamp = new Date().toISOString(); const rows = [ headers.join(','), ...quotes.map((quote) => `"${quote.replace(/"/g, '""')}","${timestamp}"`), ]; return rows.join('\n'); } /** * Format content as XML */ static formatAsXML(content, options) { const rootElement = options?.['rootElement'] || 'quotes'; const itemElement = options?.['itemElement'] || 'quote'; const escapeXML = (str) => str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&apos;'); const quotes = content.split('\n').filter((line) => line.trim()); return `<?xml version="1.0" encoding="UTF-8"?> <${rootElement}> ${quotes.map((quote) => ` <${itemElement}>${escapeXML(quote)}</${itemElement}>`).join('\n')} </${rootElement}>`; } } //# sourceMappingURL=templateRenderer.js.map