UNPKG

astro-maintenance

Version:

Maintenance and Coming Soon integration for Astro

261 lines (221 loc) 8.06 kB
export interface TemplateContext { [key: string]: any; } export interface TemplateToken { type: 'text' | 'variable' | 'if' | 'else' | 'endif'; content: string; variable?: string; startPos: number; endPos: number; } export class HandlebarsCompatibleEngine { private static readonly VARIABLE_REGEX = /\{\{([^#\/}][^}]*)\}\}/g; private static readonly IF_REGEX = /\{\{#if\s+([^}]+)\}\}/g; private static readonly ELSE_REGEX = /\{\{else\}\}/g; private static readonly ENDIF_REGEX = /\{\{\/if\}\}/g; /** * Tokenize the template into processable chunks */ private static tokenize(template: string): TemplateToken[] { const tokens: TemplateToken[] = []; const allMatches: Array<{ match: RegExpExecArray; type: string }> = []; // Find all Handlebars expressions let match; // Variables const varRegex = new RegExp(this.VARIABLE_REGEX.source, 'g'); while ((match = varRegex.exec(template)) !== null) { allMatches.push({ match, type: 'variable' }); } // If statements const ifRegex = new RegExp(this.IF_REGEX.source, 'g'); while ((match = ifRegex.exec(template)) !== null) { allMatches.push({ match, type: 'if' }); } // Else statements const elseRegex = new RegExp(this.ELSE_REGEX.source, 'g'); while ((match = elseRegex.exec(template)) !== null) { allMatches.push({ match, type: 'else' }); } // End if statements const endifRegex = new RegExp(this.ENDIF_REGEX.source, 'g'); while ((match = endifRegex.exec(template)) !== null) { allMatches.push({ match, type: 'endif' }); } // Sort by position allMatches.sort((a, b) => a.match.index! - b.match.index!); // Create tokens including text between expressions let lastIndex = 0; for (const { match, type } of allMatches) { const startPos = match.index!; const endPos = startPos + match[0].length; // Add text before this match if (startPos > lastIndex) { tokens.push({ type: 'text', content: template.slice(lastIndex, startPos), startPos: lastIndex, endPos: startPos }); } // Add the expression token tokens.push({ type: type as any, content: match[0], variable: type === 'variable' || type === 'if' ? match[1]?.trim() : undefined, startPos, endPos }); lastIndex = endPos; } // Add remaining text if (lastIndex < template.length) { tokens.push({ type: 'text', content: template.slice(lastIndex), startPos: lastIndex, endPos: template.length }); } return tokens; } /** * Get value from context, supporting nested properties */ private static getValue(context: TemplateContext, path: string): any { if (!path) return undefined; path = path.trim(); if (path.includes('.')) { const parts = path.split('.'); let current = context; for (const part of parts) { if (current === null || current === undefined) return undefined; if (typeof current !== 'object') return undefined; current = current[part]; } return current; } return context[path]; } /** * Check if a value is truthy in Handlebars context */ private static isTruthy(value: any): boolean { if (value === null || value === undefined) return false; if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; if (typeof value === 'string') return value.length > 0; if (Array.isArray(value)) return value.length > 0; if (typeof value === 'object') return Object.keys(value).length > 0; return Boolean(value); } /** * Process conditional blocks using a stack-based approach */ private static processConditionals(tokens: TemplateToken[], context: TemplateContext): TemplateToken[] { const result: TemplateToken[] = []; const stack: Array<{ condition: boolean; tokens: TemplateToken[] }> = []; for (const token of tokens) { if (token.type === 'if') { const value = this.getValue(context, token.variable!); const condition = this.isTruthy(value); stack.push({ condition, tokens: [] }); } else if (token.type === 'endif') { const frame = stack.pop(); if (frame) { // Process the conditional block const processedTokens = this.processConditionalBlock(frame.tokens, frame.condition); if (stack.length > 0) { // We're still inside another conditional stack[stack.length - 1].tokens.push(...processedTokens); } else { // We're at the top level result.push(...processedTokens); } } } else { if (stack.length > 0) { // We're inside a conditional block stack[stack.length - 1].tokens.push(token); } else { // We're at the top level result.push(token); } } } return result; } /** * Process tokens within a conditional block based on the condition result */ private static processConditionalBlock(tokens: TemplateToken[], condition: boolean): TemplateToken[] { // Find if there's an else token const elseIndex = tokens.findIndex(t => t.type === 'else'); if (elseIndex === -1) { // Simple {{#if}}...{{/if}} - include all tokens if condition is true return condition ? tokens : []; } // {{#if}}...{{else}}...{{/if}} - split at {{else}} and choose the appropriate part const beforeElse = tokens.slice(0, elseIndex); const afterElse = tokens.slice(elseIndex + 1); // Return the appropriate tokens based on condition, excluding the else token itself return condition ? beforeElse : afterElse; } /** * Substitute variables in the processed tokens */ private static substituteVariables(tokens: TemplateToken[], context: TemplateContext): string { let result = ''; for (const token of tokens) { if (token.type === 'text') { result += token.content; } else if (token.type === 'variable') { const value = this.getValue(context, token.variable!); if (typeof value === 'string') { result += value; } else if (value !== null && value !== undefined) { result += String(value); } // If value is null/undefined, we output nothing (Handlebars behavior) } // Other token types (if, else, endif) should have been processed away } return result; } /** * Main render method */ public static render(template: string, context: TemplateContext = {}): string { try { // Step 1: Tokenize the template const tokens = this.tokenize(template); // Step 2: Process conditionals const processedTokens = this.processConditionals(tokens, context); // Step 3: Substitute variables and generate final output const result = this.substituteVariables(processedTokens, context); return result; } catch (error) { console.error('Template rendering error:', error); // Return the original template as fallback return template; } } /** * Compile a template for repeated use (optimization for future use) */ public static compile(template: string): (context: TemplateContext) => string { const tokens = this.tokenize(template); return (context: TemplateContext = {}) => { try { const processedTokens = this.processConditionals(tokens, context); return this.substituteVariables(processedTokens, context); } catch (error) { console.error('Template rendering error:', error); return template; } }; } } // Export a simple render function for ease of use export function renderTemplate(template: string, context: TemplateContext = {}): string { return HandlebarsCompatibleEngine.render(template, context); }