gitorial-cli 2.0.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitorial-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "CLI tools for building and maintaining Gitorial tutorials",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -132,11 +132,109 @@ body {
132
132
  cursor: pointer;
133
133
  }
134
134
 
135
+ .gitorial-monaco-toolbar .copy-toggle {
136
+ background: transparent;
137
+ color: #f0f0f2;
138
+ border: 1px solid #3a3a3f;
139
+ border-radius: 999px;
140
+ padding: 6px 12px;
141
+ font-size: 12px;
142
+ cursor: pointer;
143
+ }
144
+
145
+ .gitorial-monaco-toolbar .retry-toggle {
146
+ background: transparent;
147
+ color: #f0f0f2;
148
+ border: 1px solid #6d737d;
149
+ border-radius: 999px;
150
+ padding: 6px 12px;
151
+ font-size: 12px;
152
+ cursor: pointer;
153
+ display: none;
154
+ }
155
+
156
+ .gitorial-monaco-toolbar button:disabled {
157
+ opacity: 0.55;
158
+ cursor: not-allowed;
159
+ }
160
+
135
161
  .gitorial-monaco-editor {
162
+ position: relative;
163
+ overflow: hidden;
164
+ background: #0f0f10;
136
165
  height: 70vh;
137
166
  min-height: 520px;
138
167
  }
139
168
 
169
+ .gitorial-monaco-editor .gitorial-monaco-frame {
170
+ width: 100%;
171
+ height: 100%;
172
+ border: 0;
173
+ display: block;
174
+ }
175
+
176
+ .gitorial-monaco-editor .gitorial-monaco-fallback {
177
+ margin: 0;
178
+ padding: 14px 16px;
179
+ height: 100%;
180
+ overflow: auto;
181
+ background: #0f0f10;
182
+ color: #d6d9df;
183
+ font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
184
+ font-size: 13px;
185
+ line-height: 1.45;
186
+ white-space: normal;
187
+ }
188
+
189
+ .gitorial-monaco-editor .gitorial-monaco-fallback.hidden {
190
+ display: none;
191
+ }
192
+
193
+ .gitorial-monaco-editor .gitorial-monaco-fallback pre {
194
+ margin: 0;
195
+ white-space: pre;
196
+ }
197
+
198
+ .gitorial-monaco-editor .gitorial-monaco-fallback code.hljs {
199
+ display: block;
200
+ padding: 0;
201
+ background: transparent;
202
+ }
203
+
204
+ .gitorial-monaco-editor .gitorial-monaco-fallback .fallback-diff {
205
+ display: grid;
206
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
207
+ gap: 12px;
208
+ height: 100%;
209
+ }
210
+
211
+ .gitorial-monaco-editor .gitorial-monaco-fallback .fallback-pane {
212
+ border: 1px solid #2a2a2d;
213
+ background: #111214;
214
+ display: flex;
215
+ flex-direction: column;
216
+ min-width: 0;
217
+ }
218
+
219
+ .gitorial-monaco-editor .gitorial-monaco-fallback .fallback-pane h4 {
220
+ margin: 0;
221
+ padding: 10px 12px;
222
+ font-size: 12px;
223
+ font-family: "Inter", system-ui, sans-serif;
224
+ font-weight: 600;
225
+ border-bottom: 1px solid #2a2a2d;
226
+ background: #191a1d;
227
+ color: #f0f0f2;
228
+ }
229
+
230
+ .gitorial-monaco-editor .gitorial-monaco-fallback .fallback-pane pre {
231
+ margin: 0;
232
+ padding: 12px;
233
+ height: 100%;
234
+ overflow: auto;
235
+ white-space: pre;
236
+ }
237
+
140
238
  .gitorial-monaco-footer {
141
239
  padding: 10px 16px;
142
240
  background: #1b1b1d;
@@ -174,11 +272,18 @@ body {
174
272
  min-height: 480px;
175
273
  height: 60vh;
176
274
  }
275
+
276
+ .gitorial-monaco-editor .gitorial-monaco-fallback .fallback-diff {
277
+ grid-template-columns: 1fr;
278
+ }
177
279
  }
178
280
  `;
179
281
 
180
282
  const monacoSetup = `
