1import { canonicalizeSvg, materializeSvg, supportedAppearances } from './client-svg.js'2import { httpRendererUnitFor } from './renderer-units.js'34const MAX_CONCURRENT_RENDERS = 456export function createLauncherPreviewRenderer({7 fetchImpl = fetch,8 endpointFor = httpRendererUnitFor,9 createObjectURL = value => URL.createObjectURL(value),10 revokeObjectURL = value => URL.revokeObjectURL(value),11 transformSvg = materializeLauncherPreview,12 maxConcurrent = MAX_CONCURRENT_RENDERS,13} = {}) {14 const cache = new Map()15 const pending = new Map()16 const queued = []17 let active = 01819 function render(state, { priority = false, appearance = 'light-transparent' } = {}) {20 const key = previewKey(state, appearance)21 if (cache.has(key)) return Promise.resolve(cache.get(key))22 if (pending.has(key)) return pending.get(key)2324 const promise = schedule(() => renderState(state), priority)25 .then(rendered => {26 const svg = transformSvg(rendered, state, appearance)27 const url = createObjectURL(new Blob([svg], { type: 'image/svg+xml' }))28 cache.set(key, url)29 pending.delete(key)30 return url31 })32 .catch(error => {33 pending.delete(key)34 throw error35 })36 pending.set(key, promise)37 return promise38 }3940 function schedule(task, priority) {41 return new Promise((resolve, reject) => {42 const item = { task, resolve, reject }43 if (priority) queued.unshift(item)44 else queued.push(item)45 drain()46 })47 }4849 function drain() {50 while (active < maxConcurrent && queued.length) {51 const item = queued.shift()52 active++53 Promise.resolve()54 .then(item.task)55 .then(item.resolve, item.reject)56 .finally(() => {57 active--58 drain()59 })60 }61 }6263 async function renderState({ type, source, options = {}, meta = {}, presentation = {} }) {64 if (!type || typeof source !== 'string' || !source.trim()) throw new Error('A diagram source is required for its preview.')65 const endpoint = endpointFor(type)66 if (!endpoint) throw new Error(`No renderer is registered for ${type}.`)67 const response = await fetchImpl(endpoint, {68 method: 'POST',69 headers: { Accept: 'image/svg+xml', 'Content-Type': 'application/json' },70 body: JSON.stringify({71 source,72 format: 'svg',73 options,74 metadata: meta,75 presentation: {76 background: presentation.background ?? '',77 padding: presentation.padding ?? 0,78 frame: presentation.frame ?? false,79 },80 }),81 })82 if (!response.ok) throw new Error(`Preview render failed with HTTP ${response.status}.`)83 return {84 body: validateSvg(await response.text()),85 version: response.headers?.get?.('X-Diagram-Engine-Version')86 ?? response.headers?.get?.('X-Renderer-Build')87 ?? '',88 }89 }9091 function dispose() {92 for (const url of cache.values()) revokeObjectURL(url)93 cache.clear()94 }9596 return { render, dispose }97}9899export function previewKey({ type, source, options = {} }, appearance = 'light-transparent') {100 return JSON.stringify({ type, source, options, appearance })101}102103export function materializeLauncherPreview({ body, version = '' }, { type, meta = {} }, requestedAppearance) {104 const canonical = canonicalizeSvg(body, meta, type, version)105 const appearances = supportedAppearances(canonical)106 const appearance = appearances.includes(requestedAppearance)107 ? requestedAppearance108 : appearances.find(value => value.endsWith('-transparent')) ?? 'raw'109 return appearance === 'raw' ? canonical : materializeSvg(canonical, appearance)110}111112function validateSvg(source) {113 if (typeof source !== 'string') throw new Error('The renderer did not return an SVG preview.')114 const documentStart = source115 .replace(/^\uFEFF/, '')116 .trimStart()117 .replace(/^<\?xml[\s\S]*?\?>\s*/i, '')118 if (!/^<svg(?:\s|>)/i.test(documentStart)) throw new Error('The renderer did not return an SVG preview.')119 return source120}