UNPKG

@combino/plugin-ejs-mate

Version:

EJS-Mate template engine plugin for Combino with layout support

590 lines 25.1 kB
import * as ejsEngine from 'ejs'; import * as fs from 'fs'; import * as path from 'path'; // ===== TYPES ===== class Block { constructor() { this.html = []; } toString() { return this.html.join('\n'); } append(more) { this.html.push(more); return this; } prepend(more) { this.html.unshift(more); return this; } replace(instead) { this.html = [instead]; return this; } } // ===== CONSTANTS ===== const PATTERNS = { explicitLayout: /<% layout\(['"]([^'"]+)['"]\) %>/, blockStart: /^\s*<%\s*block\(['"]([^'\"]+)['"]\)\s*%>\s*$/, blockEnd: /^\s*<%\s*end\s*%>\s*$/, inlineBlock: /<% block\(['"]([^'\"]+)['"]\) %>([^<]*)<% end %>/g, layoutBlockStart: /^(\s*)<% block\('([^']+)'\) %>(\s*)$/, bodyBlock: /<%-?\s*body\s*%>/, }; const LAYOUT_EXTENSIONS = ['.ejs', '.md', '.html', '.txt']; // ===== UTILITY FUNCTIONS ===== function resolveLayoutDirectory(templatePath, layoutDir) { return layoutDir.startsWith('./') || layoutDir.startsWith('../') ? path.resolve(templatePath, layoutDir) : layoutDir; } function collectLayoutDirectories(context) { const layoutDirectories = []; if (context.allTemplates) { for (const template of context.allTemplates) { if (template.config?.layout) { for (const layoutDir of template.config.layout) { const resolvedLayoutDir = resolveLayoutDirectory(template.path, layoutDir); if (!layoutDirectories.includes(resolvedLayoutDir)) { layoutDirectories.push(resolvedLayoutDir); } } } } } return layoutDirectories; } function shouldSkipProcessing(context) { return context.sourcePath.includes('/output/') || context.sourcePath.includes('\\output\\'); } function resolveLayoutPath(sourcePath, layoutPath) { return path.resolve(path.dirname(sourcePath), layoutPath); } function fileExistsWithExtensions(basePath, extensions = LAYOUT_EXTENSIONS) { // Try exact path first if (fs.existsSync(basePath)) { return basePath; } // Try with extensions for (const ext of extensions) { const pathWithExt = basePath + ext; if (fs.existsSync(pathWithExt)) { return pathWithExt; } } return null; } function readFileIfExists(filePath) { try { return fs.readFileSync(filePath, 'utf8'); } catch (error) { return null; } } function cleanupBlankLines(content) { return content .split('\n') .reduce((acc, line) => { const trimmedLine = line.trim(); if (acc.length === 0 && trimmedLine === '') return acc; if (trimmedLine === '') { const lastLine = acc[acc.length - 1]; if (lastLine && lastLine.trim() !== '') { acc.push(line); } } else { acc.push(line); } return acc; }, []) .join('\n') .trim(); } // ===== BLOCK PROCESSING ===== function createBlockProcessingState() { return { inBlock: false, currentBlockName: null, currentBlockContent: [], blockStartIndex: 0, blockIndentation: '', }; } function processInlineBlocks(content) { return content.replace(PATTERNS.inlineBlock, (match, blockName, blockContent) => { return `<% block('${blockName}', \`${blockContent.trim()}\`) %>`; }); } function processBlockStart(line, state, processedLines, isLayout) { const blockStartMatch = line.match(isLayout ? PATTERNS.layoutBlockStart : PATTERNS.blockStart); if (blockStartMatch && !state.inBlock) { state.inBlock = true; state.currentBlockName = blockStartMatch[2] || blockStartMatch[1]; state.currentBlockContent = []; state.blockStartIndex = processedLines.length; state.blockIndentation = blockStartMatch[1] || ''; processedLines.push(line); return true; } return false; } function processBlockEnd(line, state, processedLines, isLayout) { if (PATTERNS.blockEnd.test(line) && state.inBlock) { state.inBlock = false; const blockContent = state.currentBlockContent.join('\n'); // Replace the block start line with the appropriate format if (isLayout) { processedLines[state.blockStartIndex] = `${state.blockIndentation}<%= block('${state.currentBlockName}') || \`${blockContent}\` %>`; } else { processedLines[state.blockStartIndex] = `<% block('${state.currentBlockName}', \`${blockContent}\`) %>`; } // Reset state state.currentBlockName = null; state.currentBlockContent = []; state.blockIndentation = ''; return true; } return false; } function processBlocks(content, isLayout = false) { let processedContent = content; // Handle inline blocks first (only for non-layout content) if (!isLayout) { processedContent = processInlineBlocks(processedContent); } const lines = processedContent.split('\n'); const processedLines = []; const state = createBlockProcessingState(); for (const line of lines) { // Try to process block start if (processBlockStart(line, state, processedLines, isLayout)) { continue; } // Try to process block end if (processBlockEnd(line, state, processedLines, isLayout)) { continue; } // Collect block content or regular lines if (state.inBlock) { state.currentBlockContent.push(line); } else { processedLines.push(line); } } return processedLines.join('\n'); } // ===== RENDERING ===== function createBlockFunction(blocks, isLayout) { if (isLayout) { return (name) => blocks[name] || undefined; } return (name, html) => { if (!blocks[name]) { blocks[name] = new Block(); } if (html) { blocks[name].append(html); } return blocks[name]; }; } function createRenderingContext(data, blocks, isLayout = false, body) { const context = { ...data, block: createBlockFunction(blocks, isLayout), layout: () => { }, partial: (view) => { console.warn(`Partial '${view}' not found - partials not yet implemented`); return ''; }, }; if (isLayout && body) { context.body = body; } return context; } async function renderWithBlocks(content, data, ejsOptions = {}) { const blocks = {}; const renderContext = createRenderingContext(data, blocks); try { const rawBody = await ejsEngine.render(content, renderContext, { async: true, ...ejsOptions, // Add EJS options to handle undefined variables gracefully strict: false, localsName: 'locals', // This option makes undefined variables return undefined instead of throwing rmWhitespace: false, }); const body = cleanupBlankLines(rawBody); return { body, blocks }; } catch (error) { // Handle ReferenceError for undefined variables if (error instanceof ReferenceError && error.message.includes('is not defined')) { // Check if this is an output context (like <%- variable %>) rather than a logic context const errorLineMatch = error.message.match(/ejs:(\d+)/); if (errorLineMatch) { const errorLineNumber = parseInt(errorLineMatch[1]); const lines = content.split('\n'); const errorLine = lines[errorLineNumber - 1] || ''; // Extract the undefined variable name from the error message const varMatch = error.message.match(/(\w+) is not defined/); if (varMatch) { const undefinedVar = varMatch[1]; // Check if the variable is used in an output context on this line // Output contexts: <%- variable %>, <%= variable %> // Logic contexts: <% if (variable) { %>, <% for (variable in items) { %>, etc. const outputPattern = new RegExp(`<%-?\\s*${undefinedVar}\\s*%>`); const logicPattern = new RegExp(`<%\\s*(if|for|while|switch|case)\\s*\\([^)]*${undefinedVar}[^)]*\\)`); const isOutputContext = outputPattern.test(errorLine); const isLogicContext = logicPattern.test(errorLine); // Only show warning if it's an output context and not a logic context if (isOutputContext && !isLogicContext) { console.warn(`Warning: Undefined variable in template: ${error.message}`); } } } // Try to render with a safer approach - replace all undefined variables with empty strings try { // Create a safer data object with default values for all potentially undefined variables const safeData = { ...data }; // Extract all variable names from the template that might be undefined const variableMatches = content.match(/<%[^%]*\b(\w+)\b[^%]*%>/g); if (variableMatches) { for (const match of variableMatches) { // Extract variable names from EJS tags const varMatches = match.match(/\b(\w+)\b/g); if (varMatches) { for (const varName of varMatches) { // Skip EJS keywords and common variables if (![ 'if', 'else', 'end', 'for', 'in', 'each', 'block', 'layout', 'partial', 'include', ].includes(varName) && !safeData.hasOwnProperty(varName)) { safeData[varName] = ''; } } } } } const safeContext = createRenderingContext(safeData, blocks); const rawBody = await ejsEngine.render(content, safeContext, { async: true, ...ejsOptions, strict: false, localsName: 'locals', }); const body = cleanupBlankLines(rawBody); return { body, blocks }; } catch (retryError) { // If retry fails, return empty content console.warn(`Failed to retry rendering after undefined variable fix: ${retryError}`); return { body: '', blocks }; } } // Re-throw other errors throw error; } } async function renderLayout(layoutContent, data, body, blocks, ejsOptions = {}) { const layoutContext = createRenderingContext(data, blocks, true, body); try { return await ejsEngine.render(layoutContent, layoutContext, { async: true, ...ejsOptions, // Add EJS options to handle undefined variables gracefully strict: false, localsName: 'locals', }); } catch (error) { // Handle ReferenceError for undefined variables if (error instanceof ReferenceError && error.message.includes('is not defined')) { // Check if this is an output context (like <%- variable %>) rather than a logic context const errorLineMatch = error.message.match(/ejs:(\d+)/); if (errorLineMatch) { const errorLineNumber = parseInt(errorLineMatch[1]); const lines = layoutContent.split('\n'); const errorLine = lines[errorLineNumber - 1] || ''; // Extract the undefined variable name from the error message const varMatch = error.message.match(/(\w+) is not defined/); if (varMatch) { const undefinedVar = varMatch[1]; // Check if the variable is used in an output context on this line // Output contexts: <%- variable %>, <%= variable %> // Logic contexts: <% if (variable) { %>, <% for (variable in items) { %>, etc. const outputPattern = new RegExp(`<%-?\\s*${undefinedVar}\\s*%>`); const logicPattern = new RegExp(`<%\\s*(if|for|while|switch|case)\\s*\\([^)]*${undefinedVar}[^)]*\\)`); const isOutputContext = outputPattern.test(errorLine); const isLogicContext = logicPattern.test(errorLine); // Only show warning if it's an output context and not a logic context if (isOutputContext && !isLogicContext) { console.warn(`Warning: Undefined variable in layout: ${error.message}`); } } } // Try to render with a safer approach try { const safeData = { ...data }; // Extract all variable names from the layout template that might be undefined const variableMatches = layoutContent.match(/<%[^%]*\b(\w+)\b[^%]*%>/g); if (variableMatches) { for (const match of variableMatches) { // Extract variable names from EJS tags const varMatches = match.match(/\b(\w+)\b/g); if (varMatches) { for (const varName of varMatches) { // Skip EJS keywords and common variables if (![ 'if', 'else', 'end', 'for', 'in', 'each', 'block', 'layout', 'partial', 'include', ].includes(varName) && !safeData.hasOwnProperty(varName)) { safeData[varName] = ''; } } } } } const safeContext = createRenderingContext(safeData, blocks, true, body); return await ejsEngine.render(layoutContent, safeContext, { async: true, ...ejsOptions, strict: false, localsName: 'locals', }); } catch (retryError) { console.warn(`Failed to retry layout rendering after undefined variable fix: ${retryError}`); return ''; } } // Re-throw other errors throw error; } } async function processWithoutLayout(content, data, ejsOptions = {}) { const blocks = {}; const renderContext = createRenderingContext(data, blocks); try { return await ejsEngine.render(content, renderContext, { async: true, ...ejsOptions, // Add EJS options to handle undefined variables gracefully strict: false, localsName: 'locals', }); } catch (error) { // Handle ReferenceError for undefined variables if (error instanceof ReferenceError && error.message.includes('is not defined')) { // Check if this is an output context (like <%- variable %>) rather than a logic context const errorLineMatch = error.message.match(/ejs:(\d+)/); if (errorLineMatch) { const errorLineNumber = parseInt(errorLineMatch[1]); const lines = content.split('\n'); const errorLine = lines[errorLineNumber - 1] || ''; // Extract the undefined variable name from the error message const varMatch = error.message.match(/(\w+) is not defined/); if (varMatch) { const undefinedVar = varMatch[1]; // Check if the variable is used in an output context on this line // Output contexts: <%- variable %>, <%= variable %> // Logic contexts: <% if (variable) { %>, <% for (variable in items) { %>, etc. const outputPattern = new RegExp(`<%-?\\s*${undefinedVar}\\s*%>`); const logicPattern = new RegExp(`<%\\s*(if|for|while|switch|case)\\s*\\([^)]*${undefinedVar}[^)]*\\)`); const isOutputContext = outputPattern.test(errorLine); const isLogicContext = logicPattern.test(errorLine); // Only show warning if it's an output context and not a logic context if (isOutputContext && !isLogicContext) { console.warn(`Warning: Undefined variable in template: ${error.message}`); } } } // Try to render with a safer approach try { const safeData = { ...data }; // Extract all variable names from the template that might be undefined const variableMatches = content.match(/<%[^%]*\b(\w+)\b[^%]*%>/g); if (variableMatches) { for (const match of variableMatches) { // Extract variable names from EJS tags const varMatches = match.match(/\b(\w+)\b/g); if (varMatches) { for (const varName of varMatches) { // Skip EJS keywords and common variables if (![ 'if', 'else', 'end', 'for', 'in', 'each', 'block', 'layout', 'partial', 'include', ].includes(varName) && !safeData.hasOwnProperty(varName)) { safeData[varName] = ''; } } } } } const safeContext = createRenderingContext(safeData, blocks); return await ejsEngine.render(content, safeContext, { async: true, ...ejsOptions, strict: false, localsName: 'locals', }); } catch (retryError) { console.warn(`Failed to retry rendering after undefined variable fix: ${retryError}`); return ''; } } // Re-throw other errors throw error; } } // ===== LAYOUT PROCESSING ===== function findLayoutInDirectory(layoutDir, layoutPath) { const layoutInConfiguredDir = path.resolve(layoutDir, layoutPath); return fileExistsWithExtensions(layoutInConfiguredDir); } async function findLayoutContent(context, layoutPath) { const resolvedLayoutPath = resolveLayoutPath(context.sourcePath, layoutPath); // Try exact path first let foundPath = fileExistsWithExtensions(resolvedLayoutPath); if (foundPath) { const content = readFileIfExists(foundPath); if (content !== null) return content; } // Try configured layout directories const layoutDirectories = collectLayoutDirectories(context); for (const layoutDir of layoutDirectories) { foundPath = findLayoutInDirectory(layoutDir, layoutPath); if (foundPath) { const content = readFileIfExists(foundPath); if (content !== null) return content; } } throw new Error(`Layout file not found: ${layoutPath}`); } function isValidLayoutTemplate(content) { // A valid layout template should contain layout-specific markers // like <%- body %> or block definitions return (PATTERNS.bodyBlock.test(content) || content.includes('<% block(') || content.includes('<%- block(') || content.includes('<%= block(')); } async function findDynamicLayout(context) { const layoutDirectories = collectLayoutDirectories(context); if (layoutDirectories.length === 0) return null; const currentFileName = path.basename(context.sourcePath); // Look for exact filename matches in configured layout directories for (const layoutDir of layoutDirectories) { const layoutPath = path.resolve(layoutDir, currentFileName); const content = readFileIfExists(layoutPath); if (content !== null) { // Only treat the file as a layout if it actually contains layout markers if (isValidLayoutTemplate(content)) { return content; } // If it's just a data file with the same name, don't use it as a layout } } return null; } async function processWithLayout(context, layoutPath, contentToRender, layoutContent, ejsOptions = {}) { // Render content with blocks const { body, blocks } = await renderWithBlocks(contentToRender, context.data, ejsOptions); // Process and render layout const processedLayoutContent = processBlocks(layoutContent, true); const renderedContent = await renderLayout(processedLayoutContent, context.data, body, blocks, ejsOptions); return { content: renderedContent, id: context.id }; } // ===== MAIN PLUGIN ===== export default function plugin(options = {}) { const defaultPatterns = ['*']; const patterns = options.patterns || defaultPatterns; // Extract EJS options (everything except patterns) const { patterns: _, ...ejsOptions } = options; // Helper function to check if file matches patterns function matchesPatterns(filePath) { // Extract just the filename from the path const fileName = path.basename(filePath); return patterns.some((pattern) => { const regex = new RegExp(pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.')); return regex.test(fileName); }); } async function processTemplate(context) { // Preprocess blocks const preprocessedContent = processBlocks(context.content); // Check for explicit layout const layoutMatch = preprocessedContent.match(PATTERNS.explicitLayout); if (layoutMatch) { const contentWithoutLayout = preprocessedContent.replace(PATTERNS.explicitLayout, ''); const layoutContent = await findLayoutContent(context, layoutMatch[1]); return await processWithLayout(context, layoutMatch[1], contentWithoutLayout, layoutContent, ejsOptions); } // Check for dynamic layout in configured directories const dynamicLayoutContent = await findDynamicLayout(context); if (dynamicLayoutContent) { return await processWithLayout(context, 'dynamic', preprocessedContent, dynamicLayoutContent, ejsOptions); } // No layout, just render EJS const renderedContent = await processWithoutLayout(preprocessedContent, context.data, ejsOptions); return { content: renderedContent, id: context.id }; } return { compile: async (context) => { // Only compile files that match our patterns if (!matchesPatterns(context.id)) { return { content: context.content, id: context.id }; } try { if (shouldSkipProcessing(context)) { return { content: context.content, id: context.id }; } return await processTemplate(context); } catch (error) { throw new Error(`Error processing EJS-Mate template: ${error}`); } }, }; } //# sourceMappingURL=index.js.map