clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

feat: generalize block transforms and normalize dark SVGs

Dami committed 482971b
Add the generic block transform endpoint while retaining the legacy tiny route, update SVG normalization for Mermaid dark and transparent output, and document the integration contract.
commit
482971b8151308f04d9c285d566f450b69974e33
parent
92f4e4963b028ee61b8e19bbf7e02951fe475cc8
author
Dami <dami.etoile@gmail.com>
15 files changed, +269 -194
Mapps/docs/docs/index.mdx2+-
Aapps/docs/docs/integrate/block-transform.md154++++++++++++++++++++++++++++++
Mapps/docs/docs/integrate/tiny-transform.md150+----------------------------
Mapps/docs/docs/style/svg-normalization.md4+-
Mapps/docs/sidebars.js2+-
Mapps/docs/static/llms-full.txt4+-
Mapps/editor/test/svg-contract.test.js17+++-
Mdocs/svg-normalization.md2+-
Mrenderers/catalog/test/catalog.test.ts2+-
Rservices/shell/src/tiny-transform.ts → services/shell/src/block-transform.ts46+++++----
Mservices/shell/src/index.ts7+-
Rservices/shell/test/tiny-transform.test.ts → services/shell/test/block-transform.test.ts49++++++++--
Mservices/shell/wrangler.jsonc2+-
Mshared/svg/index.d.ts2+-
Mshared/svg/index.js20+++-
M apps/docs/docs/index.mdx +1 -1
A apps/docs/docs/integrate/block-transform.md +154 -0
@@ -0,0 +1,154 @@
1+---2+id: block-transform3+title: Block transform4+description: Render fenced diagram blocks from Nostr events into static SVG artifacts through the diagram.zip transform endpoint.5+sidebar_position: 16+---7+8+# Block transform9+10+tinyrelay is a self-hosted Nostr relay with custom views. A view POSTs the11+fenced snippets of a source event to a transform URL and stores the answer as12+static artifacts attached to that event. Diagram.zip provides the block13+transform at this endpoint:14+15+```text16+POST https://diagram.zip/transform/blocks17+```18+19+The former `/transform/tiny` route remains available for tinyrelay clients.20+It uses the legacy `X-Tiny-Signature` header and `TINY_TRANSFORM_SECRET`21+binding. New integrations should use the generic route and names below.22+23+The relay sends fenced code blocks. The endpoint maps each block language to a24+renderer and renders the blocks in parallel. It materializes the SVG in the25+requested appearance and returns one artifact per successful block. This is a server to26+server call. The endpoint does not send CORS headers and browsers cannot call27+it directly.28+29+## Request30+31+Send a JSON body with the fenced blocks the relay found in the event content.32+The body identifies the source event but never carries it:33+34+```json35+{36+ "relay": "https://relay.example",37+ "view": "diagrams",38+ "appearance": "auto-transparent",39+ "source": { "id": "<hex>", "kind": 30818 },40+ "blocks": [41+ { "index": 0, "lang": "mermaid", "source": "graph TD; a-->b" },42+ { "index": 1, "lang": "python", "source": "print(1)" }43+ ]44+}45+```46+47+| Field | Meaning |48+| --- | --- |49+| `relay` | The relay origin. Informational. |50+| `view` | The view name. Informational. |51+| `appearance` | Optional. One of the seven SVG appearances. The default is `auto-transparent`. |52+| `source` | Optional. The id and kind of the source event, kept as metadata. The endpoint reads nothing else from it. |53+| `blocks[].index` | The block position in the event content. It is echoed back on each artifact. |54+| `blocks[].lang` | The fence language. Blocks whose language is not a renderer are skipped. |55+| `blocks[].source` | The diagram source. |56+57+A body that includes an `event` field is rejected with `400`. The endpoint58+never receives event content, and this rule keeps that boundary visible.59+60+Send these headers with the request:61+62+| Header | Value |63+| --- | --- |64+| `Content-Type` | `application/json` |65+| `X-Transform-View` | The view name. |66+| `X-Transform-Relay` | The relay origin. |67+| `X-Transform-Signature` | `sha256=<hex>`, the HMAC-SHA256 of the raw request body. |68+69+## Signature and secret70+71+The signature is the hex HMAC-SHA256 of the raw request body, keyed with a72+shared secret. The worker verifies it with a constant-time comparison before it73+parses the body. A request without a valid signature is answered with `401`.74+75+The operator stores the secret as a Workers secret named `BLOCK_TRANSFORM_SECRET`:76+77+```sh78+wrangler secret put BLOCK_TRANSFORM_SECRET --config services/shell/wrangler.jsonc79+```80+81+When the secret is not set, the endpoint answers `503` with a JSON error. A82+misdeployed worker never renders unsigned input.83+84+## Languages and renderers85+86+Each block language is lowercased and looked up in this alias table. Every87+renderer id is also accepted as a language.88+89+| Language | Renderer |90+| --- | --- |91+| `mermaid`, `mmd` | `mermaid` |92+| `dot`, `graphviz` | `graphviz` |93+| `plantuml`, `puml`, `uml` | `plantuml` |94+| `c4plantuml`, `c4` | `c4plantuml` |95+| `svgbob`, `bob` | `svgbob` |96+| `vegalite`, `vega-lite` | `vegalite` |97+| `diagramsnet`, `drawio` | `diagramsnet` |98+| `d2`, `pikchr`, `goat`, `tikz`, `ditaa`, `erd`, `nomnoml`, `wavedrom`, `vega`, `dbml`, `bpmn`, `bytefield`, `excalidraw`, `structurizr`, `umlet`, `squaring`, `trn`, `wireviz`, `symbolator` | the same id |99+| `blockdiag`, `seqdiag`, `actdiag`, `nwdiag`, `packetdiag`, `rackdiag` | the same id |100+101+Each block is posted to its renderer unit at102+`https://{engine}.render.diagram.zip/v1/svg` with an empty presentation and no103+renderer options. Up to six blocks render at the same time. Each render has a104+20 second timeout.105+106+## Appearance107+108+The renderer unit answers with canonical SVG. The endpoint materializes it with109+the requested appearance, as described in110+[SVG normalization](/style/svg-normalization/). When the normalization profile111+of a renderer does not support the requested appearance, the artifact falls112+back to `raw` and reports that value. The `raw` appearance keeps the renderer113+palette.114+115+## Response116+117+A successful transform answers `200` with `Cache-Control: no-store`:118+119+```json120+{121+ "artifacts": [122+ {123+ "block": 0,124+ "engine": "mermaid",125+ "type": "image/svg+xml",126+ "body": "<svg ...>",127+ "appearance": "auto-transparent"128+ }129+ ],130+ "errors": [131+ { "block": 1, "engine": "d2", "error": "renderer answered 422: ..." }132+ ]133+}134+```135+136+A block failure never fails the request. A renderer error, a timeout, an137+unreachable unit, or SVG that cannot be materialized becomes one entry in138+`errors`. The other blocks still produce artifacts. Skipped languages139+appear in neither list.140+141+## Limits142+143+The endpoint rejects a request with a JSON error in these cases:144+145+| Condition | Status |146+| --- | --- |147+| Body larger than 1 MiB | `413` |148+| More than 32 blocks | `413` |149+| A block source longer than 512 KiB | `413` |150+| Unknown appearance, invalid JSON, a malformed block, or an `event` field | `400` |151+| Missing or wrong signature | `401` |152+| Secret not configured | `503` |153+154+Error bodies have the form `{"error": {"code": "...", "message": "..."}}`.
M apps/docs/docs/integrate/tiny-transform.md +7 -143
@@ -1,150 +1,14 @@
11 ---22 id: tiny-transform33 title: Tiny transform4-description: Render fenced diagram blocks from Nostr events into static SVG artifacts through the diagram.zip transform endpoint.5-sidebar_position: 14+description: Compatibility entry for the former tiny transform endpoint.5+slug: /integrate/tiny-transform/6+hide_table_of_contents: true7+sidebar: false68 ---79 810 # Tiny transform911 10-tinyrelay is a self-hosted Nostr relay with custom views. A view POSTs the11-fenced snippets of a source event to a transform URL and stores the answer as12-static artifacts attached to that event. Diagram.zip provides the diagram13-transform at this endpoint:14-15-```text16-POST https://diagram.zip/transform/tiny17-```18-19-The relay sends fenced code blocks. The endpoint maps each block language to a20-renderer and renders the blocks in parallel. It materializes the SVG in the21-requested appearance and returns one artifact per successful block. This is a server to22-server call. The endpoint does not send CORS headers and browsers cannot call23-it directly.24-25-## Request26-27-Send a JSON body with the fenced blocks the relay found in the event content.28-The body identifies the source event but never carries it:29-30-```json31-{32- "relay": "https://relay.example",33- "view": "diagrams",34- "appearance": "auto-transparent",35- "source": { "id": "<hex>", "kind": 30818 },36- "blocks": [37- { "index": 0, "lang": "mermaid", "source": "graph TD; a-->b" },38- { "index": 1, "lang": "python", "source": "print(1)" }39- ]40-}41-```42-43-| Field | Meaning |44-| --- | --- |45-| `relay` | The relay origin. Informational. |46-| `view` | The view name. Informational. |47-| `appearance` | Optional. One of the seven SVG appearances. The default is `auto-transparent`. |48-| `source` | Optional. The id and kind of the source event, kept as metadata. The endpoint reads nothing else from it. |49-| `blocks[].index` | The block position in the event content. It is echoed back on each artifact. |50-| `blocks[].lang` | The fence language. Blocks whose language is not a renderer are skipped. |51-| `blocks[].source` | The diagram source. |52-53-A body that includes an `event` field is rejected with `400`. The endpoint54-never receives event content, and this rule keeps that boundary visible.55-56-Send these headers with the request:57-58-| Header | Value |59-| --- | --- |60-| `Content-Type` | `application/json` |61-| `X-Tiny-View` | The view name. |62-| `X-Tiny-Relay` | The relay origin. |63-| `X-Tiny-Signature` | `sha256=<hex>`, the HMAC-SHA256 of the raw request body. |64-65-## Signature and secret66-67-The signature is the hex HMAC-SHA256 of the raw request body, keyed with a68-shared secret. The worker verifies it with a constant-time comparison before it69-parses the body. A request without a valid signature is answered with `401`.70-71-The operator stores the secret as a Workers secret named `TINY_TRANSFORM_SECRET`:72-73-```sh74-wrangler secret put TINY_TRANSFORM_SECRET --config services/shell/wrangler.jsonc75-```76-77-When the secret is not set, the endpoint answers `503` with a JSON error. A78-misdeployed worker never renders unsigned input.79-80-## Languages and renderers81-82-Each block language is lowercased and looked up in this alias table. Every83-renderer id is also accepted as a language.84-85-| Language | Renderer |86-| --- | --- |87-| `mermaid`, `mmd` | `mermaid` |88-| `dot`, `graphviz` | `graphviz` |89-| `plantuml`, `puml`, `uml` | `plantuml` |90-| `c4plantuml`, `c4` | `c4plantuml` |91-| `svgbob`, `bob` | `svgbob` |92-| `vegalite`, `vega-lite` | `vegalite` |93-| `diagramsnet`, `drawio` | `diagramsnet` |94-| `d2`, `pikchr`, `goat`, `tikz`, `ditaa`, `erd`, `nomnoml`, `wavedrom`, `vega`, `dbml`, `bpmn`, `bytefield`, `excalidraw`, `structurizr`, `umlet`, `squaring`, `trn`, `wireviz`, `symbolator` | the same id |95-| `blockdiag`, `seqdiag`, `actdiag`, `nwdiag`, `packetdiag`, `rackdiag` | the same id |96-97-Each block is posted to its renderer unit at98-`https://{engine}.render.diagram.zip/v1/svg` with an empty presentation and no99-renderer options. Up to six blocks render at the same time. Each render has a100-20 second timeout.101-102-## Appearance103-104-The renderer unit answers with canonical SVG. The endpoint materializes it with105-the requested appearance, as described in106-[SVG normalization](/style/svg-normalization/). When the normalization profile107-of a renderer does not support the requested appearance, the artifact falls108-back to `raw` and reports that value. The `raw` appearance keeps the renderer109-palette.110-111-## Response112-113-A successful transform answers `200` with `Cache-Control: no-store`:114-115-```json116-{117- "artifacts": [118- {119- "block": 0,120- "engine": "mermaid",121- "type": "image/svg+xml",122- "body": "<svg ...>",123- "appearance": "auto-transparent"124- }125- ],126- "errors": [127- { "block": 1, "engine": "d2", "error": "renderer answered 422: ..." }128- ]129-}130-```131-132-A block failure never fails the request. A renderer error, a timeout, an133-unreachable unit, or SVG that cannot be materialized becomes one entry in134-`errors`. The other blocks still produce artifacts. Skipped languages135-appear in neither list.136-137-## Limits138-139-The endpoint rejects a request with a JSON error in these cases:140-141-| Condition | Status |142-| --- | --- |143-| Body larger than 1 MiB | `413` |144-| More than 32 blocks | `413` |145-| A block source longer than 512 KiB | `413` |146-| Unknown appearance, invalid JSON, a malformed block, or an `event` field | `400` |147-| Missing or wrong signature | `401` |148-| Secret not configured | `503` |149-150-Error bodies have the form `{"error": {"code": "...", "message": "..."}}`.12+The transform moved to [Block transform](/integrate/block-transform/). The13+former `/transform/tiny` endpoint remains available for existing tinyrelay14+clients; new integrations should use `/transform/blocks`.
M apps/docs/docs/style/svg-normalization.md +2 -2
M apps/docs/sidebars.js +1 -1
M apps/docs/static/llms-full.txt +2 -2
M apps/editor/test/svg-contract.test.js +13 -4
@@ -41,7 +41,7 @@ test('produces deterministic, idempotent raw canonical SVG', () => {
4141 4242 assert.equal(first, repeated)4343 assert.equal(first, normalizedAgain)44- assert.match(first, /data-dz-normalizer="svg-normalizer-2"/)44+ assert.match(first, /data-dz-normalizer="svg-normalizer-3"/)4545 assert.match(first, /data-dz-profile="safe-raw-1"/)4646 assert.match(first, /data-dz-palette="renderer"/)4747 assert.match(first, /data-dz-conformance="raw"/)
@@ -59,7 +59,7 @@ test('recomputes stale semantic annotations when upgrading the normalizer build'
5959 const stale = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" data-dz-schema="1" data-dz-normalizer="svg-normalizer-1" data-dz-profile="graphviz-15-semantic-1" data-dz-materializer="svg-materializer-1"><style data-dz-owned="materializer">stale</style><rect data-dz-owned="materializer" data-dz-role="canvas" width="100" height="50"></rect><rect data-dz-owned="normalizer" data-dz-role="object-surface" data-dz-fill="surface-1" width="10" height="10"></rect><g class="node"><ellipse fill="none" stroke="black" stroke-width="0" cx="50" cy="25" rx="40" ry="20" data-dz-fill="surface-1"></ellipse><rect x="20" y="10" width="60" height="30" fill="white" stroke="black" data-renderer-id="kept" data-dz-fill="surface-3"></rect></g></svg>'6060 const upgraded = canonicalizeSvg(stale, metadata, 'dbml', 'dbml@1.0.31+graphviz@15.1.1')6161 62- assert.match(upgraded, /data-dz-normalizer="svg-normalizer-2"/)62+ assert.match(upgraded, /data-dz-normalizer="svg-normalizer-3"/)6363 assert.doesNotMatch(upgraded, /data-dz-owned=/)6464 assert.doesNotMatch(upgraded, /<ellipse[^>]*data-dz-/)6565 assert.match(upgraded, /data-renderer-id="kept" data-dz-fill="surface-1" data-dz-stroke="line"/)
@@ -83,7 +83,7 @@ test('exposes structured normalization failures to both callers', () => {
8383 )8484 })8585 86-const semanticCanonical = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" width="10" height="10" data-dz-schema="1" data-dz-normalizer="svg-normalizer-2" data-dz-profile="fixture-semantic-1" data-dz-palette="renderer" data-dz-engine="fixture" data-dz-appearance="raw" data-dz-conformance="semantic" data-dz-bounds="0 0 10 10" data-dz-appearances="auto-transparent light-transparent dark-transparent auto-framed light-framed dark-framed"><rect x="1" y="1" width="8" height="8" fill="white" stroke="black" data-dz-fill="surface-1" data-dz-stroke="line"></rect><text x="5" y="6" fill="black" data-dz-fill="ink">A</text></svg>'86+const semanticCanonical = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" width="10" height="10" data-dz-schema="1" data-dz-normalizer="svg-normalizer-3" data-dz-profile="fixture-semantic-1" data-dz-palette="renderer" data-dz-engine="fixture" data-dz-appearance="raw" data-dz-conformance="semantic" data-dz-bounds="0 0 10 10" data-dz-appearances="auto-transparent light-transparent dark-transparent auto-framed light-framed dark-framed"><rect x="1" y="1" width="8" height="8" fill="white" stroke="black" data-dz-fill="surface-1" data-dz-stroke="line"></rect><text x="5" y="6" fill="black" data-dz-fill="ink">A</text></svg>'8787 8888 test('materializes explicit and automatic palettes without changing diagram geometry', () => {8989 const light = materializeSvg(semanticCanonical, 'light-transparent')
@@ -302,13 +302,22 @@ test('recognizes nested D2 and WaveDrom canvases while leaving masks renderer-de
302302 303303 test('normalizes CSS-driven Mermaid and BPMN neutral structures', () => {304304 const mermaid = canonicalizeSvg(305- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><defs><marker><path class="arrowMarkerPath" d="M0 0L5 2L0 4Z"></path></marker></defs><path class="flowchart-link" d="M20 20L80 20"></path><g class="node default"><rect class="basic label-container" x="2" y="2" width="36" height="24"></rect><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Idea</span></div></foreignObject></g></svg>',305+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><defs><marker id="diagramzip-sequence-arrowhead"><path class="arrowMarkerPath" d="M0 0L5 2L0 4Z"></path></marker></defs><path class="flowchart-link" d="M20 20L80 20"></path><g class="node default"><rect class="basic label-container" x="2" y="2" width="36" height="24"></rect><foreignObject><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel">Idea</span></div></foreignObject></g></svg>',306306 metadata, 'mermaid', 'mermaid@11.17.0',307307 )308308 assert.match(mermaid, /class="arrowMarkerPath"[^>]*data-dz-fill="line"[^>]*data-dz-stroke="line"/)309+ assert.match(mermaid, /id="diagramzip-sequence-arrowhead"[^>]*>.*data-dz-fill="line"[^>]*data-dz-stroke="line"/)309310 assert.match(mermaid, /class="flowchart-link"[^>]*data-dz-stroke="line"/)310311 assert.match(mermaid, /class="basic label-container"[^>]*data-dz-fill="surface-1"[^>]*data-dz-stroke="line"/)311312 assert.match(mermaid, /class="nodeLabel"[^>]*data-dz-fill="ink"/)313+ const sequence = canonicalizeSvg(314+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><rect class="actor actor-top" x="2" y="2" width="36" height="24" fill="#eaeaea"></rect><text class="actor actor-box"><tspan>Owner</tspan></text><line class="actor-line" x1="20" y1="26" x2="20" y2="40"></line><line class="messageLine0" x1="20" y1="30" x2="80" y2="30"></line><rect class="labelBox" x="60" y="2" width="36" height="24"></rect></svg>',315+ metadata, 'mermaid', 'mermaid@11.17.0',316+ )317+ assert.match(sequence, /class="actor actor-top"[^>]*data-dz-fill="surface-1"[^>]*data-dz-stroke="line"/)318+ assert.match(sequence, /class="actor-line"[^>]*data-dz-stroke="line-muted"/)319+ assert.match(sequence, /class="messageLine0"[^>]*data-dz-stroke="line"/)320+ assert.match(sequence, /class="labelBox"[^>]*data-dz-fill="surface-1"[^>]*data-dz-stroke="line"/)312321 313322 const bpmn = canonicalizeSvg(314323 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40"><rect x="2" y="2" width="70" height="30" style="stroke:rgb(34,36,42);stroke-width:2px;fill:white"></rect><text class="djs-label" x="10" y="20" style="fill:rgb(34,36,42)"><tspan x="10" y="20">Task</tspan></text><rect class="djs-hit" x="0" y="0" width="100" height="40" style="fill:none;stroke-opacity:0;stroke:white"></rect></svg>',
M docs/svg-normalization.md +1 -1
M renderers/catalog/test/catalog.test.ts +1 -1
R services/shell/src/tiny-transform.ts → services/shell/src/block-transform.ts +25 -21
@@ -1,6 +1,7 @@
11 import { APPEARANCES, materializeSvg, SvgNormalizationError, supportedAppearances, type SvgAppearance } from '../../../shared/svg/index.js'22 import { ENGINE_IDS, type EngineId } from '../../../renderers/shared/engines'33 4+export const BLOCK_TRANSFORM_PATH = '/transform/blocks'45 export const TINY_TRANSFORM_PATH = '/transform/tiny'56 export const DEFAULT_RENDER_UNIT_ORIGIN_PATTERN = 'https://{engine}.render.diagram.zip'67 export const DEFAULT_APPEARANCE: SvgAppearance = 'auto-transparent'
@@ -24,18 +25,19 @@ const LANG_ALIASES: ReadonlyMap<string, EngineId> = new Map<string, EngineId>([
2425 ['drawio', 'diagramsnet'],2526 ])2627 27-export interface TinyTransformEnv {28+export interface BlockTransformEnv {29+ BLOCK_TRANSFORM_SECRET?: string2830 TINY_TRANSFORM_SECRET?: string2931 RENDER_UNIT_ORIGIN_PATTERN?: string3032 }3133 32-export interface TinyBlock {34+export interface Block {3335 index: number3436 lang: string3537 source: string3638 }3739 38-export interface TinyArtifact {40+export interface BlockArtifact {3941 block: number4042 engine: EngineId4143 type: 'image/svg+xml'
@@ -43,16 +45,16 @@ export interface TinyArtifact {
4345 appearance: SvgAppearance4446 }4547 46-export interface TinyBlockError {48+export interface BlockError {4749 block: number4850 engine: EngineId4951 error: string5052 }5153 52-interface TinyTransformRequest {54+interface BlockTransformRequest {5355 appearance: SvgAppearance5456 metadata: { title: string; description: string }55- blocks: TinyBlock[]57+ blocks: Block[]5658 }5759 5860 class TransformError extends Error {
@@ -76,7 +78,7 @@ function json(value: unknown, status = 200, headers: Record<string, string> = {}
7678 7779 function errorResponse(error: unknown): Response {7880 if (error instanceof TransformError) return json({ error: { code: error.code, message: error.message } }, error.status)79- console.error(JSON.stringify({ message: 'tiny transform error', error: error instanceof Error ? error.message : String(error) }))81+ console.error(JSON.stringify({ message: 'block transform error', error: error instanceof Error ? error.message : String(error) }))8082 return json({ error: { code: 'internal_error', message: 'The transform could not be completed.' } }, 500)8183 }8284
@@ -125,12 +127,12 @@ export async function signBody(secret: string, body: BufferSource): Promise<stri
125127 return `${SIGNATURE_PREFIX}${Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')}`126128 }127129 128-async function verifySignature(secret: string, body: Uint8Array, header: string | null): Promise<void> {130+async function verifySignature(secret: string, body: Uint8Array, header: string | null, headerName: string): Promise<void> {129131 const presented = header?.trim() ?? ''130132 const provided = presented.toLowerCase().startsWith(SIGNATURE_PREFIX) ? hexBytes(presented.slice(SIGNATURE_PREFIX.length)) : undefined131133 const expected = hexBytes((await signBody(secret, body)).slice(SIGNATURE_PREFIX.length))132134 if (provided === undefined || expected === undefined || provided.byteLength !== expected.byteLength || !crypto.subtle.timingSafeEqual(provided, expected)) {133- throw new TransformError(401, 'invalid_signature', 'X-Tiny-Signature does not match the request body.')135+ throw new TransformError(401, 'invalid_signature', `${headerName} does not match the request body.`)134136 }135137 }136138
@@ -139,7 +141,7 @@ function objectValue(value: unknown, name: string): Record<string, unknown> {
139141 return value as Record<string, unknown>140142 }141143 142-function parseBlock(value: unknown, position: number): TinyBlock {144+function parseBlock(value: unknown, position: number): Block {143145 const block = objectValue(value, `blocks[${position}]`)144146 const index = block.index ?? position145147 if (!Number.isInteger(index) || Number(index) < 0) throw new TransformError(400, 'invalid_request', `blocks[${position}].index must be a non-negative integer.`)
@@ -149,7 +151,7 @@ function parseBlock(value: unknown, position: number): TinyBlock {
149151 return { index: Number(index), lang: block.lang, source: block.source }150152 }151153 152-function parseTransformRequest(bytes: Uint8Array): TinyTransformRequest {154+function parseTransformRequest(bytes: Uint8Array): BlockTransformRequest {153155 let input: unknown154156 try { input = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes)) } catch {155157 throw new TransformError(400, 'invalid_json', 'Transform request is not valid JSON.')
@@ -185,7 +187,7 @@ function chooseAppearance(canonical: string, requested: SvgAppearance): SvgAppea
185187 return (supportedAppearances(canonical) as readonly string[]).includes(requested) ? requested : 'raw'186188 }187189 188-async function renderBlock(block: TinyBlock, engine: EngineId, request: TinyTransformRequest, env: TinyTransformEnv): Promise<TinyArtifact | TinyBlockError> {190+async function renderBlock(block: Block, engine: EngineId, request: BlockTransformRequest, env: BlockTransformEnv): Promise<BlockArtifact | BlockError> {189191 try {190192 const response = await fetch(renderUnitUrl(engine, env.RENDER_UNIT_ORIGIN_PATTERN), {191193 method: 'POST',
@@ -213,12 +215,12 @@ async function renderBlock(block: TinyBlock, engine: EngineId, request: TinyTran
213215 }214216 }215217 216-async function renderAll(request: TinyTransformRequest, env: TinyTransformEnv): Promise<Array<TinyArtifact | TinyBlockError>> {218+async function renderAll(request: BlockTransformRequest, env: BlockTransformEnv): Promise<Array<BlockArtifact | BlockError>> {217219 const jobs = request.blocks.flatMap(block => {218220 const engine = engineForLang(block.lang)219221 return engine === undefined ? [] : [{ block, engine }]220222 })221- const results = new Array<TinyArtifact | TinyBlockError>(jobs.length)223+ const results = new Array<BlockArtifact | BlockError>(jobs.length)222224 let next = 0223225 const worker = async (): Promise<void> => {224226 while (next < jobs.length) {
@@ -232,17 +234,19 @@ async function renderAll(request: TinyTransformRequest, env: TinyTransformEnv):
232234 return results233235 }234236 235-export async function tinyTransform(request: Request, env: TinyTransformEnv): Promise<Response> {237+export async function blockTransform(request: Request, env: BlockTransformEnv, legacy = false): Promise<Response> {236238 try {237- if (request.method !== 'POST') return json({ error: { code: 'method_not_allowed', message: 'Use POST for the transform endpoint.' } }, 405, { allow: 'POST' })238- const secret = env.TINY_TRANSFORM_SECRET239- if (typeof secret !== 'string' || secret === '') return json({ error: { code: 'transform_unavailable', message: 'TINY_TRANSFORM_SECRET is not configured.' } }, 503)239+ if (request.method !== 'POST') return json({ error: { code: 'method_not_allowed', message: 'Use POST for the block transform endpoint.' } }, 405, { allow: 'POST' })240+ const secretName = legacy ? 'TINY_TRANSFORM_SECRET' : 'BLOCK_TRANSFORM_SECRET'241+ const secret = legacy ? (env.TINY_TRANSFORM_SECRET ?? env.BLOCK_TRANSFORM_SECRET) : env.BLOCK_TRANSFORM_SECRET242+ if (typeof secret !== 'string' || secret === '') return json({ error: { code: 'transform_unavailable', message: `${secretName} is not configured.` } }, 503)240243 const bytes = await bodyBytes(request)241- await verifySignature(secret, bytes, request.headers.get('x-tiny-signature'))244+ const signatureHeader = legacy ? 'X-Tiny-Signature' : 'X-Transform-Signature'245+ await verifySignature(secret, bytes, request.headers.get(legacy ? 'x-tiny-signature' : 'x-transform-signature'), signatureHeader)242246 const results = await renderAll(parseTransformRequest(bytes), env)243247 return json({244- artifacts: results.filter((result): result is TinyArtifact => 'body' in result),245- errors: results.filter((result): result is TinyBlockError => 'error' in result),248+ artifacts: results.filter((result): result is BlockArtifact => 'body' in result),249+ errors: results.filter((result): result is BlockError => 'error' in result),246250 })247251 } catch (error) {248252 return errorResponse(error)
M services/shell/src/index.ts +4 -3
R services/shell/test/tiny-transform.test.ts → services/shell/test/block-transform.test.ts +39 -10
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
33 import { canonicalizeSvg } from '../../../shared/svg/index.js'44 import { ENGINE_IDS } from '../../../renderers/shared/engines'55 import worker from '../src/index'6-import { engineForLang, renderUnitUrl, signBody } from '../src/tiny-transform'6+import { engineForLang, renderUnitUrl, signBody } from '../src/block-transform'77 88 const SECRET = 'tiny-test-secret'99 const ORIGIN_PATTERN = 'https://{engine}.units.test'
@@ -45,17 +45,27 @@ function payload(blocks: Array<Record<string, unknown>>, extra: Record<string, u
4545 async function transform(body: string, options: { secret?: string | null; signature?: string; env?: Record<string, string> } = {}): Promise<Response> {4646 const bytes = new TextEncoder().encode(body)4747 const signature = options.signature ?? await signBody(SECRET, bytes)48- const request = new Request('https://diagram.zip/transform/tiny', {48+ const request = new Request('https://diagram.zip/transform/blocks', {4949 method: 'POST',50- headers: { 'content-type': 'application/json', 'x-tiny-view': 'diagrams', 'x-tiny-relay': 'https://relay.example', 'x-tiny-signature': signature },50+ headers: { 'content-type': 'application/json', 'x-transform-view': 'diagrams', 'x-transform-relay': 'https://relay.example', 'x-transform-signature': signature },5151 body: bytes,5252 })5353 const secret = options.secret === undefined ? SECRET : options.secret54- const testEnv = { ...env, RENDER_UNIT_ORIGIN_PATTERN: ORIGIN_PATTERN, ...(secret === null ? {} : { TINY_TRANSFORM_SECRET: secret }), ...options.env } as Env54+ const testEnv = { ...env, RENDER_UNIT_ORIGIN_PATTERN: ORIGIN_PATTERN, ...(secret === null ? {} : { BLOCK_TRANSFORM_SECRET: secret }), ...options.env } as Env5555 return worker.fetch(request as Parameters<typeof worker.fetch>[0], testEnv)5656 }5757 58-describe('tiny transform aliases', () => {58+async function legacyTransform(body: string): Promise<Response> {59+ const bytes = new TextEncoder().encode(body)60+ const request = new Request('https://diagram.zip/transform/tiny', {61+ method: 'POST',62+ headers: { 'content-type': 'application/json', 'x-tiny-signature': await signBody(SECRET, bytes) },63+ body: bytes,64+ })65+ return worker.fetch(request as Parameters<typeof worker.fetch>[0], { ...env, RENDER_UNIT_ORIGIN_PATTERN: ORIGIN_PATTERN, TINY_TRANSFORM_SECRET: SECRET } as Env)66+}67+68+describe('block transform aliases', () => {5969 it('maps every engine id to itself', () => {6070 for (const engine of ENGINE_IDS) expect(engineForLang(engine)).toBe(engine)6171 })
@@ -84,20 +94,39 @@ describe('tiny transform aliases', () => {
8494 })8595 })8696 87-describe('POST /transform/tiny', () => {97+describe('POST /transform/blocks', () => {8898 afterEach(() => { vi.unstubAllGlobals() })8999 100+ it('keeps the legacy tiny route and headers working', async () => {101+ unitFetch(() => svgResponse(MERMAID_CANONICAL))102+ const response = await legacyTransform(payload([{ index: 0, lang: 'mermaid', source: 'graph TD; a-->b' }]))103+ expect(response.status).toBe(200)104+ expect((await response.json() as { artifacts: unknown[] }).artifacts).toHaveLength(1)105+ })106+107+ it('does not accept a legacy signature on the canonical route', async () => {108+ const body = payload([])109+ const bytes = new TextEncoder().encode(body)110+ const request = new Request('https://diagram.zip/transform/blocks', {111+ method: 'POST',112+ headers: { 'x-tiny-signature': await signBody(SECRET, bytes) },113+ body: bytes,114+ })115+ const response = await worker.fetch(request as Parameters<typeof worker.fetch>[0], { ...env, BLOCK_TRANSFORM_SECRET: SECRET } as Env)116+ expect(response.status).toBe(401)117+ })118+90119 it('answers 503 when the transform secret is not configured', async () => {91120 const response = await transform(payload([]), { secret: null })92121 93122 expect(response.status).toBe(503)94123 expect(response.headers.get('content-type')).toContain('application/json')95- expect(await response.json()).toEqual({ error: { code: 'transform_unavailable', message: 'TINY_TRANSFORM_SECRET is not configured.' } })124+ expect(await response.json()).toEqual({ error: { code: 'transform_unavailable', message: 'BLOCK_TRANSFORM_SECRET is not configured.' } })96125 })97126 98127 it('is routed ahead of the GET and HEAD only check', async () => {99- const response = await SELF.fetch('https://diagram.zip/transform/tiny', { method: 'POST', body: payload([]) })100- const get = await SELF.fetch('https://diagram.zip/transform/tiny')128+ const response = await SELF.fetch('https://diagram.zip/transform/blocks', { method: 'POST', body: payload([]) })129+ const get = await SELF.fetch('https://diagram.zip/transform/blocks')101130 102131 expect(response.status).toBe(503)103132 expect(get.status).toBe(405)
@@ -113,7 +142,7 @@ describe('POST /transform/tiny', () => {
113142 114143 for (const response of [missing, wrong, otherSecret, short]) {115144 expect(response.status).toBe(401)116- expect(await response.json()).toEqual({ error: { code: 'invalid_signature', message: 'X-Tiny-Signature does not match the request body.' } })145+ expect(await response.json()).toEqual({ error: { code: 'invalid_signature', message: 'X-Transform-Signature does not match the request body.' } })117146 }118147 })119148
M services/shell/wrangler.jsonc +1 -1
M shared/svg/index.d.ts +1 -1
M shared/svg/index.js +17 -3
@@ -11,7 +11,7 @@ const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\
1111 1212 export const SVG_SCHEMA = '1'1313 export const EDITABLE_SVG_SCHEMA = '1'14-export const NORMALIZER_BUILD = 'svg-normalizer-2'14+export const NORMALIZER_BUILD = 'svg-normalizer-3'1515 export const MATERIALIZER_BUILD = 'svg-materializer-4'1616 export const PALETTE_BUILD = 'diagramzip-palette-2'1717 export const RAW_PROFILE = 'safe-raw-1'
@@ -639,16 +639,30 @@ function applyWavedromProfile(root) {
639639 }640640 641641 function applyNeutralRendererDetails(root, engine) {642- const visit = node => {642+ const visit = (node, marker = '') => {643643 if (node.type === 'text') return644644 const name = localName(node)645645 const classes = classNames(node)646646 const fill = ownPaint(node, 'fill')647+ const nextMarker = engine === 'mermaid' && name === 'marker' ? node.attributes.get('id') ?? '' : marker647648 if (engine === 'mermaid') {648649 if (classes.has('label-container')) {649650 node.attributes.set('data-dz-fill', 'surface-1')650651 node.attributes.set('data-dz-stroke', 'line')651652 }653+ // Sequence diagrams use stylesheet-driven actor and label boxes. Their654+ // literal fills are light Mermaid presentation values, so leaving them655+ // unannotated makes dark appearances put light text on pale boxes.656+ if (SHAPE_ELEMENTS.has(name) && (classes.has('actor') || classes.has('labelBox'))) {657+ node.attributes.set('data-dz-fill', 'surface-1')658+ node.attributes.set('data-dz-stroke', 'line')659+ }660+ if (classes.has('actor-line')) node.attributes.set('data-dz-stroke', 'line-muted')661+ if (classes.has('messageLine0') || classes.has('messageLine1')) node.attributes.set('data-dz-stroke', 'line')662+ if (name === 'path' && (nextMarker.endsWith('-arrowhead') || nextMarker.endsWith('-crosshead'))) {663+ node.attributes.set('data-dz-fill', nextMarker.endsWith('-arrowhead') ? 'line' : 'none')664+ node.attributes.set('data-dz-stroke', 'line')665+ }652666 if (classes.has('flowchart-link')) node.attributes.set('data-dz-stroke', 'line')653667 if (classes.has('arrowMarkerPath')) {654668 node.attributes.set('data-dz-fill', 'line')
@@ -662,7 +676,7 @@ function applyNeutralRendererDetails(root, engine) {
662676 node.attributes.set('data-dz-fill', 'line')663677 node.attributes.set('data-dz-stroke', 'line')664678 }665- for (const child of node.children) visit(child)679+ for (const child of node.children) visit(child, engine === 'mermaid' ? nextMarker : marker)666680 }667681 visit(root)668682 }