UNPKG

simple-blog-engine

Version:

Современный легковесный генератор статического блога с поддержкой Markdown

470 lines (411 loc) 14.4 kB
/** * Template Engine Module * Handles HTML template loading and rendering for the static site generator */ const fs = require('fs'); const path = require('path'); const { getConfig } = require('./configManager'); // Template cache to improve performance const templateCache = {}; // Default configuration const DEFAULT_CONFIG = { cacheTemplates: true, templatesDir: 'templates', defaultLanguage: 'ru-RU' }; // Module configuration let config = { ...DEFAULT_CONFIG }; /** * Update template engine configuration * @param {Object} newConfig - New configuration options */ function updateConfig(newConfig = {}) { config = { ...DEFAULT_CONFIG, ...newConfig }; // Clear cache if disabled if (!config.cacheTemplates) { clearCache(); } } /** * Clear the template cache */ function clearCache() { Object.keys(templateCache).forEach(key => delete templateCache[key]); } /** * Load a template file * @param {string} templateName - Name of the template * @returns {string} - Template content */ function loadTemplate(templateName) { const config = getConfig(); const templatePath = path.join(config.paths.templatesDir || path.join(process.cwd(), 'blog/templates'), `${templateName}.html`); // Try loading from user templates first try { // Return from cache if available and caching is enabled if (config.cacheTemplates && templateCache[templatePath]) { return templateCache[templatePath]; } const template = fs.readFileSync(templatePath, 'utf-8'); // Cache template if caching is enabled if (config.cacheTemplates) { templateCache[templatePath] = template; } return template; } catch (error) { // If template not found in user templates, try engine defaults const engineDefaultPath = path.join(__dirname, '..', 'defaults', 'templates', `${templateName}.html`); try { const template = fs.readFileSync(engineDefaultPath, 'utf-8'); if (config.cacheTemplates) { templateCache[templatePath] = template; } return template; } catch (defaultError) { console.error(`Error loading template ${templateName}:`, error); console.error(`Also tried engine default at ${engineDefaultPath}:`, defaultError); return ''; // Return empty string on error } } } /** * Get nested property from object using a path string (e.g. "user.profile.name") * @param {Object} obj - Object to get value from * @param {string} path - Path to property * @returns {*} - Value or undefined */ function getNestedValue(obj, path) { return path.split('.').reduce((prev, curr) => { return prev ? prev[curr] : undefined; }, obj); } /** * Process {{#each items}} blocks in template * @param {string} template - Template string with #each blocks * @param {Object} data - Data object with values * @returns {string} - Processed template with #each blocks resolved */ function processEachBlocks(template, data) { const eachRegex = /\{\{#each\s+([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g; return template.replace(eachRegex, (match, itemsPath, blockContent) => { const items = getNestedValue(data, itemsPath.trim()); if (!items || !Array.isArray(items) || items.length === 0) { return ''; } return items.map(item => { // Process the block content with the item as context // Also pass the original data as context under _parent return processTemplate(blockContent, { ...item, _parent: data, _index: items.indexOf(item) }); }).join(''); }); } /** * Process {{#if condition}} blocks in template * @param {string} template - Template string with #if blocks * @param {Object} data - Data object with values * @returns {string} - Processed template with #if blocks resolved */ function processIfBlocks(template, data) { const ifRegex = /\{\{#if\s+([^}]+)\}\}([\s\S]*?)(?:\{\{else\}\}([\s\S]*?))?\{\{\/if\}\}/g; return template.replace(ifRegex, (match, condition, ifContent, elseContent = '') => { let result = false; // If condition is a path to a value if (condition.trim().indexOf(' ') === -1) { result = !!getNestedValue(data, condition.trim()); } else { // For more complex conditions in the future, could use a safe eval approach try { // For now, just handle simple equality/inequality operations const [left, operator, right] = condition.trim().split(/\s+/); const leftValue = getNestedValue(data, left) || left; const rightValue = getNestedValue(data, right) || right; switch (operator) { case '==': result = leftValue == rightValue; break; case '===': result = leftValue === rightValue; break; case '!=': result = leftValue != rightValue; break; case '!==': result = leftValue !== rightValue; break; default: result = !!getNestedValue(data, condition.trim()); } } catch (e) { result = false; } } return result ? processTemplate(ifContent, data) : processTemplate(elseContent, data); }); } /** * Replace variables in template with actual values * @param {string} template - Template string with variables * @param {Object} data - Data object with values to replace variables * @returns {string} - Processed template with variables replaced */ function processTemplate(template, data) { if (!template) return ''; // Process control structures first let processed = template; processed = processEachBlocks(processed, data); processed = processIfBlocks(processed, data); // Then process simple variable replacements return processed.replace(/\{\{([^#/][^}]*)\}\}/g, (match, key) => { key = key.trim(); const value = getNestedValue(data, key); if (value === undefined) { return ''; // Empty string for undefined values } else if (typeof value === 'function') { return value(); // Execute functions } else { return value; } }); } /** * Generic component renderer that handles loading template and processing with data * @param {string} templateName - Template name to use * @param {Object} data - Data to use for template variables * @param {Function} preprocessor - Optional function to preprocess data before rendering * @returns {string} - Rendered HTML */ function renderComponent(templateName, data = {}, preprocessor = null) { // Load the template const template = loadTemplate(templateName); // Apply preprocessor if provided const processedData = preprocessor ? preprocessor(data) : data; // Process template with data return processTemplate(template, processedData); } /** * Generate the base HTML structure * @param {Object} options - Template options * @param {string} options.title - Page title * @param {string} options.description - Page description * @param {string} options.content - Main content HTML * @param {Object} options.config - Site configuration * @param {Object} options.meta - Additional meta information * @param {string} options.bodyClass - CSS class for body tag * @returns {string} - Complete HTML document */ function generatePage({ title, description, content, config, meta = {}, bodyClass = '' }) { // Generate header and footer const header = generateHeader(config); const footer = generateFooter(config); return renderComponent('base', { language: config.site.language || config.defaultLanguage, site_title: config.site.title, title_prefix: title ? `${title} | ` : '', description: description || config.site.description, canonical: meta.canonical ? `<link rel="canonical" href="${meta.canonical}">` : '', header: header, content: content, footer: footer, body_class: bodyClass }); } /** * Generate the header HTML * @param {Object} config - Site configuration * @returns {string} - Header HTML */ function generateHeader(config) { return renderComponent('header', { site_title: config.site.title, site_description: config.site.description, navigation: config.navigation }); } /** * Generate the footer HTML * @param {Object} config - Site configuration * @returns {string} - Footer HTML */ function generateFooter(config) { // Generate social links HTML const socialLinks = config.social && config.social.links ? config.social.links.map(link => `<a href="${link.url}" target="_blank" rel="noopener noreferrer">${link.platform}</a>` ).join('') : ''; // Get current year for copyright const currentYear = new Date().getFullYear(); // Use copyright from config or generate a fallback const copyright = config.site.copyright || ${currentYear} ${config.site.title}`; return renderComponent('footer', { site_title: config.site.title, copyright: copyright, current_year: currentYear, social_links: socialLinks }); } /** * Generate HTML for a blog post * @param {Object} post - Post data * @param {Object} config - Site configuration * @returns {string} - Post HTML content */ function generatePostContent(post, config) { // Only show reading time if post.showReadingTime is true const readingTimeDisplay = (post.showReadingTime && post.readingTime) ? `<span class="reading-time">${post.readingTime} мин. чтения</span>` : ''; return renderComponent('post', { title: post.title, date: post.date, formatted_date: post.formattedDate, author: post.author || '', reading_time: readingTimeDisplay, tags: post.tags && post.tags.length > 0 ? generateTagsList(post.tags) : '', content: post.html }); } /** * Generate HTML for post card (used in listings) * @param {Object} post - Post data * @returns {string} - Post card HTML */ function generatePostCard(post) { // Only show reading time if post.showReadingTime is true const readingTimeDisplay = (post.showReadingTime && post.readingTime) ? `<span class="reading-time">${post.readingTime} мин. чтения</span>` : ''; return renderComponent('post-card', { url: post.url, title: post.title, date: post.date, formatted_date: post.formattedDate, author: post.author || '', reading_time: readingTimeDisplay, tags: post.tags && post.tags.length > 0 ? generateTagsList(post.tags) : '', summary: post.summary ? `<p class="post-summary">${post.summary}</p>` : '' }); } /** * Generate HTML for tags list * @param {Array} tags - Array of tags * @returns {string} - Tags HTML */ function generateTagsList(tags) { if (!tags || tags.length === 0) return ''; // Generate individual tag links const tagLinks = tags.map(tag => `<a href="/tags/${encodeURIComponent(tag)}/" class="tag">${tag}</a>` ).join(''); return renderComponent('tags', { tag_links: tagLinks }); } /** * Calculate pagination range * @param {Object} options - Pagination options * @returns {Object} - Pagination data */ function calculatePagination({ currentPage, totalPages, range = 2 }) { // Calculate range of pages to show let startPage = Math.max(1, currentPage - range); let endPage = Math.min(totalPages, currentPage + range); // Ensure we always show at least 5 pages if available if (endPage - startPage < 4 && totalPages > 4) { if (startPage === 1) { endPage = Math.min(startPage + 4, totalPages); } else if (endPage === totalPages) { startPage = Math.max(endPage - 4, 1); } } return { startPage, endPage, showFirst: startPage > 1, showFirstEllipsis: startPage > 2, showLast: endPage < totalPages, showLastEllipsis: endPage < totalPages - 1 }; } /** * Generate pagination HTML * @param {Object} pagination - Pagination data * @param {number} pagination.currentPage - Current page number * @param {number} pagination.totalPages - Total number of pages * @param {string} pagination.basePath - Base path for pagination links * @returns {string} - Pagination HTML */ function generatePagination({ currentPage, totalPages, basePath }) { if (totalPages <= 1) return ''; // Calculate pagination range const { startPage, endPage, showFirst, showFirstEllipsis, showLast, showLastEllipsis } = calculatePagination({ currentPage, totalPages }); // Previous page link let prevLink; if (currentPage > 1) { const prevUrl = currentPage === 2 ? basePath : `${basePath}page/${currentPage - 1}/`; prevLink = `<a href="${prevUrl}" class="pagination-item pagination-prev">← Предыдущая</a>`; } else { prevLink = '<span class="pagination-item pagination-prev disabled">← Предыдущая</span>'; } // Page number links let pageLinks = ''; // First page link if not in range if (showFirst) { pageLinks += `<a href="${basePath}" class="pagination-item">1</a>`; if (showFirstEllipsis) { pageLinks += '<span class="pagination-ellipsis">...</span>'; } } // Page links for (let i = startPage; i <= endPage; i++) { if (i === currentPage) { pageLinks += `<span class="pagination-item pagination-current">${i}</span>`; } else { const pageUrl = i === 1 ? basePath : `${basePath}page/${i}/`; pageLinks += `<a href="${pageUrl}" class="pagination-item">${i}</a>`; } } // Last page link if not in range if (showLast) { if (showLastEllipsis) { pageLinks += '<span class="pagination-ellipsis">...</span>'; } pageLinks += `<a href="${basePath}page/${totalPages}/" class="pagination-item">${totalPages}</a>`; } // Next page link let nextLink; if (currentPage < totalPages) { nextLink = `<a href="${basePath}page/${currentPage + 1}/" class="pagination-item pagination-next">Следующая →</a>`; } else { nextLink = '<span class="pagination-item pagination-next disabled">Следующая →</span>'; } return renderComponent('pagination', { prev_link: prevLink, current_page: currentPage, total_pages: totalPages, next_link: nextLink }); } module.exports = { updateConfig, loadTemplate, processTemplate, renderComponent, generatePage, generateHeader, generateFooter, generatePostContent, generatePostCard, generatePagination, generateTagsList };