import Placekeeper from '../Utils/Placekeeper.js'; /** * Represents a markdown engine for parsing and converting markdown content to HTML. * @class */ export default class MarkdownEngine { #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, * and caching the result. * * @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. */ async handle(filepath) { const file = Bun.file(filepath); if (!(await file.exists())) throw new Error('file not found'); if (!file) throw new Error('something went wrong'); const rawContent = await file.text(); let { metadata = {}, content = '' } = await this.#extractFrontMatter(rawContent); content = this.#toHtml(content); await this.#templater.add(filepath, content); return { 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 }; } }; } /** * 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 #extractFrontMatter(content) { function parseValue(value) { value = value.trim(); if (value.startsWith('"') && value.endsWith('"')) return value.substring(1, value.length - 1); if (value === 'true' || value === 'false') return value === 'true'; if (!isNaN(value)) return Number(value); if (value.startsWith('[') && value.endsWith(']')) return value .substring(1, value.length - 1) .split(',') .map((v) => parseValue(v.trim())); return value; } let lines = content.split('\n'); let isFrontMatter = false; const variables = {}; let idx = 0; while (lines.length > 0) { const line = lines.shift().toString().trim(); if ( line === '' || // ignore empty lines line.startsWith('#') // ignore comments ) { idx++; continue; } if (line === '---') { if (isFrontMatter) break; isFrontMatter = true; idx++; continue; } if (isFrontMatter && line.indexOf(':') === -1) continue; // ignore invalid lines const key = line.substring(0, line.indexOf(':')).trim(); let value = line.substring(line.indexOf(':') + 1).trim(); if (value == '') { idx++; continue; } value = parseValue(value); variables[key] = value; idx++; } return { metadata: variables, content: lines.join("\n"), }; } /** * Escapes special characters in the given text to prevent XSS attacks. * * @param {string} text - The text to escape. * @returns {string} - The escaped text. */ #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) { let line = lines.shift().toString().trim(); if (line === '') continue; if (line.startsWith('#')) { const level = line.indexOf(' '); const headingText = line.substring(level).trim(); html += `${this.#processInlineFormatting(headingText)}`; } else if (line.startsWith('- ') || line.startsWith('1.')) { const numerical = line.startsWith('1.'); 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 += ``; } 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; } }