arcane-os 0.2.2 → 0.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +29 -22
  4. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +203 -0
  5. package/browser-runtime/ai/browser-kokoro-worker.mjs +11 -2
  6. package/browser-runtime/ai/browser-speech-artifacts.mjs +3230 -397
  7. package/browser-runtime/ai/browser-speech-providers.mjs +1141 -157
  8. package/browser-runtime/ai/browser-speech.mjs +2 -0
  9. package/browser-runtime/ai/browser-whisper-worker.mjs +11 -2
  10. package/browser-runtime/ai/model-controller.mjs +285 -95
  11. package/browser-runtime/ai/speech-worker-client.mjs +247 -32
  12. package/browser-runtime/ai/speech-worker-runtime.mjs +2310 -167
  13. package/browser-runtime/event-manager.mjs +1097 -1
  14. package/docs/architecture.md +2 -2
  15. package/docs/event-manager.md +155 -27
  16. package/docs/reference/README.md +27 -27
  17. package/docs/reference/ai/browser-speech-package-authority.json +835 -0
  18. package/docs/reference/ai/browser-speech.md +1162 -246
  19. package/docs/reference/ai/browser-wasm.md +18 -7
  20. package/docs/reference/availability-and-normalization.md +6 -3
  21. package/docs/reference/behavioral-testing.md +29 -6
  22. package/docs/reference/cli.md +117 -9
  23. package/docs/reference/core/arcane-ai-contracts.md +1 -1
  24. package/docs/reference/event-manager.md +577 -32
  25. package/docs/reference/inventory/package-api.json +478 -2
  26. package/docs/reference/inventory/runtime-components.json +108 -44
  27. package/docs/reference/inventory/runtime-modules.json +131 -53
  28. package/docs/reference/mail.md +316 -0
  29. package/docs/reference/protocols.md +157 -43
  30. package/docs/reference/runtime-components.md +258 -83
  31. package/docs/reference/runtime-modules.md +613 -77
  32. package/docs/reference/sdk-api.md +1014 -25
  33. package/package.json +5 -4
  34. package/runtime/ARCANE_RUNTIME_RELEASE.json +145 -140
  35. package/runtime/arcane/components/app-bar.html +34 -13
  36. package/runtime/arcane/components/assistant-panel.html +110 -57
  37. package/runtime/arcane/components/calculator.html +7 -4
  38. package/runtime/arcane/components/chart.html +58 -17
  39. package/runtime/arcane/components/chat.html +606 -136
  40. package/runtime/arcane/components/conversation-view.html +13 -6
  41. package/runtime/arcane/components/dashboard-config.html +96 -59
  42. package/runtime/arcane/components/data-maintenance.html +69 -14
  43. package/runtime/arcane/components/data-view.html +53 -7
  44. package/runtime/arcane/components/directory-picker.html +118 -32
  45. package/runtime/arcane/components/document-inspector.html +47 -10
  46. package/runtime/arcane/components/file-drop.html +81 -35
  47. package/runtime/arcane/components/file-inspector.html +72 -22
  48. package/runtime/arcane/components/file-manager.html +374 -79
  49. package/runtime/arcane/components/integration-settings.html +12 -5
  50. package/runtime/arcane/components/local-ai-status.html +48 -19
  51. package/runtime/arcane/components/markdown-document.html +161 -68
  52. package/runtime/arcane/components/markdown-editor.html +110 -33
  53. package/runtime/arcane/components/media-embed.html +8 -5
  54. package/runtime/arcane/components/modal.html +15 -5
  55. package/runtime/arcane/components/output-panel.html +28 -23
  56. package/runtime/arcane/components/preferences-form.html +22 -4
  57. package/runtime/arcane/components/record-timeline.html +18 -2
  58. package/runtime/arcane/components/relationship-board.html +23 -3
  59. package/runtime/arcane/components/screen-capture.html +10 -4
  60. package/runtime/arcane/components/source-code-viewer.html +76 -9
  61. package/runtime/arcane/components/source-explanation.html +23 -3
  62. package/runtime/arcane/components/speech.html +462 -384
  63. package/runtime/arcane/components/summary-strip.html +22 -11
  64. package/runtime/arcane/components/table.html +39 -21
  65. package/runtime/arcane/components/task-progress.html +79 -21
  66. package/runtime/arcane/components/terminal-workspace.html +7 -4
  67. package/runtime/arcane/components/theme-editor.html +7 -3
  68. package/runtime/arcane/components/unified-inbox.html +9 -4
  69. package/runtime/arcane/components/voice-transcription.html +639 -98
  70. package/runtime/arcane/components/weather-widget.html +5 -3
  71. package/runtime/arcane/components/web-navigator.html +48 -8
  72. package/runtime/arcane/entities/Chat.js +1 -1
  73. package/runtime/arcane/entities/User.js +110 -23
  74. package/runtime/arcane/modules/AI.js +2109 -130
  75. package/runtime/arcane/modules/AIProviderRuntime.js +720 -13
  76. package/runtime/arcane/modules/AIRuntimeState.js +109 -52
  77. package/runtime/arcane/modules/ApiModelDatabase.js +390 -17
  78. package/runtime/arcane/modules/BrowserTestSuite.js +205 -28
  79. package/runtime/arcane/modules/CalculatorEngine.js +63 -3
  80. package/runtime/arcane/modules/CommunicationAppController.js +588 -28
  81. package/runtime/arcane/modules/CommunicationHub.js +590 -10
  82. package/runtime/arcane/modules/ComponentContracts.js +470 -0
  83. package/runtime/arcane/modules/ConversationTimebox.js +152 -33
  84. package/runtime/arcane/modules/DBLS.js +40 -7
  85. package/runtime/arcane/modules/DBOPFS.js +35 -11
  86. package/runtime/arcane/modules/DataMaintenance.js +12 -2
  87. package/runtime/arcane/modules/Errors.js +65 -7
  88. package/runtime/arcane/modules/HTMLImport.js +198 -14
  89. package/runtime/arcane/modules/LocalAIReadinessController.js +208 -29
  90. package/runtime/arcane/modules/Mail.js +738 -115
  91. package/runtime/arcane/modules/MailOutbox.mjs +1395 -0
  92. package/runtime/arcane/modules/MailTransport.mjs +197 -39
  93. package/runtime/arcane/modules/Ollama.js +36 -1
  94. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +583 -7
  95. package/runtime/arcane/modules/PreferenceStore.js +367 -33
  96. package/runtime/arcane/modules/RecordReviewStore.js +322 -23
  97. package/runtime/arcane/modules/ScreenCapture.js +1397 -15
  98. package/runtime/arcane/modules/SpeechPlayback.js +438 -41
  99. package/runtime/arcane/modules/TerminalClient.js +277 -12
  100. package/runtime/arcane/modules/ThemeBootstrap.js +80 -6
  101. package/runtime/arcane/modules/ThemeManager.js +39 -7
  102. package/runtime/arcane/modules/TimeGuard.js +131 -20
  103. package/runtime/arcane/modules/WaitForComponent.js +386 -33
  104. package/schemas/arcane-lock.schema.json +2 -2
  105. package/src/cli/main.mjs +435 -9
  106. package/src/event-manager.mjs +1097 -1
  107. package/src/import-map.mjs +21 -3
  108. package/src/index.mjs +13 -0
  109. package/src/installed-sdk-runtime.mjs +112 -0
  110. package/src/mail-api.mjs +22 -0
  111. package/src/mail-credentials.mjs +667 -0
  112. package/src/mail-server.mjs +1769 -0
  113. package/src/mail.mjs +261 -0
  114. package/src/sdk-browser-runtime.mjs +85 -41
  115. package/src/testing-loader.mjs +7 -0
  116. package/src/toolchain.mjs +3 -0
  117. package/src/workspace.mjs +1 -1
