@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.
- package/package.json +1 -1
- package/src/agent/MCPserversList.svelte +44 -32
- package/src/agent/RecipeBrowser.svelte +54 -18
- package/src/agent/RemoteMCPserversDemo.svelte +7 -7
- package/src/agent/ToolBrowser.svelte +34 -5
- package/src/agent/WebMCPserversList.svelte +85 -40
- package/src/index.ts +5 -0
- package/src/primitives/MarkdownView.svelte +12 -2
- package/src/recipe/RecipeCodeBlock.svelte +331 -0
- package/src/recipe/RecipeRunModal.svelte +245 -0
- package/src/recipe/types.ts +10 -0
- package/src/widgets/WidgetRenderer.svelte +2 -0
- package/src/widgets/notebook/executors/sql.ts +21 -7
- package/src/widgets/notebook/import-modal-api.ts +15 -43
- package/src/widgets/notebook/import-modal.svelte +36 -66
- package/src/widgets/notebook/left-pane.ts +30 -12
- package/src/widgets/notebook/notebook.svelte +0 -2
- package/src/widgets/notebook/notebook.ts +12 -56
- package/src/widgets/notebook/prose.ts +0 -78
- package/src/widgets/notebook/recipes/notebook.md +0 -6
- package/src/widgets/notebook/resource-extractor.ts +21 -1
- package/src/widgets/notebook/share-handlers.ts +76 -3
- package/src/widgets/notebook/shared.ts +113 -79
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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
|
|
330
|
-
* on the connected servers (matching `*_query_sql` then
|
|
331
|
-
* calls it with `{ sql: cell.content }`, parses
|
|
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
|
-
*
|
|
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(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
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,
|
|
411
|
-
* a
|
|
412
|
-
* mount time when `state.autoRun && state.mode === 'view'`. Returns
|
|
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
|
-
*
|
|
415
|
-
*
|
|
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
|
|
438
|
-
.__canvasVanilla ?? (globalThis as { 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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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,
|
|
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 ? '
|
|
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 = `
|
|
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 ? '…
|
|
851
|
+
btn.textContent = state.publishedSlug ? '… saving' : '… publishing';
|
|
816
852
|
try {
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
|
841
|
-
|
|
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
|
-
? `
|
|
846
|
-
: `published · ${
|
|
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;
|