diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8402376..7e8f2ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,62 @@
All notable changes to Switchboard Core are documented here.
+## v0.6.5 — Renderer Pipeline + Docs Rewrite
+
+Lifts block rendering to a kernel SDK primitive so all surfaces share one
+markdown pipeline. Rewrites docs for external audience. Adds CONTRIBUTING
+guide and extension tutorial.
+
+### Added
+
+- **`sw.renderers` SDK module**: Kernel-level renderer registry. Extensions
+ register block renderers (fenced code blocks) and post renderers (DOM
+ post-processing) once; all surfaces consume via `sw.markdown`.
+- **`sw.markdown` SDK module**: Unified markdown rendering via `marked` v16
+ (vendored). Lazy-loads `marked` + DOMPurify. Custom code hook delegates
+ fenced blocks to `sw.renderers`. Wikilink tokenizer (`[[Page Name]]`)
+ built in for all surfaces.
+- **Browser extension script loader**: Server injects `
- {{/* v0.37.14 Scorched Earth IV: sb.js, events.js, switchboard-sdk.js,
- workflow-surfaces.js removed. All surfaces use Preact SDK boot().
- Survivors: debug.js, repl.js (standalone, no old-layer deps). */}}
+ {{/* All surfaces use Preact SDK boot(). Legacy script includes removed.
+ Standalone utils: debug.js, repl.js (no SDK dependencies). */}}
{{if eq .Surface "admin"}}{{template "scripts-admin" .}}{{end}}
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
@@ -142,6 +141,12 @@
{{end}}
+ {{/* v0.6.5: Browser-tier extension scripts (renderers, etc.)
+ Loaded after surface scripts so sw:ready fires first. */}}
+ {{range .BrowserExtensions}}
+
+ {{end}}
+
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
{{/* ── Debug Modal (Preact, v0.37.18) ───── */}}
diff --git a/src/js/__tests__/renderers.test.js b/src/js/__tests__/renderers.test.js
new file mode 100644
index 0000000..3096d77
--- /dev/null
+++ b/src/js/__tests__/renderers.test.js
@@ -0,0 +1,136 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const SRC = path.join(__dirname, '..');
+
+/**
+ * Load the renderers module into a VM context and return the factory.
+ */
+function loadRenderers() {
+ const source = fs.readFileSync(path.join(SRC, 'sw/sdk/renderers.js'), 'utf-8');
+ // Strip ESM syntax for VM execution
+ const cjs = source.replace(/^export /gm, '');
+ const sandbox = { console, module: { exports: {} } };
+ vm.createContext(sandbox);
+ vm.runInContext(cjs, sandbox);
+ return sandbox.createRenderers;
+}
+
+describe('sw.renderers', () => {
+ const createRenderers = loadRenderers();
+
+ it('register() adds a block renderer', () => {
+ const events = [];
+ const r = createRenderers((name, data, opts) => events.push({ name, data }));
+ r.register('test-block', { type: 'block', pattern: 'mermaid', render() {} });
+ const info = r.list();
+ assert.strictEqual(info.block.length, 1);
+ assert.strictEqual(info.block[0].name, 'test-block');
+ assert.strictEqual(events.length, 1);
+ assert.strictEqual(events[0].data.action, 'register');
+ });
+
+ it('register() adds a post renderer', () => {
+ const r = createRenderers(() => {});
+ r.register('test-post', { type: 'post', render() {} });
+ const info = r.list();
+ assert.strictEqual(info.post.length, 1);
+ assert.strictEqual(info.post[0].name, 'test-post');
+ });
+
+ it('register() replaces duplicate by name', () => {
+ const r = createRenderers(() => {});
+ r.register('dup', { type: 'block', pattern: 'x', render() {} });
+ r.register('dup', { type: 'block', pattern: 'y', render() {} });
+ const info = r.list();
+ assert.strictEqual(info.block.length, 1);
+ assert.strictEqual(info.block[0].pattern, 'y');
+ });
+
+ it('unregister() removes a renderer', () => {
+ const r = createRenderers(() => {});
+ r.register('rm-me', { type: 'block', pattern: 'x', render() {} });
+ r.unregister('rm-me');
+ assert.strictEqual(r.list().block.length, 0);
+ });
+
+ it('register() returns an unregister function', () => {
+ const r = createRenderers(() => {});
+ const unreg = r.register('auto-unreg', { type: 'post', render() {} });
+ assert.strictEqual(r.list().post.length, 1);
+ unreg();
+ assert.strictEqual(r.list().post.length, 0);
+ });
+
+ it('runBlockRenderers() matches by pattern', () => {
+ const r = createRenderers(() => {});
+ let called = false;
+ r.register('m', { type: 'block', pattern: 'mermaid', render() { called = true; } });
+ const result = r.runBlockRenderers('mermaid', 'code', {});
+ assert.ok(result);
+ assert.ok(called);
+ });
+
+ it('runBlockRenderers() matches by match function', () => {
+ const r = createRenderers(() => {});
+ let called = false;
+ r.register('k', {
+ type: 'block',
+ match(lang) { return lang === 'latex' || lang === 'math'; },
+ render() { called = true; },
+ });
+ assert.ok(r.runBlockRenderers('latex', 'code', {}));
+ assert.ok(called);
+ });
+
+ it('runBlockRenderers() returns false when no match', () => {
+ const r = createRenderers(() => {});
+ r.register('m', { type: 'block', pattern: 'mermaid', render() {} });
+ assert.strictEqual(r.runBlockRenderers('javascript', 'code', {}), false);
+ });
+
+ it('runBlockRenderers() case-insensitive on lang', () => {
+ const r = createRenderers(() => {});
+ let called = false;
+ r.register('m', { type: 'block', pattern: 'mermaid', render() { called = true; } });
+ assert.ok(r.runBlockRenderers('Mermaid', 'code', {}));
+ assert.ok(called);
+ });
+
+ it('runPostRenderers() calls all post renderers', () => {
+ const r = createRenderers(() => {});
+ const order = [];
+ r.register('a', { type: 'post', priority: 20, render() { order.push('a'); } });
+ r.register('b', { type: 'post', priority: 10, render() { order.push('b'); } });
+ r.runPostRenderers({});
+ assert.deepStrictEqual(order, ['b', 'a']); // priority 10 first
+ });
+
+ it('block renderers respect priority (first match wins)', () => {
+ const r = createRenderers(() => {});
+ let winner = '';
+ r.register('low', { type: 'block', pattern: 'test', priority: 50, render() { winner = 'low'; } });
+ r.register('high', { type: 'block', pattern: 'test', priority: 10, render() { winner = 'high'; } });
+ r.runBlockRenderers('test', '', {});
+ assert.strictEqual(winner, 'high');
+ });
+
+ it('runBlockRenderers() catches render errors gracefully', () => {
+ const r = createRenderers(() => {});
+ r.register('bad', { type: 'block', pattern: 'err', render() { throw new Error('boom'); } });
+ const result = r.runBlockRenderers('err', 'code', {});
+ assert.strictEqual(result, false); // error → returns false
+ });
+
+ it('runPostRenderers() catches errors and continues', () => {
+ const r = createRenderers(() => {});
+ const order = [];
+ r.register('bad', { type: 'post', priority: 10, render() { throw new Error('boom'); } });
+ r.register('good', { type: 'post', priority: 20, render() { order.push('good'); } });
+ r.runPostRenderers({});
+ assert.deepStrictEqual(order, ['good']); // continues after error
+ });
+});
diff --git a/src/js/sw/sdk/api-domains.js b/src/js/sw/sdk/api-domains.js
index 4ae3ade..2e87227 100644
--- a/src/js/sw/sdk/api-domains.js
+++ b/src/js/sw/sdk/api-domains.js
@@ -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: {
diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js
index 8d44b50..3d9ecea 100644
--- a/src/js/sw/sdk/index.js
+++ b/src/js/sw/sdk/index.js
@@ -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;
diff --git a/src/js/sw/sdk/markdown.js b/src/js/sw/sdk/markdown.js
new file mode 100644
index 0000000..366a969
--- /dev/null
+++ b/src/js/sw/sdk/markdown.js
@@ -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 `${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;
+ },
+ };
+}
diff --git a/src/js/sw/sdk/renderers.js b/src/js/sw/sdk/renderers.js
new file mode 100644
index 0000000..d79bc04
--- /dev/null
+++ b/src/js/sw/sdk/renderers.js
@@ -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 })),
+ };
+ },
+ };
+}
diff --git a/src/js/sw/surfaces/docs/index.js b/src/js/sw/surfaces/docs/index.js
index d50f577..d827504 100644
--- a/src/js/sw/surfaces/docs/index.js
+++ b/src/js/sw/surfaces/docs/index.js
@@ -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`
Loading\u2026
` : html`
-
+
`}
@@ -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, '&').replace(//g, '>');
- }
-
- function inline(s) {
- s = esc(s);
- // Links [text](url)
- s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
- // Bold **text** or __text__
- s = s.replace(/\*\*(.+?)\*\*/g, '$1');
- s = s.replace(/__(.+?)__/g, '$1');
- // Italic *text* or _text_
- s = s.replace(/(?$1');
- // Inline code `code`
- s = s.replace(/`([^`]+)`/g, '$1');
- return s;
- }
-
- function flushTable() {
- if (!inTable || tableRows.length === 0) return;
- inTable = false;
- html += '';
- const headers = tableRows[0];
- for (const h of headers) html += `| ${inline(h.trim())} | `;
- html += '
';
- for (let r = 2; r < tableRows.length; r++) {
- html += '';
- for (const cell of tableRows[r]) html += `| ${inline(cell.trim())} | `;
- html += '
';
- }
- html += '
';
- tableRows = [];
- }
-
- function flushList() {
- if (!inList) return;
- inList = false;
- html += listType === 'ol' ? '' : '';
- }
-
- while (i < lines.length) {
- const line = lines[i];
-
- // Fenced code blocks
- if (line.startsWith('```')) {
- if (inCode) {
- html += `${esc(codeLines.join('\n'))}
`;
- 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 += `${inline(text)}`;
- i++;
- continue;
- }
-
- // Horizontal rule
- if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
- flushList();
- html += '
';
- i++;
- continue;
- }
-
- // Unordered list
- if (/^\s*[-*+]\s+/.test(line)) {
- if (!inList || listType !== 'ul') {
- flushList();
- inList = true;
- listType = 'ul';
- html += '';
- }
- html += `- ${inline(line.replace(/^\s*[-*+]\s+/, ''))}
`;
- i++;
- continue;
- }
-
- // Ordered list
- if (/^\s*\d+\.\s+/.test(line)) {
- if (!inList || listType !== 'ol') {
- flushList();
- inList = true;
- listType = 'ol';
- html += '';
- }
- html += `- ${inline(line.replace(/^\s*\d+\.\s+/, ''))}
`;
- i++;
- continue;
- }
-
- flushList();
-
- // Empty line
- if (line.trim() === '') {
- i++;
- continue;
- }
-
- // Paragraph
- html += `${inline(line)}
`;
- i++;
- }
-
- flushList();
- flushTable();
- return html;
-}
-
// ── Styles ──────────────────────────────────
const style = document.createElement('style');
style.textContent = `