x-openapi-flow 1.2.3 → 1.3.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.
@@ -12,6 +12,20 @@ window.XOpenApiFlowPlugin = function () {
12
12
  return String(value);
13
13
  }
14
14
 
15
+ function getPrerequisiteOperationIds(transition) {
16
+ if (!transition || typeof transition !== 'object') return [];
17
+ if (Array.isArray(transition.prerequisite_operation_ids)) {
18
+ return transition.prerequisite_operation_ids.filter(Boolean);
19
+ }
20
+ if (Array.isArray(transition.pre_operation_ids)) {
21
+ return transition.pre_operation_ids.filter(Boolean);
22
+ }
23
+ if (transition.pre_operation_id) {
24
+ return [transition.pre_operation_id];
25
+ }
26
+ return [];
27
+ }
28
+
15
29
  function transitionsList(currentState, transitions) {
16
30
  if (!Array.isArray(transitions) || transitions.length === 0) {
17
31
  return h("div", { style: { opacity: 0.85, fontStyle: "italic" } }, "No transitions (terminal state)");
@@ -143,6 +157,7 @@ window.XOpenApiFlowPlugin = function () {
143
157
 
144
158
  (function () {
145
159
  const styleId = 'x-openapi-flow-ui-style';
160
+ const FLOW_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
146
161
 
147
162
  function injectStyles() {
148
163
  if (document.getElementById(styleId)) return;
@@ -150,19 +165,79 @@ window.XOpenApiFlowPlugin = function () {
150
165
  const style = document.createElement('style');
151
166
  style.id = styleId;
152
167
  style.textContent = `
153
- .xof-card { border: 1px solid rgba(255,255,255,0.28); border-radius: 8px; padding: 10px; background: rgba(0,0,0,0.12); }
154
- .xof-title { font-weight: 700; margin-bottom: 8px; }
155
- .xof-meta { display: grid; grid-template-columns: 140px 1fr; gap: 4px 10px; font-size: 12px; margin-bottom: 10px; }
168
+ .xof-card { border: 1px solid rgba(127,127,127,0.38); border-radius: 10px; padding: 10px 12px; background: rgba(127,127,127,0.08); margin-top: 8px; }
169
+ .xof-card > summary { list-style: none; }
170
+ .xof-card > summary::-webkit-details-marker { display: none; }
171
+ .xof-title-row { display: flex; align-items: center; justify-content: space-between; cursor: pointer; gap: 12px; margin: 0; }
172
+ .xof-title { font-weight: 700; font-size: 13px; }
173
+ .xof-toggle-hint { font-size: 11px; opacity: 0.75; }
174
+ .xof-card-body { margin-top: 10px; }
175
+ .xof-section-title { font-size: 12px; font-weight: 700; margin: 10px 0 6px; }
176
+ .xof-meta { display: grid; grid-template-columns: 130px 1fr; gap: 6px 10px; font-size: 12px; margin-bottom: 8px; }
156
177
  .xof-meta-label { opacity: 0.85; }
157
178
  .xof-list { margin: 0; padding-left: 18px; }
158
- .xof-list li { margin: 4px 0; }
159
- .xof-graph { margin-top: 10px; padding: 8px; border: 1px dashed rgba(255,255,255,0.32); border-radius: 6px; }
179
+ .xof-list li { margin: 6px 0; line-height: 1.45; }
180
+ .xof-next-link { margin-left: 8px; border: 0; background: none; color: inherit; padding: 0; font-size: 11px; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
181
+ .xof-pre-links { margin-left: 8px; }
182
+ .xof-pre-link { margin-left: 4px; border: 0; background: none; color: inherit; padding: 0; font-size: 11px; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
183
+ .xof-next-link:hover { opacity: 0.95; text-decoration-thickness: 2px; }
184
+ .xof-pre-link:hover { opacity: 0.95; text-decoration-thickness: 2px; }
185
+ .xof-next-link:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 3px; background: rgba(127,127,127,0.14); }
186
+ .xof-pre-link:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 3px; background: rgba(127,127,127,0.14); }
187
+ .xof-graph { margin-top: 10px; padding: 8px; border: 1px dashed rgba(127,127,127,0.42); border-radius: 8px; }
160
188
  .xof-graph-title { font-size: 12px; font-weight: 700; margin-bottom: 6px; }
161
189
  .xof-edge { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; }
162
190
  .xof-empty { opacity: 0.85; font-style: italic; }
163
- .xof-overview { margin: 10px 0 16px; }
164
- .xof-overview img { width: 100%; max-width: 760px; border: 1px solid rgba(255,255,255,0.3); border-radius: 6px; background: #fff; }
165
- .xof-overview-code { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 11px; opacity: 0.9; white-space: pre-wrap; }
191
+ .xof-overview { margin: 12px 0 0; }
192
+ .xof-overview-details > summary { list-style: none; }
193
+ .xof-overview-details > summary::-webkit-details-marker { display: none; }
194
+ .xof-overview-details .xof-overview-toggle::after { content: 'expand'; }
195
+ .xof-overview-details[open] .xof-overview-toggle::after { content: 'collapse'; }
196
+ .xof-overview-sub { font-size: 12px; opacity: 0.82; margin-bottom: 8px; }
197
+ .xof-overview-graph-wrap {
198
+ display: flex;
199
+ justify-content: center;
200
+ align-items: flex-start;
201
+ margin-top: 4px;
202
+ max-height: 320px;
203
+ overflow: auto;
204
+ border: 1px solid rgba(127,127,127,0.3);
205
+ border-radius: 8px;
206
+ background: rgba(255,255,255,0.96);
207
+ padding: 8px;
208
+ }
209
+ .xof-overview img { width: auto; max-width: 100%; height: auto; border-radius: 4px; background: transparent; }
210
+ .xof-overview-code {
211
+ margin-top: 8px;
212
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
213
+ font-size: 10px;
214
+ opacity: 0.9;
215
+ white-space: pre-wrap;
216
+ max-height: 180px;
217
+ overflow: auto;
218
+ }
219
+ @keyframes xof-target-pulse {
220
+ 0% { box-shadow: 0 0 0 0 rgba(127,127,127,0.5); }
221
+ 100% { box-shadow: 0 0 0 10px rgba(127,127,127,0); }
222
+ }
223
+ .xof-jump-target {
224
+ animation: xof-target-pulse 0.9s ease-out 1;
225
+ }
226
+ .xof-jump-feedback {
227
+ position: fixed;
228
+ right: 16px;
229
+ bottom: 16px;
230
+ z-index: 9999;
231
+ max-width: 360px;
232
+ border: 1px solid rgba(127,127,127,0.5);
233
+ border-radius: 8px;
234
+ background: rgba(20,20,20,0.92);
235
+ color: #fff;
236
+ padding: 8px 10px;
237
+ font-size: 12px;
238
+ line-height: 1.35;
239
+ box-shadow: 0 6px 18px rgba(0,0,0,0.25);
240
+ }
166
241
  `;
167
242
 
168
243
  document.head.appendChild(style);
@@ -174,6 +249,29 @@ window.XOpenApiFlowPlugin = function () {
174
249
  return String(value);
175
250
  }
176
251
 
252
+ function getPrerequisiteOperationIds(transition) {
253
+ if (!transition || typeof transition !== 'object') return [];
254
+ if (Array.isArray(transition.prerequisite_operation_ids)) {
255
+ return transition.prerequisite_operation_ids.filter(Boolean);
256
+ }
257
+ if (Array.isArray(transition.pre_operation_ids)) {
258
+ return transition.pre_operation_ids.filter(Boolean);
259
+ }
260
+ if (transition.pre_operation_id) {
261
+ return [transition.pre_operation_id];
262
+ }
263
+ return [];
264
+ }
265
+
266
+ function escapeHtml(value) {
267
+ return text(value)
268
+ .replace(/&/g, '&')
269
+ .replace(/</g, '&lt;')
270
+ .replace(/>/g, '&gt;')
271
+ .replace(/"/g, '&quot;')
272
+ .replace(/'/g, '&#39;');
273
+ }
274
+
177
275
  function renderTransitions(currentState, transitions) {
178
276
  if (!Array.isArray(transitions) || transitions.length === 0) {
179
277
  return '<div class="xof-empty">No transitions (terminal state)</div>';
@@ -181,40 +279,56 @@ window.XOpenApiFlowPlugin = function () {
181
279
 
182
280
  return `<ul class="xof-list">${transitions
183
281
  .map((transition) => {
184
- const condition = transition.condition ? ` — ${text(transition.condition)}` : '';
185
- const nextOperation = transition.next_operation_id ? ` (next: ${text(transition.next_operation_id)})` : '';
186
- return `<li><strong>${text(transition.trigger_type)}</strong> <strong>${text(transition.target_state)}</strong>${condition}${nextOperation}</li>`;
282
+ const condition = transition.condition ? ` — ${escapeHtml(transition.condition)}` : '';
283
+ const nextOperation = transition.next_operation_id
284
+ ? ` <button class="xof-next-link" data-xof-jump="${escapeHtml(transition.next_operation_id)}" type="button" title="Go to operation ${escapeHtml(transition.next_operation_id)}" aria-label="Go to operation ${escapeHtml(transition.next_operation_id)}">next: ${escapeHtml(transition.next_operation_id)}</button>`
285
+ : '';
286
+ const preOperations = getPrerequisiteOperationIds(transition);
287
+ const preOperationLinks = preOperations.length
288
+ ? `<span class="xof-pre-links">requires:${preOperations
289
+ .map(
290
+ (operationId) =>
291
+ ` <button class="xof-pre-link" data-xof-jump="${escapeHtml(operationId)}" type="button" title="Go to operation ${escapeHtml(operationId)}" aria-label="Go to operation ${escapeHtml(operationId)}">${escapeHtml(operationId)}</button>`
292
+ )
293
+ .join('')}</span>`
294
+ : '';
295
+ return `<li><strong>${escapeHtml(transition.trigger_type)}</strong> → <strong>${escapeHtml(transition.target_state)}</strong>${condition}${nextOperation}${preOperationLinks}</li>`;
187
296
  })
188
297
  .join('')}</ul>`;
189
298
  }
190
299
 
191
300
  function renderGraph(currentState, transitions) {
192
301
  if (!Array.isArray(transitions) || transitions.length === 0) {
193
- return `<div class="xof-edge">${text(currentState)} [terminal]</div>`;
302
+ return `<div class="xof-edge">${escapeHtml(currentState)} [terminal]</div>`;
194
303
  }
195
304
 
196
305
  return transitions
197
- .map((transition) => `<div class="xof-edge">${text(currentState)} --> ${text(transition.target_state)} [${text(transition.trigger_type)}]</div>`)
306
+ .map((transition) => `<div class="xof-edge">${escapeHtml(currentState)} --> ${escapeHtml(transition.target_state)} [${escapeHtml(transition.trigger_type)}]</div>`)
198
307
  .join('');
199
308
  }
200
309
 
201
310
  function renderCard(flow) {
202
311
  const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
203
312
  return `
204
- <div class="xof-card">
205
- <div class="xof-title">x-openapi-flow</div>
206
- <div class="xof-meta">
207
- <div class="xof-meta-label">version</div><div>${text(flow.version)}</div>
208
- <div class="xof-meta-label">id</div><div>${text(flow.id)}</div>
209
- <div class="xof-meta-label">current_state</div><div>${text(flow.current_state)}</div>
210
- </div>
211
- <div><strong>Transitions</strong></div>
212
- ${renderTransitions(flow.current_state, transitions)}
213
- <div class="xof-graph">
214
- <div class="xof-graph-title">Flow graph (operation-level)</div>
215
- ${renderGraph(flow.current_state, transitions)}
313
+ <details class="xof-card" open>
314
+ <summary class="xof-title-row">
315
+ <span class="xof-title">x-openapi-flow</span>
316
+ <span class="xof-toggle-hint">toggle</span>
317
+ </summary>
318
+ <div class="xof-card-body">
319
+ <div class="xof-meta">
320
+ <div class="xof-meta-label">version</div><div>${escapeHtml(flow.version)}</div>
321
+ <div class="xof-meta-label">id</div><div>${escapeHtml(flow.id)}</div>
322
+ <div class="xof-meta-label">current_state</div><div>${escapeHtml(flow.current_state)}</div>
323
+ </div>
324
+ <div class="xof-section-title">Transitions</div>
325
+ ${renderTransitions(flow.current_state, transitions)}
326
+ <div class="xof-graph">
327
+ <div class="xof-graph-title">Flow graph (operation-level)</div>
328
+ ${renderGraph(flow.current_state, transitions)}
329
+ </div>
216
330
  </div>
217
- </div>
331
+ </details>
218
332
  `;
219
333
  }
220
334
 
@@ -234,12 +348,11 @@ window.XOpenApiFlowPlugin = function () {
234
348
  function extractFlowsFromSpec(spec) {
235
349
  const result = [];
236
350
  const paths = (spec && spec.paths) || {};
237
- const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
238
351
 
239
352
  Object.entries(paths).forEach(([pathKey, pathItem]) => {
240
353
  if (!pathItem || typeof pathItem !== 'object') return;
241
354
 
242
- methods.forEach((method) => {
355
+ FLOW_METHODS.forEach((method) => {
243
356
  const operation = pathItem[method];
244
357
  if (!operation || typeof operation !== 'object') return;
245
358
 
@@ -248,6 +361,8 @@ window.XOpenApiFlowPlugin = function () {
248
361
 
249
362
  result.push({
250
363
  operationId: operation.operationId || `${method}_${pathKey}`,
364
+ method,
365
+ pathKey,
251
366
  flow,
252
367
  });
253
368
  });
@@ -260,46 +375,106 @@ window.XOpenApiFlowPlugin = function () {
260
375
  return extractFlowsFromSpec(spec).length > 0;
261
376
  }
262
377
 
378
+ function createOverviewHash(flows) {
379
+ const normalized = flows
380
+ .map(({ operationId, flow }) => ({
381
+ operationId: text(operationId),
382
+ current: text(flow && flow.current_state),
383
+ transitions: (Array.isArray(flow && flow.transitions) ? flow.transitions : [])
384
+ .map((transition) => ({
385
+ trigger: text(transition.trigger_type),
386
+ target: text(transition.target_state),
387
+ next: text(transition.next_operation_id),
388
+ requires: text(getPrerequisiteOperationIds(transition)),
389
+ }))
390
+ .sort((first, second) => JSON.stringify(first).localeCompare(JSON.stringify(second))),
391
+ }))
392
+ .sort((first, second) => first.operationId.localeCompare(second.operationId));
393
+
394
+ return JSON.stringify(normalized);
395
+ }
396
+
263
397
  function buildOverviewMermaid(flows) {
264
- const lines = ['stateDiagram-v2'];
265
- const states = new Set();
398
+ const lines = ['stateDiagram-v2', ' direction LR'];
399
+ const statesByName = new Map();
266
400
  const seen = new Set();
401
+ let stateCounter = 0;
402
+ const edgeLines = [];
403
+
404
+ function getStateId(stateName) {
405
+ const normalized = text(stateName);
406
+ if (statesByName.has(normalized)) {
407
+ return statesByName.get(normalized);
408
+ }
409
+
410
+ const safeBase = normalized
411
+ .toLowerCase()
412
+ .replace(/[^a-z0-9]+/g, '_')
413
+ .replace(/^_+|_+$/g, '');
414
+ stateCounter += 1;
415
+ const candidate = safeBase ? `s_${safeBase}_${stateCounter}` : `s_state_${stateCounter}`;
416
+ statesByName.set(normalized, candidate);
417
+ return candidate;
418
+ }
419
+
420
+ function sanitizeLabel(label) {
421
+ return text(label)
422
+ .replace(/[|]/g, ' / ')
423
+ .replace(/[\n\r]+/g, ' ')
424
+ .replace(/"/g, "'")
425
+ .trim();
426
+ }
267
427
 
268
428
  flows.forEach(({ flow }) => {
269
- const current = flow.current_state;
429
+ const current = text(flow.current_state);
270
430
  if (!current) return;
271
431
 
272
- states.add(current);
432
+ const fromId = getStateId(current);
273
433
  const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
274
434
  transitions.forEach((transition) => {
275
- const target = transition.target_state;
435
+ const target = text(transition.target_state);
276
436
  if (!target) return;
277
- states.add(target);
437
+ const toId = getStateId(target);
278
438
 
279
439
  const labelParts = [];
280
440
  if (transition.next_operation_id) {
281
- labelParts.push(`next:${text(transition.next_operation_id)}`);
441
+ labelParts.push(`next ${text(transition.next_operation_id)}`);
282
442
  }
283
- if (Array.isArray(transition.prerequisite_operation_ids) && transition.prerequisite_operation_ids.length) {
284
- labelParts.push(`requires:${transition.prerequisite_operation_ids.join(',')}`);
443
+ const preOperations = getPrerequisiteOperationIds(transition);
444
+ if (preOperations.length) {
445
+ labelParts.push(`requires ${preOperations.join(',')}`);
285
446
  }
286
- const label = labelParts.join(' | ');
287
- const key = `${current}::${target}::${label}`;
447
+ const label = sanitizeLabel(labelParts.join(' / '));
448
+ const key = `${fromId}::${toId}::${label}`;
288
449
  if (seen.has(key)) return;
289
450
  seen.add(key);
290
- lines.push(` ${current} --> ${target}${label ? `: ${label}` : ''}`);
451
+ edgeLines.push(` ${fromId} --> ${toId}${label ? `: ${label}` : ''}`);
291
452
  });
292
453
  });
293
454
 
294
- Array.from(states)
295
- .sort()
296
- .forEach((state) => {
297
- lines.splice(1, 0, ` state ${state}`);
298
- });
455
+ statesByName.forEach((stateId, stateName) => {
456
+ lines.push(` state "${sanitizeLabel(stateName)}" as ${stateId}`);
457
+ });
458
+
459
+ lines.push(...edgeLines);
299
460
 
300
461
  return lines.join('\n');
301
462
  }
302
463
 
464
+ function hasOverviewTransitionData(flows) {
465
+ return flows.some(({ flow }) => Array.isArray(flow && flow.transitions) && flow.transitions.length > 0);
466
+ }
467
+
468
+ function buildStatesSummary(flows) {
469
+ const states = new Set();
470
+ flows.forEach(({ flow }) => {
471
+ if (flow && flow.current_state) {
472
+ states.add(text(flow.current_state));
473
+ }
474
+ });
475
+ return Array.from(states).sort().join(', ');
476
+ }
477
+
303
478
  let mermaidLoaderPromise = null;
304
479
  function ensureMermaid() {
305
480
  if (window.mermaid) {
@@ -316,7 +491,25 @@ window.XOpenApiFlowPlugin = function () {
316
491
  script.async = true;
317
492
  script.onload = () => {
318
493
  if (window.mermaid) {
319
- window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
494
+ window.mermaid.initialize({
495
+ startOnLoad: false,
496
+ securityLevel: 'loose',
497
+ theme: 'neutral',
498
+ themeCSS: `
499
+ .edgeLabel {
500
+ background: rgba(255,255,255,0.96) !important;
501
+ padding: 2px 6px !important;
502
+ border-radius: 6px;
503
+ font-size: 12px !important;
504
+ line-height: 1.2;
505
+ }
506
+ .edgeLabel rect {
507
+ fill: rgba(255,255,255,0.96) !important;
508
+ rx: 6;
509
+ ry: 6;
510
+ }
511
+ `,
512
+ });
320
513
  resolve(window.mermaid);
321
514
  } else {
322
515
  reject(new Error('Mermaid library not available after load'));
@@ -334,31 +527,96 @@ window.XOpenApiFlowPlugin = function () {
334
527
  return `data:image/svg+xml;base64,${encoded}`;
335
528
  }
336
529
 
530
+ function getMermaidFallbackMessage() {
531
+ return 'Could not render Mermaid image. Check CDN/network access or load mermaid manually before Swagger UI.';
532
+ }
533
+
534
+ function getOverviewTitleFromSpec(spec) {
535
+ const apiTitle = spec && spec.info && spec.info.title ? spec.info.title : 'API';
536
+ return `${text(apiTitle)} — Flow Overview (x-openapi-flow)`;
537
+ }
538
+
337
539
  let overviewRenderedHash = null;
338
540
  let overviewRenderInProgress = false;
339
541
  let overviewPendingHash = null;
340
- async function renderOverview() {
341
- const spec = getSpecFromUi();
342
- const flows = extractFlowsFromSpec(spec);
343
- if (!flows.length) return;
344
-
345
- const mermaid = buildOverviewMermaid(flows);
346
- const currentHash = `${flows.length}:${mermaid}`;
347
- if (overviewRenderedHash === currentHash) return;
348
- if (overviewRenderInProgress && overviewPendingHash === currentHash) return;
542
+ let overviewTimeoutId = null;
349
543
 
544
+ function getOrCreateOverviewHolder() {
350
545
  const infoContainer = document.querySelector('.swagger-ui .information-container');
351
- if (!infoContainer) return;
546
+ if (!infoContainer) return null;
352
547
 
353
548
  let holder = document.getElementById('xof-overview-holder');
354
549
  if (!holder) {
355
550
  holder = document.createElement('div');
356
551
  holder.id = 'xof-overview-holder';
357
- holder.className = 'xof-overview xof-card';
358
- infoContainer.parentNode.insertBefore(holder, infoContainer.nextSibling);
552
+ holder.className = 'xof-overview';
553
+ infoContainer.appendChild(holder);
554
+ }
555
+
556
+ return holder;
557
+ }
558
+
559
+ function clearOverviewHolder() {
560
+ const holder = document.getElementById('xof-overview-holder');
561
+ if (holder && holder.parentNode) {
562
+ holder.parentNode.removeChild(holder);
359
563
  }
564
+ overviewRenderedHash = null;
565
+ }
360
566
 
361
- holder.innerHTML = '<div class="xof-title">x-openapi-flow — Flow Overview</div><div class="xof-empty">Rendering Mermaid graph...</div>';
567
+ async function renderOverview() {
568
+ const spec = getSpecFromUi();
569
+ const flows = extractFlowsFromSpec(spec);
570
+ if (!flows.length) {
571
+ clearOverviewHolder();
572
+ return;
573
+ }
574
+
575
+ const currentHash = createOverviewHash(flows);
576
+ const overviewTitle = escapeHtml(getOverviewTitleFromSpec(spec));
577
+ const hasTransitions = hasOverviewTransitionData(flows);
578
+ if (!hasTransitions) {
579
+ const noTransitionsHash = `no-transitions:${currentHash}`;
580
+ if (overviewRenderedHash === noTransitionsHash) return;
581
+ const holderNoTransitions = getOrCreateOverviewHolder();
582
+ if (!holderNoTransitions) return;
583
+ const statesSummary = escapeHtml(buildStatesSummary(flows) || '-');
584
+ holderNoTransitions.innerHTML = `
585
+ <details class="xof-card xof-overview-details">
586
+ <summary class="xof-title-row">
587
+ <span class="xof-title">${overviewTitle}</span>
588
+ <span class="xof-toggle-hint xof-overview-toggle"></span>
589
+ </summary>
590
+ <div class="xof-card-body">
591
+ <div class="xof-overview-sub">All operation transitions in one graph.</div>
592
+ <div class="xof-empty">No transitions found yet. Add transitions in the sidecar and run apply to render the Mermaid overview.</div>
593
+ <div class="xof-overview-code">Current states: ${statesSummary}</div>
594
+ </div>
595
+ </details>
596
+ `;
597
+ overviewRenderedHash = noTransitionsHash;
598
+ return;
599
+ }
600
+
601
+ const mermaid = buildOverviewMermaid(flows);
602
+ if (overviewRenderedHash === currentHash) return;
603
+ if (overviewRenderInProgress && overviewPendingHash === currentHash) return;
604
+
605
+ const holder = getOrCreateOverviewHolder();
606
+ if (!holder) return;
607
+
608
+ holder.innerHTML = `
609
+ <details class="xof-card xof-overview-details">
610
+ <summary class="xof-title-row">
611
+ <span class="xof-title">${overviewTitle}</span>
612
+ <span class="xof-toggle-hint xof-overview-toggle"></span>
613
+ </summary>
614
+ <div class="xof-card-body">
615
+ <div class="xof-overview-sub">All operation transitions in one graph.</div>
616
+ <div class="xof-empty">Rendering Mermaid graph...</div>
617
+ </div>
618
+ </details>
619
+ `;
362
620
  overviewRenderInProgress = true;
363
621
  overviewPendingHash = currentHash;
364
622
 
@@ -370,18 +628,37 @@ window.XOpenApiFlowPlugin = function () {
370
628
  const dataUri = svgToDataUri(svg);
371
629
 
372
630
  holder.innerHTML = `
373
- <div class="xof-title">x-openapi-flow — Flow Overview</div>
374
- <img src="${dataUri}" alt="x-openapi-flow overview graph" />
375
- <details style="margin-top:8px;">
376
- <summary style="cursor:pointer;">Mermaid source</summary>
377
- <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
631
+ <details class="xof-card xof-overview-details">
632
+ <summary class="xof-title-row">
633
+ <span class="xof-title">${overviewTitle}</span>
634
+ <span class="xof-toggle-hint xof-overview-toggle"></span>
635
+ </summary>
636
+ <div class="xof-card-body">
637
+ <div class="xof-overview-sub">All operation transitions in one graph.</div>
638
+ <div class="xof-overview-graph-wrap">
639
+ <img src="${dataUri}" alt="x-openapi-flow overview graph" />
640
+ </div>
641
+ <details style="margin-top:8px;">
642
+ <summary style="cursor:pointer;">Mermaid source</summary>
643
+ <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
644
+ </details>
645
+ </div>
378
646
  </details>
379
647
  `;
380
- } catch (_error) {
648
+ } catch (error) {
649
+ const details = error && error.message ? escapeHtml(error.message) : 'Unknown Mermaid error';
381
650
  holder.innerHTML = `
382
- <div class="xof-title">x-openapi-flow — Flow Overview</div>
383
- <div class="xof-empty">Could not render Mermaid image in this environment.</div>
384
- <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
651
+ <details class="xof-card xof-overview-details">
652
+ <summary class="xof-title-row">
653
+ <span class="xof-title">${overviewTitle}</span>
654
+ <span class="xof-toggle-hint xof-overview-toggle"></span>
655
+ </summary>
656
+ <div class="xof-card-body">
657
+ <div class="xof-empty">${getMermaidFallbackMessage()}</div>
658
+ <div class="xof-overview-code">Details: ${details}</div>
659
+ <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
660
+ </div>
661
+ </details>
385
662
  `;
386
663
  } finally {
387
664
  overviewRenderInProgress = false;
@@ -390,6 +667,79 @@ window.XOpenApiFlowPlugin = function () {
390
667
  overviewRenderedHash = currentHash;
391
668
  }
392
669
 
670
+ function scheduleOverviewRender() {
671
+ if (overviewTimeoutId) {
672
+ window.clearTimeout(overviewTimeoutId);
673
+ }
674
+
675
+ overviewTimeoutId = window.setTimeout(() => {
676
+ overviewTimeoutId = null;
677
+ renderOverview().catch(() => {
678
+ // keep plugin resilient in environments where async rendering fails
679
+ });
680
+ }, 120);
681
+ }
682
+
683
+ function findOperationById(spec, operationId) {
684
+ if (!spec || !spec.paths || !operationId) return null;
685
+
686
+ for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
687
+ if (!pathItem || typeof pathItem !== 'object') continue;
688
+
689
+ for (const method of FLOW_METHODS) {
690
+ const operation = pathItem[method];
691
+ if (!operation || typeof operation !== 'object') continue;
692
+ if (operation.operationId === operationId) {
693
+ return { method, pathKey };
694
+ }
695
+ }
696
+ }
697
+
698
+ return null;
699
+ }
700
+
701
+ function jumpToOperationById(operationId) {
702
+ function highlightTarget(opblock) {
703
+ if (!opblock) return;
704
+ opblock.classList.remove('xof-jump-target');
705
+ window.requestAnimationFrame(() => {
706
+ opblock.classList.add('xof-jump-target');
707
+ window.setTimeout(() => opblock.classList.remove('xof-jump-target'), 950);
708
+ });
709
+ }
710
+
711
+ function getOperationSummaries() {
712
+ return Array.from(document.querySelectorAll('.swagger-ui .opblock-summary')).map((summary) => {
713
+ const opblock = summary.closest('.opblock');
714
+ const pathNode = summary.querySelector('.opblock-summary-path');
715
+ const path = pathNode ? pathNode.textContent.trim() : '';
716
+ return { summary, opblock, path };
717
+ });
718
+ }
719
+
720
+ function tryJump(match) {
721
+ const summaries = getOperationSummaries();
722
+ for (const { summary, opblock, path } of summaries) {
723
+ if (!opblock || !opblock.classList.contains(`opblock-${match.method}`)) continue;
724
+ if (path !== match.pathKey) continue;
725
+
726
+ summary.scrollIntoView({ behavior: 'smooth', block: 'center' });
727
+ if (!opblock.classList.contains('is-open')) {
728
+ summary.click();
729
+ }
730
+ highlightTarget(opblock);
731
+ return true;
732
+ }
733
+ return false;
734
+ }
735
+
736
+ const spec = getSpecFromUi();
737
+ const match = findOperationById(spec, operationId);
738
+ if (!match) return false;
739
+
740
+ return tryJump(match);
741
+ }
742
+
393
743
  function findXOpenApiFlowValueCell(opblock) {
394
744
  const rows = opblock.querySelectorAll('tr');
395
745
  for (const row of rows) {
@@ -420,18 +770,43 @@ window.XOpenApiFlowPlugin = function () {
420
770
  valueCell.dataset.xofEnhanced = '1';
421
771
  }
422
772
 
773
+ let jumpFeedbackTimeoutId = null;
774
+ function showJumpFeedback(message) {
775
+ injectStyles();
776
+
777
+ let feedback = document.getElementById('xof-jump-feedback');
778
+ if (!feedback) {
779
+ feedback = document.createElement('div');
780
+ feedback.id = 'xof-jump-feedback';
781
+ feedback.className = 'xof-jump-feedback';
782
+ document.body.appendChild(feedback);
783
+ }
784
+
785
+ feedback.textContent = message;
786
+
787
+ if (jumpFeedbackTimeoutId) {
788
+ window.clearTimeout(jumpFeedbackTimeoutId);
789
+ }
790
+
791
+ jumpFeedbackTimeoutId = window.setTimeout(() => {
792
+ if (feedback && feedback.parentNode) {
793
+ feedback.parentNode.removeChild(feedback);
794
+ }
795
+ jumpFeedbackTimeoutId = null;
796
+ }, 2200);
797
+ }
798
+
423
799
  function enhanceAll() {
424
800
  const spec = getSpecFromUi();
425
801
  if (!hasFlowData(spec)) {
802
+ clearOverviewHolder();
426
803
  return;
427
804
  }
428
805
 
429
806
  injectStyles();
430
807
  const opblocks = document.querySelectorAll('.opblock');
431
808
  opblocks.forEach((opblock) => enhanceOperation(opblock));
432
- renderOverview().catch(() => {
433
- // keep plugin resilient in environments where async rendering fails
434
- });
809
+ scheduleOverviewRender();
435
810
  }
436
811
 
437
812
  let enhanceScheduled = false;
@@ -448,8 +823,34 @@ window.XOpenApiFlowPlugin = function () {
448
823
  scheduleEnhance();
449
824
  });
450
825
 
826
+ document.addEventListener('click', (event) => {
827
+ const target = event.target;
828
+ if (!target || !target.closest) return;
829
+
830
+ const jumpButton = target.closest('[data-xof-jump]');
831
+ if (!jumpButton) return;
832
+
833
+ event.preventDefault();
834
+ const operationId = jumpButton.getAttribute('data-xof-jump');
835
+ if (!operationId) return;
836
+ const jumped = jumpToOperationById(operationId);
837
+ if (!jumped) {
838
+ showJumpFeedback(`Could not locate operation '${operationId}' in the rendered Swagger view.`);
839
+ }
840
+ });
841
+
451
842
  window.addEventListener('load', () => {
452
843
  scheduleEnhance();
453
844
  observer.observe(document.body, { childList: true, subtree: true });
454
845
  });
846
+
847
+ window.XOpenApiFlowUiInternals = {
848
+ extractFlowsFromSpec,
849
+ hasFlowData,
850
+ hasOverviewTransitionData,
851
+ buildOverviewMermaid,
852
+ createOverviewHash,
853
+ getOverviewTitleFromSpec,
854
+ getMermaidFallbackMessage,
855
+ };
455
856
  })();