From 9addad2a9d82d74cb55cce6dbbc87a7495c094f1 Mon Sep 17 00:00:00 2001 From: gh0sTedBuddy Date: Mon, 1 Apr 2024 22:25:33 +0100 Subject: [PATCH] enh(templates) template parsing and placeholder handling --- src/Utils/Placekeeper.js | 516 +++++++++++++++++++++++++++++++-------- src/Views/Html.js | 73 ++---- src/Views/Markdown.js | 293 +++++++++++++--------- 3 files changed, 615 insertions(+), 267 deletions(-) diff --git a/src/Utils/Placekeeper.js b/src/Utils/Placekeeper.js index ce085ba..a4cf234 100644 --- a/src/Utils/Placekeeper.js +++ b/src/Utils/Placekeeper.js @@ -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} - 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} - 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} - 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 += ``; + 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(''); - } } diff --git a/src/Views/Html.js b/src/Views/Html.js index 3314518..4843971 100644 --- a/src/Views/Html.js +++ b/src/Views/Html.js @@ -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} - 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; - } } diff --git a/src/Views/Markdown.js b/src/Views/Markdown.js index cd6b9fe..3800754 100644 --- a/src/Views/Markdown.js +++ b/src/Views/Markdown.js @@ -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'), '$1'); + text = text.replace(this.#regex.get('italic'), '$1'); + text = text.replace(this.#regex.get('inline-code'), '$1'); + text = text.replace(this.#regex.get('link'), '$1'); + text = text.replace(this.#regex.get('image'), '$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 += `${line - .substring(level) - .trim()}`; - } else { - if (line.startsWith('- ') || line.startsWith('1.')) { - const numerical = line.startsWith('1.'); - html += numerical ? '
    ' : '
      '; - 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 += `
    • ${item - .substring(item.indexOf(' ') + 1) - .trim()}
    • `; - } - html += numerical ? '
' : ''; - } 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 += `
${code}
`; - } else if (line.startsWith('---')) { - html += '

'; - } else if (line.startsWith('> ')) { - let item = line; - html += '
'; - while (lines.length > 0 && lines[0].startsWith('>')) { - html += lines - .shift() - .toString() - .trim() - .substring(2) - .trim(); - } - html += '
'; - } else { - html += `

${line}

`; + const headingText = line.substring(level).trim(); + html += `${this.#processInlineFormatting(headingText)}`; + } else if (line.startsWith('- ') || line.startsWith('1.')) { + const numerical = line.startsWith('1.'); + html += numerical ? '
    ' : '
      '; + 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 += `
    • ${this.#processInlineFormatting(itemText)}
    • `; } + html += numerical ? '
' : ''; + } 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 += `
${this.#escapeHtml(code.trim())}
`; + } else if (line.startsWith('---')) { + html += '
'; + } else if (line.startsWith('> ')) { + html += '
'; + while (lines.length > 0 && lines[0].startsWith('>')) { + const blockquoteText = lines.shift().toString().trim().substring(2).trim(); + html += `

${this.#processInlineFormatting(blockquoteText)}

`; + } + html += '
'; + } 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 += ''; + html += ''; + headers.forEach((header, index) => { + let alignClass = align[index].trim().slice(1, -1).replace(/:/g, ''); + html += `${this.#processInlineFormatting(header.slice(1, -1))}`; + }); + html += ''; + html += ''; + cells.forEach(row => { + html += ''; + row.forEach(cell => { + html += ``; + }); + html += ''; + }); + html += ''; + html += '
${this.#processInlineFormatting(cell.slice(1, -1))}
'; + } 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 += `
    ${this.#processInlineFormatting(taskText)}
`; + } 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 += `${footnoteId}`; + html += `
${this.#processInlineFormatting(footnoteText.trim())}
`; + } else { + html += `

${this.#processInlineFormatting(line)}

`; } } return html;