rapidoc 11.0.1 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +171 -54
  2. package/dist/rapidoc-flow-min.js +580 -0
  3. package/dist/rapidoc-flow-min.js.LICENSE.txt +171 -0
  4. package/dist/rapidoc-min.js +645 -202
  5. package/dist/rapidoc-min.js.LICENSE.txt +381 -0
  6. package/package.json +57 -42
  7. package/src/README.md +150 -0
  8. package/src/components/README.md +91 -0
  9. package/src/components/api-request.ts +912 -0
  10. package/src/components/api-response.ts +514 -0
  11. package/src/components/json-tree.ts +171 -0
  12. package/src/components/schema-base-element.ts +329 -0
  13. package/src/components/schema-table.ts +475 -0
  14. package/src/components/schema-tree.ts +393 -0
  15. package/src/components/tag-input.ts +180 -0
  16. package/src/flow.js +3 -0
  17. package/src/index.js +6 -0
  18. package/src/oauth-receiver.ts +29 -0
  19. package/src/rapi-flow.ts +1178 -0
  20. package/src/rapidoc-mini.ts +603 -0
  21. package/src/rapidoc.ts +1854 -0
  22. package/src/styles/README.md +96 -0
  23. package/src/styles/auth-styles.js +318 -0
  24. package/src/styles/border-styles.js +24 -0
  25. package/src/styles/css-bundle.js +129 -0
  26. package/src/styles/custom-styles.js +8 -0
  27. package/src/styles/dialog-box-styles.js +102 -0
  28. package/src/styles/endpoint-styles.js +564 -0
  29. package/src/styles/flex-styles.js +16 -0
  30. package/src/styles/flow-styles.js +248 -0
  31. package/src/styles/font-styles.js +432 -0
  32. package/src/styles/info-styles.js +36 -0
  33. package/src/styles/input-styles.js +555 -0
  34. package/src/styles/layout-styles.js +396 -0
  35. package/src/styles/microlighter-styles.js +99 -0
  36. package/src/styles/nav-styles.js +633 -0
  37. package/src/styles/param-list-styles.js +261 -0
  38. package/src/styles/schema-styles.js +296 -0
  39. package/src/styles/tab-styles.js +106 -0
  40. package/src/styles/theme-tokens.js +582 -0
  41. package/src/templates/README.md +89 -0
  42. package/src/templates/advanced-search-dialog-template.js +94 -0
  43. package/src/templates/api-response-template.js +223 -0
  44. package/src/templates/callback-template.js +86 -0
  45. package/src/templates/code-samples-template.js +52 -0
  46. package/src/templates/components-template.js +86 -0
  47. package/src/templates/downloads-template.js +70 -0
  48. package/src/templates/endpoint-template.js +377 -0
  49. package/src/templates/expanded-endpoint-template.js +270 -0
  50. package/src/templates/focused-endpoint-template.js +146 -0
  51. package/src/templates/header-template.js +92 -0
  52. package/src/templates/logo-template.js +21 -0
  53. package/src/templates/main-body-template.js +102 -0
  54. package/src/templates/navbar-template.js +498 -0
  55. package/src/templates/overview-template.js +104 -0
  56. package/src/templates/request-body-template.js +673 -0
  57. package/src/templates/request-params-template.js +485 -0
  58. package/src/templates/security-scheme-template.js +1272 -0
  59. package/src/templates/server-template.js +153 -0
  60. package/src/utils/README.md +146 -0
  61. package/src/utils/arazzo/README.md +119 -0
  62. package/src/utils/arazzo/components.js +15 -0
  63. package/src/utils/arazzo/graph.js +269 -0
  64. package/src/utils/arazzo/index.js +52 -0
  65. package/src/utils/arazzo/operations.js +106 -0
  66. package/src/utils/arazzo/parse.js +196 -0
  67. package/src/utils/arazzo/refs.js +40 -0
  68. package/src/utils/arazzo/svg.js +707 -0
  69. package/src/utils/auto-color-scheme.js +99 -0
  70. package/src/utils/bind-navbar.js +175 -0
  71. package/src/utils/color-utils.js +59 -0
  72. package/src/utils/common-utils.js +191 -0
  73. package/src/utils/curl-utils.js +126 -0
  74. package/src/utils/highlighter.js +419 -0
  75. package/src/utils/markdown-utils.js +110 -0
  76. package/src/utils/mcp/README.md +155 -0
  77. package/src/utils/mcp/common.js +166 -0
  78. package/src/utils/mcp/manifest.js +382 -0
  79. package/src/utils/mcp/project/server.ts +53 -0
  80. package/src/utils/mcp/project.js +88 -0
  81. package/src/utils/mcp/runtime.js +289 -0
  82. package/src/utils/mcp/schema.js +98 -0
  83. package/src/utils/mcp/workflow-runtime.js +374 -0
  84. package/src/utils/mcp/workflows.js +240 -0
  85. package/src/utils/mcp/zip.js +102 -0
  86. package/src/utils/mcp-generator.js +47 -0
  87. package/src/utils/mock-interceptor.js +557 -0
  88. package/src/utils/oauth-utils.js +87 -0
  89. package/src/utils/request-utils.js +264 -0
  90. package/src/utils/response-utils.js +203 -0
  91. package/src/utils/sanitize.js +57 -0
  92. package/src/utils/schema-utils.js +1954 -0
  93. package/src/utils/slugger.js +187 -0
  94. package/src/utils/spec-parser.js +941 -0
  95. package/src/utils/url-utils.js +246 -0
  96. package/src/utils/xml-utils.js +61 -0