@@ -4,11 +4,18 @@
4
4
  </style>
5
5
  <section class="arcane-card"><header class="arcane-card__header heading"><div><h2 id="title" class="arcane-section-heading">Connected services</h2><p id="description" class="arcane-help">Choose the services that appear in this application.</p></div><button id="close" class="arcane-button arcane-button--tertiary" type="button">Close</button></header><div class="arcane-card__body"><p class="notice">Passwords and API secrets are never stored here. Arcane delegates credentials to the local communications bridge or the provider's authorization flow.</p><div id="services" class="services"></div></div><footer class="arcane-card__footer"><p id="message" class="arcane-help" role="status" aria-live="polite"></p><button id="save" class="arcane-button" type="button">Save services</button></footer></section>
6
6
  <script type="module">
7
- const host=this,root=host.shadowRoot,services=root.querySelector('#services'),message=root.querySelector('#message');let descriptors=[],values={};
8
- host.configure=options=>{descriptors=Array.from(options?.services||[]);values={...(options?.values||{})};root.querySelector('#title').textContent=options?.title||'Connected services';root.querySelector('#description').textContent=options?.description||'Choose the services that appear in this application.';render();return host};host.getValues=()=>read();host.setStatus=value=>message.textContent=String(value||'');
9
- root.querySelector('#close').addEventListener('click',()=>host.dispatchEvent(new CustomEvent('integration-settings-close',{bubbles:true,composed:true})));root.querySelector('#save').addEventListener('click',()=>host.dispatchEvent(new CustomEvent('integration-settings-save',{bubbles:true,composed:true,detail:{values:read()}})));
10
- function render(){services.replaceChildren(...descriptors.map(item=>{const section=document.createElement('section');section.className='service';section.dataset.id=item.id;const head=document.createElement('div');head.className='service-head';const enabled=document.createElement('input');enabled.type='checkbox';enabled.name='enabled';enabled.checked=Boolean(values[item.id]?.enabled);enabled.setAttribute('aria-label',`Enable ${item.label}`);const copy=document.createElement('div');copy.className='service-copy';const label=document.createElement('strong');label.textContent=item.label;const description=document.createElement('p');description.textContent=item.description||'';const state=document.createElement('span');state.className='status';state.textContent=values[item.id]?.status||item.defaultStatus||'Disconnected';copy.append(label,description);head.append(enabled,copy,state);section.append(head);const fields=document.createElement('div');fields.className='fields';if(item.acceptsEndpoint){fields.append(field('Bridge URL','endpoint',values[item.id]?.endpoint||item.defaultEndpoint||'http://127.0.0.1:8020'),item.id)}if(item.acceptsAccountLabel!==false)fields.append(field('Account label','accountLabel',values[item.id]?.accountLabel||'',item.id));if(fields.childElementCount)section.append(fields);if(item.actionLabel){const actions=document.createElement('div');actions.className='actions';const action=document.createElement('button');action.type='button';action.className='arcane-button arcane-button--secondary';action.textContent=item.actionLabel;action.addEventListener('click',()=>host.dispatchEvent(new CustomEvent('integration-action',{bubbles:true,composed:true,detail:{service:item,values:read()[item.id]}})));actions.append(action);section.append(actions)}return section}))}
7
+ const host=this,root=host.shadowRoot,services=root.querySelector('#services'),message=root.querySelector('#message');
8
+ const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
9
+ const events=createArcaneEventSource(host,{source:'arcane.component.integration-settings',eventTypes:['integration-settings-close','integration-settings-save','integration-action','integration-settings-ready']});
10
+ const lifecycleController=new AbortController();
11
+ let descriptors=[],values={},destroyed=false,operationSequence=0;
12
+ function nextOperationId(kind){operationSequence+=1;return `${events.descriptor.instanceId}:${kind}:${operationSequence}`}
13
+ function emit(type,detail={},publicDetail={},operationId=null){if(destroyed)return false;const publication=events.dispatch(type,detail,{publicDetail,operationId});return projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true})}
14
+ host.configure=options=>{if(destroyed)return false;descriptors=Array.from(options?.services||[]);values={...(options?.values||{})};root.querySelector('#title').textContent=options?.title||'Connected services';root.querySelector('#description').textContent=options?.description||'Choose the services that appear in this application.';render();return host};host.getValues=()=>destroyed?{}:read();host.setStatus=value=>{if(destroyed)return false;message.textContent=String(value||'')};
15
+ root.querySelector('#close').addEventListener('click',()=>emit('integration-settings-close',{}, {},nextOperationId('close')),{signal:lifecycleController.signal});root.querySelector('#save').addEventListener('click',()=>emit('integration-settings-save',{values:read()},{serviceCount:descriptors.length},nextOperationId('save')),{signal:lifecycleController.signal});
16
+ function render(){services.replaceChildren(...descriptors.map(item=>{const section=document.createElement('section');section.className='service';section.dataset.id=item.id;const head=document.createElement('div');head.className='service-head';const enabled=document.createElement('input');enabled.type='checkbox';enabled.name='enabled';enabled.checked=Boolean(values[item.id]?.enabled);enabled.setAttribute('aria-label',`Enable ${item.label}`);const copy=document.createElement('div');copy.className='service-copy';const label=document.createElement('strong');label.textContent=item.label;const description=document.createElement('p');description.textContent=item.description||'';const state=document.createElement('span');state.className='status';state.textContent=values[item.id]?.status||item.defaultStatus||'Disconnected';copy.append(label,description);head.append(enabled,copy,state);section.append(head);const fields=document.createElement('div');fields.className='fields';if(item.acceptsEndpoint){fields.append(field('Bridge URL','endpoint',values[item.id]?.endpoint||item.defaultEndpoint||'http://127.0.0.1:8020'),item.id)}if(item.acceptsAccountLabel!==false)fields.append(field('Account label','accountLabel',values[item.id]?.accountLabel||'',item.id));if(fields.childElementCount)section.append(fields);if(item.actionLabel){const actions=document.createElement('div');actions.className='actions';const action=document.createElement('button');action.type='button';action.className='arcane-button arcane-button--secondary';action.textContent=item.actionLabel;action.addEventListener('click',()=>emit('integration-action',{service:item,values:read()[item.id]},{serviceId:String(item.id)},nextOperationId('service-action')),{signal:lifecycleController.signal});actions.append(action);section.append(actions)}return section}))}
11
17
  function field(labelText,name,value,id){const label=document.createElement('label');label.className='arcane-field';const span=document.createElement('span');span.textContent=labelText;const input=document.createElement('input');input.name=name;input.value=value;input.dataset.service=id;input.autocomplete='off';label.append(span,input);return label}
