enh(templates) template parsing and placeholder handling
This commit is contained in:
@@ -1,112 +1,424 @@
|
|||||||
import Logger from './Logger';
|
/**
|
||||||
|
* Placekeeper class for template processing and caching.
|
||||||
const TOKEN_PAIRS = [
|
* @class
|
||||||
['{{','}}']
|
* @property {undefined} instance - The singleton instance of the Placekeeper class.
|
||||||
]
|
* @property {undefined} storage - The storage object used for caching.
|
||||||
|
* @property {Map} #cache - The cache map for storing template parts.
|
||||||
|
* @property {Map} #options - The options map for configuring the Placekeeper instance.
|
||||||
|
* @property {Object} #tags - The tags object used for template processing.
|
||||||
|
*/
|
||||||
export default class Placekeeper {
|
export default class Placekeeper {
|
||||||
static init(content) {
|
static instance = undefined;
|
||||||
const sections = Array();
|
static storage = undefined;
|
||||||
const variables = new Map();
|
#cache = new Map();
|
||||||
const currentSection = new Map();
|
#options = new Map([
|
||||||
|
['cacheDir', undefined],
|
||||||
|
['deactivateCache', false],
|
||||||
|
['safeNames', []],
|
||||||
|
['onError', (err) => {}],
|
||||||
|
]);
|
||||||
|
|
||||||
console.log('content to array', Placekeeper.contentToArray(content));
|
#tags = {
|
||||||
|
$default: (v) => ({ type: 'output', condition: v }),
|
||||||
return {
|
initBlock: (v) => {
|
||||||
variables,
|
const key = ['if', 'elseif', 'else if', 'loop'].find(k => v.startsWith(k));
|
||||||
sections,
|
if(!key) return null;
|
||||||
|
return { type: key, condition: v.slice(key.length) }
|
||||||
|
},
|
||||||
|
proceedEndBlock: (v) => {
|
||||||
|
const key = ['else', 'end'].find(k => v == k);
|
||||||
|
if(!key) return null;
|
||||||
|
return { type: key };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance of the Placekeeper class.
|
||||||
|
* @param {string} [cacheDir='./cache'] - The directory where the cache files will be stored.
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
if (Placekeeper.instance) return Placekeeper.instance;
|
||||||
|
|
||||||
|
if(typeof options == 'object' && options.constructor.name == 'Object') {
|
||||||
|
options = new Map(Object.entries(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
static walkToEnd(content, endToken = undefined) {
|
if(options instanceof Map) {
|
||||||
let result = {
|
for(const key of this.#options.keys()) {
|
||||||
stack: [],
|
this.#options.set(key, options.get(key));
|
||||||
length: 0
|
}
|
||||||
};
|
}
|
||||||
for(let idx = 0; idx < content.length; idx++) {
|
|
||||||
const char = content[idx];
|
Placekeeper.storage = options.get('storage');
|
||||||
const token = TOKEN_PAIRS.find((t) => {
|
Placekeeper.instance = this;
|
||||||
if(!t[0].startsWith(char)) return false;
|
}
|
||||||
return content.substring(idx, idx + t[0].length) == t[0];
|
|
||||||
|
/**
|
||||||
|
* Handles errors that occur during template processing.
|
||||||
|
* @param {Error} err - The error object.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#onError(err) {
|
||||||
|
const evt = this.#options.get('onError');
|
||||||
|
if(typeof(evt) == 'function') evt(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the singleton instance of the Placekeeper class.
|
||||||
|
* @param {string} [cacheDir='./cache'] - The directory to store the cache files.
|
||||||
|
* @returns {Placekeeper} - The singleton instance of the Placekeeper class.
|
||||||
|
*/
|
||||||
|
static getInstance(options) {
|
||||||
|
if (!placekeeperInstance) {
|
||||||
|
placekeeperInstance = new Placekeeper(options);
|
||||||
|
}
|
||||||
|
return placekeeperInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a template string into an array of template parts.
|
||||||
|
* @param {string} template - The template string to transform.
|
||||||
|
* @returns {Array} - An array of template parts.
|
||||||
|
*/
|
||||||
|
transform(template) {
|
||||||
|
const parts = [];
|
||||||
|
let insideTag = false;
|
||||||
|
let tagStart = 0;
|
||||||
|
let tagEnd = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < template.length; i++) {
|
||||||
|
if (!insideTag && template.slice(i, i + 2) === '<[') {
|
||||||
|
insideTag = true;
|
||||||
|
tagStart = Math.min(i + 2, template.length);
|
||||||
|
i += 1;
|
||||||
|
if(tagEnd < i - 1) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.substring(tagEnd, i - 1),
|
||||||
|
length: i - 1 - tagEnd
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (insideTag && template.slice(i, i + 2) === ']>') {
|
||||||
|
insideTag = false;
|
||||||
|
tagEnd = i;
|
||||||
|
const tag = template.slice(tagStart, tagEnd).trim();
|
||||||
|
this.processTag(tag, parts);
|
||||||
|
i += 1;
|
||||||
|
tagEnd += 2;
|
||||||
|
tagStart = i + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagEnd < template.length) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
value: template.slice(tagEnd),
|
||||||
|
length: template.length - tagEnd
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the tags used for template processing.
|
||||||
|
* @returns {Object} - An object containing the tag evaluator functions.
|
||||||
|
*/
|
||||||
|
getTags() {
|
||||||
|
return this.#tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new tag evaluator function to the list of tags.
|
||||||
|
* @param {String} key - The unique key the evaluator function should be stored under
|
||||||
|
* @param {Function} evaluator - The tag evaluator function to add.
|
||||||
|
* @throws {TypeError} - If the provided tag is not a function.
|
||||||
|
*/
|
||||||
|
setTag(key, evaluator) {
|
||||||
|
if(typeof(key) != 'string') throw new TypeError('tag key is not a string!');
|
||||||
|
if(typeof(evaluator) != 'function') throw new TypeError('tag is not a function!');
|
||||||
|
this.#tags[key] = evaluator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a new tag evaluator function from the list of tags.
|
||||||
|
* @param {String} key - The tag evaluator function to add.
|
||||||
|
* @throws {TypeError} - If the provided key is not a String.
|
||||||
|
* @throws {Error} - If the provided key is not a valid key or attempt to remove the $default evaluator function.
|
||||||
|
*/
|
||||||
|
removeTag(key) {
|
||||||
|
if(typeof(key) != 'string') throw new TypeError('tag key is not a string!');
|
||||||
|
if(!this.#tags.hasOwnProperty(key)) throw new Error(`\`${key}\` is not a valid tag key.`);
|
||||||
|
if(key == '$default') throw new Error('You cannot delete the default tag.');
|
||||||
|
delete this.#tags[key];
|
||||||
|
this.#tags[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a tag and adds the corresponding part to the parts array.
|
||||||
|
* @param {string} tag - The tag to process.
|
||||||
|
* @param {Array} parts - The array to add the processed part to.
|
||||||
|
*/
|
||||||
|
processTag(tag, parts) {
|
||||||
|
for(const tagEvaluator of Object.values(this.#tags)) {
|
||||||
|
const partValue = tagEvaluator(tag);
|
||||||
|
if(!partValue) continue;
|
||||||
|
parts.push(partValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists a specific template to disk.
|
||||||
|
* @param {string} key - The key to identify the template.
|
||||||
|
* @param {string} cacheableRepresentation - The JSON representation of the template parts.
|
||||||
|
*/
|
||||||
|
async store(filePath) {
|
||||||
|
if(!this.#options.get('cacheDir') || this.#options.get('deactivateCache')) return true;
|
||||||
|
const key = this.#cleanKey(filePath);
|
||||||
|
await Placekeeper.storage.ensureDir(this.#options.get('cacheDir'));
|
||||||
|
|
||||||
|
if(!this.#cache.has(key)) return false;
|
||||||
|
|
||||||
|
const cacheFilePath = this.buildPath(this.#options.get('cacheDir'), `${key}.json`);
|
||||||
|
try {
|
||||||
|
await Placekeeper.storage.write(
|
||||||
|
cacheFilePath,
|
||||||
|
JSON.stringify(this.#cache.get(key), null, 2)
|
||||||
|
);
|
||||||
|
} catch(err) {
|
||||||
|
this.#onError(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a path by joining the provided arguments.
|
||||||
|
* @param {...string} paths - The path segments to join.
|
||||||
|
* @returns {string} - The resulting path.
|
||||||
|
*/
|
||||||
|
buildPath() {
|
||||||
|
return Object.values(arguments)
|
||||||
|
.filter(v => typeof(v) == 'string' && v.length > 0)
|
||||||
|
.map(v => {
|
||||||
|
if(v.startsWith('/')) v = v.substring(1);
|
||||||
|
if(v.endsWith('/')) v = v.slice(-1);
|
||||||
|
return v;
|
||||||
|
}).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* transforms a possible file path to a unique hashed key for consistency
|
||||||
|
* @param {string} key - key to be converted to unique consistent key
|
||||||
|
* @returns {string} - converted ey
|
||||||
|
*/
|
||||||
|
#cleanKey (key) {
|
||||||
|
if(this.#cache.has(key)) return key;
|
||||||
|
const out = [Bun.hash(key)];
|
||||||
|
if(key.indexOf('.') >= 0) out.push(key.substring(key.lastIndexOf('.') + 1));
|
||||||
|
return out.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a template from the cache or disk.
|
||||||
|
* @param {string} filePath - The file path of the template.
|
||||||
|
* @returns {Promise<Array|null>} - A promise that resolves to the cached template parts or null if not found.
|
||||||
|
* @throws {TypeError} - If the provided file path is not a string.
|
||||||
|
*/
|
||||||
|
async load(filePath) {
|
||||||
|
if(typeof(filePath) != 'string') throw new TypeError(`filepath is not a string!`);
|
||||||
|
if(!this.#options.get('cacheDir') || this.#options.get('deactivateCache')) return null;
|
||||||
|
const key = this.#cleanKey(filePath);
|
||||||
|
let cacheFilePath = this.buildPath(this.#options.get('cacheDir'), `${key}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await Placekeeper.storage.read(cacheFilePath);
|
||||||
|
this.#cache.set(key, JSON.parse(content));
|
||||||
|
return this.#cache.get(key);
|
||||||
|
} catch(err) {
|
||||||
|
this.#onError(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a template to the cache.
|
||||||
|
* @param {string} filePath - The file path of the template.
|
||||||
|
* @param {string} [content] - The content of the template.
|
||||||
|
* @returns {Promise<boolean>} - A promise that resolves to true if the template is added successfully.
|
||||||
|
* @throws {Error} - If invalid content is provided.
|
||||||
|
*/
|
||||||
|
async add(filePath, content) {
|
||||||
|
if(typeof(filePath) != 'string') throw new TypeError(`filepath is not a string!`);
|
||||||
|
const key = this.#cleanKey(filePath);
|
||||||
|
if(!this.#cache.has(key)) {
|
||||||
|
const cachedData = await this.load(filePath);
|
||||||
|
if(cachedData) {
|
||||||
|
this.#cache.set(key, cachedData);
|
||||||
|
} else {
|
||||||
|
if(typeof(content) == 'string') {
|
||||||
|
this.#cache.set(key, this.transform(content));
|
||||||
|
await this.store(key);
|
||||||
|
} else {
|
||||||
|
throw new Error('invalid content given');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the template parts and returns the generated output.
|
||||||
|
* @param {string} filePath - The file path of the template.
|
||||||
|
* @param {Object} data - The data to be used for template parsing.
|
||||||
|
* @param {Object} [options={}] - Additional options for parsing.
|
||||||
|
* @param {string} [options.content] - The content of the template.
|
||||||
|
* @returns {Promise<string>} - A promise that resolves to the parsed template output.
|
||||||
|
* @throws {TypeError} - If the provided file path is not a string.
|
||||||
|
* @throws {Error} - If the template is not found in the cache or if invalid content is provided.
|
||||||
|
*/
|
||||||
|
async parse(filePath, data, options = {}) {
|
||||||
|
if(typeof(filePath) != 'string') throw new TypeError(`filepath is not a string!`);
|
||||||
|
const key = this.#cleanKey(filePath);
|
||||||
|
if(!this.#cache.has(key)) {
|
||||||
|
const cachedData = await this.load(filePath);
|
||||||
|
if(cachedData) {
|
||||||
|
this.#cache.set(key, cachedData);
|
||||||
|
} else if(options.hasOwnProperty('content')) {
|
||||||
|
if(typeof(options.content) == 'string') {
|
||||||
|
this.#cache.set(key, this.transform(options.content));
|
||||||
|
await this.store(key);
|
||||||
|
} else {
|
||||||
|
throw new Error('invalid content given');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let viewFile = Bun.file(filePath);
|
||||||
|
if(await viewFile.exists()) {
|
||||||
|
this.#cache.set(key, this.transform(await viewFile.text()));
|
||||||
|
await this.store(key);
|
||||||
|
} else {
|
||||||
|
throw new Error('file does not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = this.#cache.get(key);
|
||||||
|
if(this.#options.get('deactivateCache')) this.#cache.delete(key);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template with key '${key}' not found in cache.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = this.parseSection(template, data);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a section of the template and returns the generated output.
|
||||||
|
* @param {Array} section - The section to be parsed.
|
||||||
|
* @param {Object} data - The data to be used for section parsing.
|
||||||
|
* @returns {string} - The parsed section output.
|
||||||
|
* @throws {TypeError} - If the provided section is not an array.
|
||||||
|
*/
|
||||||
|
parseSection(section, data) {
|
||||||
|
if(!Array.isArray(section)) {
|
||||||
|
throw new TypeError('sections is no array');
|
||||||
|
}
|
||||||
|
let output = '';
|
||||||
|
for (let idx = 0; idx < section.length; idx ++) {
|
||||||
|
const part = section[idx];
|
||||||
|
switch (part.type) {
|
||||||
|
case 'text':
|
||||||
|
output += part.value;
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
case 'if':
|
||||||
|
case 'elseif':
|
||||||
|
try {
|
||||||
|
output += this.#evaluate(part.condition, data);
|
||||||
|
} catch(err) {
|
||||||
|
this.#onError(err);
|
||||||
|
output += err.message;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'loop':
|
||||||
|
let loopData;
|
||||||
|
if(data.hasOwnProperty(part.condition)) loopData = data[part.condition];
|
||||||
|
if(!loopData) {
|
||||||
|
try {
|
||||||
|
loopData = this.#evaluate(part.condition, data);
|
||||||
|
} catch(err) {
|
||||||
|
this.#onError(err);
|
||||||
|
output += err.message;
|
||||||
|
loopData = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!isNaN(loopData)) loopData = Array(Number(loopData)).fill(loopData);
|
||||||
|
|
||||||
|
if (!Array.isArray(loopData)) {
|
||||||
|
output += `<script>alert(\'no iterateable value: ${part.condition}\')</script>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx ++;
|
||||||
|
let level = 0;
|
||||||
|
let loopBlock = [];
|
||||||
|
for(let i = 0; i < section.length - idx; i++) {
|
||||||
|
if(section[idx + i].type == 'end' && level == 0) {
|
||||||
|
idx += i;
|
||||||
|
break;
|
||||||
|
} else if(['if', 'loop'].indexOf(section[idx + i].type) >= 0) {
|
||||||
|
level ++;
|
||||||
|
} else {
|
||||||
|
loopBlock.push(section[idx + i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(loopBlock.length == 0) continue;
|
||||||
|
for (let i = 0; i < loopData.length; i++) {
|
||||||
|
const itemData = { ...data, $i: i + 1, item: loopData[i] };
|
||||||
|
output += this.parseSection(loopBlock, itemData);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'include':
|
||||||
|
// output += await this.load(part.template, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates an expression using the provided data.
|
||||||
|
* @param {string} expression - The expression to evaluate.
|
||||||
|
* @param {Object} [data={}] - The data to use for evaluation.
|
||||||
|
* @returns {*} - The result of the evaluated expression.
|
||||||
|
*/
|
||||||
|
#evaluate(expression, data = {}) {
|
||||||
|
if(!expression) return undefined;
|
||||||
|
|
||||||
|
const dataProxy = new Proxy(data, {
|
||||||
|
has(target, prop) {
|
||||||
|
return true; // Pretend all properties exist
|
||||||
|
},
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop in target) {
|
||||||
|
return target[prop];
|
||||||
|
}
|
||||||
|
return null; // Return null for undefined variables
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if(token) {
|
const func = (new Function(
|
||||||
// found new start token walk to end:
|
...Object.keys(dataProxy),
|
||||||
idx += token[0].length
|
`"use strict"; return (${expression});`
|
||||||
const subset = Placekeeper.walkToEnd(content.substring(idx), token[1]);
|
)).bind(dataProxy);
|
||||||
|
|
||||||
// is a subset convertable to a function?
|
const result = func(...Object.values(dataProxy));
|
||||||
// can't we convert the whole tree to one function so its only one call instead of iteration on runtime?
|
|
||||||
|
|
||||||
result.stack.push(subset.stack);
|
|
||||||
result.length += subset.length;
|
|
||||||
idx += subset.length + token[1].length - 1;
|
|
||||||
} else {
|
|
||||||
if(endToken && endToken.startsWith(char) && content.substring(idx, idx + endToken.length)) {
|
|
||||||
// found end token
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(result.stack.length == 0 || Array.isArray(result.stack[result.stack.length - 1])) result.stack.push('');
|
|
||||||
result.stack[result.stack.length - 1] += char;
|
|
||||||
result.length += char.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
static contentToArray(content) {
|
|
||||||
return Placekeeper.walkToEnd(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
static variableRegex = new RegExp(/[\w\d]/, 'i');
|
|
||||||
|
|
||||||
static parseVariables(content) {
|
|
||||||
// iterates over the content character by character to extract variables and returns a list of them
|
|
||||||
const variables = new Map();
|
|
||||||
let currentVar;
|
|
||||||
|
|
||||||
for (let idx = 0; idx < content.length; idx++) {
|
|
||||||
const char = content[idx];
|
|
||||||
if (Placekeeper.variableRegex.test(char)) {
|
|
||||||
if (!(currentVar instanceof Map))
|
|
||||||
currentVar = new Map([
|
|
||||||
['name', ''],
|
|
||||||
['start', idx],
|
|
||||||
['end', -1],
|
|
||||||
]);
|
|
||||||
currentVar.set('name', currentVar.get('name') + char);
|
|
||||||
} else if (currentVar instanceof Map) {
|
|
||||||
currentVar.set('end', idx);
|
|
||||||
if (currentVar.get('name').length > 0) {
|
|
||||||
variables.set(currentVar.get('name'), currentVar);
|
|
||||||
}
|
|
||||||
currentVar = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return variables;
|
|
||||||
}
|
|
||||||
|
|
||||||
static parse(content, sections) {
|
|
||||||
// replace all sections with an empty string
|
|
||||||
if (!Array.isArray(sections)) {
|
|
||||||
if (!sections instanceof Map) return content;
|
|
||||||
sections = [...sections.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let parts = [];
|
|
||||||
let offset = 0;
|
|
||||||
for (const section of sections) {
|
|
||||||
if (!(section instanceof Map)) {
|
|
||||||
if (!Array.isArray(section)) continue;
|
|
||||||
if (section.length < 3) continue;
|
|
||||||
}
|
|
||||||
parts.push(content.substring(offset, section.get('start')));
|
|
||||||
offset = section.get('end');
|
|
||||||
}
|
|
||||||
parts.push(content.substring(offset));
|
|
||||||
|
|
||||||
return parts.join('');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,36 @@
|
|||||||
import Placekeeper from '../Utils/Placekeeper';
|
import Placekeeper from '../Utils/Placekeeper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an HTML engine that handles templating and parsing.
|
||||||
|
*/
|
||||||
export default class HtmlEngine {
|
export default class HtmlEngine {
|
||||||
#internalCache = new Map();
|
#templater = undefined;
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the specified file by loading its content, parsing sections and variables,
|
* Creates an instance of HtmlEngine.
|
||||||
* and caching the result.
|
|
||||||
*
|
*
|
||||||
* @param {string} filepath - The path of the file to handle.
|
* @param {Object} templateEngine - The template engine to use for parsing.
|
||||||
* @returns {Object} - An object with a `parse` method that can be used to parse the file.
|
*/
|
||||||
* @throws {Error} - If the file is not found.
|
constructor(templateEngine) {
|
||||||
|
this.#templater = templateEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the file content with the provided data and options.
|
||||||
|
*
|
||||||
|
* @param {Object} [data=null] - The data to be used for parsing.
|
||||||
|
* @param {Object} [options={}] - Additional options for parsing.
|
||||||
|
* @returns {Promise<Object>} - A promise that resolves to an object containing the parsed content and data.
|
||||||
*/
|
*/
|
||||||
async handle(filepath) {
|
async handle(filepath) {
|
||||||
// load file
|
|
||||||
const file = Bun.file(filepath);
|
|
||||||
if (!(await file.exists())) throw new Error('file not found');
|
|
||||||
|
|
||||||
const rawContent = await file.text();
|
|
||||||
const { sections, variables } = Placekeeper.init(rawContent);
|
|
||||||
|
|
||||||
this.#internalCache.set(filepath, {
|
|
||||||
content: rawContent,
|
|
||||||
sections,
|
|
||||||
variables,
|
|
||||||
mimeType: file.type || 'text/html',
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mimeType: file.type || 'text/html',
|
mimeType: 'text/html',
|
||||||
parse: (data = null, options = {}) =>
|
parse: async (data = null, options = {}) => {
|
||||||
this.parse(filepath, data, options),
|
return {
|
||||||
|
data,
|
||||||
|
content: await this.#templater.parse(filepath, data)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
/**
|
|
||||||
* Parses the specified identifier using the provided data and options.
|
|
||||||
* @param {string} identifier - The identifier of the file to parse.
|
|
||||||
* @param {Object} [data={}] - The data object to replace placeholders in the file content.
|
|
||||||
* @param {Object} [options={}] - The options object.
|
|
||||||
* @returns {Response} - The parsed response object.
|
|
||||||
* @throws {Error} - If the file is not found.
|
|
||||||
*/
|
|
||||||
parse(identifier, data = {}, options = {}) {
|
|
||||||
if (!this.#internalCache.has(identifier)) {
|
|
||||||
throw new Error('file not found');
|
|
||||||
}
|
|
||||||
const view = this.#internalCache.get(identifier);
|
|
||||||
let parsed = Placekeeper.parse(view.content, view.sections);
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
parsed = parsed.replace(
|
|
||||||
new RegExp(`{{\s?${key}\s?}}`, 'g'),
|
|
||||||
value.toString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import Placekeeper from '../Utils/Placekeeper.js';
|
import Placekeeper from '../Utils/Placekeeper.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* symbolic markdown engine implementation for illustrational purposes
|
* Represents a markdown engine for parsing and converting markdown content to HTML.
|
||||||
* @note at this point its preferable to avoid dependencies for as long as possible
|
|
||||||
* @class
|
* @class
|
||||||
*/
|
*/
|
||||||
export default class MarkdownEngine {
|
export default class MarkdownEngine {
|
||||||
#internalCache = new Map();
|
#templater = undefined;
|
||||||
constructor() {}
|
#regex = new Map([
|
||||||
|
['bold', new RegExp('\\*\\*(.+?)\\*\\*', 'g')],
|
||||||
|
['italic', new RegExp('\\*(.+?)\\*', 'g')],
|
||||||
|
['inline-code', new RegExp('`(.+?)`', 'g')],
|
||||||
|
['link', new RegExp('\\[(.+?)\\]\\((.+?)\\)', 'g')],
|
||||||
|
['image', new RegExp('!\\[(.+?)\\]\\((.+?)\\)', 'g')],
|
||||||
|
['table', new RegExp('\\|(.*?)\\|', 'g')],
|
||||||
|
['tasks', new RegExp('\\[([ xX])\\](.+)', 'g')],
|
||||||
|
['footnote', new RegExp('\\[\\^(.+?)\\]', 'g')],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance of HtmlEngine.
|
||||||
|
*
|
||||||
|
* @param {Object} templateEngine - The template engine to use for parsing.
|
||||||
|
*/
|
||||||
|
constructor(templater) {
|
||||||
|
this.#templater = templater;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the specified file by loading its content, parsing sections and variables,
|
* Handles the specified file by loading its content, parsing sections and variables,
|
||||||
@@ -23,73 +40,44 @@ export default class MarkdownEngine {
|
|||||||
if (!file) throw new Error('something went wrong');
|
if (!file) throw new Error('something went wrong');
|
||||||
|
|
||||||
const rawContent = await file.text();
|
const rawContent = await file.text();
|
||||||
let { metadata, content } = await this.extractFrontMatter(rawContent);
|
let { metadata = {}, content = '' } = await this.#extractFrontMatter(rawContent);
|
||||||
content = this.toHtml(content);
|
content = this.#toHtml(content);
|
||||||
const { sections, variables } = Placekeeper.init(content);
|
|
||||||
|
|
||||||
this.#internalCache.set(
|
await this.#templater.add(filepath, content);
|
||||||
filepath,
|
|
||||||
new Map([
|
|
||||||
['content', content],
|
|
||||||
['sections', sections],
|
|
||||||
['variables', variables],
|
|
||||||
['metadata', metadata],
|
|
||||||
['mimeType', file.type || 'text/markdown'],
|
|
||||||
['size', file.size],
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(filepath, this.#internalCache.get(filepath));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mimeType: file.type || 'text/markdown',
|
mimeType: 'text/html',
|
||||||
parse: (data = null, options = {}) =>
|
parse: async (data = null, options = {}) => {
|
||||||
this.parse(filepath, data, options),
|
data = (
|
||||||
|
!data ||
|
||||||
|
typeof(data) != 'object' ||
|
||||||
|
data.constructor.name != 'Object'
|
||||||
|
) ? metadata : {
|
||||||
|
...metadata,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedContent = await this.#templater.parse(
|
||||||
|
filepath, data,
|
||||||
|
{ content }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
content: parsedContent
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the specified identifier using the provided data and options.
|
* Extracts the front matter from the content.
|
||||||
* @param {string} identifier - The identifier of the file to parse.
|
*
|
||||||
* @param {Object} [data={}] - The data object to replace placeholders in the file content.
|
* @param {string} content - The content to extract the front matter from.
|
||||||
* @param {Object} [options={}] - The options object.
|
* @returns {Object} - An object containing the metadata and content.
|
||||||
* @returns {Response} - The parsed response object.
|
|
||||||
* @throws {Error} - If the file is not found.
|
|
||||||
*/
|
|
||||||
async parse(identifier, data = {}, options = {}) {
|
|
||||||
if (!this.#internalCache.has(identifier)) {
|
|
||||||
throw new Error('file not found');
|
|
||||||
}
|
|
||||||
const view = this.#internalCache.get(identifier);
|
|
||||||
let parsed = Placekeeper.parse(
|
|
||||||
view.get('content'),
|
|
||||||
view.get('sections')
|
|
||||||
);
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
parsed = parsed.replace(
|
|
||||||
new RegExp(`{{${key}}}`, 'g'),
|
|
||||||
value.toString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(view.get('content'), {
|
|
||||||
...(options || {}),
|
|
||||||
headers: {
|
|
||||||
...(options.headers || {}),
|
|
||||||
'Content-Type': 'text/html', //view.get('mimeType'),
|
|
||||||
'Content-Length': view.get(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async extractFrontMatter(content) {
|
|
||||||
/**
|
|
||||||
* Parses a value and returns the appropriate data type.
|
|
||||||
* @param {string} value - The value to be parsed.
|
|
||||||
* @returns {string|boolean|number|array} - The parsed value.
|
|
||||||
*/
|
*/
|
||||||
|
async #extractFrontMatter(content) {
|
||||||
function parseValue(value) {
|
function parseValue(value) {
|
||||||
console.log(value);
|
|
||||||
value = value.trim();
|
value = value.trim();
|
||||||
if (value.startsWith('"') && value.endsWith('"'))
|
if (value.startsWith('"') && value.endsWith('"'))
|
||||||
return value.substring(1, value.length - 1);
|
return value.substring(1, value.length - 1);
|
||||||
@@ -105,7 +93,7 @@ export default class MarkdownEngine {
|
|||||||
|
|
||||||
let lines = content.split('\n');
|
let lines = content.split('\n');
|
||||||
let isFrontMatter = false;
|
let isFrontMatter = false;
|
||||||
const variables = new Map();
|
const variables = {};
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
while (lines.length > 0) {
|
while (lines.length > 0) {
|
||||||
const line = lines.shift().toString().trim();
|
const line = lines.shift().toString().trim();
|
||||||
@@ -135,32 +123,67 @@ export default class MarkdownEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
value = parseValue(value);
|
value = parseValue(value);
|
||||||
variables.set(key, value);
|
variables[key] = value;
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadata: variables,
|
metadata: variables,
|
||||||
content: lines.join('\n'),
|
content: lines.join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* retrieves the content of a markdown file and returns it as html
|
* Escapes special characters in the given text to prevent XSS attacks.
|
||||||
|
*
|
||||||
|
* @param {string} text - The text to escape.
|
||||||
|
* @returns {string} - The escaped text.
|
||||||
*/
|
*/
|
||||||
toHtml(content) {
|
#escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes inline formatting in the given text.
|
||||||
|
*
|
||||||
|
* @param {string} text - The text to process.
|
||||||
|
* @returns {string} - The processed text with inline formatting applied.
|
||||||
|
*/
|
||||||
|
#processInlineFormatting(text) {
|
||||||
|
text = text.replace(this.#regex.get('bold'), '<strong>$1</strong>');
|
||||||
|
text = text.replace(this.#regex.get('italic'), '<em>$1</em>');
|
||||||
|
text = text.replace(this.#regex.get('inline-code'), '<code>$1</code>');
|
||||||
|
text = text.replace(this.#regex.get('link'), '<a href="$2">$1</a>');
|
||||||
|
text = text.replace(this.#regex.get('image'), '<img src="$2" alt="$1">');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given markdown content to HTML.
|
||||||
|
*
|
||||||
|
* @param {string} content - The markdown content to convert.
|
||||||
|
* @returns {string} - The converted HTML.
|
||||||
|
*/
|
||||||
|
#toHtml(content) {
|
||||||
let html = '';
|
let html = '';
|
||||||
let lines = content.split('\n');
|
let lines = content.split('\n');
|
||||||
|
|
||||||
while (lines.length > 0) {
|
while (lines.length > 0) {
|
||||||
const line = lines.shift().toString().trim();
|
let line = lines.shift().toString().trim();
|
||||||
if (line === '') continue;
|
if (line === '') continue;
|
||||||
if (line.startsWith('#')) {
|
if (line.startsWith('#')) {
|
||||||
const level = line.indexOf(' ');
|
const level = line.indexOf(' ');
|
||||||
html += `<h${level}>${line
|
const headingText = line.substring(level).trim();
|
||||||
.substring(level)
|
html += `<h${level}>${this.#processInlineFormatting(headingText)}</h${level}>`;
|
||||||
.trim()}</h${level}>`;
|
} else if (line.startsWith('- ') || line.startsWith('1.')) {
|
||||||
} else {
|
|
||||||
if (line.startsWith('- ') || line.startsWith('1.')) {
|
|
||||||
const numerical = line.startsWith('1.');
|
const numerical = line.startsWith('1.');
|
||||||
html += numerical ? '<ol>' : '<ul>';
|
html += numerical ? '<ol>' : '<ul>';
|
||||||
while (
|
while (
|
||||||
@@ -171,37 +194,75 @@ export default class MarkdownEngine {
|
|||||||
lines[0].startsWith(' '.repeat(4)))
|
lines[0].startsWith(' '.repeat(4)))
|
||||||
) {
|
) {
|
||||||
const item = lines.shift().toString().trim();
|
const item = lines.shift().toString().trim();
|
||||||
html += `<li>${item
|
const itemText = item.substring(item.indexOf(' ') + 1).trim();
|
||||||
.substring(item.indexOf(' ') + 1)
|
html += `<li>${this.#processInlineFormatting(itemText)}</li>`;
|
||||||
.trim()}</li>`;
|
|
||||||
}
|
}
|
||||||
html += numerical ? '</ol>' : '</ul>';
|
html += numerical ? '</ol>' : '</ul>';
|
||||||
} else if (line.startsWith('```')) {
|
} else if (line.startsWith('```')) {
|
||||||
const lang = line.substring(3).trim();
|
const lang = line.substring(3).trim();
|
||||||
let code = '';
|
let code = '';
|
||||||
while (lines.length > 0 && !lines[0].startsWith('```')) {
|
while (lines.length > 0 && !lines[0].startsWith('```')) {
|
||||||
code += lines.shift().toString().trim() + '\n';
|
code += lines.shift().toString() + '\n';
|
||||||
}
|
}
|
||||||
|
if (lines.length > 0) lines.shift(); // Remove closing ```
|
||||||
let attr = '';
|
let attr = '';
|
||||||
if (lang) attr = ` class="language-${lang}"`;
|
if (lang) attr = ` class="language-${lang}"`;
|
||||||
html += `<pre><code${attr}>${code}</code></pre>`;
|
html += `<pre><code${attr}>${this.#escapeHtml(code.trim())}</code></pre>`;
|
||||||
} else if (line.startsWith('---')) {
|
} else if (line.startsWith('---')) {
|
||||||
html += '<div class="hr"><hr></div>';
|
html += '<hr>';
|
||||||
} else if (line.startsWith('> ')) {
|
} else if (line.startsWith('> ')) {
|
||||||
let item = line;
|
|
||||||
html += '<blockquote>';
|
html += '<blockquote>';
|
||||||
while (lines.length > 0 && lines[0].startsWith('>')) {
|
while (lines.length > 0 && lines[0].startsWith('>')) {
|
||||||
html += lines
|
const blockquoteText = lines.shift().toString().trim().substring(2).trim();
|
||||||
.shift()
|
html += `<p>${this.#processInlineFormatting(blockquoteText)}</p>`;
|
||||||
.toString()
|
|
||||||
.trim()
|
|
||||||
.substring(2)
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
html += '</blockquote>';
|
html += '</blockquote>';
|
||||||
} else {
|
} else if (line.startsWith('|')) {
|
||||||
html += `<p>${line}</p>`;
|
// Table parsing
|
||||||
|
let headers = line.match(this.#regex.get('table'));
|
||||||
|
let align = lines.shift().toString().trim().match(this.#regex.get('table'));
|
||||||
|
let cells = [];
|
||||||
|
|
||||||
|
while (lines.length > 0 && lines[0].startsWith('|')) {
|
||||||
|
cells.push(lines.shift().toString().trim().match(this.#regex.get('table')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html += '<table>';
|
||||||
|
html += '<thead><tr>';
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
let alignClass = align[index].trim().slice(1, -1).replace(/:/g, '');
|
||||||
|
html += `<th${alignClass ? ` class="${alignClass}"` : ''}>${this.#processInlineFormatting(header.slice(1, -1))}</th>`;
|
||||||
|
});
|
||||||
|
html += '</tr></thead>';
|
||||||
|
html += '<tbody>';
|
||||||
|
cells.forEach(row => {
|
||||||
|
html += '<tr>';
|
||||||
|
row.forEach(cell => {
|
||||||
|
html += `<td>${this.#processInlineFormatting(cell.slice(1, -1))}</td>`;
|
||||||
|
});
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody>';
|
||||||
|
html += '</table>';
|
||||||
|
} else if (this.#regex.get('tasks').test(line)) {
|
||||||
|
// Task list parsing
|
||||||
|
let matches = line.match(this.#regex.get('tasks'));
|
||||||
|
let checked = matches[1].trim().toLowerCase() === 'x';
|
||||||
|
let taskText = matches[2].trim();
|
||||||
|
html += `<ul class="task-list"><li${checked ? ' class="checked"' : ''}><input type="checkbox" disabled${checked ? ' checked' : ''}>${this.#processInlineFormatting(taskText)}</li></ul>`;
|
||||||
|
} else if (this.#regex.get('footnote').test(line)) {
|
||||||
|
// Footnote parsing
|
||||||
|
let footnoteId = line.match(this.#regex.get('footnote'))[1];
|
||||||
|
let footnoteText = '';
|
||||||
|
|
||||||
|
while (lines.length > 0 && lines[0].startsWith(' ')) {
|
||||||
|
footnoteText += lines.shift().toString().slice(2) + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<sup id="fnref-${footnoteId}"><a href="#fn-${footnoteId}">${footnoteId}</a></sup>`;
|
||||||
|
html += `<div id="fn-${footnoteId}" class="footnote">${this.#processInlineFormatting(footnoteText.trim())}</div>`;
|
||||||
|
} else {
|
||||||
|
html += `<p>${this.#processInlineFormatting(line)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
|
|||||||
Reference in New Issue
Block a user