@@ -0,0 +1,707 @@
1
+ /**
2
+ * Lays out a flow graph (see graph.js) and draws it as an SVG string. No DOM: text widths are
3
+ * estimated from character counts, so the same code runs in Node and the browser.
4
+ *
5
+ * Steps are stacked top to bottom between the workflow's inputs and outputs. Values passed between
6
+ * steps run in lanes on the right, and a goto draws a line through the lanes on the left to its
7
+ * target step. The inputs box (each input's type, required flag, default and enum values) and
8
+ * the outputs table are the same in both views; the views differ in the step boxes:
9
+ * - "expanded" (default): each step lists the values it sends and where they come from (input,
10
+ * step, fixed or expr), its checks, its actions as "condition → what happens", and the outputs
11
+ * something reads. Value lines run from the output row to the line that reads it, unlabelled.
12
+ * - "compact": each step shows only its name, method, path and number of checks; values passed
13
+ * to steps are labelled lines.
14
+ */
15
+
16
+ const BOX_W = 320; // compact
17
+ const BOX_MIN = 360; // expanded, grows with its longest line up to BOX_MAX
18
+ const BOX_MAX = 560;
19
+ const PAD = 16;
20
+ const ROW = 18;
21
+ const HEADER_H = 52; // a step's title and method/path lines
22
+ const GAP = 40;
23
+ const LANE = 20;
24
+ const MARGIN = 16;
25
+ const COMPACT_GOTO_Y = 30; // compact: first goto line leaves the box's left edge this far down
26
+ const ARRIVAL_ZONE = 18; // top strip of a step's left edge where goto lines arrive
27
+ const MAX_BINDINGS = 10; // more are summed up in one line; the step details list them all
28
+ const CHAR_W = 7.2; // average advance of a 12-13px sans character
29
+ const MONO_W = 7.4; // 12px monospace
30
+ const PILL_CHAR_W = 6.4; // 11px sans
31
+
32
+ export function escapeXml(text) {
33
+ return String(text ?? "")
34
+ .replace(/&/g, "&")
35
+ .replace(/</g, "&lt;")
36
+ .replace(/>/g, "&gt;")
37
+ .replace(/"/g, "&quot;")
38
+ .replace(/'/g, "&#39;");
39
+ }
40
+
41
+ function truncate(text, maxChars) {
42
+ const s = String(text ?? "");
43
+ if (maxChars < 2) return "";
44
+ return s.length <= maxChars ? s : `${s.slice(0, maxChars - 1)}…`;
45
+ }
46
+
47
+ /** A short action label for pills: "✗ retry 3× after 2s (if)". */
48
+ export function actionLabel(a) {
49
+ const mark = a.outcome === "failure" ? "✗" : "✓";
50
+ let text;
51
+ if (a.type === "retry") {
52
+ text = "retry";
53
+ if (a.retryLimit) text += ` ${a.retryLimit}×`;
54
+ if (a.retryAfter) text += ` after ${a.retryAfter}s`;
55
+ if (a.stepId) text += ` from ${a.stepId}`;
56
+ } else if (a.type === "goto") {
57
+ text = `goto ${a.stepId || a.workflowId}`;
58
+ } else text = "end";
59
+ if (a.conditional) text += " (if)";
60
+ return `${mark} ${text}`;
61
+ }
62
+
63
+ /** An action with its criteria, without the mark: "retry 3× after 1s if $statusCode == 503". */
64
+ export function actionSentence(a) {
65
+ const text = actionLabel({ ...a, conditional: false }).slice(2);
66
+ const when = (a.criteria || []).map((c) => c.condition).join(" and ");
67
+ return when ? `${text} if ${when}` : text;
68
+ }
69
+
70
+ /** Shortens one condition for a step box: "$statusCode == 503" reads "status 503". */
71
+ function shortCondition(condition) {
72
+ const m = condition.match(/^\$statusCode\s*==\s*(\d+)$/);
73
+ return m ? `status ${m[1]}` : condition;
74
+ }
75
+
76
+ /**
77
+ * An action in words, for a step box.
78
+ * `{ when: "status 503", does: "retry 3×, 1s apart", name: "retryOnServiceUnavailable",
79
+ * tag: "workflow" }`; `name` is the action's own name ("" when the file gives none), `tag` is
80
+ * "workflow" for an action the step inherits and "" for the step's own. An action without criteria applies whenever the
81
+ * step's checks fail (or pass); `checks` is how many the step has, for the wording. After other
82
+ * actions (`index` > 0 in its outcome's list) it reads "otherwise".
83
+ */
84
+ export function describeAction(a, checks = 0, index = 0) {
85
+ const criteria = a.criteria || [];
86
+ let when;
87
+ if (criteria.length) when = criteria.map((c) => shortCondition(c.condition)).join(" and ");
88
+ else if (index > 0) when = "otherwise";
89
+ else if (a.outcome === "failure") {
90
+ when = checks > 1 ? "checks fail" : checks ? "check fails" : "request fails";
91
+ } else when = checks > 1 ? "checks pass" : checks ? "check passes" : "request succeeds";
92
+ let does;
93
+ if (a.type === "end") does = "stop the workflow";
94
+ else if (a.type === "goto")
95
+ does = a.stepId ? `go to ${a.stepId}` : `run workflow ${a.workflowId}`;
96
+ else {
97
+ does = "retry";
98
+ if (a.retryLimit) does += ` ${a.retryLimit}×`;
99
+ if (a.retryAfter) does += `, ${a.retryAfter}s apart`;
100
+ if (a.stepId) does += ` from ${a.stepId}`;
101
+ }
102
+ return { when, does, name: a.name || "", tag: a.inherited ? "workflow" : "" };
103
+ }
104
+
105
+ /** Greedy lane assignment: edges whose spans overlap get different lanes. */
106
+ function assignLanes(edges) {
107
+ const lanes = [];
108
+ const sorted = [...edges].sort((a, b) => a.span[1] - a.span[0] - (b.span[1] - b.span[0]));
109
+ for (const e of sorted) {
110
+ let lane = 0;
111
+ while ((lanes[lane] || []).some((o) => e.span[0] < o.span[1] && o.span[0] < e.span[1])) lane++;
112
+ (lanes[lane] ||= []).push(e);
113
+ e.lane = lane;
114
+ }
115
+ return lanes.length;
116
+ }
117
+
118
+ function textWidth(text, charW) {
119
+ return Math.ceil(String(text).length * charW);
120
+ }
121
+
122
+ // A row is one line of text in a box, made of parts: { text, cls, w (char width), x | dx }.
123
+ // `x` places a part from the box's left edge; `dx` follows the previous part.
124
+ const part = (text, cls, w, place = {}) => ({ text: String(text), cls, w, ...place });
125
+
126
+ function rowExtent(row) {
127
+ let pos = 0;
128
+ const starts = row.parts.map((p) => {
129
+ pos = p.x !== undefined ? p.x : pos + (p.dx || 0);
130
+ const start = pos;
131
+ pos += textWidth(p.text, p.w);
132
+ return start;
133
+ });
134
+ return { starts, end: pos };
135
+ }
136
+
137
+ // Room a row's right-aligned tag takes, gap included
138
+ const tagWidth = (row) => (row.tag ? textWidth(row.tag, row.tagW || PILL_CHAR_W) + 16 : 0);
139
+
140
+ /** Cuts a row's last part so the row, and its tag, fit the box. */
141
+ function fitRow(row, boxW) {
142
+ const { starts } = rowExtent(row);
143
+ const last = row.parts.at(-1);
144
+ if (!last) return;
145
+ const room = boxW - PAD - tagWidth(row) - starts.at(-1);
146
+ last.text = truncate(last.text, Math.floor(room / last.w));
147
+ }
148
+
149
+ /**
150
+ * One row as SVG text. A row with `fromStep` (an output row, or a value line that reads a step)
151
+ * carries `data-from-step`; with `interactive` it is also an `rf-jump` link to that step, and the
152
+ * step name in its tag is focusable.
153
+ */
154
+ function renderRow(row, boxX, boxW, baseline, interactive = false) {
155
+ const jump = interactive && row.fromStep;
156
+ const from = row.fromStep ? ` data-from-step="${escapeXml(row.fromStep)}"` : "";
157
+ const rowCls = jump ? "rf-row rf-jump" : "rf-row";
158
+ const tspans = row.parts
159
+ .map((p) => {
160
+ const place = p.x !== undefined ? ` x="${boxX + p.x}"` : p.dx ? ` dx="${p.dx}"` : "";
161
+ return `<tspan class="${p.cls}"${place}>${escapeXml(p.text)}</tspan>`;
162
+ })
163
+ .join("");
164
+ const tag = row.tag
165
+ ? `<text class="${row.tagCls || "rf-tag"}${jump ? " rf-jump" : ""}" x="${boxX + boxW - PAD}" y="${baseline}" text-anchor="end"${from}${jump ? ` tabindex="0" role="link" aria-label="${escapeXml(`Go to step ${row.fromStep}`)}"` : ""}>${escapeXml(row.tag)}</text>`
166
+ : "";
167
+ const tip = row.tip ? `<title>${escapeXml(row.tip)}</title>` : "";
168
+ const rule = row.rule
169
+ ? `<path class="rf-sep" d="M${boxX} ${baseline - 13}H${boxX + boxW}"/>`
170
+ : "";
171
+ return `${rule}<text class="${rowCls}" y="${baseline}"${from}>${tip}${tspans}</text>${tag}`;
172
+ }
173
+
174
+ // The word before each value in a step box or the outputs box, by source kind
175
+ const KEYWORDS = { input: "input", step: "step", value: "fixed", expression: "expr" };
176
+ const KEYWORD_W = 6 * MONO_W; // "input" plus a space
177
+
178
+ function sourceParts(source, col) {
179
+ const keyword = part(KEYWORDS[source.kind], `rf-kw rf-kw-${source.kind}`, MONO_W, { x: col });
180
+ // A step output reads "<step>.<output>": the step name in the step color, the output plain
181
+ if (source.kind === "step") {
182
+ return [
183
+ keyword,
184
+ part(source.stepId, "rf-step-name", MONO_W, { x: col + KEYWORD_W }),
185
+ part(`.${source.name}`, "rf-bind-val", MONO_W),
186
+ ];
187
+ }
188
+ return [keyword, part(source.text, "rf-bind-val", MONO_W, { x: col + KEYWORD_W })];
189
+ }
190
+
191
+ /** "$response.body#/status" as "response body /status", for a step output's source. */
192
+ function shortExpression(expr) {
193
+ const text = typeof expr === "string" ? expr : JSON.stringify(expr);
194
+ const m = text.match(/^\$response\.(body|header)(?:#(.*)|\.(.+))?$/);
195
+ if (m) return `response ${m[1]}${m[2] || m[3] ? ` ${m[2] || m[3]}` : ""}`;
196
+ if (text === "$statusCode") return "status code";
197
+ return text;
198
+ }
199
+
200
+ /**
201
+ * The step's own outputs that something later reads, with where they come from. Lines to the
202
+ * steps and outputs that read them start at these rows. Unused outputs are counted on one line.
203
+ */
204
+ function stepOutputRows(step) {
205
+ if (!step.usedOutputs.length) return [];
206
+ const nameChars = Math.min(24, Math.max(4, ...step.usedOutputs.map((o) => o.name.length)));
207
+ const col = PAD + nameChars * MONO_W + 12;
208
+ const rows = [
209
+ { parts: [part("Outputs", "rf-sec-title", PILL_CHAR_W, { x: PAD })] },
210
+ ...step.usedOutputs.map((o) => ({
211
+ departs: o.name,
212
+ tip: typeof o.expr === "string" ? o.expr : JSON.stringify(o.expr),
213
+ parts: [
214
+ part(truncate(o.name, nameChars), "rf-bind-name", MONO_W, { x: PAD }),
215
+ part(shortExpression(o.expr), "rf-out-from", MONO_W, { x: col }),
216
+ ],
217
+ })),
218
+ ];
219
+ const unused = step.outputExprs.length - step.usedOutputs.length;
220
+ if (unused) {
221
+ rows.push({ parts: [part(`+${unused} unused`, "rf-more", PILL_CHAR_W, { x: PAD })] });
222
+ }
223
+ return rows;
224
+ }
225
+
226
+ /**
227
+ * The step's success criteria. Not numbered: all must pass, in no order. Regex, JSONPath and
228
+ * XPath ones show their type and context.
229
+ */
230
+ function checkRows(step) {
231
+ if (!step.criteria.length) return [];
232
+ return [
233
+ { parts: [part("Checks", "rf-sec-title", PILL_CHAR_W, { x: PAD })] },
234
+ ...step.criteria.map((c) => ({
235
+ parts: [
236
+ ...(c.type !== "simple"
237
+ ? [
238
+ part(`${c.type}${c.context ? ` on ${c.context}` : ""}:`, "rf-check-type", MONO_W, {
239
+ x: PAD,
240
+ }),
241
+ ]
242
+ : []),
243
+ part(c.condition, "rf-check", MONO_W, c.type !== "simple" ? { dx: 8 } : { x: PAD }),
244
+ ],
245
+ })),
246
+ ];
247
+ }
248
+
249
+ /**
250
+ * "On success" and "On failure" sections: one line per action, in the order a runner checks.
251
+ * Actions that can never run (`shadowed`) are left out; the graph's `warnings` name them.
252
+ */
253
+ function actionSections(step) {
254
+ return ["success", "failure"]
255
+ .map((outcome) => {
256
+ const actions = step.actions.filter((a) => a.outcome === outcome && !a.shadowed);
257
+ if (!actions.length) return [];
258
+ const described = actions.map((a, i) => describeAction(a, step.criteria.length, i));
259
+ const whenChars = Math.min(34, Math.max(...described.map((d) => d.when.length)));
260
+ const col = PAD + whenChars * CHAR_W + 12;
261
+ return [
262
+ {
263
+ parts: [
264
+ part(`On ${outcome} · first match runs`, "rf-sec-title", PILL_CHAR_W, { x: PAD }),
265
+ ],
266
+ },
267
+ ...actions.map((a, i) => ({
268
+ action: a,
269
+ tag: described[i].tag,
270
+ parts: [
271
+ part(truncate(described[i].when, whenChars), "rf-act-when", CHAR_W, { x: PAD }),
272
+ part(`→ ${described[i].does}`, "rf-act-then", CHAR_W, { x: col }),
273
+ // The name often says what the action is for; it is cut first when the line is long
274
+ ...(described[i].name
275
+ ? [part(`(${described[i].name})`, "rf-act-name", CHAR_W, { dx: 8 })]
276
+ : []),
277
+ ],
278
+ })),
279
+ ];
280
+ })
281
+ .filter((rows) => rows.length);
282
+ }
283
+
284
+ function inputRows(graph) {
285
+ const nameChars = Math.min(22, Math.max(4, ...graph.inputDetails.map((i) => i.name.length)));
286
+ const col = PAD + nameChars * CHAR_W + 16;
287
+ const rows = [];
288
+ for (const i of graph.inputDetails) {
289
+ const parts = [part(truncate(i.name, nameChars), "rf-in-name", CHAR_W, { x: PAD })];
290
+ // As <rapi-doc> labels it (schema-utils getTypeInfo): a string enum reads "enum"
291
+ const base = i.enum?.length ? i.type.replace("string", "enum") : i.type;
292
+ const type = base ? `${base}${i.format ? ` (${i.format})` : ""}` : "any";
293
+ parts.push(part(type, "rf-in-type", MONO_W, { x: col }));
294
+ if (i.required) parts.push(part("required", "rf-req", PILL_CHAR_W, { dx: 8 }));
295
+ if (i.default !== undefined) {
296
+ const value = typeof i.default === "string" ? i.default : JSON.stringify(i.default);
297
+ parts.push(part(`= ${value}`, "rf-in-default", MONO_W, { dx: 8 }));
298
+ }
299
+ rows.push({ parts, tip: i.description });
300
+ if (i.enum?.length) {
301
+ const values = i.enum.map((v) => (typeof v === "string" ? v : JSON.stringify(v)));
302
+ rows.push({ parts: [part(values.join(" | "), "rf-in-enum", MONO_W, { x: col })] });
303
+ }
304
+ }
305
+ return rows;
306
+ }
307
+
308
+ function bindingRows(step) {
309
+ const shown = step.bindings.slice(0, MAX_BINDINGS);
310
+ const nameChars = Math.min(18, Math.max(4, ...shown.map((b) => b.name.length)));
311
+ const col = PAD + nameChars * MONO_W + 12;
312
+ const rows = shown.map((b) => ({
313
+ arrivals: b.fromOutputs,
314
+ fromStep: b.fromSteps[0],
315
+ parts: [
316
+ part(truncate(b.name, nameChars), "rf-bind-name", MONO_W, { x: PAD }),
317
+ ...sourceParts(b.source, col),
318
+ ],
319
+ }));
320
+ const hidden = step.bindings.slice(MAX_BINDINGS);
321
+ if (hidden.length) {
322
+ rows.push({
323
+ arrivals: hidden.flatMap((b) => b.fromOutputs),
324
+ parts: [
325
+ part(`+${hidden.length} more in the step details`, "rf-more", PILL_CHAR_W, { x: PAD }),
326
+ ],
327
+ });
328
+ }
329
+ return rows;
330
+ }
331
+
332
+ /**
333
+ * The outputs box as a table: one flat column of output names (the workflow's result is a flat
334
+ * object), and on the right the step each group comes from, shown once per group, with a rule
335
+ * between groups. Groups follow the order each step first appears; outputs not taken from a step
336
+ * come last, their source kind in the step column. A step output with another name follows the
337
+ * output's name as "← <name>". A step's line arrives at its group's first row, beside the name.
338
+ */
339
+ function outputRows(graph) {
340
+ const groups = new Map();
341
+ for (const o of graph.outputs) {
342
+ const source = o.source || { kind: "expression", text: o.expr };
343
+ const key = o.stepId ? `step:${o.stepId}` : `kind:${source.kind}`;
344
+ if (!groups.has(key)) {
345
+ groups.set(key, { stepId: o.stepId, title: o.stepId || KEYWORDS[source.kind], items: [] });
346
+ }
347
+ groups.get(key).items.push({ ...o, source });
348
+ }
349
+ const ordered = [...groups.values()].sort((x, y) => Number(!x.stepId) - Number(!y.stepId));
350
+ return ordered.flatMap((g, gi) =>
351
+ g.items.map((o, i) => {
352
+ let from = "";
353
+ if (o.source.kind === "step") from = o.output && o.output !== o.name ? `← ${o.output}` : "";
354
+ else from = `${o.source.kind === "value" ? "=" : "←"} ${o.source.text}`;
355
+ return {
356
+ fromStep: g.stepId,
357
+ rule: gi > 0 && i === 0,
358
+ ...(i === 0
359
+ ? {
360
+ tag: g.title,
361
+ tagCls: g.stepId ? "rf-tag rf-out-step" : "rf-tag",
362
+ tagW: g.stepId ? CHAR_W : PILL_CHAR_W,
363
+ arrivals: g.items
364
+ .filter((x) => x.stepId && x.output)
365
+ .map((x) => ({ stepId: x.stepId, name: x.output })),
366
+ }
367
+ : {}),
368
+ parts: [
369
+ part(o.name, "rf-out-name", CHAR_W, { x: PAD }),
370
+ ...(from ? [part(from, "rf-out-from", MONO_W, { dx: 8 })] : []),
371
+ ],
372
+ };
373
+ }),
374
+ );
375
+ }
376
+
377
+ function headerWidth(step) {
378
+ const op = step.operation;
379
+ const sub = op
380
+ ? textWidth(op.method, MONO_W) + 8 + textWidth(op.path, MONO_W)
381
+ : textWidth(step.workflowId || step.operationRef, MONO_W) + 90;
382
+ return Math.max(textWidth(step.stepId, CHAR_W) + 90, sub) + PAD * 2;
383
+ }
384
+
385
+ /**
386
+ * Draws the diagram and reports which kinds of lines it drew, for a legend.
387
+ *
388
+ * @param {object} graph from buildFlowGraph()
389
+ * @param {object} [options]
390
+ * @param {'expanded'|'compact'} [options.view='expanded']
391
+ * @param {string} [options.idPrefix='rf'] prefix for marker ids, unique per page
392
+ * @param {boolean} [options.interactive=false] make operation steps focusable buttons
393
+ * @returns {{ svg: string, legend: string[] }} legend entries in drawing order, from
394
+ * `seq` (next step), `data` (value to a later step), `output` (value to the outputs box),
395
+ * `success` / `failure` (goto lines)
396
+ */
397
+ export function renderFlowDiagram(graph, options = {}) {
398
+ if (!graph) return { svg: "", legend: [] };
399
+ const { idPrefix = "rf", interactive = false } = options;
400
+ const expanded = options.view !== "compact";
401
+ const drawn = new Set();
402
+
403
+ // ---- Nodes, top to bottom, with their rows ----
404
+ const nodes = [];
405
+ // The inputs and outputs boxes are the same in both views; only step boxes are compacted
406
+ if (graph.inputs.length) {
407
+ nodes.push({ key: "inputs", kind: "io", title: "Inputs", rows: inputRows(graph) });
408
+ }
409
+ for (const step of graph.steps) {
410
+ if (expanded) {
411
+ nodes.push({
412
+ key: step.stepId,
413
+ kind: "step",
414
+ step,
415
+ sections: [
416
+ bindingRows(step),
417
+ checkRows(step),
418
+ ...actionSections(step),
419
+ stepOutputRows(step),
420
+ ].filter((rows) => rows.length),
421
+ });
422
+ } else {
423
+ // Compact boxes show no actions; a step's own gotos still draw their lines
424
+ const gotos = step.actions.filter(
425
+ (a) => !a.inherited && !a.shadowed && a.type === "goto" && a.stepId,
426
+ );
427
+ nodes.push({ key: step.stepId, kind: "step", step, gotos, sections: [] });
428
+ }
429
+ }
430
+ if (graph.outputs.length) {
431
+ nodes.push({ key: "outputs", kind: "io", title: "Outputs", rows: outputRows(graph) });
432
+ }
433
+ const index = new Map(nodes.map((n, i) => [n.key, i]));
434
+ const rowsOf = (n) => (n.kind === "io" ? n.rows : n.sections.flat());
435
+
436
+ // ---- Box width: the longest line, at least BOX_MIN (expanded) or BOX_W (compact) ----
437
+ const widths = nodes.flatMap((n) => [
438
+ ...rowsOf(n).map((r) => rowExtent(r).end + tagWidth(r) + PAD),
439
+ n.kind === "step" && expanded ? headerWidth(n.step) : 0,
440
+ ]);
441
+ const boxW = Math.min(BOX_MAX, Math.max(expanded ? BOX_MIN : BOX_W, ...widths));
442
+ for (const n of nodes) for (const r of rowsOf(n)) fitRow(r, boxW);
443
+ const boxChars = Math.floor((boxW - 32) / CHAR_W);
444
+
445
+ // ---- Heights and row baselines (relative to the node's top) ----
446
+ for (const n of nodes) {
447
+ if (n.kind === "io") {
448
+ n.rows.forEach((r, ri) => (r.by = 40 + ri * ROW));
449
+ n.h = 34 + n.rows.length * ROW;
450
+ continue;
451
+ }
452
+ if (!expanded) {
453
+ n.h = Math.max(60, COMPACT_GOTO_Y + n.gotos.length * 8 + 4);
454
+ continue;
455
+ }
456
+ let bottom = HEADER_H;
457
+ n.separators = [];
458
+ for (const rows of n.sections) {
459
+ const sepY = bottom + 6;
460
+ n.separators.push(sepY);
461
+ rows.forEach((r, ri) => (r.by = sepY + 18 + ri * ROW));
462
+ bottom = sepY + 18 + (rows.length - 1) * ROW + 6;
463
+ }
464
+ n.h = Math.max(60, bottom + 8);
465
+ }
466
+
467
+ // ---- Right-hand data edges ----
468
+ // Compact: one lane per source step and label, branching to the middle of each target.
469
+ // Expanded: one lane per source step, branching to each line that uses one of its outputs.
470
+ const groups = new Map();
471
+ const addTarget = (fromIdx, toIdx, label, rowBy, outputName) => {
472
+ const output = nodes[toIdx].key === "outputs";
473
+ const key = `${fromIdx}\u0000${output}\u0000${label}`;
474
+ const group = groups.get(key) || {
475
+ from: fromIdx,
476
+ label,
477
+ output,
478
+ targets: [],
479
+ departs: new Set(),
480
+ };
481
+ if (!group.targets.some((t) => t.node === toIdx && t.by === rowBy)) {
482
+ group.targets.push({ node: toIdx, by: rowBy });
483
+ }
484
+ if (outputName) group.departs.add(outputName);
485
+ groups.set(key, group);
486
+ };
487
+ if (expanded) {
488
+ // Lines start at each output row of the source step that is read
489
+ nodes.forEach((n, j) => {
490
+ for (const r of rowsOf(n)) {
491
+ for (const ref of r.arrivals || []) {
492
+ const i = index.get(ref.stepId);
493
+ if (i !== undefined && i < j) addTarget(i, j, "", r.by, ref.name);
494
+ }
495
+ }
496
+ });
497
+ } else {
498
+ for (const e of graph.dataEdges) {
499
+ if (!index.has(e.from) || !index.has(e.to) || index.get(e.from) >= index.get(e.to)) continue;
500
+ if (e.to === "outputs") {
501
+ // Into the outputs table: arrive at the step's group, as in the expanded view
502
+ const row = rowsOf(nodes[index.get("outputs")]).find((r) =>
503
+ r.arrivals?.some((ref) => ref.stepId === e.from),
504
+ );
505
+ addTarget(index.get(e.from), index.get(e.to), "", row?.by);
506
+ continue;
507
+ }
508
+ addTarget(index.get(e.from), index.get(e.to), truncate(e.labels.join(", "), 28), undefined);
509
+ }
510
+ }
511
+ const dataEdges = [...groups.values()].map((g) => {
512
+ const targets = g.targets.sort((a, b) => a.node - b.node || (a.by ?? 0) - (b.by ?? 0));
513
+ return { ...g, targets, span: [g.from, targets.at(-1).node] };
514
+ });
515
+ const dataLaneCount = assignLanes(dataEdges);
516
+
517
+ // ---- Left-hand goto edges, from the box's left edge (compact) or an action line (expanded) ----
518
+ const gotoEdges = [];
519
+ nodes.forEach((n, i) => {
520
+ if (n.kind !== "step") return;
521
+ const sources = expanded
522
+ ? rowsOf(n)
523
+ .filter((r) => r.action)
524
+ .map((r) => ({ action: r.action, by: r.by }))
525
+ : n.gotos.map((a, gi) => ({ action: a, by: COMPACT_GOTO_Y + gi * 8 + 4 }));
526
+ for (const src of sources) {
527
+ const target = src.action.type === "goto" ? src.action.stepId : "";
528
+ if (!target || !index.has(target) || target === n.key) continue;
529
+ const j = index.get(target);
530
+ gotoEdges.push({ ...src, from: i, to: j, span: [Math.min(i, j), Math.max(i, j)] });
531
+ }
532
+ });
533
+ const gotoLaneCount = assignLanes(gotoEdges);
534
+
535
+ // ---- Horizontal geometry ----
536
+ const gotoW = gotoLaneCount ? gotoLaneCount * LANE + 8 : 0;
537
+ const boxX = MARGIN + gotoW;
538
+ const boxR = boxX + boxW;
539
+ const laneX = (lane) => boxR + 20 + lane * LANE;
540
+ const labelW = Math.max(
541
+ 8,
542
+ ...dataEdges.map((e) => (e.label ? textWidth(e.label, PILL_CHAR_W) + 14 : 0)),
543
+ );
544
+ const width = (dataLaneCount ? laneX(dataLaneCount - 1) + labelW : boxR) + MARGIN;
545
+
546
+ // ---- Vertical geometry ----
547
+ let y = MARGIN;
548
+ for (const n of nodes) {
549
+ n.y = y;
550
+ n.cy = y + n.h / 2;
551
+ y += n.h + GAP;
552
+ }
553
+ const height = y - GAP + MARGIN;
554
+
555
+ // Connection points on the right edge. Compact: arrivals above the middle, departures below.
556
+ // Expanded: departures beside the header, arrivals at the line that uses the value.
557
+ const ports = new Map();
558
+ const portY = (nodeIdx, dir, by) => {
559
+ const n = nodes[nodeIdx];
560
+ if (dir === "in" && by !== undefined) return n.y + by - 4;
561
+ const key = `${nodeIdx}:${dir}`;
562
+ const used = ports.get(key) || 0;
563
+ ports.set(key, used + 1);
564
+ if (expanded && dir === "out") return n.y + 18 + used * 8;
565
+ return dir === "in" ? n.cy - 6 - used * 8 : n.cy + 6 + used * 8;
566
+ };
567
+
568
+ const out = [];
569
+ const marker = (id, cls) =>
570
+ `<marker id="${idPrefix}-${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="${cls}"/></marker>`;
571
+ out.push(
572
+ `<svg xmlns="http://www.w3.org/2000/svg" class="rf-svg rf-${expanded ? "expanded" : "compact"}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="group" aria-label="${escapeXml(`Workflow ${graph.workflowId}`)}">`,
573
+ `<defs>${marker("seq", "rf-head-seq")}${marker("data", "rf-head-data")}${marker("output", "rf-head-output")}${marker("success", "rf-head-success")}${marker("failure", "rf-head-failure")}</defs>`,
574
+ );
575
+
576
+ // Sequence arrows. A step whose success always ends or jumps never falls through to the next
577
+ // step, so no arrow is drawn into the next step (the outputs box still gets one).
578
+ const stopsOnSuccess = (n) =>
579
+ n.kind === "step" &&
580
+ n.step.actions.some(
581
+ (a) => a.outcome === "success" && !a.conditional && (a.type === "end" || a.type === "goto"),
582
+ );
583
+ for (let i = 0; i + 1 < nodes.length; i++) {
584
+ if (nodes[i + 1].kind === "step" && stopsOnSuccess(nodes[i])) continue;
585
+ const cx = boxX + boxW / 2;
586
+ drawn.add("seq");
587
+ out.push(
588
+ `<path class="rf-seq" d="M${cx} ${nodes[i].y + nodes[i].h}V${nodes[i + 1].y - 2}" marker-end="url(#${idPrefix}-seq)"/>`,
589
+ );
590
+ }
591
+
592
+ // Data edges and their labels
593
+ const placedLabels = [];
594
+ for (const e of dataEdges) {
595
+ const x = laneX(e.lane);
596
+ const cls = e.output ? "rf-data rf-to-output" : "rf-data";
597
+ const head = e.output ? "output" : "data";
598
+ drawn.add(head);
599
+ const ys = e.targets.map((t) => portY(t.node, "in", t.by));
600
+ const source = nodes[e.from];
601
+ const departs = expanded
602
+ ? rowsOf(source)
603
+ .filter((r) => r.departs && e.departs.has(r.departs))
604
+ .map((r) => source.y + r.by - 4)
605
+ : [];
606
+ if (!departs.length) departs.push(portY(e.from, "out"));
607
+ const y1 = Math.min(...departs);
608
+ const from = ` data-from-step="${escapeXml(source.key)}"`;
609
+ for (const dy of departs) out.push(`<path class="${cls}"${from} d="M${boxR} ${dy}H${x}"/>`);
610
+ out.push(`<path class="${cls}"${from} d="M${x} ${y1}V${Math.max(...ys)}"/>`);
611
+ for (const ty of ys) {
612
+ out.push(
613
+ `<path class="${cls}"${from} d="M${x} ${ty}H${boxR + 2}" marker-end="url(#${idPrefix}-${head})"/>`,
614
+ );
615
+ }
616
+ if (!e.label) continue;
617
+ const w = textWidth(e.label, PILL_CHAR_W) + 12;
618
+ let ly = (y1 + ys[0]) / 2 - 9;
619
+ while (placedLabels.some((l) => x < l.x + l.w && l.x < x + w && ly < l.y + 20 && l.y < ly + 20))
620
+ ly += 20;
621
+ placedLabels.push({ x, y: ly, w });
622
+ out.push(
623
+ `<g class="rf-data-label"><rect x="${x + 4}" y="${ly}" width="${w}" height="18" rx="9"/><text x="${x + 10}" y="${ly + 13}">${escapeXml(e.label)}</text></g>`,
624
+ );
625
+ }
626
+
627
+ // Goto edges
628
+ for (const e of gotoEdges) {
629
+ const from = nodes[e.from];
630
+ const to = nodes[e.to];
631
+ const x = MARGIN + (gotoLaneCount - 1 - e.lane) * LANE + 4;
632
+ const py = from.y + e.by - 4;
633
+ const start = boxX;
634
+ const ty = to.y + ARRIVAL_ZONE / 2;
635
+ drawn.add(e.action.outcome);
636
+ out.push(
637
+ `<path class="rf-goto rf-${e.action.outcome}" d="M${start} ${py}H${x}V${ty}H${boxX - 2}" marker-end="url(#${idPrefix}-${e.action.outcome})"/>`,
638
+ );
639
+ }
640
+
641
+ // Nodes
642
+ for (const n of nodes) {
643
+ if (n.kind === "io") {
644
+ out.push(
645
+ `<g class="rf-node rf-io rf-${n.key}"><rect x="${boxX}" y="${n.y}" width="${boxW}" height="${n.h}" rx="8"/>`,
646
+ `<text class="rf-io-title" x="${boxX + PAD}" y="${n.y + 20}">${n.title}</text>`,
647
+ n.key === "outputs" && graph.outputs.some((o) => o.stepId)
648
+ ? `<text class="rf-io-title" x="${boxX + boxW - PAD}" y="${n.y + 20}" text-anchor="end">From step</text>`
649
+ : "",
650
+ ...n.rows.map((r) => renderRow(r, boxX, boxW, n.y + r.by, interactive)),
651
+ `</g>`,
652
+ );
653
+ continue;
654
+ }
655
+ const s = n.step;
656
+ const op = s.operation;
657
+ const clickable = interactive && (op || s.kind === "workflow");
658
+ let sub;
659
+ if (s.kind === "workflow") {
660
+ sub = `<text class="rf-sub" x="${boxX + PAD}" y="${n.y + 44}">workflow ▸ ${escapeXml(truncate(s.workflowId, boxChars - 11))}</text>`;
661
+ } else if (op) {
662
+ const method = op.method.toUpperCase();
663
+ const methodW = textWidth(method, MONO_W) + 8;
664
+ const pathChars = Math.floor((boxW - 32 - methodW) / MONO_W);
665
+ sub =
666
+ `<text class="rf-method rf-method-${op.method}" x="${boxX + PAD}" y="${n.y + 44}">${method}</text>` +
667
+ `<text class="rf-path" x="${boxX + PAD + methodW}" y="${n.y + 44}">${escapeXml(truncate(op.path, pathChars))}</text>`;
668
+ } else {
669
+ const note = graph.operationsKnown ? " (not found)" : "";
670
+ sub = `<text class="rf-sub rf-unresolved" x="${boxX + PAD}" y="${n.y + 44}">${escapeXml(truncate(s.operationRef, boxChars - note.length))}${note}</text>`;
671
+ }
672
+ const tip = [
673
+ s.description,
674
+ op?.summary,
675
+ s.inputs.length ? `Inputs: ${s.inputs.join(", ")}` : "",
676
+ s.outputs.length ? `Outputs: ${s.outputs.join(", ")}` : "",
677
+ ]
678
+ .filter(Boolean)
679
+ .join("\n");
680
+ const label = op ? `${s.stepId}: ${op.method.toUpperCase()} ${op.path}` : s.stepId;
681
+ const attrs = clickable ? ` tabindex="0" role="button" aria-label="${escapeXml(label)}"` : "";
682
+ out.push(
683
+ `<g class="rf-node rf-step${clickable ? " rf-clickable" : ""}" data-step-id="${escapeXml(s.stepId)}"${attrs}>`,
684
+ tip ? `<title>${escapeXml(tip)}</title>` : "",
685
+ `<rect x="${boxX}" y="${n.y}" width="${boxW}" height="${n.h}" rx="8"/>`,
686
+ `<text class="rf-title" x="${boxX + PAD}" y="${n.y + 24}">${escapeXml(truncate(s.stepId, boxChars - 10))}</text>`,
687
+ sub,
688
+ s.successCriteria && !expanded
689
+ ? `<text class="rf-criteria" x="${boxR - 12}" y="${n.y + 24}" text-anchor="end">${s.successCriteria} check${s.successCriteria > 1 ? "s" : ""}</text>`
690
+ : "",
691
+ ...(n.separators || []).map(
692
+ (sy) => `<path class="rf-sep" d="M${boxX} ${n.y + sy}H${boxR}"/>`,
693
+ ),
694
+ ...n.sections.flat().map((r) => renderRow(r, boxX, boxW, n.y + r.by, interactive)),
695
+ `</g>`,
696
+ );
697
+ }
698
+
699
+ out.push("</svg>");
700
+ const order = ["seq", "data", "output", "success", "failure"];
701
+ return { svg: out.join(""), legend: order.filter((k) => drawn.has(k)) };
702
+ }
703
+
704
+ /** The diagram as an SVG string; see renderFlowDiagram for the options. */
705
+ export function renderFlowSvg(graph, options = {}) {
706
+ return renderFlowDiagram(graph, options).svg;
707
+ }