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

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:
2026-03-31 15:26:44 +00:00
parent 36d6158940
commit 2adaabe5fa
32 changed files with 1769 additions and 1974 deletions

View File

@@ -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 });
}
})();