enh(templates) template parsing and placeholder handling
This commit is contained in:
@@ -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