Remove the five in-browser renderer adapters from application code and send every preview and documentation example through its public renderer hostname. Delete the legacy iframe coordinator while preserving canonical SVG materialization and add routing coverage for all 30 engines.
- commit
- 820567b5b7b420c083fc3c256988b49b666a1dbe
- parent
- 02991b85b1609dda412c9ce5abfd808ac56db1ba
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 47 | +---------- |
| M | apps/ | 141 | ++++-------------------------- |
| M | apps/ | 14 | --- |
| M | apps/ | 12 | +- |
| M | apps/ | 3 | +- |
| D | apps/ | 139 | ------------------------------ |
| M | apps/ | 8 | -- |
| M | apps/ | 20 | +---- |
| M | apps/ | 5 | + |
| D | apps/ | 70 | --------------- |
| M | apps/ | 24 | +---- |
| M | apps/ | 59 | +------------ |
| M | apps/ | 21 | ++--- |
@@ -2,59 +2,14 @@ import { readFile, readdir } from 'node:fs/promises'
22 import { dirname, join } from 'node:path'33 import { fileURLToPath } from 'node:url'44 import { canonicalizeSvg, materializeSvg, supportedAppearances } from '../../../shared/svg/index.js'5-import { CLIENT_RENDERERS, httpRendererUrlFor } from '../src/components/DiagramExample/rendererRouting.mjs'5+import { httpRendererUrlFor } from '../src/components/DiagramExample/rendererRouting.mjs'66 77 const site = join(dirname(fileURLToPath(import.meta.url)), '..')88 const examplesDir = join(site, 'static', 'examples')99 const names = (await readdir(examplesDir)).filter((name) => name.endsWith('.json')).sort()1010 11-function cspSource(document, headers) {12- return headers.get('Content-Security-Policy')13- ?? document.match(/http-equiv="Content-Security-Policy"\s+content="([^"]+)"/i)?.[1]14- ?? ''15-}16-17-function cspAllows(csp, directive, origin) {18- const values = csp.split(';')19- .map(value => value.trim().split(/\s+/))20- .find(([name]) => name === directive)21- ?.slice(1) ?? []22- return values.includes('*') || values.includes(origin)23-}24-2511 async function check(name) {2612 const example = JSON.parse(await readFile(join(examplesDir, name), 'utf8'))27- if (CLIENT_RENDERERS[example.engine]) {28- const frameUrl = CLIENT_RENDERERS[example.engine]29- const response = await fetch(frameUrl)30- const body = await response.text()31- const scriptUrls = [...body.matchAll(/<script[^>]+src="([^"]+)"/gi)]32- .map((match) => new URL(match[1], frameUrl).href)33- const scripts = await Promise.all(scriptUrls.map(async (scriptUrl) => {34- const scriptResponse = await fetch(scriptUrl)35- return {36- ok: scriptResponse.ok,37- body: await scriptResponse.text(),38- }39- }))40- const hasProtocol = scripts.some((script) => script.ok && script.body.includes('diagram.zip:renderer:v1'))41- if (!response.ok || !hasProtocol) {42- throw new Error(`${example.engine}: client frame unavailable (HTTP ${response.status})`)43- }44- if (example.engine === 'excalidraw' && scripts.some((script) => script.body.includes('https://esm.sh'))) {45- const fallbackOrigin = 'https://esm.sh'46- if (!cspAllows(cspSource(body, response.headers), 'font-src', fallbackOrigin)) {47- throw new Error(`${example.engine}: frame CSP font-src blocks ${fallbackOrigin}`)48- }49- const fallbackFont = await fetch(`${fallbackOrigin}/@excalidraw/excalidraw@0.18.1/dist/prod/fonts/Cascadia/CascadiaCode-Regular.woff2`)50- const fallbackAvailable = fallbackFont.ok && fallbackFont.headers.get('Access-Control-Allow-Origin') === '*'51- await fallbackFont.body?.cancel()52- if (!fallbackAvailable) {53- throw new Error(`${example.engine}: packaged font fallback is unavailable (HTTP ${fallbackFont.status})`)54- }55- }56- return `${example.engine} client frame`57- }5813 const response = await fetch(httpRendererUrlFor(example.engine), {5914 method: 'POST',6015 headers: { Accept: 'image/svg+xml', 'Content-Type': 'application/json' },@@ -1,112 +1,10 @@
11 import React, {useCallback, useEffect, useRef, useState} from 'react';2-import {canonicalizeSvg, materializeSvg, supportedAppearances} from '../../../../../shared/svg/index.js';2+import {materializeSvg, supportedAppearances} from '../../../../../shared/svg/index.js';33 import {canonicalizeHttpRendererSvg} from './httpSvg.mjs';44 import {fitView, zoomView} from './viewMath.mjs';5-import {clientFrameUrlFor, httpRendererUrlFor} from './rendererRouting.mjs';5+import {httpRendererUrlFor} from './rendererRouting.mjs';66 import styles from './styles.module.css';77 8-const CHANNEL = 'diagram.zip:renderer:v1';9-const RENDER_TIMEOUT = 60_000;10-const clientFrames = new Map();11-12-class RendererFrame {13- constructor(frameUrl) {14- this.frameUrl = frameUrl;15- this.frame = null;16- this.ready = null;17- this.pending = new Map();18- this.sequence = 0;19- this.handleMessage = (event) => this.receive(event);20- window.addEventListener('message', this.handleMessage);21- }22-23- ensureFrame() {24- if (this.frame) return this.ready;25- const frame = document.createElement('iframe');26- frame.title = 'Diagram renderer';27- frame.setAttribute('sandbox', 'allow-scripts');28- frame.setAttribute('aria-hidden', 'true');29- frame.tabIndex = -1;30- Object.assign(frame.style, {31- position: 'fixed', left: '-200vw', top: '0', width: '1280px', height: '800px',32- border: '0', pointerEvents: 'none', visibility: 'hidden',33- });34- this.ready = new Promise((resolve, reject) => {35- this.resolveReady = resolve;36- this.rejectReady = reject;37- });38- frame.addEventListener('error', () => this.rejectReady?.(new Error('The client renderer could not be loaded.')), {once: true});39- frame.src = this.frameUrl;40- this.frame = frame;41- document.body.append(frame);42- return this.ready;43- }44-45- receive(event) {46- if (!this.frame || event.source !== this.frame.contentWindow || event.data?.channel !== CHANNEL) return;47- if (event.data.type === 'ready') {48- this.resolveReady?.();49- this.resolveReady = null;50- this.rejectReady = null;51- return;52- }53- if (event.data.type !== 'result') return;54- const pending = this.pending.get(event.data.requestId);55- if (!pending) return;56- this.pending.delete(event.data.requestId);57- pending.finish();58- if (event.data.ok && typeof event.data.svg === 'string') pending.resolve({59- svg: event.data.svg,60- version: typeof event.data.version === 'string' ? event.data.version : '',61- });62- else pending.reject(new Error(event.data.error || 'Client rendering failed.'));63- }64-65- async render(engine, source, signal) {66- let loadTimeout;67- try {68- await Promise.race([69- this.ensureFrame(),70- new Promise((_, reject) => { loadTimeout = setTimeout(() => reject(new Error('The client renderer timed out while loading.')), RENDER_TIMEOUT); }),71- ]);72- } finally {73- clearTimeout(loadTimeout);74- }75- if (signal.aborted) throw signal.reason || new DOMException('Render superseded.', 'AbortError');76- const requestId = `${Date.now().toString(36)}-${++this.sequence}`;77- return new Promise((resolve, reject) => {78- const timeout = setTimeout(() => {79- this.pending.delete(requestId);80- signal.removeEventListener('abort', onAbort);81- reject(new Error('The client renderer timed out.'));82- }, RENDER_TIMEOUT);83- const onAbort = () => {84- this.pending.delete(requestId);85- clearTimeout(timeout);86- reject(signal.reason || new DOMException('Render superseded.', 'AbortError'));87- };88- const finish = () => {89- clearTimeout(timeout);90- signal.removeEventListener('abort', onAbort);91- };92- signal.addEventListener('abort', onAbort, {once: true});93- this.pending.set(requestId, {resolve, reject, finish});94- this.frame.contentWindow.postMessage({channel: CHANNEL, type: 'render', requestId, engine, source}, '*');95- });96- }97-}98-99-function clientRendererFor(engine) {100- const frameUrl = clientFrameUrlFor(engine);101- if (!frameUrl) return null;102- let renderer = clientFrames.get(frameUrl);103- if (!renderer) {104- renderer = new RendererFrame(frameUrl);105- clientFrames.set(frameUrl, renderer);106- }107- return renderer;108-}109-1108 function normalizeSvg(source) {1119 const document = new DOMParser().parseFromString(source, 'image/svg+xml');11210 const svg = document.documentElement;@@ -172,27 +70,22 @@ export default function DiagramExample({engine, label, sourceUrl}) {
17270 const exampleResponse = await fetch(sourceUrl, {signal: abortController.signal});17371 if (!exampleResponse.ok) throw new Error('The example source is not available.');17472 const example = await exampleResponse.json();175- const clientRenderer = clientRendererFor(engine);17673 let source;177- if (clientRenderer) {178- const clientResult = await clientRenderer.render(engine, example.source, abortController.signal);179- source = canonicalizeSvg(clientResult.svg, {title: '', description: ''}, engine, clientResult.version);180- } else {181- const renderResponse = await fetch(httpRendererUrlFor(engine), {182- method: 'POST',183- headers: {Accept: 'image/svg+xml', 'Content-Type': 'application/json'},184- body: JSON.stringify({185- source: example.source,186- format: 'svg',187- options: {},188- metadata: {},189- presentation: {background: '', padding: 0, frame: false},190- }),191- signal: abortController.signal,192- });193- if (!renderResponse.ok) throw new Error(await responseError(renderResponse));194- source = canonicalizeHttpRendererSvg(await renderResponse.text(), engine, renderResponse.headers);195- }74+ const renderResponse = await fetch(httpRendererUrlFor(engine), {75+ method: 'POST',76+ headers: {Accept: 'image/svg+xml', 'Content-Type': 'application/json'},77+ body: JSON.stringify({78+ source: example.source,79+ format: 'svg',80+ options: {},81+ metadata: {},82+ presentation: {background: '', padding: 0, frame: false},83+ }),84+ signal: abortController.signal,85+ });86+ if (!renderResponse.ok) throw new Error(await responseError(renderResponse));87+ source = canonicalizeHttpRendererSvg(await renderResponse.text(), engine, renderResponse.headers);88+19689 const appearances = supportedAppearances(source);19790 const appearance = appearances.includes('auto-transparent')19891 ? 'auto-transparent'@@ -1,17 +1,3 @@
1-export const CLIENT_RENDERERS = Object.freeze({2- mermaid: 'https://mermaid.render.diagram.zip/index.html?v=1',3- bpmn: 'https://bpmn.render.diagram.zip/index.html?v=1',4- excalidraw: 'https://excalidraw.render.diagram.zip/index.html?v=4',5- diagramsnet: 'https://diagramsnet.render.diagram.zip/index.html?v=1',6- tikz: 'https://tikz.render.diagram.zip/index.html?v=1',7-})8-9-export const CLIENT_RENDERER_IDS = Object.freeze(Object.keys(CLIENT_RENDERERS))10-11-export function clientFrameUrlFor(engine) {12- return CLIENT_RENDERERS[engine] || null13-}14-151 export function httpRendererUrlFor(engine) {162 if (!/^[a-z0-9-]+$/.test(engine)) throw new Error('The renderer name is invalid.')173 return `https://${engine}.render.diagram.zip/v1/svg`@@ -1,17 +1,17 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 import {fitView, zoomView} from '../src/components/DiagramExample/viewMath.mjs';4-import {clientFrameUrlFor, httpRendererUrlFor} from '../src/components/DiagramExample/rendererRouting.mjs';4+import {httpRendererUrlFor} from '../src/components/DiagramExample/rendererRouting.mjs';55 import {canonicalizeHttpRendererSvg, rendererVersionFromHeaders} from '../src/components/DiagramExample/httpSvg.mjs';66 import {materializeSvg} from '../../../shared/svg/index.js';77 8-test('routes client examples to their isolated renderer frame', () => {9- assert.equal(clientFrameUrlFor('tikz'), 'https://tikz.render.diagram.zip/index.html?v=1');10- assert.equal(clientFrameUrlFor('graphviz'), null);11-});12-138 test('routes HTTP examples to their dedicated renderer unit', () => {149 assert.equal(httpRendererUrlFor('graphviz'), 'https://graphviz.render.diagram.zip/v1/svg');10+ assert.equal(httpRendererUrlFor('mermaid'), 'https://mermaid.render.diagram.zip/v1/svg');11+ assert.equal(httpRendererUrlFor('bpmn'), 'https://bpmn.render.diagram.zip/v1/svg');12+ assert.equal(httpRendererUrlFor('excalidraw'), 'https://excalidraw.render.diagram.zip/v1/svg');13+ assert.equal(httpRendererUrlFor('diagramsnet'), 'https://diagramsnet.render.diagram.zip/v1/svg');14+ assert.equal(httpRendererUrlFor('tikz'), 'https://tikz.render.diagram.zip/v1/svg');1515 });1616 1717 test('canonicalizes raw HTTP renderer SVG before docs appearance materialization', () => {@@ -207,7 +207,8 @@ materializes its own palette and, for framed appearances, its own canvas.
207207 208208 An anonymous draft instead exports the current deterministic editable SVG and209209 packs those bytes into `/svg/{compressed_editable_svg}`. This path does not210-proxy a render request and supports both HTTP and client renderer outputs.210+proxy a render request and works with the canonical output from every HTTP211+renderer unit.211212 212213 ## Editor and share UX213214 @@ -1,139 +0,0 @@
1-const CHANNEL = 'diagram.zip:renderer:v1'2-const RENDER_TIMEOUT = 60_0003-4-export const CLIENT_RENDERERS = Object.freeze({5- mermaid: Object.freeze({ frameUrl: 'https://mermaid.render.diagram.zip/index.html?v=1' }),6- bpmn: Object.freeze({ frameUrl: 'https://bpmn.render.diagram.zip/index.html?v=1' }),7- excalidraw: Object.freeze({ frameUrl: 'https://excalidraw.render.diagram.zip/index.html?v=4' }),8- diagramsnet: Object.freeze({ frameUrl: 'https://diagramsnet.render.diagram.zip/index.html?v=1' }),9- tikz: Object.freeze({ frameUrl: 'https://tikz.render.diagram.zip/index.html?v=2' }),10-})11-12-export const CLIENT_RENDERER_IDS = Object.freeze(Object.keys(CLIENT_RENDERERS))13-14-function abortError(reason) {15- if (reason?.name === 'AbortError') return reason16- return new DOMException('Render superseded.', 'AbortError')17-}18-19-export class RendererFrame {20- constructor({ documentObject = document, windowObject = window, frameUrl } = {}) {21- this.document = documentObject22- this.window = windowObject23- this.frameUrl = frameUrl24- this.frame = null25- this.ready = null26- this.resolveReady = null27- this.rejectReady = null28- this.sequence = 029- this.pending = new Map()30- this.handleMessage = event => this.receive(event)31- this.window.addEventListener('message', this.handleMessage)32- }33-34- ensureFrame() {35- if (this.frame) return this.ready36- const frame = this.document.createElement('iframe')37- frame.title = 'Diagram renderer'38- frame.setAttribute('sandbox', 'allow-scripts')39- frame.setAttribute('aria-hidden', 'true')40- frame.tabIndex = -141- Object.assign(frame.style, {42- position: 'fixed',43- left: '-200vw',44- top: '0',45- width: '1280px',46- height: '800px',47- border: '0',48- pointerEvents: 'none',49- visibility: 'hidden',50- })51- this.ready = new Promise((resolve, reject) => {52- this.resolveReady = resolve53- this.rejectReady = reject54- })55- frame.addEventListener('error', () => this.rejectReady?.(new Error('The client renderer could not be loaded.')), { once: true })56- frame.src = this.frameUrl57- this.frame = frame58- this.document.body.append(frame)59- return this.ready60- }61-62- receive(event) {63- if (!this.frame || event.source !== this.frame.contentWindow || event.data?.channel !== CHANNEL) return64- if (event.data.type === 'ready') {65- this.resolveReady?.()66- this.resolveReady = null67- this.rejectReady = null68- return69- }70- if (event.data.type !== 'result') return71- const pending = this.pending.get(event.data.requestId)72- if (!pending) return73- this.pending.delete(event.data.requestId)74- pending.finish()75- if (event.data.ok && typeof event.data.svg === 'string') {76- pending.resolve({77- body: event.data.svg,78- version: event.data.version,79- build: event.data.build,80- pipeline: event.data.pipeline,81- runtime: 'client',82- })83- } else {84- pending.reject(new Error(event.data.error || 'Client rendering failed.'))85- }86- }87-88- async render(engine, source, signal) {89- let loadTimeout90- try {91- await Promise.race([92- this.ensureFrame(),93- new Promise((_, reject) => {94- loadTimeout = setTimeout(() => reject(new Error('The client renderer timed out while loading.')), RENDER_TIMEOUT)95- }),96- ])97- } finally {98- clearTimeout(loadTimeout)99- }100- if (signal.aborted) throw abortError(signal.reason)101- const requestId = `${Date.now().toString(36)}-${++this.sequence}`102- return new Promise((resolve, reject) => {103- const timeout = setTimeout(() => {104- this.pending.delete(requestId)105- signal.removeEventListener('abort', onAbort)106- reject(new Error('The client renderer timed out.'))107- }, RENDER_TIMEOUT)108- const onAbort = () => {109- this.pending.delete(requestId)110- clearTimeout(timeout)111- reject(abortError(signal.reason))112- }113- const finish = () => {114- clearTimeout(timeout)115- signal.removeEventListener('abort', onAbort)116- }117- signal.addEventListener('abort', onAbort, { once: true })118- this.pending.set(requestId, { resolve, reject, finish })119- this.frame.contentWindow.postMessage({ channel: CHANNEL, type: 'render', requestId, engine, source }, '*')120- })121- }122-}123-124-const frames = new Map()125-126-export function clientAdapterFor(engine) {127- const renderer = CLIENT_RENDERERS[engine]128- if (!renderer) return null129- let frame = frames.get(renderer.frameUrl)130- if (!frame) {131- frame = new RendererFrame({ frameUrl: renderer.frameUrl })132- frames.set(renderer.frameUrl, frame)133- }134- return {135- id: engine,136- runtime: 'client',137- render: ({ source }, signal) => frame.render(engine, source, signal),138- }139-}@@ -1,4 +1,3 @@
1-import { clientAdapterFor } from './client-renderers.js'21 import { canonicalizeSvg, materializeSvg, supportedAppearances } from './client-svg.js'32 import { httpRendererUnitFor } from './renderer-units.js'43 @@ -6,7 +5,6 @@ const MAX_CONCURRENT_RENDERS = 4
65 76 export function createLauncherPreviewRenderer({87 fetchImpl = fetch,9- clientAdapterForImpl = clientAdapterFor,108 endpointFor = httpRendererUnitFor,119 createObjectURL = value => URL.createObjectURL(value),1210 revokeObjectURL = value => URL.revokeObjectURL(value),@@ -64,12 +62,6 @@ export function createLauncherPreviewRenderer({
6462 6563 async function renderState({ type, source, options = {}, meta = {}, presentation = {} }) {6664 if (!type || typeof source !== 'string' || !source.trim()) throw new Error('A diagram source is required for its preview.')67- const clientAdapter = clientAdapterForImpl(type)68- if (clientAdapter) {69- const result = await clientAdapter.render({ type, source, options }, new AbortController().signal)70- return { body: validateSvg(result.body), version: result.version ?? result.build ?? '' }71- }72-7365 const endpoint = endpointFor(type)7466 if (!endpoint) throw new Error(`No renderer is registered for ${type}.`)7567 const response = await fetchImpl(endpoint, {@@ -1,4 +1,3 @@
1-import { clientAdapterFor } from './client-renderers.js'21 import { canonicalizeSvg, materializePresentation, materializeSvg, supportedAppearances } from './client-svg.js'32 import { httpRendererUnitFor } from './renderer-units.js'43 @@ -147,24 +146,7 @@ export class PreviewController {
147146 this.stage.style.setProperty('--render-background', 'var(--preview-bg)')148147 149148 try {150- const clientAdapter = clientAdapterFor(type)151- let rendered152- if (clientAdapter) {153- const clientRender = await clientAdapter.render({ type, source, options }, abortController.signal)154- rendered = {155- body: clientRender.body,156- identity: {157- unit: type,158- build: clientRender.build || clientRender.version,159- version: clientRender.version || clientRender.build || '',160- pipeline: Array.isArray(clientRender.pipeline) ? clientRender.pipeline : [type],161- },162- }163- this.status.dataset.cache = 'browser'164- this.status.dataset.renderer = type165- } else {166- rendered = await this.renderThroughUnit({ type, source, options, meta, presentation }, abortController.signal)167- }149+ const rendered = await this.renderThroughUnit({ type, source, options, meta, presentation }, abortController.signal)168150 if (requestNumber !== this.requestNumber) return169151 const canonicalSvg = canonicalizeSvg(rendered.body, meta, type, rendered.identity.version)170152 const canonical = this.normalizedSvgBlob(canonicalSvg)@@ -24,6 +24,11 @@ export const HTTP_RENDERER_UNITS = Object.freeze({
2424 vegalite: 'https://vegalite.render.diagram.zip/v1/svg',2525 wavedrom: 'https://wavedrom.render.diagram.zip/v1/svg',2626 wireviz: 'https://wireviz.render.diagram.zip/v1/svg',27+ mermaid: 'https://mermaid.render.diagram.zip/v1/svg',28+ bpmn: 'https://bpmn.render.diagram.zip/v1/svg',29+ excalidraw: 'https://excalidraw.render.diagram.zip/v1/svg',30+ diagramsnet: 'https://diagramsnet.render.diagram.zip/v1/svg',31+ tikz: 'https://tikz.render.diagram.zip/v1/svg',2732 })2833 2934 export function httpRendererUnitFor(engine) {@@ -1,70 +0,0 @@
1-import assert from 'node:assert/strict'2-import test from 'node:test'3-import { CLIENT_RENDERERS, CLIENT_RENDERER_IDS, RendererFrame, clientAdapterFor } from '../src/client-renderers.js'4-5-const CHANNEL = 'diagram.zip:renderer:v1'6-7-function fakeBrowser() {8- let listener9- const attributes = new Map()10- const contentWindow = {11- postMessage(message) {12- queueMicrotask(() => listener({13- source: contentWindow,14- data: {15- channel: CHANNEL,16- type: 'result',17- requestId: message.requestId,18- ok: true,19- svg: '<svg/>',20- version: 'mermaid@test',21- build: 'mermaid-test-unit-1',22- pipeline: ['mermaid'],23- },24- }))25- },26- }27- const frame = {28- style: {},29- contentWindow,30- setAttribute(name, value) { attributes.set(name, value) },31- addEventListener() {},32- }33- const windowObject = { addEventListener(_name, callback) { listener = callback } }34- const documentObject = {35- createElement() { return frame },36- body: { append() { queueMicrotask(() => listener({ source: contentWindow, data: { channel: CHANNEL, type: 'ready' } })) } },37- }38- return { attributes, documentObject, frame, windowObject }39-}40-41-test('registers the client-native renderers', () => {42- assert.deepEqual(CLIENT_RENDERER_IDS, ['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz'])43- assert.equal(CLIENT_RENDERERS.mermaid.frameUrl, 'https://mermaid.render.diagram.zip/index.html?v=1')44- assert.equal(CLIENT_RENDERERS.bpmn.frameUrl, 'https://bpmn.render.diagram.zip/index.html?v=1')45- assert.equal(CLIENT_RENDERERS.excalidraw.frameUrl, 'https://excalidraw.render.diagram.zip/index.html?v=4')46- assert.equal(CLIENT_RENDERERS.diagramsnet.frameUrl, 'https://diagramsnet.render.diagram.zip/index.html?v=1')47- assert.equal(CLIENT_RENDERERS.tikz.frameUrl, 'https://tikz.render.diagram.zip/index.html?v=2')48- assert.equal(clientAdapterFor('graphviz'), null)49-})50-51-test('renders through a script-only unique-origin frame', async () => {52- const browser = fakeBrowser()53- const renderer = new RendererFrame(browser)54- const result = await renderer.render('mermaid', 'flowchart LR\nA-->B', new AbortController().signal)55- assert.equal(result.body, '<svg/>')56- assert.equal(result.runtime, 'client')57- assert.equal(result.version, 'mermaid@test')58- assert.equal(result.build, 'mermaid-test-unit-1')59- assert.deepEqual(result.pipeline, ['mermaid'])60- assert.equal(browser.attributes.get('sandbox'), 'allow-scripts')61- assert.equal(browser.attributes.has('allow-same-origin'), false)62-})63-64-test('ignores renderer messages from any other window', async () => {65- const browser = fakeBrowser()66- const renderer = new RendererFrame(browser)67- const render = renderer.render('mermaid', 'flowchart LR\nA-->B', new AbortController().signal)68- renderer.receive({ source: {}, data: { channel: CHANNEL, type: 'result', requestId: 'forged', ok: true, svg: '<script/>' } })69- assert.equal((await render).body, '<svg/>')70-})@@ -8,7 +8,6 @@ test('renders an HTTP preview once and caches its object URL', async () => {
88 const requests = []99 const renderer = createLauncherPreviewRenderer({1010 endpointFor: type => `https://${type}.example/render`,11- clientAdapterForImpl: () => null,1211 fetchImpl: async (url, init) => {1312 requests.push({ url, init })1413 return { ok: true, text: async () => '<svg viewBox="0 0 1 1"></svg>' }@@ -25,26 +24,10 @@ test('renders an HTTP preview once and caches its object URL', async () => {
2524 assert.equal(JSON.parse(requests[0].init.body).source, state.source)2625 })2726 28-test('uses a client renderer when one is registered', async () => {29- let calls = 030- const renderer = createLauncherPreviewRenderer({31- clientAdapterForImpl: type => type === 'mermaid' ? {32- render: async () => { calls++; return { body: '<svg></svg>' } },33- } : null,34- endpointFor: () => { throw new Error('HTTP renderer should not be used') },35- createObjectURL: () => 'blob:mermaid',36- transformSvg: rendered => rendered.body,37- })38-39- assert.equal(await renderer.render({ type: 'mermaid', source: 'flowchart LR' }), 'blob:mermaid')40- assert.equal(calls, 1)41-})42-4327 test('accepts renderer SVGs with an XML declaration', async () => {4428 const renderer = createLauncherPreviewRenderer({45- clientAdapterForImpl: () => ({46- render: async () => ({ body: '<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1 1"></svg>' }),47- }),29+ endpointFor: () => 'https://symbolator.example/render',30+ fetchImpl: async () => ({ ok: true, text: async () => '<?xml version="1.0" encoding="UTF-8"?><svg viewBox="0 0 1 1"></svg>' }),4831 createObjectURL: () => 'blob:xml-svg',4932 transformSvg: rendered => rendered.body,5033 })@@ -54,7 +37,8 @@ test('accepts renderer SVGs with an XML declaration', async () => {
5437 5538 test('rejects malformed renderer output and includes source in cache identity', async () => {5639 const renderer = createLauncherPreviewRenderer({57- clientAdapterForImpl: () => ({ render: async () => ({ body: '<html></html>' }) }),40+ endpointFor: () => 'https://bad.example/render',41+ fetchImpl: async () => ({ ok: true, text: async () => '<html></html>' }),5842 transformSvg: rendered => rendered.body,5943 })6044 await assert.rejects(renderer.render({ type: 'bad', source: 'bad' }), /did not return an SVG/)@@ -6,6 +6,7 @@ import {
66 previewThemeForAppearance,77 previewViewKey,88 } from '../src/preview.js'9+import { httpRendererUnitFor } from '../src/renderer-units.js'910 1011 const request = {1112 source: 'blockdiag { A -> B; }',@@ -66,62 +67,10 @@ test('surfaces a GraphViz-family renderer failure', async () => {
6667 assert.deepEqual(endpoints, ['https://erd.render.diagram.zip/v1/svg'])6768 })6869 69-test('does not send a failed client renderer to an HTTP unit', async () => {70- let unitCalls = 071- const statuses = []72- const errors = []73- const controller = {74- abortController: null,75- stage: { style: { setProperty() {} } },76- setStatus(message, state) { statuses.push({ message, state }) },77- handleRenderError: PreviewController.prototype.handleRenderError,78- onError(message) { errors.push(message) },79- async renderThroughUnit() { unitCalls += 1 },70+test('routes formerly client-rendered engines through HTTP units', () => {71+ for (const type of ['mermaid', 'bpmn', 'excalidraw', 'diagramsnet', 'tikz']) {72+ assert.equal(httpRendererUnitFor(type), `https://${type}.render.diagram.zip/v1/svg`)8073 }81-82- await PreviewController.prototype.performRender.call(controller, {83- type: 'mermaid',84- source: 'flowchart LR; A --> B',85- options: {},86- meta: {},87- presentation: {},88- renderKey: 'client-failure',89- requestNumber: 1,90- })91-92- assert.equal(unitCalls, 0)93- assert.equal(statuses.at(-1).state, 'idle')94- assert.equal(errors.length, 1)95- assert.equal(typeof errors[0], 'string')96-})97-98-test('does not send a failed diagrams.net renderer to an HTTP unit', async () => {99- let unitCalls = 0100- const statuses = []101- const errors = []102- const controller = {103- abortController: null,104- stage: { style: { setProperty() {} } },105- setStatus(message, state) { statuses.push({ message, state }) },106- handleRenderError: PreviewController.prototype.handleRenderError,107- onError(message) { errors.push(message) },108- async renderThroughUnit() { unitCalls += 1 },109- }110-111- await PreviewController.prototype.performRender.call(controller, {112- type: 'diagramsnet',113- source: '<mxGraphModel/>',114- options: {},115- meta: {},116- presentation: {},117- renderKey: 'diagramsnet-client-failure',118- requestNumber: 1,119- })120-121- assert.equal(unitCalls, 0)122- assert.equal(statuses.at(-1).state, 'idle')123- assert.equal(errors.length, 1)124- assert.equal(typeof errors[0], 'string')12574 })12675 12776 test('keeps render details out of the status hint', () => {@@ -1,24 +1,24 @@
11 import assert from 'node:assert/strict'22 import test from 'node:test'3-import { CLIENT_RENDERER_IDS } from '../src/client-renderers.js'43 import { diagramTypes } from '../src/diagram-types.js'54 import { HTTP_RENDERER_UNITS, httpRendererUnitFor } from '../src/renderer-units.js'65 76 test('gives each HTTP renderer its own subdomain', () => {8- assert.equal(Object.keys(HTTP_RENDERER_UNITS).length, 25)7+ assert.equal(Object.keys(HTTP_RENDERER_UNITS).length, 30)98 for (const [engine, endpoint] of Object.entries(HTTP_RENDERER_UNITS)) {109 assert.equal(new URL(endpoint).hostname, `${engine}.render.diagram.zip`)1110 assert.equal(new URL(endpoint).pathname, '/v1/svg')1211 }1312 })1413 15-test('does not route client renderers through an HTTP unit', () => {14+test('routes every formerly client-rendered engine through its HTTP unit', () => {1615 assert.equal(httpRendererUnitFor('bytefield'), 'https://bytefield.render.diagram.zip/v1/svg')1716 assert.equal(httpRendererUnitFor('plantuml'), 'https://plantuml.render.diagram.zip/v1/svg')18- assert.equal(httpRendererUnitFor('mermaid'), null)19- assert.equal(httpRendererUnitFor('bpmn'), null)20- assert.equal(httpRendererUnitFor('excalidraw'), null)21- assert.equal(httpRendererUnitFor('diagramsnet'), null)17+ assert.equal(httpRendererUnitFor('mermaid'), 'https://mermaid.render.diagram.zip/v1/svg')18+ assert.equal(httpRendererUnitFor('bpmn'), 'https://bpmn.render.diagram.zip/v1/svg')19+ assert.equal(httpRendererUnitFor('excalidraw'), 'https://excalidraw.render.diagram.zip/v1/svg')20+ assert.equal(httpRendererUnitFor('diagramsnet'), 'https://diagramsnet.render.diagram.zip/v1/svg')21+ assert.equal(httpRendererUnitFor('tikz'), 'https://tikz.render.diagram.zip/v1/svg')2222 })2323 2424 test('does not invent an endpoint for an unknown renderer', () => {@@ -27,9 +27,6 @@ test('does not invent an endpoint for an unknown renderer', () => {
2727 2828 test('assigns exactly one final renderer path to every diagram type', () => {2929 const http = Object.keys(HTTP_RENDERER_UNITS)30- const client = [...CLIENT_RENDERER_IDS]31- assert.equal(http.length, 25)32- assert.equal(client.length, 5)33- assert.deepEqual(new Set([...http, ...client]), new Set(diagramTypes.map(({ id }) => id)))34- assert.equal(http.some(engine => client.includes(engine)), false)30+ assert.equal(http.length, 30)31+ assert.deepEqual(new Set(http), new Set(diagramTypes.map(({ id }) => id)))3532 })