Feat v0.6.5 renderer pipeline (#40)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 1m20s

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:
2026-03-31 16:37:33 +00:00
committed by xcaliber
parent 36d6158940
commit 81c28a50bf
33 changed files with 2089 additions and 1974 deletions

252
src/js/sw/sdk/markdown.js Normal file
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function _escapeAttr(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ── 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;
},
};
}