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 += ``; - html += ''; - for (let r = 2; r < tableRows.length; r++) { - html += ''; - for (const cell of tableRows[r]) html += ``; - html += ''; - } - html += '
${inline(h.trim())}
${inline(cell.trim())}
'; - 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 += `
    1. ${inline(line.replace(/^\s*\d+\.\s+/, ''))}
    2. `; - 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 = `