enh(templates) template parsing and placeholder handling
This commit is contained in:
@@ -1,112 +1,424 @@
|
||||
import Logger from './Logger';
|
||||
|
||||
const TOKEN_PAIRS = [
|
||||
['{{','}}']
|
||||
]
|
||||
|
||||
/**
|
||||
* Placekeeper class for template processing and caching.
|
||||
* @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 {
|
||||
static init(content) {
|
||||
const sections = Array();
|
||||
const variables = new Map();
|
||||
const currentSection = new Map();
|
||||
static instance = undefined;
|
||||
static storage = undefined;
|
||||
#cache = 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 }),
|
||||
initBlock: (v) => {
|
||||
const key = ['if', 'elseif', 'else if', 'loop'].find(k => v.startsWith(k));
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
variables,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
static walkToEnd(content, endToken = undefined) {
|
||||
let result = {
|
||||
stack: [],
|
||||
length: 0
|
||||
};
|
||||
for(let idx = 0; idx < content.length; idx++) {
|
||||
const char = content[idx];
|
||||
const token = TOKEN_PAIRS.find((t) => {
|
||||
if(!t[0].startsWith(char)) return false;
|
||||
return content.substring(idx, idx + t[0].length) == t[0];
|
||||
});
|
||||
if(typeof options == 'object' && options.constructor.name == 'Object') {
|
||||
options = new Map(Object.entries(options));
|
||||
}
|
||||
|
||||
if(token) {
|
||||
// found new start token walk to end:
|
||||
idx += token[0].length
|
||||
const subset = Placekeeper.walkToEnd(content.substring(idx), token[1]);
|
||||
|
||||
// is a subset convertable to a function?
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
if(options instanceof Map) {
|
||||
for(const key of this.#options.keys()) {
|
||||
this.#options.set(key, options.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
Placekeeper.storage = options.get('storage');
|
||||
Placekeeper.instance = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
});
|
||||
|
||||
const func = (new Function(
|
||||
...Object.keys(dataProxy),
|
||||
`"use strict"; return (${expression});`
|
||||
)).bind(dataProxy);
|
||||
|
||||
const result = func(...Object.values(dataProxy));
|
||||
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';
|
||||
|
||||
/**
|
||||
* Represents an HTML engine that handles templating and parsing.
|
||||
*/
|
||||
export default class HtmlEngine {
|
||||
#internalCache = new Map();
|
||||
constructor() {}
|
||||
#templater = undefined;
|
||||
|
||||
/**
|
||||
* Handles the specified file by loading its content, parsing sections and variables,
|
||||
* and caching the result.
|
||||
* Creates an instance of HtmlEngine.
|
||||
*
|
||||
* @param {string} filepath - The path of the file to handle.
|
||||
* @returns {Object} - An object with a `parse` method that can be used to parse the file.
|
||||
* @throws {Error} - If the file is not found.
|
||||
* @param {Object} templateEngine - The template engine to use for parsing.
|
||||
*/
|
||||
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) {
|
||||
// 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 {
|
||||
mimeType: file.type || 'text/html',
|
||||
parse: (data = null, options = {}) =>
|
||||
this.parse(filepath, data, options),
|
||||
mimeType: 'text/html',
|
||||
parse: async (data = null, 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';
|
||||
|
||||
/**
|
||||
* symbolic markdown engine implementation for illustrational purposes
|
||||
* @note at this point its preferable to avoid dependencies for as long as possible
|
||||
* Represents a markdown engine for parsing and converting markdown content to HTML.
|
||||
* @class
|
||||
*/
|
||||
export default class MarkdownEngine {
|
||||
#internalCache = new Map();
|
||||
constructor() {}
|
||||
#templater = undefined;
|
||||
#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,
|
||||
@@ -23,73 +40,44 @@ export default class MarkdownEngine {
|
||||
if (!file) throw new Error('something went wrong');
|
||||
|
||||
const rawContent = await file.text();
|
||||
let { metadata, content } = await this.extractFrontMatter(rawContent);
|
||||
content = this.toHtml(content);
|
||||
const { sections, variables } = Placekeeper.init(content);
|
||||
let { metadata = {}, content = '' } = await this.#extractFrontMatter(rawContent);
|
||||
content = this.#toHtml(content);
|
||||
|
||||
this.#internalCache.set(
|
||||
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));
|
||||
await this.#templater.add(filepath, content);
|
||||
|
||||
return {
|
||||
mimeType: file.type || 'text/markdown',
|
||||
parse: (data = null, options = {}) =>
|
||||
this.parse(filepath, data, options),
|
||||
mimeType: 'text/html',
|
||||
parse: async (data = null, 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.
|
||||
* @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.
|
||||
* Extracts the front matter from the content.
|
||||
*
|
||||
* @param {string} content - The content to extract the front matter from.
|
||||
* @returns {Object} - An object containing the metadata and content.
|
||||
*/
|
||||
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) {
|
||||
console.log(value);
|
||||
value = value.trim();
|
||||
if (value.startsWith('"') && value.endsWith('"'))
|
||||
return value.substring(1, value.length - 1);
|
||||
@@ -97,15 +85,15 @@ export default class MarkdownEngine {
|
||||
if (!isNaN(value)) return Number(value);
|
||||
if (value.startsWith('[') && value.endsWith(']'))
|
||||
return value
|
||||
.substring(1, value.length - 1)
|
||||
.split(',')
|
||||
.map((v) => parseValue(v.trim()));
|
||||
.substring(1, value.length - 1)
|
||||
.split(',')
|
||||
.map((v) => parseValue(v.trim()));
|
||||
return value;
|
||||
}
|
||||
|
||||
let lines = content.split('\n');
|
||||
let isFrontMatter = false;
|
||||
const variables = new Map();
|
||||
const variables = {};
|
||||
let idx = 0;
|
||||
while (lines.length > 0) {
|
||||
const line = lines.shift().toString().trim();
|
||||
@@ -135,73 +123,146 @@ export default class MarkdownEngine {
|
||||
}
|
||||
|
||||
value = parseValue(value);
|
||||
variables.set(key, value);
|
||||
variables[key] = value;
|
||||
idx++;
|
||||
}
|
||||
|
||||
return {
|
||||
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 lines = content.split('\n');
|
||||
|
||||
while (lines.length > 0) {
|
||||
const line = lines.shift().toString().trim();
|
||||
let line = lines.shift().toString().trim();
|
||||
if (line === '') continue;
|
||||
if (line.startsWith('#')) {
|
||||
const level = line.indexOf(' ');
|
||||
html += `<h${level}>${line
|
||||
.substring(level)
|
||||
.trim()}</h${level}>`;
|
||||
} else {
|
||||
if (line.startsWith('- ') || line.startsWith('1.')) {
|
||||
const numerical = line.startsWith('1.');
|
||||
html += numerical ? '<ol>' : '<ul>';
|
||||
while (
|
||||
lines.length > 0 &&
|
||||
(lines[0].startsWith('-') ||
|
||||
lines[0].startsWith('\t') ||
|
||||
lines[0].startsWith('1.') ||
|
||||
lines[0].startsWith(' '.repeat(4)))
|
||||
) {
|
||||
const item = lines.shift().toString().trim();
|
||||
html += `<li>${item
|
||||
.substring(item.indexOf(' ') + 1)
|
||||
.trim()}</li>`;
|
||||
}
|
||||
html += numerical ? '</ol>' : '</ul>';
|
||||
} else if (line.startsWith('```')) {
|
||||
const lang = line.substring(3).trim();
|
||||
let code = '';
|
||||
while (lines.length > 0 && !lines[0].startsWith('```')) {
|
||||
code += lines.shift().toString().trim() + '\n';
|
||||
}
|
||||
let attr = '';
|
||||
if (lang) attr = ` class="language-${lang}"`;
|
||||
html += `<pre><code${attr}>${code}</code></pre>`;
|
||||
} else if (line.startsWith('---')) {
|
||||
html += '<div class="hr"><hr></div>';
|
||||
} else if (line.startsWith('> ')) {
|
||||
let item = line;
|
||||
html += '<blockquote>';
|
||||
while (lines.length > 0 && lines[0].startsWith('>')) {
|
||||
html += lines
|
||||
.shift()
|
||||
.toString()
|
||||
.trim()
|
||||
.substring(2)
|
||||
.trim();
|
||||
}
|
||||
html += '</blockquote>';
|
||||
} else {
|
||||
html += `<p>${line}</p>`;
|
||||
const headingText = line.substring(level).trim();
|
||||
html += `<h${level}>${this.#processInlineFormatting(headingText)}</h${level}>`;
|
||||
} else if (line.startsWith('- ') || line.startsWith('1.')) {
|
||||
const numerical = line.startsWith('1.');
|
||||
html += numerical ? '<ol>' : '<ul>';
|
||||
while (
|
||||
lines.length > 0 &&
|
||||
(lines[0].startsWith('-') ||
|
||||
lines[0].startsWith('\t') ||
|
||||
lines[0].startsWith('1.') ||
|
||||
lines[0].startsWith(' '.repeat(4)))
|
||||
) {
|
||||
const item = lines.shift().toString().trim();
|
||||
const itemText = item.substring(item.indexOf(' ') + 1).trim();
|
||||
html += `<li>${this.#processInlineFormatting(itemText)}</li>`;
|
||||
}
|
||||
html += numerical ? '</ol>' : '</ul>';
|
||||
} else if (line.startsWith('```')) {
|
||||
const lang = line.substring(3).trim();
|
||||
let code = '';
|
||||
while (lines.length > 0 && !lines[0].startsWith('```')) {
|
||||
code += lines.shift().toString() + '\n';
|
||||
}
|
||||
if (lines.length > 0) lines.shift(); // Remove closing ```
|
||||
let attr = '';
|
||||
if (lang) attr = ` class="language-${lang}"`;
|
||||
html += `<pre><code${attr}>${this.#escapeHtml(code.trim())}</code></pre>`;
|
||||
} else if (line.startsWith('---')) {
|
||||
html += '<hr>';
|
||||
} else if (line.startsWith('> ')) {
|
||||
html += '<blockquote>';
|
||||
while (lines.length > 0 && lines[0].startsWith('>')) {
|
||||
const blockquoteText = lines.shift().toString().trim().substring(2).trim();
|
||||
html += `<p>${this.#processInlineFormatting(blockquoteText)}</p>`;
|
||||
}
|
||||
html += '</blockquote>';
|
||||
} else if (line.startsWith('|')) {
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user