// ========================================== // 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; }, }; }