Feat v0.6.5 renderer pipeline (#40)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #40.
This commit is contained in:
252
src/js/sw/sdk/markdown.js
Normal file
252
src/js/sw/sdk/markdown.js
Normal file
@@ -0,0 +1,252 @@
|
||||
// ==========================================
|
||||
// Switchboard Core — 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 `<a class="wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`;
|
||||
},
|
||||
}],
|
||||
|
||||
// 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 <pre><code>
|
||||
const langClass = lang ? ` class="language-${_escapeAttr(lang)}"` : '';
|
||||
return `<pre><code${langClass}>${_escapeHtml(text)}</code></pre>\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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _escapeAttr(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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<string>} 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user