enh(templates) template parsing and placeholder handling

This commit is contained in:
gh0sTedBuddy
2024-04-01 22:25:33 +01:00
parent 6cc52e294c
commit 9addad2a9d
3 changed files with 615 additions and 267 deletions

View File

@@ -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('');
}
} }

View File

@@ -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;
} }
} }

View File

@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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;