12
18
  function read(){return Object.fromEntries(descriptors.map(item=>{const section=services.querySelector(`[data-id="${CSS.escape(item.id)}"]`);return [item.id,{enabled:section.querySelector('[name="enabled"]').checked,endpoint:section.querySelector('[name="endpoint"]')?.value.trim()||'',accountLabel:section.querySelector('[name="accountLabel"]')?.value.trim()||'',status:values[item.id]?.status||item.defaultStatus||'Disconnected'}]}))}
13
- host.ready=true;host.dispatchEvent(new CustomEvent('integration-settings-ready',{bubbles:true,composed:true}));
19
+ function destroy(){if(destroyed)return false;destroyed=true;lifecycleController.abort();descriptors=[];values={};events.dispose();host.ready=false;return true}
20
+ host.destroy=destroy;host.ready=true;emit('integration-settings-ready',{}, {ready:true});
14
21
  </script>
@@ -97,6 +97,9 @@
97
97
  <script type="module">
98
98
  const host=this;
99
99
  const root=host.shadowRoot;
100
+ const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
101
+ const events=createArcaneEventSource(host,{source:'arcane.component.local-ai-status',eventTypes:['local-ai-retry','local-ai-status-dismissed','local-ai-status-ready']});
102
+ const lifecycleController=new AbortController();
100
103
  const panel=root.querySelector('#panel');