181
283
  (function () {
284
+ const MONACO_BASE = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.47.0/min/vs';
285
+ const MONACO_TIMEOUT_MS = 3500;
286
+
182
287
  function loadJson(url) {
183
288
  return fetch(url).then((res) => {
184
289
  if (!res.ok) {
@@ -217,107 +322,402 @@ const monacoSetup = `
217
322
  }
218
323
  }
219
324
 
220
- function buildIframe() {
325
+ function escapeHtml(value) {
326
+ return String(value)
327
+ .replace(/&/g, '&')
328
+ .replace(/</g, '&lt;')
329
+ .replace(/>/g, '&gt;');
330
+ }
331
+
332
+ function escapeAttribute(value) {
333
+ return String(value).replace(/"/g, '&quot;');
334
+ }
335
+
336
+ function mapToHighlightLanguage(language) {
337
+ switch (language) {
338
+ case 'rust':
339
+ return 'rust';
340
+ case 'toml':
341
+ return 'toml';
342
+ case 'javascript':
343
+ return 'javascript';
344
+ case 'json':
345
+ return 'json';
346
+ case 'typescript':
347
+ return 'typescript';
348
+ case 'diff':
349
+ return 'diff';
350
+ case 'markdown':
351
+ return 'markdown';
352
+ default:
353
+ return '';
354
+ }
355
+ }
356
+
357
+ function runHighlight(root) {
358
+ if (!window.hljs) {
359
+ return false;
360
+ }
361
+ const codeBlocks = root.querySelectorAll('code[data-gitorial-lang]');
362
+ if (!codeBlocks.length) {
363
+ return true;
364
+ }
365
+ const highlightElement = typeof window.hljs.highlightElement === 'function';
366
+ const highlightBlock = typeof window.hljs.highlightBlock === 'function';
367
+ if (!highlightElement && !highlightBlock) {
368
+ return false;
369
+ }
370
+ codeBlocks.forEach((codeBlock) => {
371
+ try {
372
+ if (highlightElement) {
373
+ window.hljs.highlightElement(codeBlock);
374
+ } else {
375
+ window.hljs.highlightBlock(codeBlock);
376
+ }
377
+ } catch (_error) {
378
+ // Keep plain fallback text if highlighting fails.
379
+ }
380
+ });
381
+ return true;
382
+ }
383
+
384
+ function applyFallbackHighlight(root) {
385
+ if (runHighlight(root)) {
386
+ return;
387
+ }
388
+ let attempts = 0;
389
+ const maxAttempts = 30;
390
+ const retryMs = 120;
391
+ const timer = setInterval(() => {
392
+ attempts += 1;
393
+ if (runHighlight(root) || attempts >= maxAttempts) {
394
+ clearInterval(timer);
395
+ }
396
+ }, retryMs);
397
+ }
398
+
399
+ function renderFallback(editorNode, payload) {
400
+ let fallback = editorNode.querySelector('.gitorial-monaco-fallback');
401
+ if (!fallback) {
402
+ fallback = document.createElement('div');
403
+ fallback.className = 'gitorial-monaco-fallback';
404
+ editorNode.appendChild(fallback);
405
+ }
406
+
407
+ const highlightLanguage = mapToHighlightLanguage(payload.language || '');
408
+ const codeClass = highlightLanguage ? 'language-' + highlightLanguage : 'language-plaintext';
409
+ const codeMeta = 'data-gitorial-lang="' + escapeAttribute(highlightLanguage || 'plaintext') + '"';
410
+
411
+ if (payload.type === 'diff') {
412
+ fallback.innerHTML =
413
+ '<div class="fallback-diff">' +
414
+ '<section class="fallback-pane">' +
415
+ '<h4>Template</h4>' +
416
+ '<pre><code class="' +
417
+ codeClass +
418
+ '" ' +
419
+ codeMeta +
420
+ '>' +
421
+ escapeHtml(payload.original || '') +
422
+ '</code></pre>' +
423
+ '</section>' +
424
+ '<section class="fallback-pane">' +
425
+ '<h4>Solution</h4>' +
426
+ '<pre><code class="' +
427
+ codeClass +
428
+ '" ' +
429
+ codeMeta +
430
+ '>' +
431
+ escapeHtml(payload.modified || '') +
432
+ '</code></pre>' +
433
+ '</section>' +
434
+ '</div>';
435
+ applyFallbackHighlight(fallback);
436
+ fallback.classList.remove('hidden');
437
+ return;
438
+ }
439
+
440
+ fallback.innerHTML =
441
+ '<pre><code class="' +
442
+ codeClass +
443
+ '" ' +
444
+ codeMeta +
445
+ '>' +
446
+ escapeHtml(payload.content || '') +
447
+ '</code></pre>';
448
+ applyFallbackHighlight(fallback);
449
+ fallback.classList.remove('hidden');
450
+ }
451
+
452
+ function createMonacoSession(editorNode, onReady, onFailure, onEdit) {
221
453
  const iframe = document.createElement('iframe');
222
454
  iframe.setAttribute('title', 'Gitorial Editor');
223
- iframe.style.width = '100%';
224
- iframe.style.height = '100%';
225
- iframe.style.border = '0';
226
- iframe.style.display = 'block';
227
- iframe.srcdoc = \`
228
- <!doctype html>
229
- <html>
230
- <head>
231
- <meta charset="utf-8" />
232
- <style>
233
- html, body { margin: 0; height: 100%; background: #0f0f10; }
234
- #editor { height: 100%; }
235
- </style>
236
- </head>
237
- <body>
238
- <div id="editor"></div>
239
- <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.47.0/min/vs/loader.js"></script>
240
- <script>
241
- window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.47.0/min/vs' } });
242
- let editor;
243
- let diffEditor;
244
- let currentMode = 'single';
245
- function getContainer() {
246
- return document.getElementById('editor');
247
- }
248
- function clearContainer() {
249
- const container = getContainer();
250
- container.innerHTML = '';
251
- return container;
252
- }
253
- function disposeEditor() {
254
- if (editor) {
255
- editor.dispose();
256
- editor = null;
257
- }
455
+ iframe.className = 'gitorial-monaco-frame';
456
+ iframe.style.display = 'none';
457
+
458
+ const iframeDocument = [
459
+ '<!doctype html>',
460
+ '<html>',
461
+ '<head>',
462
+ '<meta charset="utf-8" />',
463
+ '<style>html, body { margin: 0; height: 100%; background: #0f0f10; } #editor { height: 100%; } .monaco-editor .gitorial-todo-line { background: rgba(255, 196, 0, 0.12); } .monaco-editor .margin .gitorial-todo-glyph { border-left: 3px solid #ffc400; box-sizing: border-box; }</style>',
464
+ '</head>',
465
+ '<body>',
466
+ '<div id="editor"></div>',
467
+ '<script>',
468
+ '(function () {',
469
+ " var MONACO_BASE = '" + MONACO_BASE + "';",
470
+ ' var editor = null;',
471
+ ' var diffEditor = null;',
472
+ ' var changeSubscription = null;',
473
+ ' var todoDecorations = [];',
474
+ ' var highlightTodos = false;',
475
+ " var currentMode = 'single';",
476
+ ' var pendingPayload = null;',
477
+ ' var isReady = false;',
478
+ ' var suppressChange = false;',
479
+ ' function post(type, detail) {',
480
+ " parent.postMessage({ source: 'gitorial-monaco', type: type, detail: detail }, '*');",
481
+ ' }',
482
+ ' function getContainer() { return document.getElementById(\\'editor\\'); }',
483
+ ' function resetContainer() {',
484
+ ' var container = getContainer();',
485
+ ' container.innerHTML = "";',
486
+ ' }',
487
+ ' function attachChangeHandler() {',
488
+ ' if (!editor) { return; }',
489
+ ' if (changeSubscription) {',
490
+ ' changeSubscription.dispose();',
491
+ ' changeSubscription = null;',
492
+ ' }',
493
+ ' changeSubscription = editor.onDidChangeModelContent(function () {',
494
+ ' if (suppressChange) { return; }',
495
+ ' syncTodoDecorations();',
496
+ " post('change', editor.getValue());",
497
+ ' });',
498
+ ' }',
499
+ ' function clearTodoDecorations() {',
500
+ ' if (!editor) { return; }',
501
+ ' todoDecorations = editor.deltaDecorations(todoDecorations, []);',
502
+ ' }',
503
+ ' function syncTodoDecorations() {',
504
+ ' if (!editor) { return; }',
505
+ ' if (!highlightTodos) {',
506
+ ' clearTodoDecorations();',
507
+ ' return;',
508
+ ' }',
509
+ ' var model = editor.getModel();',
510
+ ' if (!model) {',
511
+ ' clearTodoDecorations();',
512
+ ' return;',
513
+ ' }',
514
+ ' var matches = model.findMatches("TODO", true, false, false, null, true);',
515
+ ' var next = matches.map(function (match) {',
516
+ ' return {',
517
+ ' range: match.range,',
518
+ ' options: {',
519
+ ' isWholeLine: true,',
520
+ ' className: "gitorial-todo-line",',
521
+ ' linesDecorationsClassName: "gitorial-todo-glyph"',
522
+ ' }',
523
+ ' };',
524
+ ' });',
525
+ ' todoDecorations = editor.deltaDecorations(todoDecorations, next);',
526
+ ' }',
527
+ ' function disposeEditorModel() {',
528
+ ' if (!editor) { return; }',
529
+ ' var model = editor.getModel();',
530
+ ' if (model) { model.dispose(); }',
531
+ ' }',
532
+ ' function disposeDiffModels() {',
533
+ ' if (!diffEditor) { return; }',
534
+ ' var models = diffEditor.getModel();',
535
+ ' if (!models) { return; }',
536
+ ' if (models.original) { models.original.dispose(); }',
537
+ ' if (models.modified) { models.modified.dispose(); }',
538
+ ' }',
539
+ ' function disposeEditor() {',
540
+ ' if (!editor) { return; }',
541
+ ' if (changeSubscription) {',
542
+ ' changeSubscription.dispose();',
543
+ ' changeSubscription = null;',
544
+ ' }',
545
+ ' disposeEditorModel();',
546
+ ' todoDecorations = [];',
547
+ ' editor.dispose();',
548
+ ' editor = null;',
549
+ ' }',
550
+ ' function disposeDiffEditor() {',
551
+ ' if (!diffEditor) { return; }',
552
+ ' disposeDiffModels();',
553
+ ' diffEditor.dispose();',
554
+ ' diffEditor = null;',
555
+ ' }',
556
+ ' function ensureEditor(content, language, readOnly) {',
557
+ " if (currentMode !== 'single') {",
558
+ ' disposeDiffEditor();',
559
+ ' resetContainer();',
560
+ " currentMode = 'single';",
561
+ ' }',
562
+ ' if (!editor) {',
563
+ ' editor = monaco.editor.create(getContainer(), {',
564
+ ' value: content,',
565
+ ' language: language,',
566
+ " theme: 'vs-dark',",
567
+ ' automaticLayout: true,',
568
+ ' readOnly: !!readOnly',
569
+ ' });',
570
+ ' highlightTodos = !readOnly;',
571
+ ' syncTodoDecorations();',
572
+ ' attachChangeHandler();',
573
+ ' return;',
574
+ ' }',
575
+ ' editor.updateOptions({ readOnly: !!readOnly });',
576
+ ' highlightTodos = !readOnly;',
577
+ ' suppressChange = true;',
578
+ ' disposeEditorModel();',
579
+ ' editor.setModel(monaco.editor.createModel(content, language));',
580
+ ' suppressChange = false;',
581
+ ' syncTodoDecorations();',
582
+ ' }',
583
+ ' function ensureDiffEditor(original, modified, language) {',
584
+ " if (currentMode !== 'diff') {",
585
+ ' disposeEditor();',
586
+ ' resetContainer();',
587
+ " currentMode = 'diff';",
588
+ ' }',
589
+ ' if (!diffEditor) {',
590
+ ' diffEditor = monaco.editor.createDiffEditor(getContainer(), {',
591
+ " theme: 'vs-dark',",
592
+ ' automaticLayout: true,',
593
+ ' readOnly: true,',
594
+ ' renderSideBySide: true',
595
+ ' });',
596
+ ' }',
597
+ ' disposeDiffModels();',
598
+ ' var originalModel = monaco.editor.createModel(original, language);',
599
+ ' var modifiedModel = monaco.editor.createModel(modified, language);',
600
+ ' diffEditor.setModel({ original: originalModel, modified: modifiedModel });',
601
+ ' }',
602
+ ' function renderPayload(payload) {',
603
+ " if (payload.type === 'diff') {",
604
+ " ensureDiffEditor(payload.original || '', payload.modified || '', payload.language || 'plaintext');",
605
+ ' return;',
606
+ ' }',
607
+ " ensureEditor(payload.content || '', payload.language || 'plaintext', !!payload.readOnly);",
608
+ ' }',
609
+ ' window.addEventListener(\\'message\\', function (event) {',
610
+ ' var data = event.data || {};',
611
+ " if (data.source !== 'gitorial-monaco-host' || !data.type) { return; }",
612
+ ' if (!isReady) {',
613
+ ' pendingPayload = data;',
614
+ ' return;',
615
+ ' }',
616
+ ' renderPayload(data);',
617
+ ' });',
618
+ ' function bootMonaco() {',
619
+ " if (typeof window.require !== 'function') {",
620
+ " post('error', 'require_missing');",
621
+ ' return;',
622
+ ' }',
623
+ ' window.require.config({ paths: { vs: MONACO_BASE } });',
624
+ " window.require(['vs/editor/editor.main'], function () {",
625
+ ' isReady = true;',
626
+ " post('ready');",
627
+ ' if (pendingPayload) {',
628
+ ' renderPayload(pendingPayload);',
629
+ ' pendingPayload = null;',
630
+ ' }',
631
+ ' }, function (err) {',
632
+ " post('error', String(err || 'require_failed'));",
633
+ ' });',
634
+ ' }',
635
+ " var loader = document.createElement('script');",
636
+ " loader.src = MONACO_BASE + '/loader.js';",
637
+ ' loader.async = true;',
638
+ " loader.onload = bootMonaco;",
639
+ " loader.onerror = function () { post('error', 'loader_failed'); };",
640
+ ' document.head.appendChild(loader);',
641
+ '})();',
642
+ '</script>',
643
+ '</body>',
644
+ '</html>',
645
+ ].join('');
646
+
647
+ let settled = false;
648
+ let timeoutId = null;
649
+
650
+ function clearTimer() {
651
+ if (timeoutId) {
652
+ clearTimeout(timeoutId);
653
+ timeoutId = null;
654
+ }
655
+ }
656
+
657
+ function cleanUp() {
658
+ clearTimer();
659
+ window.removeEventListener('message', onMessage);
660
+ }
661
+
662
+ function onMessage(event) {
663
+ if (event.source !== iframe.contentWindow) {
664
+ return;
665
+ }
666
+ const data = event.data || {};
667
+ if (data.source !== 'gitorial-monaco') {
668
+ return;
669
+ }
670
+ if (data.type === 'change') {
671
+ onEdit(data.detail || '');
672
+ return;
258
673
  }
259
- function disposeDiffEditor() {
260
- if (diffEditor) {
261
- diffEditor.dispose();
262
- diffEditor = null;
674
+ if (data.type === 'ready') {
675
+ if (settled) {
676
+ return;
263
677
  }
678
+ settled = true;
679
+ clearTimer();
680
+ onReady(iframe);
681
+ return;
264
682
  }
265
- function ensureEditor(content, language) {
266
- window.require(['vs/editor/editor.main'], function () {
267
- if (currentMode !== 'single') {
268
- disposeDiffEditor();
269
- clearContainer();
270
- currentMode = 'single';
271
- }
272
- if (!editor) {
273
- editor = monaco.editor.create(getContainer(), {
274
- value: content,
275
- language: language,
276
- theme: 'vs-dark',
277
- automaticLayout: true,
278
- readOnly: true,
279
- });
280
- } else {
281
- const model = monaco.editor.createModel(content, language);
282
- editor.setModel(model);
283
- }
284
- });
683
+ if (data.type === 'error') {
684
+ if (settled) {
685
+ return;
686
+ }
687
+ settled = true;
688
+ cleanUp();
689
+ onFailure(data.detail || 'load_error');
285
690
  }
286
- function ensureDiffEditor(original, modified, language) {
287
- window.require(['vs/editor/editor.main'], function () {
288
- if (currentMode !== 'diff') {
289
- disposeEditor();
290
- clearContainer();
291
- currentMode = 'diff';
292
- }
293
- if (!diffEditor) {
294
- diffEditor = monaco.editor.createDiffEditor(getContainer(), {
295
- theme: 'vs-dark',
296
- automaticLayout: true,
297
- readOnly: true,
298
- renderSideBySide: true,
299
- });
300
- }
301
- const originalModel = monaco.editor.createModel(original, language);
302
- const modifiedModel = monaco.editor.createModel(modified, language);
303
- diffEditor.setModel({ original: originalModel, modified: modifiedModel });
304
- });
691
+ }
692
+
693
+ timeoutId = setTimeout(() => {
694
+ if (settled) {
695
+ return;
305
696
  }
306
- window.addEventListener('message', function (event) {
307
- const data = event.data || {};
308
- if (!data.type) {
697
+ settled = true;
698
+ cleanUp();
699
+ onFailure('timeout');
700
+ }, MONACO_TIMEOUT_MS);
701
+
702
+ window.addEventListener('message', onMessage);
703
+ editorNode.appendChild(iframe);
704
+ iframe.srcdoc = iframeDocument;
705
+
706
+ return {
707
+ iframe,
708
+ post(payload) {
709
+ if (!iframe.contentWindow) {
309
710
  return;
310
711
  }
311
- if (data.type === 'init' || data.type === 'set') {
312
- ensureEditor(data.content || '', data.language || 'plaintext');
313
- } else if (data.type === 'diff') {
314
- ensureDiffEditor(data.original || '', data.modified || '', data.language || 'plaintext');
712
+ iframe.contentWindow.postMessage({ source: 'gitorial-monaco-host', ...payload }, '*');
713
+ },
714
+ destroy() {
715
+ cleanUp();
716
+ if (iframe.parentNode) {
717
+ iframe.parentNode.removeChild(iframe);
315
718
  }
316
- });
317
- </script>
318
- </body>
319
- </html>\`;
320
- return iframe;
719
+ },
720
+ };
321
721
  }
322
722
 
323
723
  async function initMonaco(container) {
@@ -328,26 +728,27 @@ const monacoSetup = `
328
728
  const select = container.querySelector('[data-gitorial-files]');
329
729
  const toggle = container.querySelector('[data-gitorial-toggle]');
330
730
  const diffToggle = container.querySelector('[data-gitorial-diff]');
731
+ const copyToggle = container.querySelector('[data-gitorial-copy]');
331
732
  const footer = container.querySelector('[data-gitorial-footer]');
332
733
  const editorNode = container.querySelector('[data-gitorial-editor]');
333
- const iframe = buildIframe();
334
- editorNode.appendChild(iframe);
335
- let iframeReady = false;
336
- let pendingPayload = null;
337
-
338
- iframe.addEventListener('load', () => {
339
- iframeReady = true;
340
- if (pendingPayload) {
341
- iframe.contentWindow.postMessage(pendingPayload, '*');
342
- pendingPayload = null;
343
- }
344
- });
734
+ const retryToggle = document.createElement('button');
735
+ retryToggle.type = 'button';
736
+ retryToggle.className = 'retry-toggle';
737
+ retryToggle.textContent = 'Retry editor';
738
+ toolbar.appendChild(retryToggle);
739
+ copyToggle.disabled = true;
345
740
 
346
741
  let currentMode = 'template';
347
742
  let previousMode = 'template';
348
743
  let selectedLabel = null;
349
- let templateFiles = config.template || [];
350
- let solutionFiles = config.solution || [];
744
+ const templateFiles = config.template || [];
745
+ const solutionFiles = config.solution || [];
746
+ const fileContentCache = new Map();
747
+ const templateEdits = new Map();
748
+ let monacoSession = null;
749
+ let monacoReady = false;
750
+ let lastPayload = null;
751
+ let copyState = { text: '', label: '' };
351
752
 
352
753
  if (!solutionFiles.length) {
353
754
  toggle.style.display = 'none';
@@ -407,19 +808,114 @@ const monacoSetup = `
407
808
  }
408
809
 
409
810
  function getFileContent(filePath) {
410
- return fetch(filePath).then((res) => res.text());
811
+ if (fileContentCache.has(filePath)) {
812
+ return Promise.resolve(fileContentCache.get(filePath));
813
+ }
814
+ return fetch(filePath)
815
+ .then((res) => res.text())
816
+ .then((text) => {
817
+ fileContentCache.set(filePath, text);
818
+ return text;
819
+ });
411
820
  }
412
821
 
413
822
  function findFile(list, label) {
414
823
  return list.find((file) => file.label === label) || null;
415
824
  }
416
825
 
417
- async function postToIframe(payload) {
418
- if (iframeReady) {
419
- iframe.contentWindow.postMessage(payload, '*');
420
- } else {
421
- pendingPayload = payload;
826
+ async function copyTextToClipboard(text) {
827
+ if (!text) {
828
+ return false;
829
+ }
830
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
831
+ try {
832
+ await navigator.clipboard.writeText(text);
833
+ return true;
834
+ } catch (_error) {}
835
+ }
836
+ const textarea = document.createElement('textarea');
837
+ textarea.value = text;
838
+ textarea.setAttribute('readonly', 'readonly');
839
+ textarea.style.position = 'absolute';
840
+ textarea.style.left = '-9999px';
841
+ document.body.appendChild(textarea);
842
+ textarea.select();
843
+ let copied = false;
844
+ try {
845
+ copied = document.execCommand('copy');
846
+ } catch (_error) {
847
+ copied = false;
848
+ }
849
+ document.body.removeChild(textarea);
850
+ return copied;
851
+ }
852
+
853
+ function setCopyState(text, label) {
854
+ copyState = { text: text || '', label: label || '' };
855
+ copyToggle.disabled = !copyState.text;
856
+ }
857
+
858
+ function flashCopyLabel(label) {
859
+ copyToggle.textContent = label;
860
+ setTimeout(() => {
861
+ copyToggle.textContent = 'Copy code';
862
+ }, 1300);
863
+ }
864
+
865
+ function setRetryVisible(visible) {
866
+ retryToggle.style.display = visible ? 'inline-block' : 'none';
867
+ }
868
+
869
+ function startMonacoSession() {
870
+ if (monacoSession) {
871
+ monacoSession.destroy();
872
+ monacoSession = null;
873
+ }
874
+ monacoReady = false;
875
+ setRetryVisible(false);
876
+ monacoSession = createMonacoSession(
877
+ editorNode,
878
+ (iframe) => {
879
+ monacoReady = true;
880
+ iframe.style.display = 'block';
881
+ const fallback = editorNode.querySelector('.gitorial-monaco-fallback');
882
+ if (fallback) {
883
+ fallback.classList.add('hidden');
884
+ }
885
+ if (lastPayload) {
886
+ monacoSession.post(lastPayload);
887
+ }
888
+ },
889
+ (reason) => {
890
+ monacoReady = false;
891
+ if (monacoSession) {
892
+ monacoSession.destroy();
893
+ monacoSession = null;
894
+ }
895
+ setRetryVisible(true);
896
+ footer.textContent = 'Monaco is unavailable (' + reason + '). Using fallback renderer.';
897
+ },
898
+ (content) => {
899
+ if (currentMode !== 'template' || !selectedLabel) {
900
+ return;
901
+ }
902
+ templateEdits.set(selectedLabel, content);
903
+ setCopyState(content, selectedLabel + ' (template)');
904
+ }
905
+ );
906
+ }
907
+
908
+ function renderPayload(payload) {
909
+ lastPayload = payload;
910
+ if (monacoReady && monacoSession) {
911
+ const fallback = editorNode.querySelector('.gitorial-monaco-fallback');
912
+ if (fallback) {
913
+ fallback.classList.add('hidden');
914
+ }
915
+ monacoSession.post(payload);
916
+ return;
422
917
  }
918
+ renderFallback(editorNode, payload);
423
919
  }
424
920
 
425
921
  async function setEditorFile(label) {
@@ -429,10 +925,20 @@ const monacoSetup = `
429
925
  if (currentMode === 'diff') {
430
926
  const templateFile = findFile(templateFiles, label);
431
927
  const solutionFile = findFile(solutionFiles, label);
432
- const original = templateFile ? await getFileContent(templateFile.path) : '';
928
+ let original = '';
929
+ if (templateEdits.has(label)) {
930
+ original = templateEdits.get(label);
931
+ } else if (templateFile) {
932
+ original = await getFileContent(templateFile.path);
933
+ }
433
934
  const modified = solutionFile ? await getFileContent(solutionFile.path) : '';
434
935
  const language = detectMode((solutionFile || templateFile || { path: '' }).path);
435
- await postToIframe({ type: 'diff', original, modified, language });
936
+ renderPayload({ type: 'diff', original, modified, language });
937
+ if (solutionFile) {
938
+ setCopyState(modified, label + ' (solution)');
939
+ } else {
940
+ setCopyState(original, label + ' (template)');
941
+ }
436
942
  return;
437
943
  }
438
944
 
@@ -441,13 +947,15 @@ const monacoSetup = `
441
947
  if (!file) {
442
948
  return;
443
949
  }
444
- const content = await getFileContent(file.path);
950
+ let content = '';
951
+ if (currentMode === 'template' && templateEdits.has(label)) {
952
+ content = templateEdits.get(label);
953
+ } else {
954
+ content = await getFileContent(file.path);
955
+ }
445
956
  const language = detectMode(file.path);
446
- await postToIframe({ type: 'set', content, language });
447
- }
448
-
449
- function currentFiles() {
450
- return currentMode === 'template' ? templateFiles : solutionFiles;
957
+ renderPayload({ type: 'set', content, language, readOnly: currentMode !== 'template' });
958
+ setCopyState(content, label + ' (' + currentMode + ')');
451
959
  }
452
960
 
453
961
  updateFileOptions();
@@ -458,6 +966,7 @@ const monacoSetup = `
458
966
  }
459
967
  selectedLabel = select.value;
460
968
  await setEditorFile(selectedLabel);
969
+ startMonacoSession();
461
970
 
462
971
  select.addEventListener('change', async () => {
463
972
  selectedLabel = select.value;
@@ -497,11 +1006,38 @@ const monacoSetup = `
497
1006
  }
498
1007
  updateFileOptions();
499
1008
  toggle.disabled = currentMode === 'diff';
1009
+ if (monacoSession) {
1010
+ startMonacoSession();
1011
+ }
500
1012
  if (select.value) {
501
1013
  selectedLabel = select.value;
502
1014
  await setEditorFile(selectedLabel);
503
1015
  }
504
1016
  });
1017
+
1018
+ copyToggle.addEventListener('click', async () => {
1019
+ if (!copyState.text) {
1020
+ return;
1021
+ }
1022
+ const ok = await copyTextToClipboard(copyState.text);
1023
+ if (ok) {
1024
+ flashCopyLabel('Copied');
1025
+ return;
1026
+ }
1027
+ footer.textContent = 'Copy failed for ' + copyState.label + '.';
1028
+ flashCopyLabel('Copy failed');
1029
+ });
1030
+
1031
+ retryToggle.addEventListener('click', () => {
1032
+ startMonacoSession();
1033
+ if (!solutionFiles.length) {
1034
+ footer.textContent = 'Template view only.';
1035
+ } else if (currentMode === 'template') {
1036
+ footer.textContent = 'Template view. Click View solution to compare.';
1037
+ } else {
1038
+ footer.textContent = 'Solution view. Click Back to template to continue.';
1039
+ }
1040
+ });
505
1041
  }
506
1042
 
507
1043
  function boot() {
@@ -510,8 +1046,13 @@ const monacoSetup = `
510
1046
  return;
511
1047
  }
512
1048
  containers.forEach((container) => {
1049
+ if (container.dataset.gitorialInitialized === 'true') {
1050
+ return;
1051
+ }
1052
+ container.dataset.gitorialInitialized = 'true';
513
1053
  initMonaco(container).catch((error) => {
514
1054
  console.error(error);
1055
+ delete container.dataset.gitorialInitialized;
515
1056
  });
516
1057
  });
517
1058
  }
@@ -530,6 +1071,7 @@ const monacoEmbed = (relativeAssetBase, manifestPath) => `
530
1071
  <select class="file-select" data-gitorial-files></select>
531
1072
  <button class="toggle" data-gitorial-toggle>View solution</button>
532
1073
  <button class="diff-toggle" data-gitorial-diff>View diff</button>
1074
+ <button class="copy-toggle" data-gitorial-copy>Copy code</button>
533
1075
  </div>
534
1076
  <div class="gitorial-monaco-editor" data-gitorial-editor></div>
535
1077
  <div class="gitorial-monaco-footer" data-gitorial-footer></div>