// ========================================== // Armature — SDK: Markdown Renderer // ========================================== // Unified markdown rendering via `marked` (v16, vendored). // Delegates fenced code blocks to sw.renderers and // sanitizes output with DOMPurify. // // Factory: createMarkdown(renderers) // ========================================== /** * Create the markdown module. * * @param {object} renderers — sw.renderers instance * @returns {object} markdown */ export function createMarkdown(renderers) { let _marked = null; let _purify = null; let _markedLoading = null; let _purifyLoading = null; const BASE = () => window.__BASE__ || ''; // ── Lazy loaders ────────────────────────── function _loadMarked() { if (_marked) return Promise.resolve(_marked); if (_markedLoading) return _markedLoading; _markedLoading = new Promise((resolve, reject) => { if (window.marked) { _marked = window.marked; _configure(); resolve(_marked); return; } const script = document.createElement('script'); script.src = `${BASE()}/vendor/marked.min.js`; script.onload = () => { _marked = window.marked; _configure(); resolve(_marked); }; script.onerror = () => { reject(new Error('[sw.markdown] Failed to load marked')); }; document.head.appendChild(script); }); return _markedLoading; } function _loadPurify() { if (_purify) return Promise.resolve(_purify); if (_purifyLoading) return _purifyLoading; _purifyLoading = new Promise((resolve, reject) => { if (window.DOMPurify) { _purify = window.DOMPurify; resolve(_purify); return; } const script = document.createElement('script'); script.src = `${BASE()}/vendor/purify.min.js`; script.onload = () => { _purify = window.DOMPurify; resolve(_purify); }; script.onerror = () => { reject(new Error('[sw.markdown] Failed to load DOMPurify')); }; document.head.appendChild(script); }); return _purifyLoading; } // ── Marked configuration ────────────────── function _configure() { if (!_marked) return; _marked.use({ // Wikilink tokenizer extension: [[Page Name]] and [[Page Name|Display]] extensions: [{ name: 'wikilink', level: 'inline', start(src) { return src.indexOf('[['); }, tokenizer(src) { const match = src.match(/^\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/); if (match) { return { type: 'wikilink', raw: match[0], target: match[1].trim(), display: (match[2] || match[1]).trim(), }; } }, renderer(token) { const target = _escapeAttr(token.target); const display = _escapeHtml(token.display); return `${display}`; }, }], // Custom code block renderer — delegates to sw.renderers renderer: { code({ text, lang }) { if (lang && renderers) { const container = document.createElement('div'); if (renderers.runBlockRenderers(lang, text, container)) { return container.innerHTML; } } // Default: standard
const langClass = lang ? ` class="language-${_escapeAttr(lang)}"` : '';
return `${_escapeHtml(text)}
\n`;
},
},
});
}
// ── DOMPurify config ──────────────────────
// Allow SVG elements (for mermaid) + data attributes (for all renderers)
const PURIFY_CONFIG = {
ADD_TAGS: [
'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line',
'polyline', 'polygon', 'text', 'tspan', 'defs', 'marker',
'foreignObject', 'use', 'clipPath', 'mask', 'pattern',
'linearGradient', 'radialGradient', 'stop', 'title', 'desc',
],
ADD_ATTR: [
'viewBox', 'd', 'fill', 'stroke', 'stroke-width', 'stroke-dasharray',
'xmlns', 'xmlns:xlink', 'transform', 'cx', 'cy', 'r', 'rx', 'ry',
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'width', 'height',
'preserveAspectRatio', 'points', 'font-size', 'font-family',
'text-anchor', 'dominant-baseline', 'dy', 'dx',
'marker-start', 'marker-mid', 'marker-end',
'refX', 'refY', 'markerWidth', 'markerHeight', 'orient',
'gradientTransform', 'gradientUnits', 'offset', 'stop-color', 'stop-opacity',
'clip-path', 'opacity', 'fill-opacity', 'stroke-opacity',
],
ALLOW_DATA_ATTR: true,
};
// ── Helpers ───────────────────────────────
function _escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function _escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
}
// ── Public API ────────────────────────────
return {
/**
* Render markdown source to HTML string.
* Loads marked lazily on first call.
*
* @param {string} src — markdown source
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true] — run DOMPurify on output
* @returns {Promise} HTML
*/
async render(src, opts = {}) {
const sanitize = opts.sanitize !== false;
await _loadMarked();
let html = _marked.parse(src || '');
if (sanitize) {
await _loadPurify();
html = _purify.sanitize(html, PURIFY_CONFIG);
}
return html;
},
/**
* Synchronous render — only works after marked has been loaded.
* Falls back to escaping if marked isn't ready.
*
* @param {string} src
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true]
* @returns {string} HTML
*/
renderSync(src, opts = {}) {
const sanitize = opts.sanitize !== false;
if (!_marked) {
return _escapeHtml(src || '');
}
let html = _marked.parse(src || '');
if (sanitize && _purify) {
html = _purify.sanitize(html, PURIFY_CONFIG);
}
return html;
},
/**
* Render markdown into a DOM element, then run post renderers.
*
* @param {HTMLElement} container
* @param {string} src
* @param {object} [opts]
* @param {boolean} [opts.sanitize=true]
*/
async renderToElement(container, src, opts = {}) {
const html = await this.render(src, opts);
container.innerHTML = html;
if (renderers) {
renderers.runPostRenderers(container);
}
},
/**
* Preload both marked and DOMPurify.
* Call early to avoid latency on first render.
*/
async preload() {
await Promise.all([_loadMarked(), _loadPurify()]);
},
/** True if marked is loaded and ready for sync use. */
get ready() {
return !!_marked;
},
};
}