101
104
  const title=root.querySelector('#localAIStatusTitle');
102
105
  const summary=root.querySelector('#summary');
@@ -118,28 +121,36 @@
118
121
  let options={};
119
122
  let readyDismissTimer=null;
120
123
  let readyForDismiss=false;
124
+ let operationSequence=0;
125
+ let destroyed=false;
126
+
127
+ function emit(type,detail={},publicDetail={},operationId=null){
128
+ if(destroyed){
129
+ return false;
130
+ }
131
+ const publication=events.dispatch(type,detail,{publicDetail,operationId});
132
+ return projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true});
133
+ }
121
134
 
122
135
  host.begin=begin;
123
136
  host.configure=configure;
137
+ host.destroy=destroy;
124
138
  host.present=present;
125
139
 
126
140
  retry.addEventListener('click',function dispatchRetryRequest(){
127
- host.dispatchEvent(new CustomEvent('local-ai-retry',{
128
- bubbles:true,
129
- composed:true
130
- }));
131
- });
141
+ emit('local-ai-retry',{}, {},`${events.descriptor.instanceId}:retry:${++operationSequence}`);
142
+ },{signal:lifecycleController.signal});
132
143
  settings.addEventListener('click',function showNativeRecoveryHelp(){
133
144
  nativeHelp.hidden=false;
134
145
  nativeHelp.focus?.();
135
- });
146
+ },{signal:lifecycleController.signal});
136
147
  dismiss.addEventListener('click',function dismissStatusManually(){
137
148
  hideStatus('manual');
138
- });
139
- host.addEventListener('pointerenter',cancelReadyDismiss);
140
- host.addEventListener('pointerleave',scheduleReadyDismiss);
141
- host.addEventListener('focusin',cancelReadyDismiss);
142
- host.addEventListener('focusout',scheduleReadyDismiss);
149
+ },{signal:lifecycleController.signal});
150
+ host.addEventListener('pointerenter',cancelReadyDismiss,{signal:lifecycleController.signal});
151
+ host.addEventListener('pointerleave',scheduleReadyDismiss,{signal:lifecycleController.signal});
152
+ host.addEventListener('focusin',cancelReadyDismiss,{signal:lifecycleController.signal});
153
+ host.addEventListener('focusout',scheduleReadyDismiss,{signal:lifecycleController.signal});
143
154
 
