cb-template
Version:
A Node.js server-side template engine that supports multi-level template inheritance.
556 lines (427 loc) • 14.9 kB
JavaScript
import os from 'node:os';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import * as lockfile from './lockfile.js';
import * as utils from './utils.js';
// Template layout class
class Layout {
constructor(core) {
this.core = core;
this.leftDelimiter = utils.encodeReg(core.leftDelimiter);
this.rightDelimiter = utils.encodeReg(core.rightDelimiter);
this.depth = 0;
// Store template file modification times
this.templateTimes = {};
// Store processed blocks
this.blocks = {};
// Store unprocessed raw blocks
this.rawBlocks = [];
// Store template contents
this.templates = [];
// Track files being processed for circular inheritance detection
this.visitedFiles = new Set();
// Default options
this.defaultOptions = {
block: '',
cache: true,
cacheName: 'cb-template-cache'
}
}
make(name, options = {}, callback) {
options = Object.assign({}, this.defaultOptions, options);
// Reset instance state to support multiple calls
this.depth = 0;
this.templateTimes = {};
this.blocks = {};
this.rawBlocks = [];
this.templates = [];
this.visitedFiles.clear();
const extName = path.extname(name);
if (!extName) {
name += this.core.defaultExtName;
}
const block = options.block;
// Set cache directory
const cachePath = this.core.cachePath || path.join(os.tmpdir(), options.cacheName, utils.getHash(this.core.basePath));
// Set cache filename
const cacheFilename = path.join(cachePath, utils.getHash(name + (block === '' ? '' : ':' + block)) + extName);
// Check if recompilation is needed
this.getCache(cacheFilename, options.cache, (cacheContent) => {
if (cacheContent !== false) {
return callback(null, cacheContent);
}
try {
// Read filename first
const filename = path.join(this.core.basePath, name);
fs.readFile(filename, async(err, data) => {
if (err) {
return callback(err);
}
try {
let content = data.toString();
utils.getFileTime(filename, async(time) => {
try {
this.templateTimes[filename] = time;
const extendsName = this.getExtends(content);
// Check if the first line has an extends directive
if (extendsName) {
// Has extends directive, need to parse parent template
await this.parseParent(extendsName, content, filename);
// Process template
content = this.processParent(content);
}
else {
// No inheritance, still need to collect blocks
const blocks = this.getBlocks(content);
Object.assign(this.blocks, blocks);
}
content = this.removeCommand(content);
// Prepare cache file data
const cacheInfo = {
version: this.core.version,
files: this.templateTimes
};
let result = `'/* cb template engine\n${JSON.stringify(cacheInfo)}\n*/+'`;
if (block !== '') {
// Support getting block content directly
result += this.blocks[block] ? this.blocks[block] : `Block ${block} not found!`;
}
else {
result += this.core._parse(content);
}
if (options.cache) {
// Write cache (asynchronous)
this.writeCache(cacheFilename, result);
}
callback(null, result);
}
catch (err) {
callback(err);
}
});
}
catch (err) {
callback(err);
}
});
}
catch (err) {
callback(err);
}
});
}
// Parse parent template
async parseParent(name, subContent, subFilename) {
this.depth++;
const extName = path.extname(name);
if (!extName) {
name += this.core.defaultExtName;
}
let filename;
if (name.indexOf('/') === 0) {
filename = path.join(this.core.basePath, name);
}
else {
filename = path.join(path.dirname(subFilename), name);
}
// Circular inheritance detection
if (this.visitedFiles.has(filename)) {
throw new Error(`Circular inheritance detected: ${filename} is already being processed`);
}
this.visitedFiles.add(filename);
const data = await fsPromises.readFile(filename);
const content = data.toString();
// Get file modification time
const stats = await fsPromises.stat(filename);
this.templateTimes[filename] = stats.mtime.getTime();
// Template content goes to stack, waiting for subsequent processing
this.templates.push(content);
// Get parent template name
const extendsName = this.getExtends(content);
if (extendsName) {
// Has extends directive, need to load parent template
await this.parseParent(extendsName, content, filename);
}
// Merge each level's block information for later use
this.rawBlocks.push(this.getBlocks(content));
this.depth--;
if (this.depth === 0) {
// If parsed to the last level, merge the block content of the last level template
// Special handling needed here, otherwise the block content of the last level template will be lost
this.rawBlocks.push(this.getBlocks(subContent));
}
// Clean up visit records
this.visitedFiles.delete(filename);
}
processParent(subContent) {
let content = '';
let subBlocks = this.getBlocks(subContent);
const length = this.rawBlocks.length;
this.templates.forEach((item, index) => {
let parentsBlocks = {};
for (let i = 0; i < length - index; i++) {
parentsBlocks = Object.assign(parentsBlocks, this.rawBlocks[i]);
}
content = this.parseParentBlock(item, subBlocks, parentsBlocks);
subBlocks = this.getBlocks(content);
});
return content;
}
parseParentBlock(content, subBlocks, currentBlocks) {
Object.assign(this.blocks, subBlocks);
const pattern = this.createPlainMatcher('block');
content = content.replace(pattern, (match, p1, p2, p3, p4, p5) => {
if (!p3) {
return '';
}
const params = p3.split(/\s+/);
const name = params[0];
const mode = params[1];
if (mode === 'hide') {
return '';
}
if (typeof this.blocks[name] !== 'undefined') {
let str = this.blocks[name];
p4 = p4.trim();
str = this.commandUse(str, currentBlocks);
str = this.commandCall(str, currentBlocks, 'apply');
str = this.commandCall(str, currentBlocks);
str = this.commandParent(str, p4);
str = this.commandChild(str, p4);
str = this.commandSlot(str, p4);
// Return: <% block xxx %>processed content<% /block %>
return p1 + str + p5;
}
return match;
});
return content;
}
getExtends(content) {
const pattern = this.createOpenMatcher('extends');
const match = pattern.exec(content);
return match ? match[2] : '';
}
getBlocks(content) {
const blocks = {};
let match = null;
const pattern = this.createMatcher('block');
while ((match = pattern.exec(content)) !== null) {
if (match[2]) {
const param = match[2].trim().split(/\s+/);
blocks[param[0]] = match[3].trim();
}
}
return blocks;
}
commandParent(content, parentContent) {
const pattern = this.createOpenMatcher('parent');
return content.replace(pattern, parentContent.trim());
}
commandChild(content, parentContent) {
const pattern = this.createOpenMatcher('child');
if (parentContent.match(pattern)) {
content = parentContent.replace(pattern, content.trim());
}
return content;
}
commandSlot(content, parentContent) {
const pattern = this.createMatcher('slot');
if (!parentContent.match(pattern)) {
return content;
}
const slots = {};
let defaultSlot;
let match = null;
// Find all slots in child block
while ((match = pattern.exec(content)) !== null) {
if (match[2]) {
slots[match[2]] = match[3].trim();
}
else {
defaultSlot = match[3].trim();
}
}
// Get content with slot directives cleaned
const plainContent = content.replace(pattern, '').trim();
content = parentContent.replace(pattern, (match, p1, p2, p3) => {
if (!p2) {
return typeof defaultSlot !== 'undefined' ? defaultSlot : plainContent;
}
else {
return typeof slots[p2] !== 'undefined' ? slots[p2] : p3.trim();
}
});
return content;
}
commandUse(content, currentBlocks) {
const pattern = this.createOpenMatcher('use');
const patternSlot = this.createMatcher('slot');
content = content.replace(pattern, (p0, p1, p2) => {
const params = p2.split(/\s+/);
const name = params[0];
const other = params.splice(1).join(' ');
if (!currentBlocks[name]) {
return '';
}
const blocks = {};
let match = null;
const paramsPattern = /(\S+?)="(.*?)"/g;
while ((match = paramsPattern.exec(other)) !== null) {
blocks[match[1]] = match[2].trim();
}
return currentBlocks[name].replace(patternSlot, (match, p1, p2, p3) => {
return blocks[p2] ? blocks[p2] : p3.trim();
});
});
return content;
}
commandCall(content, currentBlocks, command = 'call') {
const pattern = this.createMatcher(command);
const patternSlot = this.createMatcher('slot');
content = content.replace(pattern, (p0, p1, p2, p3) => {
const params = p2.split(/\s+/);
const name = params[0];
const other = params.splice(1).join(' ');
if (!currentBlocks[name]) {
return '';
}
const blocks = {};
let match = null;
const paramsPattern = /(\S+?)="(.*?)"/g;
while ((match = paramsPattern.exec(other)) !== null) {
blocks[match[1]] = match[2].trim();
}
while ((match = patternSlot.exec(p3)) !== null) {
blocks[match[2]] = match[3].trim();
}
return currentBlocks[name].replace(patternSlot, (match, slotP1, slotP2, slotP3) => {
if (!slotP2) {
return this.removeCommandWithContent('slot', p3).trim();
}
else {
return blocks[slotP2] ? blocks[slotP2] : slotP3.trim();
}
});
});
return content;
}
removeCommand(content) {
const pattern = this.createPlainMatcher('block');
content = content.replace(pattern, (match, p1, p2, p3, p4) => {
// Check if there is hide mode
if (p3) {
const params = p3.split(/\s+/);
const mode = params[1];
if (mode === 'hide') {
return '';
}
}
return p4.trim();
});
['extends', 'block', '/block', 'parent', 'child', 'use', 'apply', '/apply', 'call', '/call', 'slot', '/slot'].forEach((item) => {
const pattern = this.createOpenMatcher(item);
content = content.replace(pattern, '');
});
return content;
}
removeCommandWithContent(commend, content) {
const pattern = this.createMatcher(commend);
return content.replace(pattern, '');
}
createMatcher(keyword) {
return new RegExp(this.leftDelimiter + '\\s*(' + keyword + ')' + '(?:\\s+((?!' + this.rightDelimiter + ')[\\s\\S]+?)|\\s*)' + '\\s*' + this.rightDelimiter
+ '([\\s\\S]*?)' + this.leftDelimiter + '\\s*/' + keyword + '\\s*' + this.rightDelimiter, 'g');
}
createOpenMatcher(keyword) {
return new RegExp(this.leftDelimiter + '\\s*(' + keyword + ')(?:\\s+((?!' + this.rightDelimiter + ')[\\s\\S]+?)|\\s*)\\s*' + this.rightDelimiter, 'g');
}
createPlainMatcher(keyword) {
return new RegExp('(' + this.leftDelimiter + '\\s*(' + keyword + ')(?:\\s+((?!' + this.rightDelimiter + ')[\\s\\S]+?)|\\s*)\\s*' + this.rightDelimiter
+ ')([\\s\\S]*?)(' + this.leftDelimiter + '\\s*/' + keyword + '\\s*' + this.rightDelimiter + ')', 'g');
}
// Get cache data
getCache(filename, enableCache, callback) {
if (!enableCache) {
return callback(false);
}
utils.fileExists(filename, (exists) => {
if (!exists) {
// Cache file does not exist, indicating no cache
return callback(false);
}
lockfile.isLocked(filename, (locked) => {
if (locked) {
// If file is locked, cache is not ready
return callback(false);
}
// Start reading cache file
fs.readFile(filename, 'utf8', (err, content) => {
if (err) {
return callback(false);
}
const contents = content.split(/\n/);
let info;
try {
info = JSON.parse(contents[1]);
}
catch (e) {
info = {};
}
if (info.version !== this.core.version) {
// Template engine version is different, mark as no cache
return callback(false);
}
// Check if each file is expired
// If any file is expired, recompile the entire template
const files = Object.keys(info.files || {});
const next = (index) => {
if (index === files.length) {
// All files checked
return callback(content);
}
const key = files[index];
utils.getFileTime(key, (newTime) => {
if (newTime < 0 || newTime > info.files[key]) {
// File has been updated, mark as no cache
return callback(false);
}
next(index + 1);
});
};
// Start
next(0)
});
});
});
}
writeCache(filename, data) {
const dir = path.dirname(filename);
const lock = () => {
lockfile.lock(filename, (err, release) => {
if (err) {
// Failed to acquire lock, handle silently
// Because cache is not the main feature, failure does not affect template rendering
return;
}
// Acquired lock, start writing file
fs.writeFile(filename, data, () => {
// Regardless of whether cache file is written successfully, always release lock
// Because cache is not the main feature
release();
});
});
};
utils.dirExists(dir, (exists) => {
if (!exists) {
utils.mkdirp(dir, () => {
lock();
});
}
else {
lock();
}
});
}
};
export default Layout;