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>
| M | apps/ | 2 | +- |
| A | apps/ | 154 | ++++++++++++++++++++++++++++++ |
| M | apps/ | 150 | +---------------------------- |
| M | apps/ | 4 | +- |
| M | apps/ | 2 | +- |
| M | apps/ | 4 | +- |
| M | apps/ | 17 | +++- |
| M | docs/ | 2 | +- |
| M | renderers/ | 2 | +- |
| R | services/ | 46 | +++++---- |
| M | services/ | 7 | +- |
| R | services/ | 49 | ++++++++-- |
| M | services/ | 2 | +- |
| M | shared/ | 2 | +- |
| M | shared/ | 20 | +++- |
@@ -24,7 +24,7 @@ Diagram.zip is a browser-based diagram editor. Write or paste source, edit struc
2424 - **Create**: choose a diagram type, edit Source and Details, and learn its short syntax guide.2525 - **Style**: set common presentation options in Details, then use type-specific settings.2626 - **Share**: understand read links, edit links, encryption, and saved state.27-- **Integrate**: render fenced diagram blocks for a relay through the [Tiny transform](/integrate/tiny-transform/) endpoint.27+- **Integrate**: render fenced diagram blocks for a relay through the [Block transform](/integrate/block-transform/) endpoint.2828 2929 The pages use short examples and direct links. Agents can use the same stable URLs and code blocks.3030 @@ -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": "..."}}`.@@ -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`.@@ -112,7 +112,7 @@ The [renderer catalog](https://diagram.zip/render/v1/catalog) reports every engi
112112 | Contract | Example identifier | Changes when |113113 | --- | --- | --- |114114 | Canonical schema | `1` | The canonical SVG structure changes incompatibly. |115-| Normalizer | `svg-normalizer-2` | Sanitization, annotation, or serialization behavior changes. |115+| Normalizer | `svg-normalizer-3` | Sanitization, annotation, or serialization behavior changes. |116116 | Profile | `neutral-svg-semantic-2` | Renderer recognition or role assignment changes. |117117 | Palette | `diagramzip-palette-2` | Materialized role colors change. |118118 | Materializer | `svg-materializer-4` | Appearance rules, padding, frame, or output assembly changes. |@@ -144,7 +144,7 @@ Normalized elements may also use `data-dz-fill`, `data-dz-stroke`, and `data-dz-
144144 ```xml145145 <svg146146 data-dz-schema="1"147- data-dz-normalizer="svg-normalizer-2"147+ data-dz-normalizer="svg-normalizer-3"148148 data-dz-engine="mermaid"149149 data-dz-profile="neutral-svg-semantic-2"150150 data-dz-palette="renderer"@@ -316,7 +316,7 @@ The [renderer catalog](https://diagram.zip/render/v1/catalog) reports every engi
316316 | Contract | Example identifier | Changes when |317317 | --- | --- | --- |318318 | Canonical schema | `1` | The canonical SVG structure changes incompatibly. |319-| Normalizer | `svg-normalizer-2` | Sanitization, annotation, or serialization behavior changes. |319+| Normalizer | `svg-normalizer-3` | Sanitization, annotation, or serialization behavior changes. |320320 | Profile | `neutral-svg-semantic-2` | Renderer recognition or role assignment changes. |321321 | Palette | `diagramzip-palette-2` | Materialized role colors change. |322322 | Materializer | `svg-materializer-4` | Appearance rules, padding, frame, or output assembly changes. |@@ -348,7 +348,7 @@ Normalized elements may also use `data-dz-fill`, `data-dz-stroke`, and `data-dz-
348348 ```xml349349 <svg350350 data-dz-schema="1"351- data-dz-normalizer="svg-normalizer-2"351+ data-dz-normalizer="svg-normalizer-3"352352 data-dz-engine="mermaid"353353 data-dz-profile="neutral-svg-semantic-2"354354 data-dz-palette="renderer"@@ -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>',@@ -134,7 +134,7 @@ Schema 1 uses the following root attributes:
134134 ```xml135135 <svg136136 data-dz-schema="1"137- data-dz-normalizer="svg-normalizer-2"137+ data-dz-normalizer="svg-normalizer-3"138138 data-dz-engine="example"139139 data-dz-profile="semantic-markup-2"140140 data-dz-palette="renderer"@@ -14,7 +14,7 @@ describe('engine catalog', () => {
1414 for (const engine of ENGINE_CATALOG) {1515 expect(engine.normalization).toMatchObject({1616 schema: '1',17- normalizer: 'svg-normalizer-2',17+ normalizer: 'svg-normalizer-3',1818 })1919 expect(engine.normalization.profile).not.toBe('safe-raw-1')2020 if (presentationOnly.has(engine.id)) {@@ -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)@@ -1,5 +1,5 @@
11 import { attachEditableDocument, extractEditableDocument } from '../../../shared/svg/index.js'2-import { TINY_TRANSFORM_PATH, tinyTransform, type TinyTransformEnv } from './tiny-transform'2+import { BLOCK_TRANSFORM_PATH, TINY_TRANSFORM_PATH, blockTransform, type BlockTransformEnv } from './block-transform'33 44 const ALIAS_PATH = /^\/d\/[A-Za-z0-9_-]{16}$/55 const PACKED_SVG_PATH = /^\/svg\/([A-Za-z0-9_-]+)$/@@ -85,7 +85,8 @@ export default {
8585 async fetch(request, env): Promise<Response> {8686 const url = new URL(request.url)8787 88- if (url.pathname === TINY_TRANSFORM_PATH) return tinyTransform(request, env)88+ if (url.pathname === BLOCK_TRANSFORM_PATH) return blockTransform(request, env)89+ if (url.pathname === TINY_TRANSFORM_PATH) return blockTransform(request, env, true)8990 9091 if (request.method !== 'GET' && request.method !== 'HEAD') {9192 return textResponse('Not found', 404, request.method)@@ -104,4 +105,4 @@ export default {
104105 105106 return textResponse('Not found', 404, request.method)106107 },107-} satisfies ExportedHandler<Env & TinyTransformEnv>108+} satisfies ExportedHandler<Env & BlockTransformEnv>@@ -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 @@ -11,7 +11,7 @@
1111 "assets": {1212 "directory": "../../apps/editor/dist",1313 "binding": "ASSETS",14- "run_worker_first": ["/", "/d/*", "/svg/*", "/healthz", "/transform/tiny"]14+ "run_worker_first": ["/", "/d/*", "/svg/*", "/healthz", "/transform/blocks", "/transform/tiny"]1515 },1616 "routes": [1717 {@@ -1,6 +1,6 @@
11 export const SVG_SCHEMA: '1'22 export const EDITABLE_SVG_SCHEMA: '1'3-export const NORMALIZER_BUILD: 'svg-normalizer-2'3+export const NORMALIZER_BUILD: 'svg-normalizer-3'44 export const MATERIALIZER_BUILD: 'svg-materializer-4'55 export const PALETTE_BUILD: 'diagramzip-palette-2'66 export const RAW_PROFILE: 'safe-raw-1'@@ -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 }