@webmcp-auto-ui/ui 2.5.35 → 2.5.37

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.
@@ -5,6 +5,7 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  import { encode, buildShortUrl } from '@webmcp-auto-ui/sdk';
8
+ import { canvasVanilla } from '@webmcp-auto-ui/sdk/canvas-vanilla';
8
9
  import type { NotebookState, NotebookCell } from './shared.js';
9
10
 
10
11
  // ---------------------------------------------------------------------------
@@ -27,8 +28,23 @@ export async function shareAsMarkdown(state: NotebookState): Promise<void> {
27
28
  triggerDownload(blob, sanitizeFilename(state.title || 'notebook') + '.md');
28
29
  }
29
30
 
30
- function serializeToMarkdown(state: NotebookState): string {
31
+ /**
32
+ * Serialize a notebook state as a HyperSkill standalone markdown:
33
+ * ---
34
+ * title: "..."
35
+ * description: "..."
36
+ * servers:
37
+ * - name: foo
38
+ * url: https://...
39
+ * ---
40
+ * <body with ```sql / ```js fenced cells>
41
+ *
42
+ * Re-parsable via @webmcp-auto-ui/core::parseFrontmatter + @webmcp-auto-ui/sdk::parseBody.
43
+ */
44
+ export function serializeToMarkdown(state: NotebookState): string {
45
+ const fm = buildFrontmatter(state);
31
46
  const parts: string[] = [];
47
+ if (fm) parts.push(fm);
32
48
  if (state.title) parts.push(`# ${state.title}`, '');
33
49
  for (const cell of state.cells) {
34
50
  if (cell.type === 'md') {
@@ -36,12 +52,70 @@ function serializeToMarkdown(state: NotebookState): string {
36
52
  } else {
37
53
  const lang = cell.type === 'sql' ? 'sql' : 'js';
38
54
  const varname = cell.varname ? ` // → ${cell.varname}` : '';
39
- parts.push('```' + lang + varname, cell.content.trim(), '```', '');
55
+ const commentPrefix = cell.type === 'sql' ? '--' : '//';
56
+ const metaLine = cell.args && Object.keys(cell.args).length > 0
57
+ ? `${commentPrefix} @meta ${JSON.stringify(cell.args)}\n`
58
+ : '';
59
+ parts.push('```' + lang + varname, metaLine + cell.content.trim(), '```', '');
40
60
  }
41
61
  }
42
62
  return parts.join('\n').trim() + '\n';
43
63
  }
44
64
 
65
+ /**
66
+ * Emit YAML frontmatter for HyperSkill format. Reads connected MCP servers from
67
+ * the canvas store. Returns '' when nothing useful to declare (no title, no
68
+ * description, no servers) — caller can skip prepending.
69
+ */
70
+ function buildFrontmatter(state: NotebookState): string {
71
+ const title = (state.title || '').trim();
72
+ const description = extractDescription(state);
73
+ const servers = collectEnabledServers();
74
+ if (!title && !description && servers.length === 0) return '';
75
+
76
+ const lines: string[] = ['---'];
77
+ if (title) lines.push(`title: ${yamlQuote(title)}`);
78
+ if (description) lines.push(`description: ${yamlQuote(description)}`);
79
+ if (servers.length > 0) {
80
+ lines.push('servers:');
81
+ for (const s of servers) {
82
+ lines.push(` - name: ${yamlQuote(s.name)}`);
83
+ lines.push(` url: ${yamlQuote(s.url)}`);
84
+ }
85
+ }
86
+ lines.push('---', '');
87
+ return lines.join('\n');
88
+ }
89
+
90
+ function extractDescription(state: NotebookState): string {
91
+ for (const cell of state.cells) {
92
+ if (cell.type !== 'md') continue;
93
+ const text = stripHtml(cell.content).trim();
94
+ if (!text) continue;
95
+ // First non-heading line of the first md cell.
96
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
97
+ const prose = lines.find((l) => !/^#{1,6}\s/.test(l) && !/^[-*]\s/.test(l));
98
+ if (prose) return prose.slice(0, 200);
99
+ }
100
+ return '';
101
+ }
102
+
103
+ function collectEnabledServers(): { name: string; url: string }[] {
104
+ try {
105
+ const servers = canvasVanilla.dataServers ?? [];
106
+ return servers
107
+ .filter((s: any) => s?.enabled && s?.url && s?.name && s.name !== 'autoui' && s.kind !== 'ui' && s.kind !== 'webmcp')
108
+ .map((s: any) => ({ name: String(s.name), url: String(s.url) }));
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /** Quote a YAML scalar safely. Conservative: always double-quote. */
115
+ function yamlQuote(s: string): string {
116
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
117
+ }
118
+
45
119
  function stripHtml(s: string): string {
46
120
  if (typeof document === 'undefined') return s;
47
121
  const d = document.createElement('div');
@@ -191,7 +265,6 @@ function minify(state: NotebookState): Record<string, unknown> {
191
265
  id: state.id,
192
266
  title: state.title,
193
267
  mode: state.mode,
194
- kicker: state.kicker,
195
268
  cells: state.cells.map((c: NotebookCell) => ({
196
269
  id: c.id,
197
270
  type: c.type,
@@ -4,7 +4,7 @@
4
4
  // Used by the four notebook layout renderers (compact/workspace/document/editorial)
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { installMultiMcpBridge } from '@webmcp-auto-ui/core';
7
+ import { serializeToMarkdown } from './share-handlers.js';
8
8
 
9
9
  export const NB_PUBLISH_HOST: string = (() => {
10
10
  try {
@@ -51,6 +51,7 @@ export interface NotebookCell {
51
51
  status?: 'fresh' | 'stale';
52
52
  comment?: { who: string; when: string; body: string } | null;
53
53
  lastResult?: CellResult;
54
+ args?: Record<string, unknown>; // override of tool args, parsed from `@meta {...}` leading comment
54
55
  }
55
56
 
56
57
  export interface NotebookState {
@@ -62,7 +63,6 @@ export interface NotebookState {
62
63
  scope: Record<string, unknown>;
63
64
  executors: CellExecutors;
64
65
  lastEditAt: number;
65
- kicker?: string;
66
66
  publishedSlug?: string;
67
67
  publishedToken?: string;
68
68
  /**
@@ -168,24 +168,48 @@ export function defaultCellContent(type: CellType): string {
168
168
  // ---------------------------------------------------------------------------
169
169
 
170
170
  export function createState(initial?: Partial<NotebookState>): NotebookState {
171
- return {
172
- id: initial?.id ?? uid(),
173
- title: initial?.title ?? 'Untitled notebook',
174
- mode: initial?.mode ?? 'edit',
175
- cells: initial?.cells ?? [
176
- { id: uid(), type: 'md', content: '### Untitled notebook\n\nAdd some context here.', hideSource: false, hideResult: false },
171
+ const title = initial?.title ?? 'Untitled notebook';
172
+ const cells = stripDuplicateTitleHeading(
173
+ initial?.cells ?? [
174
+ { id: uid(), type: 'md', content: 'Add some context here.', hideSource: false, hideResult: false },
177
175
  { id: uid(), type: 'sql', content: 'select *\nfrom source\nlimit 5', varname: 'rows', hideSource: false, hideResult: false, status: 'fresh' },
178
176
  { id: uid(), type: 'js', content: 'console.log(rows)', hideSource: false, hideResult: false, status: 'stale' },
179
177
  ],
178
+ title,
179
+ );
180
+ return {
181
+ id: initial?.id ?? uid(),
182
+ title,
183
+ mode: initial?.mode ?? 'edit',
184
+ cells,
180
185
  history: initial?.history ?? [],
181
186
  scope: initial?.scope ?? {},
182
187
  executors: initial?.executors ?? {},
183
188
  lastEditAt: initial?.lastEditAt ?? Date.now(),
184
- kicker: initial?.kicker,
185
189
  autoRun: initial?.autoRun ?? false,
190
+ publishedSlug: initial?.publishedSlug,
191
+ publishedToken: initial?.publishedToken,
186
192
  };
187
193
  }
188
194
 
195
+ // If the first md cell opens with a heading whose plain text matches the
196
+ // notebook title, drop that heading line so the title is not rendered twice.
197
+ function stripDuplicateTitleHeading(cells: NotebookCell[], title: string): NotebookCell[] {
198
+ if (!title || cells.length === 0) return cells;
199
+ const first = cells[0];
200
+ if (first.type !== 'md' || typeof first.content !== 'string') return cells;
201
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
202
+ const lines = first.content.split('\n');
203
+ let i = 0;
204
+ while (i < lines.length && lines[i].trim() === '') i++;
205
+ if (i >= lines.length) return cells;
206
+ const m = lines[i].match(/^#{1,6}\s+(.+?)\s*$/);
207
+ if (!m || norm(m[1]) !== norm(title)) return cells;
208
+ let drop = i + 1;
209
+ while (drop < lines.length && lines[drop].trim() === '') drop++;
210
+ return [{ ...first, content: lines.slice(drop).join('\n') }, ...cells.slice(1)];
211
+ }
212
+
189
213
  // ---------------------------------------------------------------------------
190
214
  // Live mode (autoRun) — RuntimeOverlay + helpers
191
215
  //
@@ -238,9 +262,9 @@ export function cellRuntimeStatus(cell: NotebookCell, overlay: RuntimeOverlay |
238
262
  return 'idle';
239
263
  }
240
264
 
241
- /** Live-mode whitelist. Only SQL cells are re-executable publicly. */
265
+ /** Live-mode whitelist. SQL and JS cells are re-executable; markdown stays frozen. */
242
266
  export function isReRunnable(cell: NotebookCell): boolean {
243
- return cell.type === 'sql';
267
+ return cell.type === 'sql' || cell.type === 'js';
244
268
  }
245
269
 
246
270
  /**
@@ -326,22 +350,17 @@ export function lastRefreshedAt(overlay: RuntimeOverlay | null | undefined): num
326
350
  }
327
351
 
328
352
  /**
329
- * Build a CellRunner backed by a MultiMcpBridge. Discovers a SQL-capable tool
330
- * on the connected servers (matching `*_query_sql` then `query|run|execute`),
331
- * calls it with `{ sql: cell.content }`, parses content-array into a table.
353
+ * Build a CellRunner that issues SQL via `callTool`. Discovers a SQL-capable
354
+ * tool on the connected servers (matching `*_query_sql` then
355
+ * `query|run|execute`), calls it with `{ sql: cell.content }`, parses the
356
+ * content-array into a table.
332
357
  *
333
- * Throws if no server is reachable / no SQL tool found. Callers are expected
334
- * to surface this as a 'stale' status, not crash.
358
+ * Returns a runtime error result if no server is reachable / no SQL tool found.
335
359
  */
336
- export function createBridgeSqlRunner(bridge: {
337
- hasServer?: (name: string) => boolean;
338
- connectedServers?: () => string[];
339
- multiClient: {
340
- listTools?: (url: string) => Promise<{ name: string }[]>;
341
- getToolsForUrl?: (url: string) => { name: string }[];
342
- };
343
- callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>;
344
- }, getServerDescriptors: () => DataServerDescriptor[]): CellRunner {
360
+ export function createBridgeSqlRunner(
361
+ callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>,
362
+ getServerDescriptors: () => DataServerDescriptor[],
363
+ ): CellRunner {
345
364
  const PATTERN_PRIMARY = /^.*query_sql$/i;
346
365
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
347
366
 
@@ -401,20 +420,18 @@ export function createBridgeSqlRunner(bridge: {
401
420
  if (!hit) {
402
421
  return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
403
422
  }
404
- const raw = await bridge.callTool(hit.serverName, hit.toolName, { sql: cell.content });
423
+ const raw = await callTool(hit.serverName, hit.toolName, { sql: cell.content });
405
424
  return parseResult(raw, startedAt);
406
425
  };
407
426
  }
408
427
 
409
428
  /**
410
- * High-level bootstrap: auto-connect declared servers, wait for handshake, build
411
- * a bridge-backed runner, fire runAutoRefresh. Safe to call from any layout at
412
- * mount time when `state.autoRun && state.mode === 'view'`. Returns a cleanup.
429
+ * High-level bootstrap: auto-connect declared servers, wait for handshake,
430
+ * build a SQL runner, fire runAutoRefresh. Safe to call from any layout at
431
+ * mount time when `state.autoRun && state.mode === 'view'`. Returns cleanup.
413
432
  *
414
- * Reuses the global singleton bridge on `globalThis.__multiMcp` (installed via
415
- * `installMultiMcpBridge` from @webmcp-auto-ui/core) never creates a parallel
416
- * bridge. If no singleton exists yet we install it here, and only the installer
417
- * is allowed to stop it on cleanup.
433
+ * Drives connection state through the canvas store; the store's internal
434
+ * sync owns the McpMultiClient and reconciles handshakes on every mutation.
418
435
  */
419
436
  export interface BootstrapLiveRefreshOptions {
420
437
  state: NotebookState;
@@ -425,36 +442,54 @@ export interface BootstrapLiveRefreshOptions {
425
442
  timeoutMs?: number;
426
443
  }
427
444
 
445
+ interface CanvasLike {
446
+ dataServers: { name: string; url: string; enabled?: boolean; connected?: boolean }[];
447
+ callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
448
+ }
449
+
450
+ async function waitForEnabledServers(canvas: CanvasLike, timeoutMs: number): Promise<void> {
451
+ const deadline = Date.now() + Math.max(0, timeoutMs);
452
+ while (Date.now() < deadline) {
453
+ const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
454
+ if (enabled.length === 0) return;
455
+ if (enabled.every((s) => s.connected)) return;
456
+ await new Promise((r) => setTimeout(r, 100));
457
+ }
458
+ }
459
+
428
460
  export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => void {
429
461
  const { state, data, overlay, onCellChange, onTick, timeoutMs } = opts;
430
462
  const ac = new AbortController();
431
- let weCreatedBridge = false;
432
- let bridgeRef: any = null;
433
463
 
434
464
  void (async () => {
435
465
  try {
436
466
  autoConnectFrontmatterServers(data);
437
- const canvas: any = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
438
- .__canvasVanilla ?? (globalThis as { canvasVanilla?: unknown }).canvasVanilla;
467
+ const canvas = ((globalThis as { __canvasVanilla?: CanvasLike; canvasVanilla?: CanvasLike })
468
+ .__canvasVanilla ?? (globalThis as { canvasVanilla?: CanvasLike }).canvasVanilla) as CanvasLike | undefined;
439
469
  if (!canvas) {
440
470
  overlay.error = 'No canvas available';
441
471
  overlay.finishedAt = Date.now();
442
472
  onTick?.(overlay);
443
473
  return;
444
474
  }
445
- const existing = (globalThis as any).__multiMcp;
446
- const bridge = existing ?? installMultiMcpBridge({ getCanvas: () => canvas });
447
- weCreatedBridge = !existing;
448
- bridgeRef = bridge;
449
- // `installMultiMcpBridge` already starts the bridge; a pre-existing
450
- // singleton is assumed to be running.
451
- await bridge.waitForEnabledServers(timeoutMs ?? 5000);
452
-
453
- const runner = createBridgeSqlRunner(bridge, () => {
454
- // filter collectDataServers to only connected ones
455
- const all = collectDataServers(data);
456
- return all.filter((s) => bridge.hasServer(s.name));
457
- });
475
+ await waitForEnabledServers(canvas, timeoutMs ?? 5000);
476
+
477
+ const sqlRunner = createBridgeSqlRunner(
478
+ canvas.callTool.bind(canvas),
479
+ () => collectDataServers(data).filter((s) => canvas.dataServers.find((d) => d.name === s.name)?.connected),
480
+ );
481
+
482
+ const runner: CellRunner = async (cell, signal) => {
483
+ if (cell.type === 'sql') return sqlRunner(cell, signal);
484
+ if (cell.type === 'js') {
485
+ const exec = state.executors?.js;
486
+ if (!exec) {
487
+ return { ok: false, error: 'No JS executor registered', errorKind: 'runtime', durationMs: 0 };
488
+ }
489
+ return exec({ cell, state, scope: state.scope, signal });
490
+ }
491
+ return { ok: false, error: `Cell type '${cell.type}' is not re-runnable`, errorKind: 'runtime', durationMs: 0 };
492
+ };
458
493
 
459
494
  await runAutoRefresh({ state, overlay, runner, onCellChange, onTick, signal: ac.signal });
460
495
  } catch (err) {
@@ -464,16 +499,7 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
464
499
  }
465
500
  })();
466
501
 
467
- return () => {
468
- ac.abort();
469
- if (weCreatedBridge && bridgeRef && typeof bridgeRef.stop === 'function') {
470
- try { bridgeRef.stop(); } catch { /* ignore */ }
471
- try {
472
- const g: any = globalThis as any;
473
- if (g.__multiMcp === bridgeRef) g.__multiMcp = undefined;
474
- } catch { /* ignore */ }
475
- }
476
- };
502
+ return () => { ac.abort(); };
477
503
  }
478
504
 
479
505
  export function registerExecutor(state: NotebookState, type: CellType, fn: CellExecutor): void {
@@ -641,6 +667,7 @@ export function tickRunningCell(cell: NotebookCell, elapsedEl: HTMLElement, onDo
641
667
  export interface DataServerTool {
642
668
  name: string;
643
669
  description?: string;
670
+ inputSchema?: unknown;
644
671
  }
645
672
 
646
673
  export interface DataServerRecipe {
@@ -649,7 +676,12 @@ export interface DataServerRecipe {
649
676
  }
650
677
 
651
678
  export interface DataServerDescriptor {
679
+ /** Canvas key (= registry id, e.g. 'wikipedia'). Stable identity used for routing. */
652
680
  name: string;
681
+ /** Display label (registry label, or URL host for manual entries). */
682
+ label?: string;
683
+ /** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
684
+ serverName?: string;
653
685
  url?: string;
654
686
  kind?: string;
655
687
  tools?: DataServerTool[];
@@ -681,6 +713,8 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
681
713
  .filter((s) => !isUiServer(String(s?.name ?? ''), s?.kind))
682
714
  .map((s) => ({
683
715
  name: String(s.name),
716
+ label: typeof s.label === 'string' ? s.label : undefined,
717
+ serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
684
718
  url: s.url ? String(s.url) : undefined,
685
719
  recipes: Array.isArray(s.recipes) ? s.recipes : [],
686
720
  tools: Array.isArray(s.tools) ? s.tools : [],
@@ -692,6 +726,8 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
692
726
  .filter((s) => s?.name && !isUiServer(String(s.name), s?.kind))
693
727
  .map((s) => ({
694
728
  name: String(s.name),
729
+ label: typeof s.label === 'string' ? s.label : undefined,
730
+ serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
695
731
  url: s.url ? String(s.url) : undefined,
696
732
  recipes: Array.isArray(s.recipes) ? s.recipes : [],
697
733
  tools: Array.isArray(s.tools) ? s.tools : [],
@@ -715,7 +751,7 @@ export interface PublishControlsOptions {
715
751
  onPublished?: (info: { slug: string; url: string; updated: boolean }) => void;
716
752
  /** Optional toast function — falls back to internal toast helper if absent. */
717
753
  toast?: (message: string, isError?: boolean) => void;
718
- /** Minimal projection of the state sent to the server. If absent, sends { id, title, kicker, mode, cells }. */
754
+ /** Minimal projection of the state sent to the server. If absent, sends { id, title, mode, cells }. */
719
755
  serializeState?: (state: NotebookState) => Record<string, unknown>;
720
756
  }
721
757
 
@@ -730,7 +766,7 @@ function publishUrlFor(slug: string): string {
730
766
  }
731
767
 
732
768
  function publishBtnLabel(state: NotebookState): string {
733
- return state.publishedSlug ? '🔄 update' : '📤 publish';
769
+ return state.publishedSlug ? '💾 save' : '📤 publish';
734
770
  }
735
771
 
736
772
  function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
@@ -738,7 +774,7 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
738
774
  btn.textContent = publishBtnLabel(state);
739
775
  btn.dataset.state = state.publishedSlug ? 'published' : 'draft';
740
776
  if (state.publishedSlug) {
741
- btn.title = `Update ${publishUrlFor(state.publishedSlug)}`;
777
+ btn.title = `Save changes to ${publishUrlFor(state.publishedSlug)}`;
742
778
  } else {
743
779
  btn.title = 'Publish this notebook';
744
780
  }
@@ -812,22 +848,16 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
812
848
  const onClick = async () => {
813
849
  const prevLabel = btn.textContent ?? '';
814
850
  btn.disabled = true;
815
- btn.textContent = state.publishedSlug ? '… updating' : '… publishing';
851
+ btn.textContent = state.publishedSlug ? '… saving' : '… publishing';
816
852
  try {
817
- const minimal = opts.serializeState
818
- ? opts.serializeState(state)
819
- : {
820
- id: state.id,
821
- title: state.title,
822
- kicker: state.kicker,
823
- mode: state.mode,
824
- cells: state.cells,
825
- };
853
+ // HyperSkill standalone markdown — frontmatter (title/description/servers)
854
+ // + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
855
+ const markdown = serializeToMarkdown(state);
826
856
  const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
827
857
  method: 'POST',
828
858
  headers: { 'content-type': 'application/json' },
829
859
  body: JSON.stringify({
830
- state: minimal,
860
+ markdown,
831
861
  slug: state.publishedSlug,
832
862
  token: state.publishedToken,
833
863
  }),
@@ -837,15 +867,19 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
837
867
  state.publishedSlug = reply.slug;
838
868
  state.publishedToken = reply.token;
839
869
  state.lastEditAt = Date.now();
840
- const url: string = reply.url ?? publishUrlFor(String(reply.slug));
841
- try { await navigator.clipboard?.writeText?.(url); } catch { /* ignore */ }
870
+ const baseUrl: string = reply.url ?? publishUrlFor(String(reply.slug));
871
+ // Author URL embeds the token via `?t=` so the viewer can hydrate it
872
+ // (one-shot — the viewer extracts it into localStorage and cleans the
873
+ // URL). Public visitors share the bare URL without ?t.
874
+ const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
875
+ try { await navigator.clipboard?.writeText?.(authorUrl); } catch { /* ignore */ }
842
876
  const updated = Boolean(reply.updated);
843
877
  toast(
844
878
  updated
845
- ? `updated · ${url.replace(/^https?:\/\//, '')} (copied)`
846
- : `published · ${url.replace(/^https?:\/\//, '')} (copied)`
879
+ ? `saved · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
880
+ : `published · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
847
881
  );
848
- opts.onPublished?.({ slug: String(reply.slug), url, updated });
882
+ opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
849
883
  } catch (err: any) {
850
884
  toast(`publish failed · ${String(err?.message ?? err)}`, true);
851
885
  btn.textContent = prevLabel;