stackchain-dashboard/frontend/markdown.js
timmy ac7a716e5c
All checks were successful
CI / lint (pull_request) Successful in 33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: render safe mobile Markdown across My Work (#343)
2026-08-08 21:38:59 +00:00

108 lines
4.1 KiB
JavaScript

(function (root, factory) {
const renderMarkdown = factory();
if (typeof module === 'object' && module.exports) module.exports = renderMarkdown;
root.renderMarkdown = renderMarkdown;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character]);
}
function renderInline(value) {
const code = [];
let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => {
const marker = '\u0000CODE' + code.length + '\u0000';
code.push('<code>' + body + '</code>');
return marker;
});
rendered = rendered
.replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+&quot;[^&]*?&quot;)?\)/g, (_match, label, escapedUrl) => {
const url = escapedUrl.replace(/&amp;/g, '&');
const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url);
if (!safe) return label;
return '<a href="' + escapedUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
})
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
}
function renderList(lines) {
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
const items = lines.map(line => {
let body = line.replace(/^[-*+]\s+/, '');
if (!taskList) return '<li>' + renderInline(body) + '</li>';
const checked = /^\[[xX]\]\s+/.test(body);
body = body.replace(/^\[[ xX]\]\s+/, '');
return '<li class="task-list-item"><input type="checkbox" disabled' +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}
return function renderMarkdown(raw) {
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
const output = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
const body = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
body.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
const language = fence[1] ? ' class="language-' + fence[1] + '"' : '';
output.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
output.push('<h' + level + '>' + renderInline(heading[2]) + '</h' + level + '>');
index += 1;
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (index < lines.length && /^>\s?/.test(lines[index])) {
quote.push(lines[index].replace(/^>\s?/, ''));
index += 1;
}
output.push('<blockquote><p>' + renderInline(quote.join('\n')).replace(/\n/g, '<br>') + '</p></blockquote>');
continue;
}
if (/^[-*+]\s+/.test(line)) {
const list = [];
while (index < lines.length && /^[-*+]\s+/.test(lines[index])) {
list.push(lines[index]);
index += 1;
}
output.push(renderList(list));
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() &&
!/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) {
paragraph.push(lines[index]);
index += 1;
}
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
}
return output.join('');
};
});