144
155
  function cancelReadyDismiss(){
145
156
  if(readyDismissTimer!==null){
@@ -150,7 +161,7 @@
150
161
 
151
162
  function scheduleReadyDismiss(){
152
163
  cancelReadyDismiss();
153
- if(!readyForDismiss){
164
+ if(destroyed||!readyForDismiss){
154
165
  return;
155
166
  }
156
167
  readyDismissTimer=setTimeout(autoDismissReadyStatus,readyDismissDelay);
@@ -158,7 +169,7 @@
158
169
 
159
170
  function autoDismissReadyStatus(){
160
171
  readyDismissTimer=null;
161
- if(!readyForDismiss){
172
+ if(destroyed||!readyForDismiss){
162
173
  return;
163
174
  }
164
175
  if(host.matches(':hover')||root.activeElement){
@@ -171,14 +182,13 @@
171
182
  cancelReadyDismiss();
172
183
  readyForDismiss=false;
173
184
  host.hidden=true;
174
- host.dispatchEvent(new CustomEvent('local-ai-status-dismissed',{
175
- bubbles:true,
176
- composed:true,
177
- detail:{reason}
178
- }));
185
+ emit('local-ai-status-dismissed',{reason},{reason:String(reason)},`${events.descriptor.instanceId}:dismiss:${++operationSequence}`);
179
186
  }
180
187
 
181
188
  function configure(input={}){
189
+ if(destroyed){
190
+ return false;
191
+ }
182
192
  options={
183
193
  profileHref:host.dataset.profileHref||null,
184
194
  ...options,
@@ -193,6 +203,9 @@
193
203
  }
194
204
 
195
205
  function begin(requirements={}){
206
+ if(destroyed){
207
+ return false;
208
+ }
196
209
  readyForDismiss=false;
197
210
  cancelReadyDismiss();
198
211
  host.hidden=false;
@@ -207,6 +220,9 @@
207
220
  }
208
221
 
209
222
  function present(report={}){
223
+ if(destroyed){
224
+ return false;
225
+ }
210
226
  const runtime=report.runtime||{};
211
227
  const results=report.slots||report.results||report;
212
228
  const requiredEntries=Object.keys(labels).map(function createRequiredEntry(key){
@@ -353,8 +369,21 @@
353
369
  services.replaceChildren(fragment);
354
370
  }
355
371
 
372
+ function destroy(){
373
+ if(destroyed){
374
+ return false;
375
+ }
376
+ destroyed=true;
377
+ cancelReadyDismiss();
378
+ readyForDismiss=false;
379
+ lifecycleController.abort('local-ai-status-destroyed');
380
+ events.dispose();
381
+ host.ready=false;
382
+ return true;
383
+ }
384
+
356
385
  configure();
357
386
  host.hidden=true;
358
387
  host.ready=true;
359
- host.dispatchEvent(new CustomEvent('local-ai-status-ready',{bubbles:true,composed:true}));
388
+ emit('local-ai-status-ready',{}, {ready:true});
360
389
  </script>
@@ -220,6 +220,9 @@
220
220
  );
221
221
  const moduleURL=name=>new URL(`../modules/${name}`,componentURL).href;
222
222
  const {default:MD}=await import(moduleURL('MD.js?v=2'));
223
+ const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
224
+ const events=createArcaneEventSource(host,{source:'arcane.component.markdown-document',eventTypes:['markdown-document-state','markdown-document-empty','markdown-document-error','markdown-document-loading','markdown-document-rendered','markdown-document-navigate','markdown-document-ready']});
225
+ const lifecycleController=new AbortController();
223
226
 
224
227
  const documentElement=host.shadowRoot.querySelector('#document');
225
228
  const statusElement=host.shadowRoot.querySelector('#status');
@@ -436,9 +439,13 @@
436
439
  let options=normalizeOptions(optionsFromDataset(),defaultOptions);
437
440
  let state='empty';
438
441
  let tableOfContents=[];
442
+ let operationSequence=0;
443
+ let loadController=null;
444
+ let destroyed=false;
439
445
 
440
446
  host.clear=clear;
441
447
  host.configure=configure;
448
+ host.destroy=destroy;
442
449
  host.fail=fail;
443
450
  host.focus=focusDocument;
444
451
  host.focusFragment=focusFragment;
@@ -448,15 +455,15 @@
448
455
  Object.defineProperties(
449
456
  host,
450
457
  {
451
- options:{get:getOptions,set:configure},
452
- sourceURL:{get:()=>currentSourceURL,set:setSourceURL},
453
- state:{get:()=>state},
454
- tableOfContents:{get:()=>tableOfContents.map(entry=>({...entry}))},
455
- value:{get:()=>currentMarkdown,set:value=>render(value)}
458
+ options:{configurable:true,get:getOptions,set:configure},
459
+ sourceURL:{configurable:true,get:()=>currentSourceURL,set:setSourceURL},
460
+ state:{configurable:true,get:()=>state},
461
+ tableOfContents:{configurable:true,get:()=>tableOfContents.map(entry=>({...entry}))},
462
+ value:{configurable:true,get:()=>currentMarkdown,set:value=>render(value)}
456
463
  }
457
464
  );
458
465
 
459
- documentElement.addEventListener('click',handleNavigation);
466
+ documentElement.addEventListener('click',handleNavigation,{signal:lifecycleController.signal});
460
467
 
461
468
  function optionsFromDataset(){
462
469
  const configured={};
@@ -533,15 +540,31 @@
533
540
  }
534
541
 
535
542
  function configure(input={}){
543
+ if(destroyed){
544
+ return false;
545
+ }
536
546
  const priorSourceURL=options.sourceURL;
537
- options=normalizeOptions(input,options);
547
+ const normalized=normalizeOptions(input,options);
548
+ const wasLoading=state==='loading';
549
+ supersedeLoad('markdown-configuration-replaced');
550
+ options=normalized;
538
551
  renderLabels();
539
552
 
540
553
  if(!currentMarkdown){
541
554
  currentSourceURL=options.sourceURL;
542
- updateStateDOM();
555
+ if(wasLoading){
556
+ transition(
557
+ 'empty',
558
+ {reason:'markdown-configuration-replaced'},
559
+ `${events.descriptor.instanceId}:configure:${++operationSequence}`
560
+ );
561
+ }else{
562
+ updateStateDOM();
563
+ }
543
564
  }else if(priorSourceURL!==options.sourceURL){
544
565
  render(currentMarkdown,{sourceURL:options.sourceURL});
566
+ }else if(wasLoading){
567
+ render(currentMarkdown,{sourceURL:options.sourceURL});
545
568
  }else{
546
569
  buildTableOfContents(tableOfContents);
547
570
  updateStateDOM();
@@ -573,37 +596,61 @@
573
596
  }
574
597
 
575
598
  function setSourceURL(value=''){
576
- options=normalizeOptions({sourceURL:value},options);
599
+ if(destroyed){
600
+ return false;
601
+ }
602
+ const normalized=normalizeOptions({sourceURL:value},options);
603
+ const wasLoading=state==='loading';
604
+ supersedeLoad('markdown-source-replaced');
605
+ options=normalized;
577
606
  if(currentMarkdown){
578
607
  render(currentMarkdown,{sourceURL:options.sourceURL});
579
608
  }else{
580
609
  currentSourceURL=options.sourceURL;
581
- updateStateDOM();
610
+ if(wasLoading){
611
+ transition(
612
+ 'empty',
613
+ {reason:'markdown-source-replaced'},
614
+ `${events.descriptor.instanceId}:source:${++operationSequence}`
615
+ );
616
+ }else{
617
+ updateStateDOM();
618
+ }
582
619
  }
583
620
  return currentSourceURL;
584
621
  }
585
622
 
586
623
  async function load(source='',loadOptions={}){
587
- const sequence=++loadSequence;
624
+ if(destroyed){
625
+ return false;
626
+ }
627
+ supersedeLoad('markdown-load-replaced');
628
+ const controller=new AbortController();
629
+ loadController=controller;
630
+ const sequence=loadSequence;
631
+ const operationId=`${events.descriptor.instanceId}:load:${++operationSequence}`;
588
632
  let sourceURL;
589
633
  try{
590
634
  sourceURL=sourceURLFrom(loadOptions,currentSourceURL||options.sourceURL);
591
635
  }catch(error){
592
636
  if(sequence===loadSequence){
593
- showError(error);
637
+ showError(error,operationId);
638
+ }
639
+ if(loadController===controller){
640
+ loadController=null;
594
641
  }
595
642
  return false;
596
643
  }
597
644
 
598
645
  currentSourceURL=sourceURL;
599
- transition('loading');
646
+ transition('loading',{},operationId);
600
647
 
601
648
  try{
602
649
  const pending=typeof source==='function'
603
- ?source({sourceURL})
650
+ ?source({sourceURL,signal:controller.signal,operationId})
604
651
  :source;
605
652
  const result=await pending;
606
- if(sequence!==loadSequence){
653
+ if(destroyed||sequence!==loadSequence){
607
654
  return false;
608
655
  }
609
656
 
@@ -616,32 +663,40 @@
616
663
  result,
617
664
  sourceURL
618
665
  );
619
- return renderMarkdown(result.markdown??result.value??'',sourceURL);
666
+ return renderMarkdown(result.markdown??result.value??'',sourceURL,operationId);
620
667
  }
621
- return renderMarkdown(result??'',sourceURL);
668
+ return renderMarkdown(result??'',sourceURL,operationId);
622
669
  }catch(error){
623
- if(sequence===loadSequence){
624
- showError(error);
670
+ if(!destroyed&&sequence===loadSequence&&!controller.signal.aborted){
671
+ showError(error,operationId);
625
672
  }
626
673
  return false;
674
+ }finally{
675
+ if(loadController===controller){
676
+ loadController=null;
677
+ }
627
678
  }
628
679
  }
629
680
 
630
681
  function render(markdown='',renderOptions={}){
631
- ++loadSequence;
682
+ if(destroyed){
683
+ return false;
684
+ }
685
+ supersedeLoad('markdown-content-replaced');
686
+ const operationId=`${events.descriptor.instanceId}:render:${++operationSequence}`;
632
687
  try{
633
688
  const sourceURL=sourceURLFrom(
634
689
  renderOptions,
635
690
  currentSourceURL||options.sourceURL
636
691
  );
637
- return renderMarkdown(markdown,sourceURL);
692
+ return renderMarkdown(markdown,sourceURL,operationId);
638
693
  }catch(error){
639
- showError(error);
694
+ showError(error,operationId);
640
695
  return false;
641
696
  }
642
697
  }
643
698
 
644
- function renderMarkdown(markdown='',sourceURL=options.sourceURL){
699
+ function renderMarkdown(markdown='',sourceURL=options.sourceURL,operationId=null){
645
700
  if(typeof markdown!=='string'){
646
701
  throw new TypeError('Markdown document content must be a string.');
647
702
  }
@@ -653,7 +708,7 @@
653
708
  tableOfContentsList.replaceChildren();
654
709
 
655
710
  if(!markdown.trim()){
656
- transition('empty');
711
+ transition('empty',{},operationId);
657
712
  return true;
658
713
  }
659
714
 
@@ -667,7 +722,7 @@
667
722
  ||fragment.querySelector('img,hr,table')
668
723
  );
669
724
  if(!meaningful){
670
- transition('empty');
725
+ transition('empty',{},operationId);
671
726
  return true;
672
727
  }
673
728
 
@@ -680,7 +735,8 @@
680
735
  {
681
736
  characters:markdown.length,
682
737
  headings:tableOfContents.map(entry=>({...entry}))
683
- }
738
+ },
739
+ operationId
684
740
  );
685
741
  return true;
686
742
  }
@@ -875,22 +931,29 @@
875
931
  }
876
932
 
877
933
  function clear(){
878
- ++loadSequence;
934
+ if(destroyed){
935
+ return false;
936
+ }
937
+ supersedeLoad('markdown-content-cleared');
938
+ const operationId=`${events.descriptor.instanceId}:clear:${++operationSequence}`;
879
939
  currentMarkdown='';
880
940
  currentSourceURL=options.sourceURL;
881
941
  contentElement.replaceChildren();
882
942
  tableOfContents=[];
883
943
  tableOfContentsList.replaceChildren();
884
- transition('empty');
944
+ transition('empty',{},operationId);
885
945
  return true;
886
946
  }
887
947
 
888
948
  function fail(error){
889
- ++loadSequence;
890
- return showError(error);
949
+ if(destroyed){
950
+ return false;
951
+ }
952
+ supersedeLoad('markdown-failure-presented');
953
+ return showError(error,`${events.descriptor.instanceId}:fail:${++operationSequence}`);
891
954
  }
892
955
 
893
- function showError(error){
956
+ function showError(error,operationId=null){
894
957
  currentMarkdown='';
895
958
  contentElement.replaceChildren();
896
959
  tableOfContents=[];
@@ -898,11 +961,16 @@
898
961
  const message=error instanceof Error
899
962
  ?error.message
900
963
  :String(error||options.labels.error);
901
- transition('error',{message:message.slice(0,2048)});
964
+ const code=String(error?.code||'ARCANE_MARKDOWN_DOCUMENT_RENDER_FAILED');
965
+ const reason=String(error?.reason||'markdown-document-render-failed');
966
+ transition('error',{message:message.slice(0,2048),code,reason},operationId);
902
967
  return false;
903
968
  }
904
969
 
905
- function transition(nextState,detail={}){
970
+ function transition(nextState,detail={},operationId=null){
971
+ if(destroyed){
972
+ return null;
973
+ }
906
974
  state=nextState;
907
975
  updateStateDOM();
908
976
 
@@ -913,12 +981,19 @@
913
981
  state
914
982
  }
915
983
  );
916
- host.dispatchEvent(
917
- new CustomEvent(
918
- 'markdown-document-state',
919
- {bubbles:true,composed:true,detail:eventDetail}
920
- )
921
- );
984
+ const publicDetail={
985
+ characters:Number(detail.characters||currentMarkdown.length||0),
986
+ headingCount:Array.isArray(detail.headings)?detail.headings.length:tableOfContents.length,
987
+ state
988
+ };
989
+ if(detail.code){
990
+ publicDetail.code=String(detail.code);
991
+ }
992
+ if(detail.reason){
993
+ publicDetail.reason=String(detail.reason);
994
+ }
995
+ const statePublication=events.dispatch('markdown-document-state',eventDetail,{publicDetail,operationId});
996
+ projectArcaneDOMEvent(host,statePublication.occurrence,{bubbles:true,composed:true});
922
997
 
923
998
  const stateEvent={
924
999
  empty:'markdown-document-empty',
@@ -927,12 +1002,8 @@
927
1002
  ready:'markdown-document-rendered'
928
1003
  }[state];
929
1004
  if(stateEvent){
930
- host.dispatchEvent(
931
- new CustomEvent(
932
- stateEvent,
933
- {bubbles:true,composed:true,detail:eventDetail}
934
- )
935
- );
1005
+ const exactPublication=events.dispatch(stateEvent,eventDetail,{publicDetail,operationId});
1006
+ projectArcaneDOMEvent(host,exactPublication.occurrence,{bubbles:true,composed:true});
936
1007
  }
937
1008
  return eventDetail;
938
1009
  }
@@ -961,6 +1032,9 @@
961
1032
  }
962
1033
 
963
1034
  function focusDocument(){
1035
+ if(destroyed){
1036
+ return false;
1037
+ }
964
1038
  if(state==='ready'){
965
1039
  const heading=contentElement.querySelector('h1,h2,h3,h4,h5,h6');
966
1040
  (heading||contentElement).focus({preventScroll:true});
@@ -971,7 +1045,7 @@
971
1045
  }
972
1046
 
973
1047
  function handleNavigation(event){
974
- if(event.defaultPrevented||event.button!==0){
1048
+ if(destroyed||event.defaultPrevented||event.button!==0){
975
1049
  return;
976
1050
  }
977
1051
  const link=event.composedPath().find(
@@ -990,16 +1064,14 @@
990
1064
  targetURL:link.dataset.markdownTargetUrl||link.href
991
1065
  }
992
1066
  );
993
- const navigationEvent=new CustomEvent(
994
- 'markdown-document-navigate',
995
- {
996
- bubbles:true,
997
- cancelable:true,
998
- composed:true,
999
- detail
1000
- }
1001
- );
1002
- const continueNavigation=host.dispatchEvent(navigationEvent);
1067
+ operationSequence+=1;
1068
+ const operationId=`${events.descriptor.instanceId}:navigate:${operationSequence}`;
1069
+ const publication=events.dispatch('markdown-document-navigate',detail,{
1070
+ operationId,
1071
+ publicDetail:{kind:detail.kind,hasFragment:Boolean(detail.fragment)},
1072
+ cancelable:true
1073
+ });
1074
+ const continueNavigation=publication.accepted&&projectArcaneDOMEvent(host,publication.occurrence,{bubbles:true,composed:true,cancelable:true});
1003
1075
 
1004
1076
  if(detail.kind==='fragment'||detail.kind==='markdown'){
1005
1077
  event.preventDefault();
@@ -1012,13 +1084,20 @@
1012
1084
  if(detail.kind==='fragment'){
1013
1085
  focusFragment(detail.fragment);
1014
1086
  }else if(detail.kind==='markdown'&&options.onNavigate){
1015
- Promise.resolve(options.onNavigate(detail)).catch(
1016
- error=>console.error('Markdown document navigation callback failed.',error)
1087
+ Promise.resolve(options.onNavigate(detail,{signal:lifecycleController.signal,operationId})).catch(
1088
+ error=>{
1089
+ if(!destroyed){
1090
+ console.error('Markdown document navigation callback failed.',error);
1091
+ }
1092
+ }
1017
1093
  );
1018
1094
  }
1019
1095
  }
1020
1096
 
1021
1097
  function focusFragment(fragment=''){
1098
+ if(destroyed){
1099
+ return false;
1100
+ }
1022
1101
  const decoded=markdownDocumentDecodeFragment(fragment);
1023
1102
  const generatedIdentifier=markdownDocumentHeadingBase(decoded);
1024
1103
  const target=Array.from(contentElement.querySelectorAll('[id]')).find(
@@ -1032,17 +1111,31 @@
1032
1111
  return true;
1033
1112
  }
1034
1113
 
1114
+ function supersedeLoad(reason){
1115
+ loadSequence+=1;
1116
+ loadController?.abort(reason);
1117
+ loadController=null;
1118
+ }
1119
+
1120
+ function destroy(){
1121
+ if(destroyed){
1122
+ return false;
1123
+ }
1124
+ destroyed=true;
1125
+ supersedeLoad('markdown-document-destroyed');
1126
+ lifecycleController.abort('markdown-document-destroyed');
1127
+ currentMarkdown='';
1128
+ tableOfContents=[];
1129
+ contentElement.replaceChildren();
1130
+ tableOfContentsList.replaceChildren();
1131
+ events.dispose();
1132
+ host.ready=false;
1133
+ return true;
1134
+ }
1135
+
1035
1136
  renderLabels();
1036
1137
  host.ready=true;
1037
1138
  transition('empty');
1038
- host.dispatchEvent(
1039
- new CustomEvent(
1040
- 'markdown-document-ready',
1041
- {
1042
- bubbles:true,
1043
- composed:true,
1044
- detail:{component:host,state}
1045
- }
1046
- )
1047
- );
1139
+ const readyPublication=events.dispatch('markdown-document-ready',{component:host,state},{publicDetail:{ready:true,state}});
1140
+ projectArcaneDOMEvent(host,readyPublication.occurrence,{bubbles:true,composed:true});
1048
1141
  </script>