v0.6.5: Renderer pipeline, docs rewrite, architecture diagrams
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m13s
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m13s
Lift block rendering to kernel SDK primitives (sw.renderers + sw.markdown) so all surfaces share one markdown pipeline. Rewrite docs for external audience — remove all fork history references. Add Mermaid architecture diagrams, CONTRIBUTING guide, and extension tutorial. - sw.renderers SDK module: kernel-level renderer registry - sw.markdown SDK module: unified marked v16 + DOMPurify pipeline - Browser extension script loader for renderer injection - Notes + Docs surfaces migrated to sw.markdown.renderSync() - 4 renderer extensions rewritten to IIFE + sw.renderers.register() - 6 Mermaid diagrams in ARCHITECTURE.md - CONTRIBUTING.md + TUTORIAL-FIRST-EXTENSION.md - DESIGN-WORKFLOWS.md replaces fork-era design doc - Surface alias routes removed from main.go - ICD/SDK runners migrated to /admin/packages/ endpoints - 13 new renderer tests - Docs, CHANGELOG, ROADMAP cleaned of fork references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,177 +3,141 @@
|
||||
// ==========================================
|
||||
// Renders ```latex / ```math code blocks and inline $...$ / $$...$$ syntax.
|
||||
// Loads KaTeX dynamically on first use.
|
||||
//
|
||||
// Registers with sw.renderers via the sw:ready event.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
id: 'katex-renderer',
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
_katexReady: false,
|
||||
_katexLoading: null,
|
||||
let _katexReady = false;
|
||||
let _katexLoading = null;
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
function _escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
// ── KaTeX Library Loading ──────────────
|
||||
|
||||
// ── Block renderer: match ```latex or ```math ──
|
||||
ctx.renderers.register('katex-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered" data-katex-id="${id}">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">
|
||||
<span class="katex-spinner"></span> Rendering math…
|
||||
</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>𝑓(𝑥) View source</summary>
|
||||
<pre><code class="language-latex">${self._escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
function _loadKaTeX() {
|
||||
if (_katexReady) return Promise.resolve();
|
||||
if (_katexLoading) return _katexLoading;
|
||||
|
||||
_katexLoading = new Promise((resolve, reject) => {
|
||||
if (typeof katex !== 'undefined') {
|
||||
_katexReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localCSS = `${base}/vendor/katex/katex.min.css`;
|
||||
const localJS = `${base}/vendor/katex/katex.min.js`;
|
||||
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
|
||||
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = localCSS;
|
||||
link.onerror = () => { link.href = cdnCSS; };
|
||||
document.head.appendChild(link);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = localJS;
|
||||
script.onload = () => { _katexReady = true; resolve(); };
|
||||
script.onerror = () => {
|
||||
console.warn('[KaTeX] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnJS;
|
||||
cdn.onload = () => { _katexReady = true; resolve(); };
|
||||
cdn.onerror = () => {
|
||||
console.error('[KaTeX] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load KaTeX'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return _katexLoading;
|
||||
}
|
||||
|
||||
// ── Post renderer: render KaTeX blocks + find inline math ──
|
||||
ctx.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
// Render code blocks with data-katex-src
|
||||
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
|
||||
if (blocks.length > 0) {
|
||||
blocks.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
self._renderBlock(el);
|
||||
});
|
||||
}
|
||||
// ── Block Rendering ──────────────────────
|
||||
|
||||
// Process inline math in text content
|
||||
self._processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load KaTeX
|
||||
this._loadKaTeX();
|
||||
},
|
||||
|
||||
async _renderBlock(el) {
|
||||
async function _renderBlock(el) {
|
||||
const code = decodeURIComponent(el.dataset.katexSrc);
|
||||
const displayMode = el.dataset.katexDisplay === 'true';
|
||||
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
await _loadKaTeX();
|
||||
el.innerHTML = katex.renderToString(code, {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
strict: false,
|
||||
displayMode, throwOnError: false, trust: false, strict: false,
|
||||
});
|
||||
el.dataset.rendered = 'true';
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
<div class="katex-error">
|
||||
<strong>Math error:</strong> ${this._escapeHtml(e.message || String(e))}
|
||||
<strong>Math error:</strong> ${_escapeHtml(e.message || String(e))}
|
||||
</div>
|
||||
`;
|
||||
el.dataset.rendered = 'error';
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async _processInlineMath(container) {
|
||||
// Skip if no $ signs present at all
|
||||
// ── Inline Math Processing ───────────────
|
||||
|
||||
async function _processInlineMath(container) {
|
||||
if (!container.textContent || !container.textContent.includes('$')) return;
|
||||
try { await _loadKaTeX(); } catch { return; }
|
||||
|
||||
try {
|
||||
await this._loadKaTeX();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk text nodes, skip code/pre/katex-already-rendered
|
||||
const walker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
// Skip code, pre, already-rendered katex, and script elements
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre')) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return NodeFilter.FILTER_REJECT;
|
||||
const tag = parent.tagName;
|
||||
if (tag === 'CODE' || tag === 'PRE' || tag === 'SCRIPT' ||
|
||||
tag === 'TEXTAREA' || tag === 'STYLE') return NodeFilter.FILTER_REJECT;
|
||||
if (parent.closest('.katex, .katex-block, .katex-display-container, code, pre'))
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
if (!node.textContent.includes('$')) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const textNodes = [];
|
||||
let node;
|
||||
while (node = walker.nextNode()) textNodes.push(node);
|
||||
for (const textNode of textNodes) _replaceInlineMath(textNode);
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
this._replaceInlineMath(textNode);
|
||||
}
|
||||
},
|
||||
|
||||
_replaceInlineMath(textNode) {
|
||||
function _replaceInlineMath(textNode) {
|
||||
const text = textNode.textContent;
|
||||
// Match $$...$$ (display) and $...$ (inline), non-greedy
|
||||
// Avoid matching escaped \$ or empty $$
|
||||
const pattern = /\$\$([^$]+?)\$\$|\$([^$\n]+?)\$/g;
|
||||
let match;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
// Text before the match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
const displayMode = !!match[1]; // $$ ... $$
|
||||
const displayMode = !!match[1];
|
||||
const expr = match[1] || match[2];
|
||||
|
||||
try {
|
||||
const html = katex.renderToString(expr.trim(), {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
strict: false,
|
||||
displayMode, throwOnError: false, trust: false, strict: false,
|
||||
});
|
||||
parts.push({ type: 'katex', value: html, displayMode });
|
||||
} catch {
|
||||
// Leave as-is on error
|
||||
parts.push({ type: 'text', value: match[0] });
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (parts.length === 0) return; // No math found
|
||||
if (parts.length === 0) return;
|
||||
if (lastIndex < text.length) parts.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
|
||||
// Remaining text after last match
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
// Replace text node with a span containing the rendered parts
|
||||
const span = document.createElement('span');
|
||||
span.className = 'katex-inline-container';
|
||||
for (const part of parts) {
|
||||
@@ -186,70 +150,12 @@ Extensions.register({
|
||||
span.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
textNode.parentNode.replaceChild(span, textNode);
|
||||
},
|
||||
}
|
||||
|
||||
_loadKaTeX() {
|
||||
if (this._katexReady) return Promise.resolve();
|
||||
if (this._katexLoading) return this._katexLoading;
|
||||
// ── Styles ──────────────────────────────
|
||||
|
||||
this._katexLoading = new Promise((resolve, reject) => {
|
||||
if (typeof katex !== 'undefined') {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = (window.__BASE__ || '');
|
||||
const localCSS = `${base}/vendor/katex/katex.min.css`;
|
||||
const localJS = `${base}/vendor/katex/katex.min.js`;
|
||||
const cdnCSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css';
|
||||
const cdnJS = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js';
|
||||
|
||||
// Load CSS first
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = localCSS;
|
||||
link.onerror = () => {
|
||||
link.href = cdnCSS;
|
||||
};
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Load JS
|
||||
const script = document.createElement('script');
|
||||
script.src = localJS;
|
||||
script.onload = () => {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('[KaTeX] Local vendor not found, trying CDN');
|
||||
const cdn = document.createElement('script');
|
||||
cdn.src = cdnJS;
|
||||
cdn.onload = () => {
|
||||
this._katexReady = true;
|
||||
resolve();
|
||||
};
|
||||
cdn.onerror = () => {
|
||||
console.error('[KaTeX] Failed to load from both local and CDN');
|
||||
reject(new Error('Failed to load KaTeX'));
|
||||
};
|
||||
document.head.appendChild(cdn);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return this._katexLoading;
|
||||
},
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
_injectStyles() {
|
||||
function _injectStyles() {
|
||||
if (document.getElementById('ext-style-katex-renderer')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ext-style-katex-renderer';
|
||||
@@ -289,9 +195,64 @@ Extensions.register({
|
||||
.katex-inline .katex { font-size: 1.05em; }
|
||||
.katex-display { display: block; text-align: center; margin: 8px 0; }`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-katex-renderer')?.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Registration ────────────────────────
|
||||
|
||||
function register() {
|
||||
if (!window.sw?.renderers) return;
|
||||
|
||||
_injectStyles();
|
||||
|
||||
// Block renderer: match ```latex, ```math, ```tex, ```katex
|
||||
sw.renderers.register('katex-block', {
|
||||
type: 'block',
|
||||
priority: 10,
|
||||
match(lang) {
|
||||
const l = (lang || '').toLowerCase();
|
||||
return l === 'latex' || l === 'math' || l === 'tex' || l === 'katex';
|
||||
},
|
||||
render(lang, code, container) {
|
||||
const id = 'katex-' + Math.random().toString(36).slice(2, 9);
|
||||
container.innerHTML = `
|
||||
<div class="katex-block ext-rendered" data-katex-id="${id}">
|
||||
<div class="katex-display-container" data-katex-src="${encodeURIComponent(code.trim())}" data-katex-display="true">
|
||||
<div class="katex-loading">
|
||||
<span class="katex-spinner"></span> Rendering math\u2026
|
||||
</div>
|
||||
</div>
|
||||
<details class="katex-source">
|
||||
<summary>\ud835\udc53(\ud835\udc65) View source</summary>
|
||||
<pre><code class="language-latex">${_escapeHtml(code.trim())}</code></pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Post renderer: render KaTeX blocks + find inline math
|
||||
sw.renderers.register('katex-post', {
|
||||
type: 'post',
|
||||
priority: 10,
|
||||
render(container) {
|
||||
const blocks = container.querySelectorAll('.katex-display-container[data-katex-src]');
|
||||
if (blocks.length > 0) {
|
||||
blocks.forEach(el => {
|
||||
if (el.dataset.rendered) return;
|
||||
el.dataset.rendered = 'pending';
|
||||
_renderBlock(el);
|
||||
});
|
||||
}
|
||||
_processInlineMath(container);
|
||||
}
|
||||
});
|
||||
|
||||
_loadKaTeX();
|
||||
}
|
||||
|
||||
if (window.sw?._sdk) {
|
||||
register();
|
||||
} else {
|
||||
document.addEventListener('sw:ready', register, { once: true });
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user