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:
@@ -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: {
|
||||
|
||||
@@ -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
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
136
src/js/sw/sdk/renderers.js
Normal file
136
src/js/sw/sdk/renderers.js
Normal 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 })),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 = `
|
||||
|
||||
Reference in New Issue
Block a user