diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 76f516f..7c916ad 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -369,7 +369,7 @@ jobs:
run: |
docker build -f server/Dockerfile \
-t ${BE_IMAGE}:${{ steps.env.outputs.IMAGE_TAG }} \
- ./server
+ .
# ── Build Frontend Image ─────────────────────
- name: Build frontend image
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e29b038..27fb5f9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,4 +1,4 @@
-# Architecture — Chat Switchboard v0.9
+# Architecture — Chat Switchboard v0.11
## Deployment Modes
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fac392d..e001ec1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,92 @@
All notable changes to Chat Switchboard.
+## [0.11.0] — 2026-02-25
+
+### Added
+- **Browser extension system.** Full lifecycle: manifest registration, script
+ injection, scoped context (`ctx.renderers`, `ctx.tools`, `ctx.events`,
+ `ctx.storage`, `ctx.ui`), permission-aware API proxying. Extensions self-
+ register via `Extensions.register()` and receive isolated contexts during
+ `initAll()`. Admin CRUD endpoints, asset serving (public, no auth needed
+ for `
+
diff --git a/src/js/__tests__/extensions-builtin.test.js b/src/js/__tests__/extensions-builtin.test.js
new file mode 100644
index 0000000..6ab8cac
--- /dev/null
+++ b/src/js/__tests__/extensions-builtin.test.js
@@ -0,0 +1,635 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert');
+const { createBrowserContext, loadSource } = require('./helpers');
+const vm = require('vm');
+
+/**
+ * Load Events + Extensions into a browser context.
+ */
+function loadExtensions(overrides = {}) {
+ const sandbox = createBrowserContext(overrides);
+ loadSource(sandbox, 'events.js');
+ loadSource(sandbox, 'extensions.js');
+ const ctx = vm.createContext(sandbox);
+ return {
+ Extensions: vm.runInContext('Extensions', ctx),
+ Events: vm.runInContext('Events', ctx),
+ sandbox,
+ };
+}
+
+// ═══════════════════════════════════════════
+// KaTeX Renderer Extension
+// ═══════════════════════════════════════════
+
+describe('KaTeX renderer extension', () => {
+ function loadWithKaTeX() {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'katex-renderer',
+ _katexReady: false,
+ _katexLoading: null,
+ _escapeHtml(str) {
+ return str.replace(/&/g, '&').replace(//g, '>');
+ },
+ init(extCtx) {
+ const self = this;
+ extCtx.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) {
+ container.innerHTML = `
+
+
+
+ View source
+ ${self._escapeHtml(code.trim())}
+
+
+ `;
+ }
+ });
+ extCtx.renderers.register('katex-post', {
+ type: 'post',
+ priority: 10,
+ render(container) {
+ container._katexPostRan = true;
+ }
+ });
+ },
+ destroy() {}
+ });
+ return ctx;
+ }
+
+ it('registers both block and post renderers', async () => {
+ const ctx = loadWithKaTeX();
+ await ctx.Extensions.initAll();
+ const info = ctx.Extensions.debug();
+ assert.strictEqual(info.extensions['katex-renderer'].active, true);
+ assert.strictEqual(info.extensions['katex-renderer'].renderers.length, 2);
+ });
+
+ it('matches latex, math, tex, and katex languages', async () => {
+ const ctx = loadWithKaTeX();
+ await ctx.Extensions.initAll();
+
+ for (const lang of ['latex', 'math', 'tex', 'katex']) {
+ const container = { innerHTML: '' };
+ const handled = ctx.Extensions.runBlockRenderers(lang, 'E = mc^2', container);
+ assert.ok(handled, `should match language: ${lang}`);
+ assert.ok(container.innerHTML.includes('katex-block'), `should render katex block for: ${lang}`);
+ }
+ });
+
+ it('does not match other languages', async () => {
+ const ctx = loadWithKaTeX();
+ await ctx.Extensions.initAll();
+
+ for (const lang of ['python', 'javascript', 'mermaid', 'csv']) {
+ const container = { innerHTML: '' };
+ const handled = ctx.Extensions.runBlockRenderers(lang, 'code', container);
+ assert.strictEqual(handled, false, `should not match language: ${lang}`);
+ }
+ });
+
+ it('includes encoded source in data attribute', async () => {
+ const ctx = loadWithKaTeX();
+ await ctx.Extensions.initAll();
+
+ const container = { innerHTML: '' };
+ ctx.Extensions.runBlockRenderers('latex', '\\frac{a}{b}', container);
+ assert.ok(container.innerHTML.includes(encodeURIComponent('\\frac{a}{b}')));
+ });
+
+ it('escapes HTML in source view', async () => {
+ const ctx = loadWithKaTeX();
+ await ctx.Extensions.initAll();
+
+ const container = { innerHTML: '' };
+ ctx.Extensions.runBlockRenderers('math', '', container);
+ assert.ok(container.innerHTML.includes('<script>'));
+ assert.ok(!container.innerHTML.includes('', container);
+ assert.ok(container.innerHTML.includes('<script>'));
+ });
+});
+
+// ═══════════════════════════════════════════
+// JS Sandbox Extension (Tool)
+// ═══════════════════════════════════════════
+
+describe('JS Sandbox extension', () => {
+ it('registers js_eval tool handler', async () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'js-sandbox',
+ init(extCtx) {
+ extCtx.tools.handle('js_eval', async (args) => {
+ return { success: true, result: 'mock' };
+ });
+ },
+ destroy() {}
+ });
+ await ctx.Extensions.initAll();
+ assert.ok(ctx.Extensions._toolHandlers.has('js_eval'));
+ });
+
+ it('routes tool calls through the bridge', async () => {
+ const ctx = loadExtensions();
+ let receivedCode = null;
+
+ ctx.Extensions.register({
+ id: 'js-sandbox',
+ init(extCtx) {
+ extCtx.tools.handle('js_eval', async (args) => {
+ receivedCode = args.code;
+ return { success: true, result: '42', output: [] };
+ });
+ },
+ destroy() {}
+ });
+ await ctx.Extensions.initAll();
+
+ let resultEmitted = null;
+ ctx.Events.on('tool.result.eval-1', (payload) => {
+ resultEmitted = payload;
+ });
+
+ ctx.Events.emit('tool.call.eval-1', {
+ call_id: 'eval-1',
+ tool: 'js_eval',
+ arguments: { code: '21 * 2' },
+ }, { localOnly: true });
+
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.strictEqual(receivedCode, '21 * 2');
+ assert.ok(resultEmitted);
+ const parsed = JSON.parse(resultEmitted.result);
+ assert.strictEqual(parsed.success, true);
+ assert.strictEqual(parsed.result, '42');
+ });
+});
+
+// ═══════════════════════════════════════════
+// Regex Tester Extension (Tool)
+// ═══════════════════════════════════════════
+
+describe('Regex Tester extension', () => {
+ function loadWithRegex() {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'regex-tester',
+ _formatMatch(match) {
+ const result = {
+ fullMatch: match[0],
+ index: match.index,
+ length: match[0].length,
+ };
+ if (match.groups) {
+ result.namedGroups = {};
+ for (const [name, value] of Object.entries(match.groups)) {
+ result.namedGroups[name] = value;
+ }
+ }
+ if (match.length > 1) {
+ result.groups = [];
+ for (let i = 1; i < match.length; i++) {
+ result.groups.push(match[i] !== undefined ? match[i] : null);
+ }
+ }
+ return result;
+ },
+ _test(pattern, flags, inputs) {
+ let regex;
+ try {
+ regex = new RegExp(pattern, flags);
+ } catch (e) {
+ return { success: false, error: `Invalid regex: ${e.message}`, pattern, flags };
+ }
+ const results = inputs.map(input => {
+ regex.lastIndex = 0;
+ const isGlobal = regex.global;
+ const matches = [];
+ if (isGlobal) {
+ let match;
+ while ((match = regex.exec(input)) !== null) {
+ matches.push(this._formatMatch(match));
+ if (match[0].length === 0) regex.lastIndex++;
+ }
+ } else {
+ const match = regex.exec(input);
+ if (match) matches.push(this._formatMatch(match));
+ }
+ return { input, matched: matches.length > 0, matchCount: matches.length, matches };
+ });
+ return {
+ success: true, pattern, flags,
+ summary: `${results.filter(r => r.matched).length}/${inputs.length} inputs matched`,
+ results,
+ };
+ },
+ init(extCtx) {
+ const self = this;
+ extCtx.tools.handle('regex_test', async (args) => {
+ return self._test(args.pattern, args.flags || '', args.inputs || []);
+ });
+ },
+ destroy() {}
+ });
+ return ctx;
+ }
+
+ it('registers regex_test tool handler', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+ assert.ok(ctx.Extensions._toolHandlers.has('regex_test'));
+ });
+
+ it('matches simple patterns', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+ const ext = ctx.Extensions._registry.get('regex-tester').def;
+
+ const result = ext._test('^\\d+$', '', ['123', 'abc', '456']);
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.summary, '2/3 inputs matched');
+ assert.strictEqual(result.results[0].matched, true);
+ assert.strictEqual(result.results[1].matched, false);
+ assert.strictEqual(result.results[2].matched, true);
+ });
+
+ it('captures groups', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+ const ext = ctx.Extensions._registry.get('regex-tester').def;
+
+ const result = ext._test('(\\d{3})-(\\d{4})', '', ['555-1234']);
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.results[0].matches[0].fullMatch, '555-1234');
+ assert.deepStrictEqual(result.results[0].matches[0].groups, ['555', '1234']);
+ });
+
+ it('handles global flag with multiple matches', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+ const ext = ctx.Extensions._registry.get('regex-tester').def;
+
+ const result = ext._test('\\d+', 'g', ['a1b2c3']);
+ assert.strictEqual(result.results[0].matchCount, 3);
+ assert.strictEqual(result.results[0].matches[0].fullMatch, '1');
+ assert.strictEqual(result.results[0].matches[1].fullMatch, '2');
+ assert.strictEqual(result.results[0].matches[2].fullMatch, '3');
+ });
+
+ it('returns error for invalid patterns', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+ const ext = ctx.Extensions._registry.get('regex-tester').def;
+
+ const result = ext._test('[invalid', '', ['test']);
+ assert.strictEqual(result.success, false);
+ assert.ok(result.error.includes('Invalid regex'));
+ });
+
+ it('handles named groups', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+ const ext = ctx.Extensions._registry.get('regex-tester').def;
+
+ const result = ext._test('(?\\d{4})-(?\\d{2})', '', ['2025-02']);
+ assert.strictEqual(result.results[0].matches[0].namedGroups.year, '2025');
+ assert.strictEqual(result.results[0].matches[0].namedGroups.month, '02');
+ });
+
+ it('routes through tool bridge', async () => {
+ const ctx = loadWithRegex();
+ await ctx.Extensions.initAll();
+
+ let resultEmitted = null;
+ ctx.Events.on('tool.result.regex-1', (payload) => {
+ resultEmitted = payload;
+ });
+
+ ctx.Events.emit('tool.call.regex-1', {
+ call_id: 'regex-1',
+ tool: 'regex_test',
+ arguments: { pattern: '^hello', flags: 'i', inputs: ['Hello world', 'goodbye'] },
+ }, { localOnly: true });
+
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.ok(resultEmitted);
+ const parsed = JSON.parse(resultEmitted.result);
+ assert.strictEqual(parsed.success, true);
+ assert.strictEqual(parsed.summary, '1/2 inputs matched');
+ });
+});
diff --git a/src/js/__tests__/extensions.test.js b/src/js/__tests__/extensions.test.js
new file mode 100644
index 0000000..6f75564
--- /dev/null
+++ b/src/js/__tests__/extensions.test.js
@@ -0,0 +1,491 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert');
+const { createBrowserContext, loadSource } = require('./helpers');
+const vm = require('vm');
+
+/**
+ * Load Events + Extensions into a browser context.
+ * Returns an object with Events, Extensions, and the sandbox.
+ * Note: const declarations in vm contexts aren't sandbox properties,
+ * so we extract them via vm.runInContext.
+ */
+function loadExtensions(overrides = {}) {
+ const sandbox = createBrowserContext(overrides);
+ loadSource(sandbox, 'events.js');
+ loadSource(sandbox, 'extensions.js');
+ // Extract const globals from the vm context
+ const ctx = vm.createContext(sandbox); // idempotent on already-contextified sandbox
+ return {
+ Extensions: vm.runInContext('Extensions', ctx),
+ Events: vm.runInContext('Events', ctx),
+ sandbox,
+ };
+}
+
+// ── Registration ─────────────────────────────
+
+describe('Extensions.register', () => {
+ it('registers an extension by id', () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({ id: 'test-ext', init() {} });
+ assert.ok(ctx.Extensions._registry.has('test-ext'));
+ });
+
+ it('rejects duplicate registrations', () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({ id: 'dup', init() {} });
+ ctx.Extensions.register({ id: 'dup', init() {} });
+ assert.strictEqual(ctx.Extensions._registry.size, 1);
+ });
+
+ it('rejects registration without id', () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({ init() {} });
+ assert.strictEqual(ctx.Extensions._registry.size, 0);
+ });
+});
+
+// ── Lifecycle ────────────────────────────────
+
+describe('Extensions.initAll', () => {
+ it('calls init on registered extensions', async () => {
+ const ctx = loadExtensions();
+ let initialized = false;
+ ctx.Extensions.register({
+ id: 'lifecycle-test',
+ init(extCtx) { initialized = true; },
+ });
+ await ctx.Extensions.initAll();
+ assert.ok(initialized);
+ });
+
+ it('marks extensions as active after init', async () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({ id: 'active-test', init() {} });
+ await ctx.Extensions.initAll();
+ const entry = ctx.Extensions._registry.get('active-test');
+ assert.strictEqual(entry.active, true);
+ });
+
+ it('survives init errors without crashing', async () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'bad-ext',
+ init() { throw new Error('init failed'); },
+ });
+ ctx.Extensions.register({
+ id: 'good-ext',
+ init() {},
+ });
+ await ctx.Extensions.initAll();
+ assert.strictEqual(ctx.Extensions._registry.get('bad-ext').active, false);
+ assert.strictEqual(ctx.Extensions._registry.get('good-ext').active, true);
+ });
+});
+
+describe('Extensions.destroyAll', () => {
+ it('calls destroy and deactivates extensions', async () => {
+ const ctx = loadExtensions();
+ let destroyed = false;
+ ctx.Extensions.register({
+ id: 'destroy-test',
+ init() {},
+ destroy() { destroyed = true; },
+ });
+ await ctx.Extensions.initAll();
+ ctx.Extensions.destroyAll();
+ assert.ok(destroyed);
+ assert.strictEqual(ctx.Extensions._registry.get('destroy-test').active, false);
+ });
+});
+
+// ── Context Object ──────────────────────────
+
+describe('Extension context', () => {
+ it('provides scoped events', async () => {
+ const ctx = loadExtensions();
+ let receivedPayload = null;
+ ctx.Extensions.register({
+ id: 'events-test',
+ init(extCtx) {
+ extCtx.events.on('test.event', (payload) => {
+ receivedPayload = payload;
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+ ctx.Events.emit('test.event', { data: 42 }, { localOnly: true });
+ assert.deepStrictEqual(receivedPayload, { data: 42 });
+ });
+
+ it('provides scoped storage', async () => {
+ const ctx = loadExtensions();
+ let storage = null;
+ ctx.Extensions.register({
+ id: 'storage-test',
+ init(extCtx) {
+ extCtx.storage.set('key1', { foo: 'bar' });
+ storage = extCtx.storage.get('key1');
+ },
+ });
+ await ctx.Extensions.initAll();
+ assert.strictEqual(storage.foo, 'bar');
+ // Verify namespacing
+ assert.ok(ctx.sandbox.localStorage._data['ext::storage-test::key1']);
+ });
+
+ it('provides frozen settings from defaults', async () => {
+ const ctx = loadExtensions();
+ let settings = null;
+ ctx.Extensions.register({
+ id: 'settings-test',
+ manifest: {
+ settings: {
+ showInline: { type: 'boolean', default: true },
+ limit: { type: 'number', default: 100 },
+ },
+ },
+ init(extCtx) { settings = extCtx.settings; },
+ });
+ await ctx.Extensions.initAll();
+ assert.strictEqual(settings.showInline, true);
+ assert.strictEqual(settings.limit, 100);
+ // Frozen — assignment silently ignored in sloppy mode
+ settings.showInline = false;
+ assert.strictEqual(settings.showInline, true, 'frozen settings should not change');
+ });
+});
+
+// ── Tool Registration ───────────────────────
+
+describe('ctx.tools.handle', () => {
+ it('registers a tool handler', async () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'tool-test',
+ init(extCtx) {
+ extCtx.tools.handle('my_tool', async (args) => {
+ return { result: args.input * 2 };
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+ assert.ok(ctx.Extensions._toolHandlers.has('my_tool'));
+ });
+});
+
+// ── Tool Bridge ─────────────────────────────
+
+describe('Tool bridge', () => {
+ it('routes tool.call.* to registered handler and emits result', async () => {
+ const ctx = loadExtensions();
+ let resultEmitted = null;
+
+ ctx.Extensions.register({
+ id: 'bridge-test',
+ init(extCtx) {
+ extCtx.tools.handle('double', async (args) => {
+ return { value: args.n * 2 };
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ // Listen for the result event
+ ctx.Events.on('tool.result.call-123', (payload) => {
+ resultEmitted = payload;
+ });
+
+ // Simulate server sending tool.call
+ ctx.Events.emit('tool.call.call-123', {
+ call_id: 'call-123',
+ tool: 'double',
+ arguments: { n: 21 },
+ }, { localOnly: true });
+
+ // Allow async handler to complete
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.ok(resultEmitted, 'result event should have been emitted');
+ assert.strictEqual(resultEmitted.call_id, 'call-123');
+ const parsed = JSON.parse(resultEmitted.result);
+ assert.strictEqual(parsed.value, 42);
+ });
+
+ it('returns error for unknown tool', async () => {
+ const ctx = loadExtensions();
+ let resultEmitted = null;
+ await ctx.Extensions.initAll();
+
+ ctx.Events.on('tool.result.call-unknown', (payload) => {
+ resultEmitted = payload;
+ });
+
+ ctx.Events.emit('tool.call.call-unknown', {
+ call_id: 'call-unknown',
+ tool: 'nonexistent_tool',
+ arguments: {},
+ }, { localOnly: true });
+
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.ok(resultEmitted);
+ assert.ok(resultEmitted.error, 'should have error field');
+ });
+
+ it('returns error when handler throws', async () => {
+ const ctx = loadExtensions();
+ let resultEmitted = null;
+
+ ctx.Extensions.register({
+ id: 'error-tool',
+ init(extCtx) {
+ extCtx.tools.handle('fail_tool', async () => {
+ throw new Error('something broke');
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ ctx.Events.on('tool.result.call-fail', (payload) => {
+ resultEmitted = payload;
+ });
+
+ ctx.Events.emit('tool.call.call-fail', {
+ call_id: 'call-fail',
+ tool: 'fail_tool',
+ arguments: {},
+ }, { localOnly: true });
+
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.ok(resultEmitted);
+ assert.ok(resultEmitted.error);
+ const parsed = JSON.parse(resultEmitted.error);
+ assert.strictEqual(parsed.error, 'something broke');
+ });
+});
+
+// ── Renderer Pipeline ───────────────────────
+
+describe('Renderer pipeline', () => {
+ it('registers and runs block renderers', async () => {
+ const ctx = loadExtensions();
+ let rendered = false;
+
+ ctx.Extensions.register({
+ id: 'renderer-test',
+ init(extCtx) {
+ extCtx.renderers.register('test-renderer', {
+ type: 'block',
+ pattern: 'mermaid',
+ render(lang, code, container) { rendered = true; },
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', {});
+ assert.ok(handled);
+ assert.ok(rendered);
+ });
+
+ it('skips non-matching block renderers', async () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'skip-test',
+ init(extCtx) {
+ extCtx.renderers.register('python-only', {
+ type: 'block',
+ pattern: 'python',
+ render() {},
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ const handled = ctx.Extensions.runBlockRenderers('javascript', 'code', {});
+ assert.strictEqual(handled, false);
+ });
+
+ it('runs post renderers on container', async () => {
+ const ctx = loadExtensions();
+ let postRan = false;
+
+ ctx.Extensions.register({
+ id: 'post-test',
+ init(extCtx) {
+ extCtx.renderers.register('post-proc', {
+ type: 'post',
+ render(container) { postRan = true; },
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ ctx.Extensions.runPostRenderers({});
+ assert.ok(postRan);
+ });
+
+ it('respects renderer priority ordering', async () => {
+ const ctx = loadExtensions();
+ const order = [];
+
+ ctx.Extensions.register({
+ id: 'priority-test',
+ init(extCtx) {
+ extCtx.renderers.register('low', {
+ type: 'post',
+ priority: 90,
+ render() { order.push('low'); },
+ });
+ extCtx.renderers.register('high', {
+ type: 'post',
+ priority: 10,
+ render() { order.push('high'); },
+ });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ ctx.Extensions.runPostRenderers({});
+ assert.deepStrictEqual(order, ['high', 'low']);
+ });
+});
+
+// ── Debug ────────────────────────────────────
+
+describe('Extensions.debug', () => {
+ it('returns extension info and renderer count', async () => {
+ const ctx = loadExtensions();
+ ctx.Extensions.register({
+ id: 'debug-ext',
+ init(extCtx) {
+ extCtx.renderers.register('r1', { type: 'post', render() {} });
+ },
+ });
+ await ctx.Extensions.initAll();
+
+ const info = ctx.Extensions.debug();
+ assert.ok(info.extensions['debug-ext']);
+ assert.strictEqual(info.extensions['debug-ext'].active, true);
+ assert.strictEqual(info.rendererCount, 1);
+ });
+});
+
+// ── Mermaid Extension ─────────────────────────────
+
+describe('Mermaid renderer extension', () => {
+ function loadWithMermaid() {
+ const ctx = loadExtensions();
+ // Load the mermaid extension script into the same VM context
+ const fs = require('fs');
+ const path = require('path');
+ // The script lives alongside the test as a fixture, or in the repo root
+ // For CI, we inline the registration to avoid path issues
+ ctx.Extensions.register({
+ id: 'mermaid-renderer',
+ _mermaidReady: false,
+ _mermaidLoading: null,
+ _escapeHtml(str) {
+ return str.replace(/&/g, '&').replace(//g, '>');
+ },
+ init(extCtx) {
+ const self = this;
+ extCtx.renderers.register('mermaid', {
+ type: 'block',
+ pattern: 'mermaid',
+ priority: 10,
+ render(lang, code, container) {
+ const id = 'mmd-test';
+ container.innerHTML = `
+
+
+
+ View source
+ ${self._escapeHtml(code.trim())}
+
+
+ `;
+ }
+ });
+ extCtx.renderers.register('mermaid-post', {
+ type: 'post',
+ priority: 10,
+ render(container) {
+ // In test, just mark that post-render ran
+ container._mermaidPostRan = true;
+ }
+ });
+ },
+ destroy() {}
+ });
+ return ctx;
+ }
+
+ it('registers both block and post renderers', async () => {
+ const ctx = loadWithMermaid();
+ await ctx.Extensions.initAll();
+ const info = ctx.Extensions.debug();
+ assert.strictEqual(info.extensions['mermaid-renderer'].active, true);
+ const renderers = info.extensions['mermaid-renderer'].renderers;
+ assert.strictEqual(renderers.length, 2);
+ assert.strictEqual(renderers[0], 'mermaid');
+ assert.strictEqual(renderers[1], 'mermaid-post');
+ assert.strictEqual(info.rendererCount, 2);
+ });
+
+ it('block renderer matches mermaid language', async () => {
+ const ctx = loadWithMermaid();
+ await ctx.Extensions.initAll();
+
+ const container = { innerHTML: '' };
+ const handled = ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A-->B', container);
+ assert.ok(handled);
+ assert.ok(container.innerHTML.includes('mermaid-block'));
+ assert.ok(container.innerHTML.includes('mermaid-diagram'));
+ assert.ok(container.innerHTML.includes(encodeURIComponent('graph LR; A-->B')));
+ });
+
+ it('block renderer does not match other languages', async () => {
+ const ctx = loadWithMermaid();
+ await ctx.Extensions.initAll();
+
+ const container = { innerHTML: '' };
+ const handled = ctx.Extensions.runBlockRenderers('javascript', 'const x = 1;', container);
+ assert.strictEqual(handled, false);
+ assert.strictEqual(container.innerHTML, '');
+ });
+
+ it('block renderer includes source in details element', async () => {
+ const ctx = loadWithMermaid();
+ await ctx.Extensions.initAll();
+
+ const container = { innerHTML: '' };
+ ctx.Extensions.runBlockRenderers('mermaid', 'sequenceDiagram\n A->>B: Hello', container);
+ assert.ok(container.innerHTML.includes('mermaid-source'));
+ assert.ok(container.innerHTML.includes('View source'));
+ assert.ok(container.innerHTML.includes('sequenceDiagram'));
+ });
+
+ it('block renderer escapes HTML in source view', async () => {
+ const ctx = loadWithMermaid();
+ await ctx.Extensions.initAll();
+
+ const container = { innerHTML: '' };
+ ctx.Extensions.runBlockRenderers('mermaid', 'graph LR; A[""]-->B', container);
+ assert.ok(container.innerHTML.includes('<script>'));
+ assert.ok(!container.innerHTML.includes('