@safagayret/bemirror 2.2.2 → 2.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.
package/README.md CHANGED
@@ -70,8 +70,8 @@ Once started, open `http://localhost:8000` (or your configured port) to use the
70
70
 
71
71
  Your mock data is stored in JSON files in your project root directory:
72
72
 
73
- - `endpoints.json`: Contains your project, entity, and endpoint configurations
74
- - `variables.json`: Contains global variables for use in endpoints
73
+ - `bemirror-endpoints.json`: Contains your project, entity, and endpoint configurations
74
+ - `bemirror-variables.json`: Contains global variables for use in endpoints
75
75
 
76
76
  These files are automatically created when you add data through the UI. You can commit them to your repository to share mock configurations with your team.
77
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safagayret/bemirror",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "Fake mirror your backend, fast",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -121,8 +121,8 @@ svg {
121
121
  .app-layout {
122
122
  display: flex;
123
123
  height: 100vh;
124
- padding: 20px;
125
- gap: 20px;
124
+ padding: 5px;
125
+ gap: 5px;
126
126
  }
127
127
 
128
128
  .app-footer {
@@ -440,14 +440,25 @@ svg {
440
440
 
441
441
  .live-url-box {
442
442
  background: rgba(88, 166, 255, 0.08);
443
- border: 1px dashed var(--accent);
444
443
  border-radius: 8px;
445
- padding: 12px 16px;
444
+ padding: 5px;
446
445
  display: flex;
447
446
  align-items: center;
448
447
  gap: 12px;
449
448
  }
450
449
 
450
+ .method-inline-wrap {
451
+ width: 110px;
452
+ flex-shrink: 0;
453
+ }
454
+
455
+ .method-inline-select {
456
+ font-family: var(--font-mono);
457
+ font-weight: 700;
458
+ letter-spacing: 0.6px;
459
+ text-transform: uppercase;
460
+ }
461
+
451
462
  .url-badge {
452
463
  font-weight: 600;
453
464
  font-size: 12px;
@@ -471,11 +482,81 @@ svg {
471
482
  text-decoration: underline;
472
483
  }
473
484
 
485
+ .mock-url-editor {
486
+ display: flex;
487
+ align-items: center;
488
+ flex: 1;
489
+ min-width: 0;
490
+ border: 1px solid var(--input-border);
491
+ border-radius: 8px;
492
+ overflow: hidden;
493
+ background: rgba(0, 0, 0, 0.18);
494
+ }
495
+
496
+ .mock-url-base {
497
+ padding: 9px 12px;
498
+ font-family: var(--font-mono);
499
+ font-size: 12px;
500
+ color: var(--text-muted);
501
+ white-space: nowrap;
502
+ max-width: 260px;
503
+ overflow: hidden;
504
+ text-overflow: ellipsis;
505
+ border-right: 1px solid var(--input-border);
506
+ }
507
+
508
+ .mock-path-input {
509
+ border: none;
510
+ background: transparent;
511
+ color: var(--text-main);
512
+ font-family: var(--font-mono);
513
+ font-size: 13px;
514
+ padding: 9px 12px;
515
+ min-width: 140px;
516
+ }
517
+
518
+ .mock-path-input:focus {
519
+ outline: none;
520
+ }
521
+
522
+ .mock-url-open {
523
+ color: var(--accent);
524
+ font-size: 12px;
525
+ font-family: var(--font-mono);
526
+ text-decoration: none;
527
+ white-space: nowrap;
528
+ }
529
+
530
+ .mock-url-open:hover {
531
+ text-decoration: underline;
532
+ }
533
+
534
+ .url-actions-group {
535
+ display: flex;
536
+ gap: 6px;
537
+ margin-left: auto;
538
+ }
539
+
540
+ .btn-url-action {
541
+ border: 1px solid var(--panel-border);
542
+ background: rgba(255, 255, 255, 0.05);
543
+ color: var(--text-main);
544
+ padding: 6px 10px;
545
+ font-family: var(--font-mono);
546
+ font-size: 11px;
547
+ white-space: nowrap;
548
+ border-radius: 6px;
549
+ cursor: pointer;
550
+ }
551
+
552
+ .btn-url-action:hover {
553
+ border-color: var(--accent);
554
+ }
555
+
474
556
  .prod-url-box {
475
557
  background: rgba(46, 160, 67, 0.08);
476
- border: 1px solid rgba(46, 160, 67, 0.4);
477
558
  border-radius: 8px;
478
- padding: 8px 12px;
559
+ padding: 5px;
479
560
  display: flex;
480
561
  align-items: center;
481
562
  gap: 12px;
@@ -486,6 +567,7 @@ svg {
486
567
  background: transparent;
487
568
  font-family: var(--font-mono);
488
569
  font-size: 14px;
570
+ min-height: 38px;
489
571
  flex: 1;
490
572
  outline: none;
491
573
  color: var(--text-main);
@@ -508,6 +590,15 @@ svg {
508
590
  background: #2ea043;
509
591
  }
510
592
 
593
+ .btn-send-secondary {
594
+ background: rgba(255, 255, 255, 0.1);
595
+ border-color: var(--panel-border);
596
+ }
597
+
598
+ .btn-send-secondary:hover {
599
+ background: rgba(255, 255, 255, 0.16);
600
+ }
601
+
511
602
  /* PROD RESPONSE PANEL */
512
603
  .prod-response-wrapper {
513
604
  margin-bottom: 20px;
@@ -544,6 +635,140 @@ svg {
544
635
  border-radius: 4px;
545
636
  }
546
637
 
638
+ .response-diff-panel {
639
+ margin-top: 12px;
640
+ border: 1px solid var(--input-border);
641
+ border-radius: 8px;
642
+ background: rgba(13, 17, 23, 0.7);
643
+ padding: 12px;
644
+ }
645
+
646
+ .response-diff-header {
647
+ display: flex;
648
+ justify-content: space-between;
649
+ align-items: center;
650
+ margin-bottom: 8px;
651
+ font-size: 12px;
652
+ color: var(--text-muted);
653
+ font-weight: 600;
654
+ }
655
+
656
+ .response-diff-summary {
657
+ font-family: var(--font-mono);
658
+ font-size: 11px;
659
+ }
660
+
661
+ .response-diff-legend {
662
+ display: flex;
663
+ gap: 8px;
664
+ margin-bottom: 10px;
665
+ }
666
+
667
+ .legend-item {
668
+ border: 1px solid var(--panel-border);
669
+ border-radius: 999px;
670
+ padding: 2px 8px;
671
+ font-size: 10px;
672
+ font-family: var(--font-mono);
673
+ text-transform: uppercase;
674
+ }
675
+
676
+ .legend-added {
677
+ background: rgba(46, 160, 67, 0.18);
678
+ color: #59d185;
679
+ }
680
+
681
+ .legend-removed {
682
+ background: rgba(218, 54, 51, 0.18);
683
+ color: #ff8f87;
684
+ }
685
+
686
+ .legend-changed {
687
+ background: rgba(210, 153, 34, 0.2);
688
+ color: #f3c86f;
689
+ }
690
+
691
+ .response-diff-grid {
692
+ display: grid;
693
+ grid-template-columns: repeat(2, minmax(0, 1fr));
694
+ gap: 12px;
695
+ }
696
+
697
+ .response-diff-column h4 {
698
+ font-size: 11px;
699
+ color: var(--text-muted);
700
+ margin-bottom: 6px;
701
+ font-weight: 600;
702
+ }
703
+
704
+ .response-diff-content {
705
+ border: 1px solid var(--panel-border);
706
+ border-radius: 6px;
707
+ max-height: 240px;
708
+ overflow: auto;
709
+ background: rgba(0, 0, 0, 0.25);
710
+ font-family: var(--font-mono);
711
+ font-size: 11px;
712
+ }
713
+
714
+ .diff-line {
715
+ display: grid;
716
+ grid-template-columns: 36px 1fr;
717
+ gap: 8px;
718
+ padding: 2px 8px;
719
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
720
+ white-space: pre;
721
+ }
722
+
723
+ .diff-line:last-child {
724
+ border-bottom: none;
725
+ }
726
+
727
+ .diff-line-no {
728
+ text-align: right;
729
+ color: var(--text-muted);
730
+ opacity: 0.8;
731
+ }
732
+
733
+ .diff-line-text {
734
+ overflow-x: auto;
735
+ }
736
+
737
+ .diff-line.added,
738
+ .diff-line.changed {
739
+ border-left: 2px solid transparent;
740
+ }
741
+
742
+ .diff-line.removed,
743
+ .diff-line.changed-old {
744
+ border-left: 2px solid transparent;
745
+ }
746
+
747
+ .diff-line.added {
748
+ background: rgba(46, 160, 67, 0.15);
749
+ border-left-color: #2ea043;
750
+ }
751
+
752
+ .diff-line.removed {
753
+ background: rgba(218, 54, 51, 0.15);
754
+ border-left-color: #da3633;
755
+ }
756
+
757
+ .diff-line.changed {
758
+ background: rgba(210, 153, 34, 0.2);
759
+ border-left-color: #d29922;
760
+ }
761
+
762
+ .diff-line.changed-old {
763
+ background: rgba(210, 153, 34, 0.12);
764
+ border-left-color: #d29922;
765
+ }
766
+
767
+ .diff-placeholder {
768
+ padding: 12px;
769
+ color: var(--text-muted);
770
+ }
771
+
547
772
  /* Forms */
548
773
  .form-row {
549
774
  display: flex;
@@ -880,16 +1105,51 @@ select:focus {
880
1105
  align-items: flex-start;
881
1106
  }
882
1107
 
883
- #btnCopyCurl,
884
- #btnCopyFetch {
885
- margin-left: 0;
886
- margin-top: 10px;
1108
+ .method-inline-wrap {
887
1109
  width: 100%;
888
1110
  }
889
1111
 
890
1112
  .live-url-box > div {
1113
+ width: 100%;
891
1114
  flex-wrap: wrap;
892
1115
  }
1116
+
1117
+ .mock-url-editor {
1118
+ width: 100%;
1119
+ }
1120
+
1121
+ .mock-url-base {
1122
+ max-width: 100%;
1123
+ overflow: hidden;
1124
+ text-overflow: ellipsis;
1125
+ }
1126
+
1127
+ .url-actions-group {
1128
+ width: 100%;
1129
+ margin-left: 0;
1130
+ }
1131
+
1132
+ .btn-url-action {
1133
+ flex: 1;
1134
+ text-align: center;
1135
+ }
1136
+
1137
+ .prod-url-box {
1138
+ flex-wrap: wrap;
1139
+ }
1140
+
1141
+ .prod-url-input {
1142
+ min-width: 100%;
1143
+ }
1144
+
1145
+ .btn-send,
1146
+ .btn-send-secondary {
1147
+ flex: 1;
1148
+ }
1149
+
1150
+ .response-diff-grid {
1151
+ grid-template-columns: 1fr;
1152
+ }
893
1153
  }
894
1154
 
895
1155
  /* SPLIT EDITOR LAYOUT */
package/public/index.html CHANGED
@@ -56,8 +56,9 @@
56
56
  <br />
57
57
  <p>
58
58
  <strong>🔧 Variables:</strong> Use variables in your endpoints with {{variableName}} syntax.
59
- Create a variables.json file in the project root to define global variables like base URLs,
60
- usernames, and passwords. This file should be added to .gitignore to keep sensitive data private.
59
+ Create a bemirror-variables.json file in the project root to define global variables like base
60
+ URLs, usernames, and passwords. This file should be added to .gitignore to keep sensitive data
61
+ private.
61
62
  </p>
62
63
  </div>
63
64
  <div class="modal-footer form-actions" style="margin-top: 20px">
@@ -310,43 +311,36 @@
310
311
 
311
312
  <!-- MOCK URL AREA -->
312
313
  <div class="http-clients-area">
313
- <div class="live-url-box" style="justify-content: space-between">
314
- <div style="display: flex; align-items: center; gap: 12px; flex: 1">
315
- <div class="url-badge">⚡ Mock URL</div>
316
- <a href="#" id="liveUrlLink" target="_blank" class="live-url-link">Select an endpoint to see the mock
317
- URL</a>
314
+ <div class="live-url-box">
315
+ <div class="method-inline-wrap">
316
+ <select id="method" required class="method-inline-select">
317
+ <option value="GET">GET</option>
318
+ <option value="POST">POST</option>
319
+ <option value="PUT">PUT</option>
320
+ <option value="PATCH">PATCH</option>
321
+ <option value="DELETE">DELETE</option>
322
+ </select>
323
+ </div>
324
+ <div style="display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0">
325
+ <div class="mock-url-editor">
326
+ <span id="mockUrlBase" class="mock-url-base"></span>
327
+ <input type="text" id="mockPathInput" class="mock-path-input" placeholder="/users/profile" />
328
+ </div>
329
+ <a href="#" id="liveUrlOpenLink" target="_blank" class="mock-url-open">Open</a>
330
+ </div>
331
+ <div class="url-actions-group">
332
+ <button id="btnCopyMockUrl" class="btn-url-action" title="Copy mock URL">Copy URL</button>
333
+ <button id="btnCopyCurl" class="btn-url-action" title="Copy terminal cURL command">Copy cURL</button>
334
+ <button id="btnCopyFetch" class="btn-url-action" title="Copy JS fetch()">Copy fetch</button>
318
335
  </div>
319
- <button id="btnCopyCurl" class="btn-icon" title="Copy terminal cURL command" style="
320
- border: 1px solid var(--panel-border);
321
- background: rgba(255, 255, 255, 0.05);
322
- padding: 4px 10px;
323
- font-family: monospace;
324
- font-size: 11px;
325
- white-space: nowrap;
326
- border-radius: 4px;
327
- margin-left: 10px;
328
- ">
329
- Copy cURL
330
- </button>
331
- <button id="btnCopyFetch" class="btn-icon" title="Copy JS fetch()" style="
332
- border: 1px solid var(--panel-border);
333
- background: rgba(255, 255, 255, 0.05);
334
- padding: 4px 10px;
335
- font-family: monospace;
336
- font-size: 11px;
337
- white-space: nowrap;
338
- border-radius: 4px;
339
- margin-left: 5px;
340
- ">
341
- Copy fetch
342
- </button>
343
336
  </div>
344
337
 
345
338
  <!-- PROD URL TESTER AREA -->
346
339
  <div class="prod-url-box">
347
- <div class="url-badge" style="color: var(--success)">🌍 Prod URL</div>
340
+ <div class="url-badge" style="color: var(--success)">Prod URL</div>
348
341
  <input type="text" id="prodUrlInput" placeholder="https://api.example.com/production/route"
349
342
  class="prod-url-input" />
343
+ <button id="btnCopyProdUrl" class="btn-send btn-send-secondary">Copy URL</button>
350
344
  <button id="btnSendProd" class="btn-send">Send Request 🚀</button>
351
345
  </div>
352
346
  </div>
@@ -366,34 +360,35 @@
366
360
  </div>
367
361
  </div>
368
362
  <div id="editorProdResponse" class="ace-wrapper"
369
- style="height: 200px; border-radius: 0 0 8px 8px; border-top: none"></div>
363
+ style="height: 280px; border-radius: 0 0 8px 8px; border-top: none"></div>
364
+ <div id="responseDiffPanel" class="response-diff-panel" style="display: none">
365
+ <div class="response-diff-header">
366
+ <span>Expected vs Production Diff</span>
367
+ <div id="responseDiffSummary" class="response-diff-summary">No diff yet.</div>
368
+ </div>
369
+ <div class="response-diff-legend">
370
+ <span class="legend-item legend-added">Added</span>
371
+ <span class="legend-item legend-removed">Removed</span>
372
+ <span class="legend-item legend-changed">Changed</span>
373
+ </div>
374
+ <div class="response-diff-grid">
375
+ <div class="response-diff-column">
376
+ <h4>Expected (Mock)</h4>
377
+ <div id="expectedDiffContent" class="response-diff-content"></div>
378
+ </div>
379
+ <div class="response-diff-column">
380
+ <h4>Received (Prod)</h4>
381
+ <div id="receivedDiffContent" class="response-diff-content"></div>
382
+ </div>
383
+ </div>
384
+ </div>
370
385
  </div>
371
386
 
372
- <form id="endpointForm" style="margin-top: 20px">
387
+ <form id="endpointForm">
373
388
  <input type="hidden" id="epId" />
374
389
  <input type="hidden" id="epEntityId" />
375
390
  <input type="hidden" id="epProjectId" />
376
391
 
377
- <div class="form-row">
378
- <div class="form-group flex-1">
379
- <label for="method">Method</label>
380
- <select id="method" required>
381
- <option value="GET">GET</option>
382
- <option value="POST">POST</option>
383
- <option value="PUT">PUT</option>
384
- <option value="PATCH">PATCH</option>
385
- <option value="DELETE">DELETE</option>
386
- </select>
387
- </div>
388
- <div class="form-group flex-4">
389
- <label for="path">Final Path Route</label>
390
- <div class="input-prefix">
391
- <span class="prefix">Path:</span>
392
- <input type="text" id="path" placeholder="/users/profile" required />
393
- </div>
394
- </div>
395
- </div>
396
-
397
392
  <!-- Tabs -->
398
393
  <!-- Tabs & Response View -->
399
394
  <div class="split-editor-layout">
@@ -420,14 +415,14 @@
420
415
  <p class="help-text">
421
416
  JSON format. Endpoint strict validation. Example: {"id": "5"}
422
417
  </p>
423
- <div id="editorParams" class="ace-wrapper" style="height: 300px"></div>
418
+ <div id="editorParams" class="ace-wrapper" style="height: 380px"></div>
424
419
  </div>
425
420
  <div class="tab-pane active" id="tab-payload">
426
421
  <label>Expected Body Payload</label>
427
422
  <p class="help-text">
428
423
  Mock server returns 400 Bad Request if missing top-level keys.
429
424
  </p>
430
- <div id="editorPayload" class="ace-wrapper" style="height: 300px"></div>
425
+ <div id="editorPayload" class="ace-wrapper" style="height: 380px"></div>
431
426
  </div>
432
427
  <div class="tab-pane" id="tab-auth">
433
428
  <label>Authorization</label>
@@ -455,7 +450,7 @@
455
450
  <p class="help-text">
456
451
  Add custom headers to the response. JSON format: [{"key": "Content-Type", "value": "application/json"}]
457
452
  </p>
458
- <div id="editorHeaders" class="ace-wrapper" style="height: 300px"></div>
453
+ <div id="editorHeaders" class="ace-wrapper" style="height: 380px"></div>
459
454
  </div>
460
455
  </div>
461
456
  </div>
@@ -481,12 +476,12 @@
481
476
  </div>
482
477
  </div>
483
478
  <label>Provide Response Body (JSON or Text)</label>
484
- <div id="editorResponse" class="ace-wrapper" style="flex: 1; min-height: 230px"></div>
479
+ <div id="editorResponse" class="ace-wrapper" style="flex: 1; min-height: 320px"></div>
485
480
  </div>
486
481
  </div>
487
482
  <div class="form-actions">
488
483
  <button type="submit" class="btn-primary" id="saveEndpointBtn">
489
- Save Configuration <span class="shortcut-hint">Ctrl + ↵</span>
484
+ Save Configuration <span class="shortcut-hint">Ctrl/Cmd + S</span>
490
485
  </button>
491
486
  </div>
492
487
  </form>
package/public/js/app.js CHANGED
@@ -620,6 +620,7 @@ window.addEndpoint = async (projectId, entityId) => {
620
620
  expectedParams: '',
621
621
  expectedPayload: '',
622
622
  responseBody: '{"success": true}',
623
+ prodUrl: '',
623
624
  }
624
625
  ent.endpoints.push(ep)
625
626
  await syncData()
@@ -657,33 +658,242 @@ const getActiveEndpointData = () =>
657
658
  ?.entities.find((e) => e.id === activeIds.entity)
658
659
  ?.endpoints.find((ep) => ep.id === activeIds.endpoint)
659
660
 
661
+ function getEndpointContext(projId, entId, epId) {
662
+ const project = state.projects.find((p) => p.id === projId)
663
+ const entity = project?.entities.find((e) => e.id === entId)
664
+ const endpoint = entity?.endpoints.find((ep) => ep.id === epId)
665
+ if (!project || !entity || !endpoint) return null
666
+ return { project, entity, endpoint }
667
+ }
668
+
669
+ function getMockBaseUrl(project, entity) {
670
+ return `${window.location.origin}/mock/${slugify(project.name)}/${slugify(entity.name)}`
671
+ }
672
+
673
+ function getDefaultProdUrl(project, entity, endpoint) {
674
+ const bUrl = (project.baseUrl || '').replace(/\/$/, '')
675
+ const fPath = normalizePathInput(endpoint.path)
676
+ const eSlug = slugify(entity.name)
677
+ return bUrl ? `${bUrl}/${eSlug}${fPath}` : ''
678
+ }
679
+
680
+ function truncateText(value, maxLen) {
681
+ if (!value || value.length <= maxLen) return value
682
+ return `${value.slice(0, maxLen)}...`
683
+ }
684
+
685
+ function normalizePathInput(pathValue) {
686
+ const trimmed = (pathValue || '').trim()
687
+ if (!trimmed) return '/'
688
+ return trimmed.startsWith('/') ? trimmed : '/' + trimmed
689
+ }
690
+
691
+ function resolveTemplateVariables(value, vars = {}) {
692
+ if (!value || typeof value !== 'string') return value
693
+ return value.replace(/\{\{(\w+)\}\}/g, (match, key) => {
694
+ return vars[key] !== undefined ? String(vars[key]) : match
695
+ })
696
+ }
697
+
698
+ function getCurrentMockUrl(project, entity) {
699
+ const mockPathInput = document.getElementById('mockPathInput')
700
+ const pathValue = normalizePathInput(mockPathInput?.value || '/')
701
+ return `${getMockBaseUrl(project, entity)}${pathValue}`
702
+ }
703
+
704
+ function updateMockUrlEditor(project, entity, endpoint) {
705
+ const base = getMockBaseUrl(project, entity)
706
+ const normalizedPath = normalizePathInput(endpoint.path)
707
+ const fullUrl = `${base}${normalizedPath}`
708
+
709
+ const mockBaseEl = document.getElementById('mockUrlBase')
710
+ mockBaseEl.innerText = truncateText(base, 25)
711
+ mockBaseEl.title = base
712
+ document.getElementById('mockPathInput').value = normalizedPath
713
+ document.getElementById('liveUrlOpenLink').href = fullUrl
714
+ document.getElementById('liveUrlOpenLink').title = fullUrl
715
+ }
716
+
717
+ function refreshMockOpenLink() {
718
+ const context = getEndpointContext(activeIds.project, activeIds.entity, activeIds.endpoint)
719
+ if (!context) return
720
+ const fullUrl = getCurrentMockUrl(context.project, context.entity)
721
+ document.getElementById('liveUrlOpenLink').href = fullUrl
722
+ document.getElementById('liveUrlOpenLink').title = fullUrl
723
+ }
724
+
725
+ async function persistActiveProdUrl() {
726
+ const ep = getActiveEndpointData()
727
+ if (!ep) return
728
+ ep.prodUrl = document.getElementById('prodUrlInput').value.trim()
729
+ await syncData()
730
+ }
731
+
660
732
  function computeLiveUrl(proj, ent, epObj) {
661
- const base = `${window.location.origin}/mock/${slugify(proj.name)}/${slugify(ent.name)}`
662
- const finalPath = epObj.path.startsWith('/') ? epObj.path : '/' + epObj.path
733
+ const base = getMockBaseUrl(proj, ent)
734
+ const finalPath = normalizePathInput(epObj.path)
663
735
  return base + finalPath
664
736
  }
665
737
 
738
+ function escapeHtml(str) {
739
+ return str
740
+ .replace(/&/g, '&amp;')
741
+ .replace(/</g, '&lt;')
742
+ .replace(/>/g, '&gt;')
743
+ .replace(/"/g, '&quot;')
744
+ .replace(/'/g, '&#039;')
745
+ }
746
+
747
+ function normalizeResponseText(rawText) {
748
+ if (!rawText || !rawText.trim()) return ''
749
+ try {
750
+ return JSON.stringify(JSON.parse(rawText), null, 2)
751
+ } catch (e) {
752
+ return rawText
753
+ }
754
+ }
755
+
756
+ function clearResponseDiff(message = 'No diff yet.') {
757
+ const panel = document.getElementById('responseDiffPanel')
758
+ const summary = document.getElementById('responseDiffSummary')
759
+ const expected = document.getElementById('expectedDiffContent')
760
+ const received = document.getElementById('receivedDiffContent')
761
+
762
+ panel.style.display = 'none'
763
+ summary.innerText = message
764
+ expected.innerHTML = `<div class="diff-placeholder">${escapeHtml(message)}</div>`
765
+ received.innerHTML = `<div class="diff-placeholder">${escapeHtml(message)}</div>`
766
+ }
767
+
768
+ function buildLineDiff(expectedLines, receivedLines) {
769
+ const n = expectedLines.length
770
+ const m = receivedLines.length
771
+ const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0))
772
+
773
+ for (let i = n - 1; i >= 0; i--) {
774
+ for (let j = m - 1; j >= 0; j--) {
775
+ if (expectedLines[i] === receivedLines[j]) {
776
+ dp[i][j] = dp[i + 1][j + 1] + 1
777
+ } else {
778
+ dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1])
779
+ }
780
+ }
781
+ }
782
+
783
+ const events = []
784
+ let i = 0
785
+ let j = 0
786
+ while (i < n && j < m) {
787
+ if (expectedLines[i] === receivedLines[j]) {
788
+ events.push({ type: 'equal', left: expectedLines[i], right: receivedLines[j] })
789
+ i++
790
+ j++
791
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
792
+ events.push({ type: 'remove', left: expectedLines[i], right: '' })
793
+ i++
794
+ } else {
795
+ events.push({ type: 'add', left: '', right: receivedLines[j] })
796
+ j++
797
+ }
798
+ }
799
+
800
+ while (i < n) {
801
+ events.push({ type: 'remove', left: expectedLines[i], right: '' })
802
+ i++
803
+ }
804
+
805
+ while (j < m) {
806
+ events.push({ type: 'add', left: '', right: receivedLines[j] })
807
+ j++
808
+ }
809
+
810
+ for (let k = 0; k < events.length - 1; k++) {
811
+ if (events[k].type === 'remove' && events[k + 1].type === 'add') {
812
+ events[k].type = 'changed-old'
813
+ events[k + 1].type = 'changed'
814
+ k++
815
+ }
816
+ }
817
+
818
+ return events
819
+ }
820
+
821
+ function renderDiffColumnHtml(events, side) {
822
+ let lineNo = 0
823
+ const rows = events.map((event) => {
824
+ const text = side === 'left' ? event.left : event.right
825
+ if (text) lineNo++
826
+ const safeText = text ? escapeHtml(text) : '&nbsp;'
827
+ const num = text ? lineNo : ''
828
+ return `<div class="diff-line ${event.type}"><span class="diff-line-no">${num}</span><span class="diff-line-text">${safeText}</span></div>`
829
+ })
830
+ return rows.join('')
831
+ }
832
+
833
+ function renderResponseDiff(expectedRaw, receivedRaw) {
834
+ const panel = document.getElementById('responseDiffPanel')
835
+ const summary = document.getElementById('responseDiffSummary')
836
+ const expected = document.getElementById('expectedDiffContent')
837
+ const received = document.getElementById('receivedDiffContent')
838
+
839
+ const expectedText = normalizeResponseText(expectedRaw)
840
+ const receivedText = normalizeResponseText(receivedRaw)
841
+
842
+ if (!expectedText && !receivedText) {
843
+ clearResponseDiff('Expected and production responses are both empty.')
844
+ return
845
+ }
846
+
847
+ const expectedLines = expectedText.split('\n')
848
+ const receivedLines = receivedText.split('\n')
849
+ const totalLines = expectedLines.length + receivedLines.length
850
+ const maxLineBudget = 1200
851
+
852
+ if (totalLines > maxLineBudget) {
853
+ panel.style.display = 'block'
854
+ summary.innerText = `Diff skipped: response too large (${totalLines} lines).`
855
+ expected.innerHTML = `<div class="diff-placeholder">Expected response is too large for detailed diff render.</div>`
856
+ received.innerHTML = `<div class="diff-placeholder">Received response is too large for detailed diff render.</div>`
857
+ return
858
+ }
859
+
860
+ const events = buildLineDiff(expectedLines, receivedLines)
861
+ let added = 0
862
+ let removed = 0
863
+ let changed = 0
864
+
865
+ events.forEach((event) => {
866
+ if (event.type === 'add') added++
867
+ if (event.type === 'remove') removed++
868
+ if (event.type === 'changed') changed++
869
+ })
870
+
871
+ const hasDiff = added > 0 || removed > 0 || changed > 0
872
+ panel.style.display = 'block'
873
+ summary.innerText = hasDiff
874
+ ? `Added: ${added} | Removed: ${removed} | Changed: ${changed}`
875
+ : 'No differences between expected and production response.'
876
+
877
+ expected.innerHTML = renderDiffColumnHtml(events, 'left')
878
+ received.innerHTML = renderDiffColumnHtml(events, 'right')
879
+ }
880
+
666
881
  function openEndpointEditor() {
667
882
  const ep = getActiveEndpointData()
668
883
  const p = state.projects.find((p) => p.id === activeIds.project)
669
884
  const e = p.entities.find((e) => e.id === activeIds.entity)
670
885
  if (!ep) return
671
886
 
887
+ clearResponseDiff('Run a production request to see highlighted differences.')
888
+
672
889
  document.getElementById('editorBreadcrumbs').innerText = `${p.name} > ${e.name}`
673
890
 
674
- const fullUrl = computeLiveUrl(p, e, ep)
675
- document.getElementById('liveUrlLink').innerText = fullUrl
676
- document.getElementById('liveUrlLink').href = fullUrl
891
+ updateMockUrlEditor(p, e, ep)
677
892
 
678
- // Prod URL Auto-Assignment based on Base URL
679
- const bUrl = (p.baseUrl || '').replace(/\/$/, '')
680
- const fPath = ep.path.startsWith('/') ? ep.path : '/' + ep.path
681
- const eSlug = slugify(e.name)
682
- document.getElementById('prodUrlInput').value = bUrl ? `${bUrl}/${eSlug}${fPath}` : ''
893
+ document.getElementById('prodUrlInput').value = ep.prodUrl || getDefaultProdUrl(p, e, ep)
683
894
 
684
895
  document.getElementById('epId').value = ep.id
685
896
  document.getElementById('method').value = ep.method
686
- document.getElementById('path').value = ep.path
687
897
  document.getElementById('statusCode').value = ep.statusCode || 200
688
898
  document.getElementById('delay').value = ep.delay || 0
689
899
 
@@ -705,9 +915,7 @@ document.getElementById('endpointForm').addEventListener('submit', async (ev) =>
705
915
  const e = p.entities.find((e) => e.id === activeIds.entity)
706
916
 
707
917
  ep.method = document.getElementById('method').value
708
- let rawPath = document.getElementById('path').value
709
- if (!rawPath.startsWith('/')) rawPath = '/' + rawPath
710
- ep.path = rawPath
918
+ ep.path = normalizePathInput(document.getElementById('mockPathInput').value)
711
919
  ep.statusCode = parseInt(document.getElementById('statusCode').value, 10)
712
920
  ep.delay = parseInt(document.getElementById('delay').value, 10)
713
921
  ep.expectedPayload = editors.payload.getValue()
@@ -727,19 +935,16 @@ document.getElementById('endpointForm').addEventListener('submit', async (ev) =>
727
935
  ep.headers = []
728
936
  }
729
937
 
730
- const fullUrl = computeLiveUrl(p, e, ep)
731
- document.getElementById('liveUrlLink').innerText = fullUrl
732
- document.getElementById('liveUrlLink').href = fullUrl
938
+ ep.prodUrl = document.getElementById('prodUrlInput').value.trim()
733
939
 
734
- const bUrl = (p.baseUrl || '').replace(/\/$/, '')
735
- const eSlug = slugify(e.name)
736
- document.getElementById('prodUrlInput').value = bUrl ? `${bUrl}/${eSlug}${ep.path}` : ''
940
+ updateMockUrlEditor(p, e, ep)
737
941
 
738
942
  const btn = document.getElementById('saveEndpointBtn')
739
943
  const originalHtml = btn.innerHTML
740
944
  btn.innerHTML = '✓ Saved Successfully!'
741
945
  btn.style.background = 'var(--success)'
742
946
  await syncData()
947
+ clearResponseDiff('Expected response changed. Run production request to compare again.')
743
948
  renderTree()
744
949
  setTimeout(() => {
745
950
  btn.innerHTML = originalHtml
@@ -777,6 +982,46 @@ function bindGlobalEvents() {
777
982
  document.getElementById('saveEndpointBtn').click()
778
983
  }
779
984
  }
985
+
986
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
987
+ const variablesModalOpen = document.getElementById('modalVariables').style.display === 'flex'
988
+ const editorScreenOpen = document.getElementById('editorScreen').style.display !== 'none'
989
+ if (!variablesModalOpen && !editorScreenOpen) return
990
+
991
+ e.preventDefault()
992
+
993
+ if (variablesModalOpen) {
994
+ document.getElementById('btnSaveVariables').click()
995
+ return
996
+ }
997
+
998
+ document.getElementById('saveEndpointBtn').click()
999
+ }
1000
+ })
1001
+
1002
+ document.getElementById('mockPathInput').addEventListener('input', () => {
1003
+ refreshMockOpenLink()
1004
+ })
1005
+
1006
+ document.getElementById('mockPathInput').addEventListener('blur', () => {
1007
+ const input = document.getElementById('mockPathInput')
1008
+ input.value = normalizePathInput(input.value)
1009
+ refreshMockOpenLink()
1010
+ })
1011
+
1012
+ document.getElementById('mockPathInput').addEventListener('keydown', (e) => {
1013
+ if (e.key !== 'Enter') return
1014
+ e.preventDefault()
1015
+ const input = document.getElementById('mockPathInput')
1016
+ input.value = normalizePathInput(input.value)
1017
+ refreshMockOpenLink()
1018
+ document.getElementById('saveEndpointBtn').click()
1019
+ })
1020
+
1021
+ document.getElementById('prodUrlInput').addEventListener('keydown', async (e) => {
1022
+ if (e.key !== 'Enter') return
1023
+ e.preventDefault()
1024
+ await persistActiveProdUrl()
780
1025
  })
781
1026
 
782
1027
  // Mobile menu toggle
@@ -903,13 +1148,24 @@ function bindGlobalEvents() {
903
1148
  })
904
1149
 
905
1150
  // Export as cURL command
1151
+ document.getElementById('btnCopyMockUrl').addEventListener('click', () => {
1152
+ const context = getEndpointContext(activeIds.project, activeIds.entity, activeIds.endpoint)
1153
+ if (!context) return
1154
+ const mockUrl = getCurrentMockUrl(context.project, context.entity)
1155
+ navigator.clipboard.writeText(mockUrl).then(() => {
1156
+ const btn = document.getElementById('btnCopyMockUrl')
1157
+ btn.innerText = '✓ Copied!'
1158
+ setTimeout(() => (btn.innerText = 'Copy URL'), 2000)
1159
+ })
1160
+ })
1161
+
906
1162
  document.getElementById('btnCopyCurl').addEventListener('click', () => {
907
1163
  const ep = getActiveEndpointData()
908
1164
  if (!ep) return
909
1165
  const p = state.projects.find((p) => p.id === activeIds.project)
910
1166
  const e = p.entities.find((e) => e.id === activeIds.entity)
911
1167
 
912
- let finalUrl = computeLiveUrl(p, e, ep)
1168
+ let finalUrl = getCurrentMockUrl(p, e)
913
1169
  if (ep.expectedParams) {
914
1170
  try {
915
1171
  const pg = JSON.parse(ep.expectedParams)
@@ -938,7 +1194,7 @@ function bindGlobalEvents() {
938
1194
  const p = state.projects.find((p) => p.id === activeIds.project)
939
1195
  const e = p.entities.find((e) => e.id === activeIds.entity)
940
1196
 
941
- let finalUrl = computeLiveUrl(p, e, ep)
1197
+ let finalUrl = getCurrentMockUrl(p, e)
942
1198
  if (ep.expectedParams) {
943
1199
  try {
944
1200
  const pg = JSON.parse(ep.expectedParams)
@@ -966,14 +1222,27 @@ function bindGlobalEvents() {
966
1222
  })
967
1223
  })
968
1224
 
1225
+ document.getElementById('btnCopyProdUrl').addEventListener('click', () => {
1226
+ const rawProdUrl = document.getElementById('prodUrlInput').value.trim()
1227
+ const resolvedProdUrl = resolveTemplateVariables(rawProdUrl, variables)
1228
+ if (!resolvedProdUrl) return
1229
+ navigator.clipboard.writeText(resolvedProdUrl).then(() => {
1230
+ const btn = document.getElementById('btnCopyProdUrl')
1231
+ btn.innerText = '✓ Copied!'
1232
+ setTimeout(() => (btn.innerText = 'Copy URL'), 2000)
1233
+ })
1234
+ })
1235
+
969
1236
  // Prod URL HTTP Client logic
970
1237
  document.getElementById('btnSendProd').addEventListener('click', async () => {
971
- const url = document.getElementById('prodUrlInput').value.trim()
972
- if (!url) return alert('Please enter a Production URL first.')
1238
+ const rawUrl = document.getElementById('prodUrlInput').value.trim()
1239
+ const resolvedUrl = resolveTemplateVariables(rawUrl, variables)
1240
+ if (!resolvedUrl) return alert('Please enter a Production URL first.')
973
1241
  const ep = getActiveEndpointData()
974
1242
 
975
1243
  document.getElementById('prodResponseWrapper').style.display = 'block'
976
1244
  editors.prodResp.setValue('Sending request...', -1)
1245
+ clearResponseDiff('Comparing responses...')
977
1246
  document.getElementById('prodStatus').innerText = 'Pending'
978
1247
  document.getElementById('prodTime').innerText = '--'
979
1248
 
@@ -982,7 +1251,7 @@ function bindGlobalEvents() {
982
1251
  method: 'POST',
983
1252
  headers: { 'Content-Type': 'application/json' },
984
1253
  body: JSON.stringify({
985
- url: url,
1254
+ url: resolvedUrl,
986
1255
  method: ep.method,
987
1256
  params: editors.params.getValue(),
988
1257
  payload: editors.payload.getValue(),
@@ -993,16 +1262,19 @@ function bindGlobalEvents() {
993
1262
  if (data.error && !data.status) {
994
1263
  document.getElementById('prodStatus').innerText = 'ERROR'
995
1264
  editors.prodResp.setValue(JSON.stringify(data.error, null, 2), -1)
1265
+ clearResponseDiff('Production request failed before diff could run.')
996
1266
  } else {
997
1267
  document.getElementById('prodStatus').innerText = data.status + ' Http St'
998
1268
  document.getElementById('prodTime').innerText = data.time + 'ms'
999
1269
  let outputStr =
1000
1270
  typeof data.body === 'object' ? JSON.stringify(data.body, null, 2) : data.body
1001
1271
  editors.prodResp.setValue(outputStr, -1)
1272
+ renderResponseDiff(editors.response.getValue(), outputStr || '')
1002
1273
  }
1003
1274
  } catch (err) {
1004
1275
  document.getElementById('prodStatus').innerText = 'CRASH'
1005
1276
  editors.prodResp.setValue('Network or proxy engine error.', -1)
1277
+ clearResponseDiff('Network or proxy error. Diff is unavailable.')
1006
1278
  }
1007
1279
  })
1008
1280
  }
package/server.js CHANGED
@@ -50,8 +50,10 @@ const saveJsonFile = (filePath, data) => {
50
50
  }
51
51
 
52
52
  const createBemirrorApp = (options = {}) => {
53
- const ENDPOINTS_FILE = options.endpointsFile || path.join(__dirname, '../../..', 'endpoints.json')
54
- const VARIABLES_FILE = options.variablesFile || path.join(__dirname, '../../..', 'variables.json')
53
+ const ENDPOINTS_FILE =
54
+ options.endpointsFile || path.join(__dirname, '../../..', 'bemirror-endpoints.json')
55
+ const VARIABLES_FILE =
56
+ options.variablesFile || path.join(__dirname, '../../..', 'bemirror-variables.json')
55
57
  const PUBLIC_DIR = options.publicDir || path.join(__dirname, 'public')
56
58
 
57
59
  const app = express()