UNPKG

litenode

Version:

Lightweight and modular web framework

292 lines (248 loc) 10.6 kB
import { Tokenizer } from "./syntax/Tokenizer.js" import { Parser } from "./parser/Parser.js" import { Evaluator } from "./evaluator/Evaluator.js" export class STE { #baseDir #templateCache #currentTemplate #includeStack // Track include stack #pathUtils = null #isRootMode #rootPrefix // Store the root component folder name #basePath // Store the base path segments /** * Creates a new instance of TemplateEngine * @param {string} baseDir - The base directory where template files are located * @param {Object} options - Configuration options */ constructor(baseDir) { this.#baseDir = baseDir this.#templateCache = new Map() this.#currentTemplate = null this.#includeStack = [] // Initialize include stack this.htmlVars = new Map() // Add persistent storage for HTML variables this.#isRootMode = baseDir === "./" this.#rootPrefix = null // Will be set on first template render this.#basePath = null // Will be set on first template render } /** * Initializes path utilities if not already initialized * @private */ async #initPathUtils() { if (!this.#pathUtils) { const { dirname, resolve, join, normalize } = await import("node:path") this.#pathUtils = { dirname, resolve, join, normalize } } return this.#pathUtils } /** * Sets the root prefix and base path from the first template path. * Used to maintain consistent path resolution across template includes. * * @private * @param {string} path - The initial template path */ #setRootPrefix(path) { if (!this.#basePath) { // Split the path into segments const segments = path.split(/[/\\]/) if (segments.length > 2) { // Multiple levels - If there are more than two segments, remove the last two (file and its parent folder) this.#basePath = segments.slice(0, -2) // Remove last two segments (e.g., layouts/index.html) } else { // Root level file - empty base path this.#basePath = [] } this.#rootPrefix = this.#basePath.join("/") } } /** * Normalizes a file path by resolving dots and maintaining correct base path context. * Handles both absolute and relative paths while preserving theme directory structure. * * @private * @param {string} path - The path to normalize * @returns {string} The normalized path */ #normalizePath(path) { // Split path into segments const segments = path.split(/[/\\]/) const result = [] // Handle absolute paths (no ./ or ../) if (!path.startsWith("./") && !path.startsWith("../")) { // For absolute paths within templates, prepend the base path if (this.#basePath && segments[0] !== this.#basePath[0]) { result.push(...this.#basePath) } } // Process remaining segments for (const segment of segments) { if (segment === "." || segment === "") { continue } if (segment === "..") { result.pop() } else { result.push(segment) } } return result.join("/") } /** * Resolves template paths, handling both absolute (from default views) and relative paths * @private */ async #resolvePath(filePath) { if (!filePath.endsWith(".html")) { throw new Error("Invalid file type. Only HTML files are supported.") } const { dirname, resolve, join, normalize } = await this.#initPathUtils() // Normalize the base directory to absolute path const absoluteBaseDir = normalize(resolve(this.#baseDir)) // If path starts with ./ or ../, it's relative to current template if (filePath.startsWith("./") || filePath.startsWith("../")) { // Get the INCLUDING template (not the one being included) const includingTemplate = this.#includeStack[this.#includeStack.length - 1] || this.#currentTemplate // Get the directory of the including template const includingTemplateDir = dirname(includingTemplate) // First join the template directory with the new path const combinedPath = join(includingTemplateDir, filePath) // Then normalize it to resolve any .. or . const normalizedPath = this.#normalizePath(combinedPath) // Create the absolute path by joining with the base directory const resolvedPath = join(absoluteBaseDir, normalizedPath) // In root mode, verify the path exists within project root if (this.#isRootMode) { const projectRoot = resolve("./") if (!resolvedPath.startsWith(projectRoot)) { throw new Error("Cannot include files outside of project root") } } return resolvedPath } // For absolute paths (from baseDir), normalize then join with baseDir const normalizedPath = this.#normalizePath(filePath) const absolutePath = join(absoluteBaseDir, normalizedPath) return absolutePath } /** * Renders a template from a file path * @param {string} filePath - The path to the template file * @param {Object} data - The data object containing values * @returns {Promise<string>} The rendered template */ async render(filePath, data) { try { // Set root prefix from the initial template path this.#setRootPrefix(filePath) // Set current template for relative path resolution const previousTemplate = this.#currentTemplate this.#currentTemplate = filePath // Process the main template const processed = await this.renderStringWithoutRestore(filePath, data) // Restore HTML content at the end let result = processed for (const { marker, value } of this.htmlVars.values()) { result = result.replace(marker, value) } // Restore previous template context this.#currentTemplate = previousTemplate return result } catch (error) { this.#currentTemplate = null // Reset on error console.error("[STE] Main render error:", error) throw new Error(`Template rendering failed for ${filePath}: ${error.message}`) } } /** * Reads a template file from the base directory * @private */ async #readFile(filePath) { try { const resolvedPath = await this.#resolvePath(filePath) const { readFile } = await import("node:fs/promises") const content = await readFile(resolvedPath, "utf8") return content } catch (error) { console.error("[STE] File reading error:", error) throw new Error(`File reading failed: ${error.message}`) } } /** * Renders a template without restoring HTML variable content. * Internal method used for template processing and includes. * * @private * @param {string} filePath - Path to the template file * @param {Object} data - Data object containing template variables * @returns {Promise<string>} The rendered template content */ async renderStringWithoutRestore(filePath, data) { try { const content = await this.#readFile(filePath) // Push the template onto the include stack AFTER reading the file this.#includeStack.push(filePath) // Process any html_ variables in the data // Add null check {} to prevent error if no data object is initialized for (const [key, value] of Object.entries(data || {})) { if (key.startsWith("html_") && !this.htmlVars.has(key)) { const marker = `__HTML_${Math.random().toString(36).substring(2, 11)}__` this.htmlVars.set(key, { marker, value }) data[key] = marker } } // Process template let processed = content processed = await this.#processExpressions(processed, data) // Pop the template from the include stack when done this.#includeStack.pop() return processed } catch (error) { // Make sure to pop from the stack even if there's an error if (this.#includeStack.includes(filePath)) { this.#includeStack.pop() } console.error("[STE] Render error:", error) throw new Error(`Template rendering failed for ${filePath}: ${error.message}`) } } /** * Processes expressions using tokenizer/parser/evaluator * @private */ async #processExpressions(content, data) { try { const tokenizer = new Tokenizer(content) const tokens = tokenizer.scanTokens() const parser = new Parser(tokens) const ast = parser.parse() const evaluator = new Evaluator(data, this) // 'this' → template engine reference const result = await evaluator.evaluate(ast) return result } catch (error) { throw new Error(`Expression processing failed: ${error.message}`) } } /** * Clears both the template cache and HTML variables cache. * Useful when templates or HTML content need to be reloaded. * * @public */ clearCache() { this.#templateCache.clear() this.htmlVars.clear() // Clear HTML variables cache too } /** * Removes a specific template from the cache. * Allows selective cache clearing when only certain templates change. * * @public * @param {string} template - The template path to remove from cache */ removeFromCache(template) { this.#templateCache.delete(template) } }