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

View File

@@ -268,12 +268,12 @@ export function createDomains(restClient) {
},
surfaces: {
list: () => rc.get('/api/v1/admin/surfaces'),
get: (id) => rc.get(`/api/v1/admin/surfaces/${id}`),
install: (file) => rc.upload('/api/v1/admin/surfaces/install', file),
enable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/enable`, {}),
disable: (id) => rc.put(`/api/v1/admin/surfaces/${id}/disable`, {}),
del: (id) => rc.del(`/api/v1/admin/surfaces/${id}`),
list: () => rc.get('/api/v1/admin/packages'),
get: (id) => rc.get(`/api/v1/admin/packages/${id}`),
install: (file) => rc.upload('/api/v1/admin/packages/install', file),
enable: (id) => rc.put(`/api/v1/admin/packages/${id}/enable`, {}),
disable: (id) => rc.put(`/api/v1/admin/packages/${id}/disable`, {}),
del: (id) => rc.del(`/api/v1/admin/packages/${id}`),
},
backup: {

View File

@@ -22,6 +22,8 @@ import { createStorage } from './storage.js';
import { createSlots } from './slots.js';
import { createActions } from './actions.js';
import { createRealtime } from './realtime.js';
import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -72,6 +74,8 @@ export async function boot() {
const slots = createSlots(events.emit.bind(events));
const actions = createActions(events.emit.bind(events));
const realtime = createRealtime(events);
const renderers = createRenderers(events.emit.bind(events));
const markdown = createMarkdown(renderers);
// 7. Assemble sw object
const sw = Object.create(null);
@@ -107,6 +111,12 @@ export async function boot() {
// Realtime — room-scoped pub/sub over WebSocket
sw.realtime = realtime;
// Renderers — kernel-level block/post renderer registry
sw.renderers = renderers;
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
sw.markdown = markdown;
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
@@ -167,7 +177,7 @@ export async function boot() {
};
// Marker for idempotency
sw._sdk = '0.5.1';
sw._sdk = '0.6.5';
// 8. Expose globally
window.sw = sw;

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

136
src/js/sw/sdk/renderers.js Normal file
View File

@@ -0,0 +1,136 @@
// ==========================================
// Switchboard Core — SDK: Renderer Registry
// ==========================================
// Kernel-level renderer registration. Extensions register
// block renderers (fenced code blocks) and post renderers
// (DOM post-processing) once; all surfaces consume via
// sw.markdown or direct runBlockRenderers/runPostRenderers.
//
// Factory: createRenderers(emitFn)
// ==========================================
/**
* Create the renderer registry.
*
* @param {Function} emitFn — events.emit for change notifications
* @returns {object} renderers
*/
export function createRenderers(emitFn) {
/** @type {Array<{name:string, type:string, pattern?:string, match?:Function, priority:number, render:Function}>} */
const _block = [];
/** @type {Array<{name:string, priority:number, render:Function}>} */
const _post = [];
function _sortByPriority(arr) {
arr.sort((a, b) => a.priority - b.priority);
}
return {
/**
* Register a renderer.
*
* @param {string} name — unique name (e.g. 'mermaid', 'katex-block')
* @param {object} opts
* @param {string} opts.type — 'block' or 'post'
* @param {string} [opts.pattern] — exact lang match (block only)
* @param {Function} [opts.match] — match(lang) → boolean (block only)
* @param {number} [opts.priority=100] — lower runs first
* @param {Function} opts.render — render(lang, code, container) for block,
* render(container) for post
* @returns {Function} unregister
*/
register(name, { type, pattern, match, priority = 100, render }) {
if (type === 'post') {
// Remove existing with same name
const idx = _post.findIndex(r => r.name === name);
if (idx !== -1) _post.splice(idx, 1);
_post.push({ name, priority, render });
_sortByPriority(_post);
emitFn('renderers.changed', { name, type, action: 'register' }, { localOnly: true });
return () => this.unregister(name);
}
// Block renderer
const idx = _block.findIndex(r => r.name === name);
if (idx !== -1) _block.splice(idx, 1);
_block.push({ name, type: 'block', pattern, match, priority, render });
_sortByPriority(_block);
emitFn('renderers.changed', { name, type: 'block', action: 'register' }, { localOnly: true });
return () => this.unregister(name);
},
/**
* Remove a renderer by name.
* @param {string} name
*/
unregister(name) {
let found = false;
const bi = _block.findIndex(r => r.name === name);
if (bi !== -1) { _block.splice(bi, 1); found = true; }
const pi = _post.findIndex(r => r.name === name);
if (pi !== -1) { _post.splice(pi, 1); found = true; }
if (found) {
emitFn('renderers.changed', { name, action: 'unregister' }, { localOnly: true });
}
},
/**
* Run block renderers for a fenced code block.
* First match wins. Returns true if handled.
*
* @param {string} lang — language tag from fenced block
* @param {string} code — raw code content
* @param {HTMLElement} container — DOM element to render into
* @returns {boolean} true if a renderer handled it
*/
runBlockRenderers(lang, code, container) {
const l = (lang || '').toLowerCase();
for (const r of _block) {
const matched = r.pattern
? r.pattern === l
: (r.match ? r.match(l) : false);
if (matched) {
try {
r.render(lang, code, container);
} catch (e) {
console.error(`[sw.renderers] Block renderer "${r.name}" error:`, e);
return false;
}
return true;
}
}
return false;
},
/**
* Run all post renderers on a container.
* @param {HTMLElement} container
*/
runPostRenderers(container) {
for (const r of _post) {
try {
r.render(container);
} catch (e) {
console.error(`[sw.renderers] Post renderer "${r.name}" error:`, e);
}
}
},
/**
* List registered renderers (debug).
* @returns {{ block: Array<{name:string, priority:number}>, post: Array<{name:string, priority:number}> }}
*/
list() {
return {
block: _block.map(r => ({ name: r.name, priority: r.priority, pattern: r.pattern })),
post: _post.map(r => ({ name: r.name, priority: r.priority })),
};
},
};
}

View File

@@ -10,9 +10,12 @@
*
*/
const { html } = window;
const { useState, useEffect, useCallback, useMemo } = hooks;
const { useState, useEffect, useCallback, useMemo, useRef } = hooks;
const { render } = preact;
// Preload marked so renderSync works in useMemo
if (sw?.markdown) sw.markdown.preload();
import { Topbar } from '../../shell/topbar.js';
function DocsSurface() {
@@ -83,6 +86,20 @@ function DocsSurface() {
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Rendered HTML via sw.markdown ──
const articleRef = useRef(null);
const renderedHtml = useMemo(() => {
if (!content || !sw?.markdown?.ready) return '';
return sw.markdown.renderSync(content, { sanitize: false });
}, [content]);
// Run post-renderers (mermaid, katex, etc.) after article is painted
useEffect(() => {
const el = articleRef.current;
if (!el || !renderedHtml) return;
if (sw?.renderers) sw.renderers.runPostRenderers(el);
}, [renderedHtml]);
function retryList() {
setListError(false);
sw.api.get('/api/v1/docs').then(r => {
@@ -113,8 +130,8 @@ function DocsSurface() {
${loading ? html`
<div class="docs-loading">Loading\u2026</div>
` : html`
<article class="docs-article"
dangerouslySetInnerHTML=${{ __html: renderMarkdown(content) }}>
<article class="docs-article" ref=${articleRef}
dangerouslySetInnerHTML=${{ __html: renderedHtml }}>
</article>
`}
</main>
@@ -135,170 +152,6 @@ function DocsSurface() {
`;
}
// ── Simple markdown renderer ─────────────────
// Handles: headings, code blocks, inline code, bold, italic, links, lists, paragraphs, tables, hr.
// Not a full CommonMark parser — good enough for documentation.
function renderMarkdown(md) {
if (!md) return '';
let html = '';
const lines = md.split('\n');
let i = 0;
let inCode = false;
let codeLang = '';
let codeLines = [];
let inList = false;
let listType = '';
let inTable = false;
let tableRows = [];
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function inline(s) {
s = esc(s);
// Links [text](url)
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Bold **text** or __text__
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic *text* or _text_
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
// Inline code `code`
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
return s;
}
function flushTable() {
if (!inTable || tableRows.length === 0) return;
inTable = false;
html += '<table class="data-table"><thead><tr>';
const headers = tableRows[0];
for (const h of headers) html += `<th>${inline(h.trim())}</th>`;
html += '</tr></thead><tbody>';
for (let r = 2; r < tableRows.length; r++) {
html += '<tr>';
for (const cell of tableRows[r]) html += `<td>${inline(cell.trim())}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
tableRows = [];
}
function flushList() {
if (!inList) return;
inList = false;
html += listType === 'ol' ? '</ol>' : '</ul>';
}
while (i < lines.length) {
const line = lines[i];
// Fenced code blocks
if (line.startsWith('```')) {
if (inCode) {
html += `<pre><code class="language-${esc(codeLang)}">${esc(codeLines.join('\n'))}</code></pre>`;
inCode = false;
codeLines = [];
codeLang = '';
} else {
flushList();
flushTable();
inCode = true;
codeLang = line.slice(3).trim();
}
i++;
continue;
}
if (inCode) {
codeLines.push(line);
i++;
continue;
}
// Table rows
if (line.includes('|') && line.trim().startsWith('|')) {
flushList();
const cells = line.split('|').slice(1, -1);
if (!inTable) inTable = true;
// Skip separator row (---|---)
if (cells.every(c => /^[\s:-]+$/.test(c))) {
tableRows.push(cells); // keep for row counting
} else {
tableRows.push(cells);
}
i++;
continue;
} else {
flushTable();
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headingMatch) {
flushList();
const level = headingMatch[1].length;
const text = headingMatch[2];
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
html += `<h${level} id="${id}">${inline(text)}</h${level}>`;
i++;
continue;
}
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
flushList();
html += '<hr>';
i++;
continue;
}
// Unordered list
if (/^\s*[-*+]\s+/.test(line)) {
if (!inList || listType !== 'ul') {
flushList();
inList = true;
listType = 'ul';
html += '<ul>';
}
html += `<li>${inline(line.replace(/^\s*[-*+]\s+/, ''))}</li>`;
i++;
continue;
}
// Ordered list
if (/^\s*\d+\.\s+/.test(line)) {
if (!inList || listType !== 'ol') {
flushList();
inList = true;
listType = 'ol';
html += '<ol>';
}
html += `<li>${inline(line.replace(/^\s*\d+\.\s+/, ''))}</li>`;
i++;
continue;
}
flushList();
// Empty line
if (line.trim() === '') {
i++;
continue;
}
// Paragraph
html += `<p>${inline(line)}</p>`;
i++;
}
flushList();
flushTable();
return html;
}
// ── Styles ──────────────────────────────────
const style = document.createElement('style');
style.textContent = `