@webmcp-auto-ui/ui 2.5.26 → 2.5.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +15 -3
- package/src/agent/AgentConsole.svelte +1 -21
- package/src/agent/DataServersPanel.svelte +164 -0
- package/src/agent/LLMSelector.svelte +26 -8
- package/src/agent/ModelCacheManager.svelte +359 -0
- package/src/agent/{GemmaLoader.svelte → ModelLoader.svelte} +1 -1
- package/src/agent/SettingsPanel.svelte +16 -2
- package/src/index.ts +45 -31
- package/src/widgets/WidgetRenderer.svelte +118 -115
- package/src/widgets/export-widget.ts +28 -1
- package/src/widgets/helpers/safe-image.ts +78 -0
- package/src/widgets/notebook/.gitkeep +0 -0
- package/src/widgets/notebook/chart-renderer.ts +63 -0
- package/src/widgets/notebook/compact.ts +823 -0
- package/src/widgets/notebook/document.ts +1065 -0
- package/src/widgets/notebook/editorial.ts +936 -0
- package/src/widgets/notebook/executors/.gitkeep +1 -0
- package/src/widgets/notebook/executors/index.ts +4 -0
- package/src/widgets/notebook/executors/js-worker.ts +269 -0
- package/src/widgets/notebook/executors/sql.ts +206 -0
- package/src/widgets/notebook/import-modals.ts +553 -0
- package/src/widgets/notebook/left-pane.ts +249 -0
- package/src/widgets/notebook/prose.ts +280 -0
- package/src/widgets/notebook/recipe-browser.ts +350 -0
- package/src/widgets/notebook/recipes/compact.md +124 -0
- package/src/widgets/notebook/recipes/document.md +139 -0
- package/src/widgets/notebook/recipes/editorial.md +120 -0
- package/src/widgets/notebook/recipes/workspace.md +119 -0
- package/src/widgets/notebook/resource-extractor.ts +162 -0
- package/src/widgets/notebook/share-handlers.ts +222 -0
- package/src/widgets/notebook/shared.ts +1592 -0
- package/src/widgets/notebook/workspace.ts +852 -0
- package/src/widgets/rich/cards.ts +181 -0
- package/src/widgets/rich/carousel.ts +319 -0
- package/src/widgets/rich/chart-rich.ts +386 -0
- package/src/widgets/rich/d3.ts +503 -0
- package/src/widgets/rich/data-table.ts +342 -0
- package/src/widgets/rich/gallery.ts +350 -0
- package/src/widgets/rich/grid-data.ts +173 -0
- package/src/widgets/rich/hemicycle.ts +313 -0
- package/src/widgets/rich/js-sandbox.ts +106 -0
- package/src/widgets/rich/json-viewer.ts +202 -0
- package/src/widgets/rich/log.ts +143 -0
- package/src/widgets/rich/map.ts +218 -0
- package/src/widgets/rich/profile.ts +256 -0
- package/src/widgets/rich/sankey.ts +262 -0
- package/src/widgets/rich/stat-card.ts +125 -0
- package/src/widgets/rich/timeline.ts +179 -0
- package/src/widgets/rich/trombinoscope.ts +246 -0
- package/src/widgets/simple/actions.ts +89 -0
- package/src/widgets/simple/alert.ts +100 -0
- package/src/widgets/simple/chart.ts +189 -0
- package/src/widgets/simple/code.ts +79 -0
- package/src/widgets/simple/kv.ts +68 -0
- package/src/widgets/simple/list.ts +89 -0
- package/src/widgets/simple/stat.ts +58 -0
- package/src/widgets/simple/tags.ts +125 -0
- package/src/widgets/simple/text.ts +198 -0
- package/src/wm/FloatingLayout.svelte +2 -0
- package/src/wm/LinkIndicators.svelte +8 -15
- package/src/widgets/SafeImage.svelte +0 -76
- package/src/widgets/rich/Cards.svelte +0 -39
- package/src/widgets/rich/Carousel.svelte +0 -88
- package/src/widgets/rich/Chart.svelte +0 -142
- package/src/widgets/rich/D3Widget.svelte +0 -373
- package/src/widgets/rich/DataTable.svelte +0 -62
- package/src/widgets/rich/Gallery.svelte +0 -94
- package/src/widgets/rich/GridData.svelte +0 -44
- package/src/widgets/rich/Hemicycle.svelte +0 -78
- package/src/widgets/rich/JsSandbox.svelte +0 -51
- package/src/widgets/rich/JsonViewer.svelte +0 -42
- package/src/widgets/rich/LogViewer.svelte +0 -24
- package/src/widgets/rich/MapView.svelte +0 -140
- package/src/widgets/rich/ProfileCard.svelte +0 -59
- package/src/widgets/rich/Sankey.svelte +0 -38
- package/src/widgets/rich/StatCard.svelte +0 -35
- package/src/widgets/rich/Timeline.svelte +0 -43
- package/src/widgets/rich/Trombinoscope.svelte +0 -48
- package/src/widgets/simple/ActionsBlock.svelte +0 -15
- package/src/widgets/simple/AlertBlock.svelte +0 -11
- package/src/widgets/simple/ChartBlock.svelte +0 -21
- package/src/widgets/simple/CodeBlock.svelte +0 -11
- package/src/widgets/simple/KVBlock.svelte +0 -16
- package/src/widgets/simple/ListBlock.svelte +0 -17
- package/src/widgets/simple/StatBlock.svelte +0 -14
- package/src/widgets/simple/TagsBlock.svelte +0 -15
- package/src/widgets/simple/TextBlock.svelte +0 -122
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Left pane — collapsible resource browser (Chrome-bookmarks-style).
|
|
4
|
+
// Lists recipes + tools per connected MCP server.
|
|
5
|
+
// Click opens the appropriate viewer modal (recipe / tool).
|
|
6
|
+
// Collapsed by default.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
|
|
10
|
+
import { openRecipeViewerModal, openToolViewerModal, type ImportedRecipe } from './import-modals.js';
|
|
11
|
+
import type { NotebookCell, NotebookState, DataServerDescriptor } from './shared.js';
|
|
12
|
+
|
|
13
|
+
export interface LeftPaneHandlers {
|
|
14
|
+
/** Called when the user injects one or more cells from a recipe/tool viewer. */
|
|
15
|
+
onInjectCells: (cells: NotebookCell[]) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LeftPaneHandle {
|
|
19
|
+
root: HTMLElement;
|
|
20
|
+
setServers(servers: DataServerDescriptor[]): void;
|
|
21
|
+
toggle(open?: boolean): void;
|
|
22
|
+
destroy(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mount a left pane inside `host`. The pane is collapsed by default;
|
|
27
|
+
* click the handle to expand. Returns a handle the widget can use to
|
|
28
|
+
* feed updated server lists or toggle programmatically.
|
|
29
|
+
*/
|
|
30
|
+
export function mountLeftPane(
|
|
31
|
+
host: HTMLElement,
|
|
32
|
+
state: NotebookState,
|
|
33
|
+
initialServers: DataServerDescriptor[],
|
|
34
|
+
handlers: LeftPaneHandlers,
|
|
35
|
+
): LeftPaneHandle {
|
|
36
|
+
injectLeftPaneStyles();
|
|
37
|
+
|
|
38
|
+
const root = document.createElement('aside');
|
|
39
|
+
root.className = 'nb-lp';
|
|
40
|
+
root.innerHTML = `
|
|
41
|
+
<button type="button" class="nb-lp-handle" aria-label="Toggle resources" title="Resources">
|
|
42
|
+
<span class="nb-lp-handle-icon">▸</span>
|
|
43
|
+
<span class="nb-lp-handle-label">Resources</span>
|
|
44
|
+
</button>
|
|
45
|
+
<div class="nb-lp-body" hidden>
|
|
46
|
+
<header class="nb-lp-head">
|
|
47
|
+
<span class="nb-lp-title">Resources</span>
|
|
48
|
+
<button type="button" class="nb-lp-close" aria-label="Close">×</button>
|
|
49
|
+
</header>
|
|
50
|
+
<div class="nb-lp-servers" data-role="servers"></div>
|
|
51
|
+
</div>
|
|
52
|
+
`;
|
|
53
|
+
host.appendChild(root);
|
|
54
|
+
|
|
55
|
+
const handle = root.querySelector('.nb-lp-handle') as HTMLElement;
|
|
56
|
+
const body = root.querySelector('.nb-lp-body') as HTMLElement;
|
|
57
|
+
const closeBtn = root.querySelector('.nb-lp-close') as HTMLElement;
|
|
58
|
+
const serversEl = root.querySelector('[data-role="servers"]') as HTMLElement;
|
|
59
|
+
|
|
60
|
+
let servers: DataServerDescriptor[] = initialServers ?? [];
|
|
61
|
+
// Recipe-body cache per serverName/name key
|
|
62
|
+
const recipeBodyCache = new Map<string, string>();
|
|
63
|
+
|
|
64
|
+
function render() {
|
|
65
|
+
serversEl.innerHTML = '';
|
|
66
|
+
if (!servers.length) {
|
|
67
|
+
serversEl.innerHTML = '<div class="nb-lp-empty">No servers connected.</div>';
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const srv of servers) {
|
|
71
|
+
const section = document.createElement('section');
|
|
72
|
+
section.className = 'nb-lp-srv';
|
|
73
|
+
section.innerHTML = `
|
|
74
|
+
<header class="nb-lp-srv-head">
|
|
75
|
+
<span class="nb-lp-srv-dot"></span>
|
|
76
|
+
<span class="nb-lp-srv-name">${escapeHtml(srv.name)}</span>
|
|
77
|
+
<span class="nb-lp-srv-meta">${(srv.recipes?.length ?? 0)} recipes · ${(srv.tools?.length ?? 0)} tools</span>
|
|
78
|
+
</header>
|
|
79
|
+
<div class="nb-lp-srv-groups">
|
|
80
|
+
${srv.recipes?.length ? `
|
|
81
|
+
<details class="nb-lp-group" open>
|
|
82
|
+
<summary>📜 Recipes (${srv.recipes.length})</summary>
|
|
83
|
+
<ul class="nb-lp-list" data-role="recipes"></ul>
|
|
84
|
+
</details>
|
|
85
|
+
` : ''}
|
|
86
|
+
${srv.tools?.length ? `
|
|
87
|
+
<details class="nb-lp-group">
|
|
88
|
+
<summary>⚙ Tools (${srv.tools.length})</summary>
|
|
89
|
+
<ul class="nb-lp-list" data-role="tools"></ul>
|
|
90
|
+
</details>
|
|
91
|
+
` : ''}
|
|
92
|
+
</div>
|
|
93
|
+
`;
|
|
94
|
+
const recList = section.querySelector('[data-role="recipes"]') as HTMLElement | null;
|
|
95
|
+
if (recList && srv.recipes) {
|
|
96
|
+
for (const r of srv.recipes) {
|
|
97
|
+
const li = document.createElement('li');
|
|
98
|
+
li.className = 'nb-lp-item';
|
|
99
|
+
li.innerHTML = `
|
|
100
|
+
<span class="nb-lp-item-name">${escapeHtml(r.name)}</span>
|
|
101
|
+
${r.description ? `<span class="nb-lp-item-desc">${escapeHtml(r.description)}</span>` : ''}
|
|
102
|
+
`;
|
|
103
|
+
li.addEventListener('click', () => onRecipeClick(srv, r));
|
|
104
|
+
recList.appendChild(li);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const toolList = section.querySelector('[data-role="tools"]') as HTMLElement | null;
|
|
108
|
+
if (toolList && srv.tools) {
|
|
109
|
+
for (const t of srv.tools) {
|
|
110
|
+
const li = document.createElement('li');
|
|
111
|
+
li.className = 'nb-lp-item';
|
|
112
|
+
li.innerHTML = `
|
|
113
|
+
<span class="nb-lp-item-name nb-lp-tool">${escapeHtml(t.name)}</span>
|
|
114
|
+
${t.description ? `<span class="nb-lp-item-desc">${escapeHtml(t.description)}</span>` : ''}
|
|
115
|
+
`;
|
|
116
|
+
li.addEventListener('click', () => onToolClick(srv, t));
|
|
117
|
+
toolList.appendChild(li);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
serversEl.appendChild(section);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function onRecipeClick(srv: DataServerDescriptor, r: { name: string; description?: string; body?: string }) {
|
|
125
|
+
const imported: ImportedRecipe = {
|
|
126
|
+
name: r.name,
|
|
127
|
+
description: r.description,
|
|
128
|
+
body: r.body,
|
|
129
|
+
serverName: srv.name,
|
|
130
|
+
originalName: r.name,
|
|
131
|
+
};
|
|
132
|
+
const key = srv.name + ':' + r.name;
|
|
133
|
+
if (!imported.body && recipeBodyCache.has(key)) {
|
|
134
|
+
imported.body = recipeBodyCache.get(key);
|
|
135
|
+
}
|
|
136
|
+
if (!imported.body) {
|
|
137
|
+
try {
|
|
138
|
+
const res: any = await callToolViaPostMessage(`${srv.name}_get_recipe`, { name: r.name, id: r.name });
|
|
139
|
+
const text = res?.content?.find?.((c: any) => c.type === 'text')?.text;
|
|
140
|
+
if (text) {
|
|
141
|
+
let body = text;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(text);
|
|
144
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.content === 'string') body = parsed.content;
|
|
145
|
+
} catch { /* not JSON */ }
|
|
146
|
+
imported.body = body;
|
|
147
|
+
recipeBodyCache.set(key, body);
|
|
148
|
+
}
|
|
149
|
+
} catch { /* pass empty body to viewer */ }
|
|
150
|
+
}
|
|
151
|
+
openRecipeViewerModal(imported, (cell) => handlers.onInjectCells([cell]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function onToolClick(srv: DataServerDescriptor, t: { name: string; description?: string; inputSchema?: unknown }) {
|
|
155
|
+
openToolViewerModal({ ...t, serverName: srv.name }, (cells) => handlers.onInjectCells(cells));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
handle.addEventListener('click', () => toggle());
|
|
159
|
+
closeBtn.addEventListener('click', () => toggle(false));
|
|
160
|
+
|
|
161
|
+
function toggle(open?: boolean) {
|
|
162
|
+
const nextOpen = typeof open === 'boolean' ? open : !!body.hidden;
|
|
163
|
+
body.hidden = !nextOpen;
|
|
164
|
+
root.classList.toggle('nb-lp-open', nextOpen);
|
|
165
|
+
(root.querySelector('.nb-lp-handle-icon') as HTMLElement).textContent = nextOpen ? '◂' : '▸';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
render();
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
root,
|
|
172
|
+
setServers(next) {
|
|
173
|
+
servers = next ?? [];
|
|
174
|
+
render();
|
|
175
|
+
},
|
|
176
|
+
toggle,
|
|
177
|
+
destroy() {
|
|
178
|
+
root.remove();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function escapeHtml(s: string): string {
|
|
184
|
+
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function injectLeftPaneStyles() {
|
|
188
|
+
if (document.getElementById('nb-lp-styles')) return;
|
|
189
|
+
const s = document.createElement('style');
|
|
190
|
+
s.id = 'nb-lp-styles';
|
|
191
|
+
s.textContent = `
|
|
192
|
+
.nb-lp {
|
|
193
|
+
position: relative; display: flex; flex-direction: row; flex-shrink: 0;
|
|
194
|
+
font-family: var(--font-sans, system-ui); font-size: 12px;
|
|
195
|
+
color: var(--color-text1, #111);
|
|
196
|
+
}
|
|
197
|
+
.nb-lp-handle {
|
|
198
|
+
writing-mode: vertical-rl; transform: rotate(180deg);
|
|
199
|
+
background: var(--color-surface2, #f4f4f5);
|
|
200
|
+
border: 1px solid var(--color-border, #e4e4e7); border-radius: 6px;
|
|
201
|
+
padding: 10px 5px; cursor: pointer;
|
|
202
|
+
display: flex; align-items: center; gap: 6px;
|
|
203
|
+
height: fit-content; font-size: 11px; color: var(--color-text2, #666);
|
|
204
|
+
align-self: flex-start; margin-top: 12px; margin-right: 4px;
|
|
205
|
+
}
|
|
206
|
+
.nb-lp-handle:hover { background: var(--color-surface3, #eeeef0); }
|
|
207
|
+
.nb-lp-handle-icon { font-size: 10px; }
|
|
208
|
+
.nb-lp.nb-lp-open .nb-lp-handle { display: none; }
|
|
209
|
+
.nb-lp-body[hidden] { display: none !important; }
|
|
210
|
+
.nb-lp-body {
|
|
211
|
+
width: 260px; border-right: 1px solid var(--color-border, #e4e4e7);
|
|
212
|
+
background: var(--color-surface, #fff);
|
|
213
|
+
display: flex; flex-direction: column; max-height: 100%; overflow: hidden;
|
|
214
|
+
}
|
|
215
|
+
.nb-lp-head {
|
|
216
|
+
display: flex; align-items: center; padding: 10px 12px;
|
|
217
|
+
border-bottom: 1px solid var(--color-border, #e4e4e7);
|
|
218
|
+
}
|
|
219
|
+
.nb-lp-title { flex: 1; font-weight: 600; font-size: 12px; }
|
|
220
|
+
.nb-lp-close { background: none; border: none; cursor: pointer; font-size: 16px; color: var(--color-text2, #666); }
|
|
221
|
+
.nb-lp-servers { overflow-y: auto; padding: 8px 10px 12px; flex: 1; }
|
|
222
|
+
.nb-lp-srv { margin-bottom: 10px; }
|
|
223
|
+
.nb-lp-srv-head {
|
|
224
|
+
display: flex; align-items: center; gap: 6px;
|
|
225
|
+
padding: 6px 2px; font-size: 11px; color: var(--color-text2, #666);
|
|
226
|
+
}
|
|
227
|
+
.nb-lp-srv-dot {
|
|
228
|
+
width: 6px; height: 6px; border-radius: 50%; background: var(--color-accent, #6a55ff);
|
|
229
|
+
flex-shrink: 0;
|
|
230
|
+
}
|
|
231
|
+
.nb-lp-srv-name { font-weight: 600; color: var(--color-text1, #111); }
|
|
232
|
+
.nb-lp-srv-meta { margin-left: auto; font-family: monospace; font-size: 10.5px; }
|
|
233
|
+
.nb-lp-group > summary {
|
|
234
|
+
cursor: pointer; padding: 4px 2px; font-size: 11px;
|
|
235
|
+
color: var(--color-text2, #666); font-family: monospace;
|
|
236
|
+
}
|
|
237
|
+
.nb-lp-list { list-style: none; padding: 0 0 0 6px; margin: 2px 0 6px; }
|
|
238
|
+
.nb-lp-item {
|
|
239
|
+
padding: 5px 8px; border-radius: 4px; cursor: pointer;
|
|
240
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
241
|
+
}
|
|
242
|
+
.nb-lp-item:hover { background: var(--color-surface2, #f4f4f5); }
|
|
243
|
+
.nb-lp-item-name { font-size: 12px; }
|
|
244
|
+
.nb-lp-tool { font-family: monospace; }
|
|
245
|
+
.nb-lp-item-desc { font-size: 10.5px; color: var(--color-text2, #666); }
|
|
246
|
+
.nb-lp-empty { padding: 12px; font-size: 11px; color: var(--color-text2, #666); text-align: center; }
|
|
247
|
+
`;
|
|
248
|
+
document.head.appendChild(s);
|
|
249
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Lightweight markdown renderer + allowlist sanitizer for notebook prose cells.
|
|
4
|
+
// Also: renderMarkdownWithInjectButtons — used by recipe viewer modal.
|
|
5
|
+
// No external dependencies.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const BLOCK_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr', 'br']);
|
|
9
|
+
const INLINE_TAGS = new Set(['strong', 'em', 'code', 'a', 'mark', 's', 'u']);
|
|
10
|
+
const ALLOWED_TAGS = new Set([...BLOCK_TAGS, ...INLINE_TAGS]);
|
|
11
|
+
const ALLOWED_ATTRS_BY_TAG: Record<string, Set<string>> = {
|
|
12
|
+
a: new Set(['href', 'title', 'target', 'rel']),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function escapeHtml(s: string): string {
|
|
16
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Scan and wrap `*italic*` segments. Avoids the lookbehind regex (Safari < 16.4)
|
|
21
|
+
* and avoids consuming the preceding char (the previous regex did, which broke
|
|
22
|
+
* `*a* *b*` since the space between them was eaten by the first match).
|
|
23
|
+
* A `*` is treated as an italic delimiter only when it is NOT part of a `**`
|
|
24
|
+
* sequence (bold has already been replaced, so any remaining `**` is leftover).
|
|
25
|
+
*/
|
|
26
|
+
function scanItalic(s: string): string {
|
|
27
|
+
let out = '';
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < s.length) {
|
|
30
|
+
const ch = s[i];
|
|
31
|
+
if (ch === '*' && s[i + 1] !== '*' && (i === 0 || s[i - 1] !== '*')) {
|
|
32
|
+
// Look for closing `*` on the same line, with non-empty content
|
|
33
|
+
let j = i + 1;
|
|
34
|
+
while (j < s.length && s[j] !== '*' && s[j] !== '\n') j++;
|
|
35
|
+
if (j < s.length && s[j] === '*' && j > i + 1 && s[j + 1] !== '*') {
|
|
36
|
+
out += `<em>${s.slice(i + 1, j)}</em>`;
|
|
37
|
+
i = j + 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
out += ch;
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderInline(s: string): string {
|
|
48
|
+
// Escape first, then re-apply allowed inline constructs
|
|
49
|
+
let out = escapeHtml(s);
|
|
50
|
+
// inline code `...`
|
|
51
|
+
out = out.replace(/`([^`\n]+)`/g, (_m, g1) => `<code>${g1}</code>`);
|
|
52
|
+
// bold **...**
|
|
53
|
+
out = out.replace(/\*\*([^\*\n]+)\*\*/g, (_m, g1) => `<strong>${g1}</strong>`);
|
|
54
|
+
// italic *...* — manual scan avoids consuming the leading char (which broke
|
|
55
|
+
// patterns like `*a* *b*` when the previous regex used [^\*] as a guard).
|
|
56
|
+
out = scanItalic(out);
|
|
57
|
+
// links [text](href)
|
|
58
|
+
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, href) => {
|
|
59
|
+
const safeHref = /^https?:\/\//i.test(href) ? href : (href.startsWith('/') || href.startsWith('#') ? href : '#');
|
|
60
|
+
return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
61
|
+
});
|
|
62
|
+
// marks ==...==
|
|
63
|
+
out = out.replace(/==([^=\n]+)==/g, (_m, g1) => `<mark>${g1}</mark>`);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render markdown to a sanitized HTML string. Handles headings, paragraphs,
|
|
69
|
+
* fenced code blocks, unordered lists, ordered lists, blockquotes, hr.
|
|
70
|
+
* Not a full MD spec — covers the ~90% used in notebook prose cells.
|
|
71
|
+
*/
|
|
72
|
+
export function renderProse(content: string): string {
|
|
73
|
+
if (!content) return '';
|
|
74
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
let i = 0;
|
|
77
|
+
|
|
78
|
+
while (i < lines.length) {
|
|
79
|
+
const line = lines[i];
|
|
80
|
+
|
|
81
|
+
// Fenced code block
|
|
82
|
+
if (/^```/.test(line)) {
|
|
83
|
+
const lang = line.replace(/^```/, '').trim();
|
|
84
|
+
const code: string[] = [];
|
|
85
|
+
i++;
|
|
86
|
+
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
87
|
+
code.push(lines[i]);
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
i++; // skip closing fence
|
|
91
|
+
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
92
|
+
out.push(`<pre><code${cls}>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Heading
|
|
97
|
+
const mH = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
98
|
+
if (mH) {
|
|
99
|
+
const level = mH[1].length;
|
|
100
|
+
out.push(`<h${level}>${renderInline(mH[2])}</h${level}>`);
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Horizontal rule
|
|
106
|
+
if (/^---+\s*$/.test(line) || /^\*\*\*+\s*$/.test(line)) {
|
|
107
|
+
out.push('<hr/>');
|
|
108
|
+
i++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unordered list
|
|
113
|
+
if (/^[-*+]\s+/.test(line)) {
|
|
114
|
+
const items: string[] = [];
|
|
115
|
+
while (i < lines.length && /^[-*+]\s+/.test(lines[i])) {
|
|
116
|
+
items.push(lines[i].replace(/^[-*+]\s+/, ''));
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
out.push('<ul>' + items.map((t) => `<li>${renderInline(t)}</li>`).join('') + '</ul>');
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ordered list
|
|
124
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
125
|
+
const items: string[] = [];
|
|
126
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
127
|
+
items.push(lines[i].replace(/^\d+\.\s+/, ''));
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
out.push('<ol>' + items.map((t) => `<li>${renderInline(t)}</li>`).join('') + '</ol>');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Blockquote
|
|
135
|
+
if (/^>\s?/.test(line)) {
|
|
136
|
+
const quote: string[] = [];
|
|
137
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
138
|
+
quote.push(lines[i].replace(/^>\s?/, ''));
|
|
139
|
+
i++;
|
|
140
|
+
}
|
|
141
|
+
out.push(`<blockquote>${renderInline(quote.join(' '))}</blockquote>`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Blank → paragraph break
|
|
146
|
+
if (/^\s*$/.test(line)) {
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Paragraph: gather contiguous non-empty lines
|
|
152
|
+
const para: string[] = [];
|
|
153
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|```|[-*+]\s|\d+\.\s|>\s?|---+\s*$)/.test(lines[i])) {
|
|
154
|
+
para.push(lines[i]);
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
out.push(`<p>${renderInline(para.join(' '))}</p>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return sanitize(out.join('\n'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Final sanitization pass: allowlist tags + attrs, strip javascript: hrefs and on* handlers.
|
|
165
|
+
*/
|
|
166
|
+
function sanitize(html: string): string {
|
|
167
|
+
if (typeof document === 'undefined') return html;
|
|
168
|
+
const tpl = document.createElement('template');
|
|
169
|
+
tpl.innerHTML = html;
|
|
170
|
+
walk(tpl.content);
|
|
171
|
+
return tpl.innerHTML;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function walk(node: Node): void {
|
|
175
|
+
const children = Array.from(node.childNodes);
|
|
176
|
+
for (const child of children) {
|
|
177
|
+
if (child.nodeType === 1) {
|
|
178
|
+
const el = child as Element;
|
|
179
|
+
const tag = el.tagName.toLowerCase();
|
|
180
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
181
|
+
// Unwrap disallowed tags (keep text)
|
|
182
|
+
while (el.firstChild) node.insertBefore(el.firstChild, el);
|
|
183
|
+
node.removeChild(el);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const allowedAttrs = ALLOWED_ATTRS_BY_TAG[tag] ?? new Set<string>();
|
|
187
|
+
for (const attr of Array.from(el.attributes)) {
|
|
188
|
+
if (!allowedAttrs.has(attr.name)) {
|
|
189
|
+
el.removeAttribute(attr.name);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (attr.name === 'href' && /^\s*javascript:/i.test(attr.value)) {
|
|
193
|
+
el.removeAttribute(attr.name);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
walk(el);
|
|
197
|
+
} else if (child.nodeType !== 3 && child.nodeType !== 8) {
|
|
198
|
+
// Remove anything that's not element / text / comment
|
|
199
|
+
node.removeChild(child);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Renderer with inject buttons — used by recipe viewer modal.
|
|
206
|
+
// Each fenced code block gets an "↳ inject" button next to it.
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
export interface InjectFenceEvent {
|
|
210
|
+
lang: string;
|
|
211
|
+
content: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Render markdown into a container. Each fenced code block is rendered with
|
|
216
|
+
* an "↳ inject" button; clicking it calls onInject({lang, content}).
|
|
217
|
+
* Returns a cleanup function.
|
|
218
|
+
*/
|
|
219
|
+
export function renderMarkdownWithInjectButtons(
|
|
220
|
+
body: string,
|
|
221
|
+
onInject: (e: InjectFenceEvent) => void,
|
|
222
|
+
): { root: HTMLElement; destroy: () => void } {
|
|
223
|
+
const root = document.createElement('div');
|
|
224
|
+
root.className = 'nb-md-render';
|
|
225
|
+
|
|
226
|
+
const lines = (body || '').replace(/\r\n/g, '\n').split('\n');
|
|
227
|
+
const buf: string[] = [];
|
|
228
|
+
let i = 0;
|
|
229
|
+
const cleanups: Array<() => void> = [];
|
|
230
|
+
|
|
231
|
+
const flushProse = () => {
|
|
232
|
+
if (buf.length) {
|
|
233
|
+
const chunk = buf.join('\n');
|
|
234
|
+
const p = document.createElement('div');
|
|
235
|
+
p.innerHTML = renderProse(chunk);
|
|
236
|
+
root.appendChild(p);
|
|
237
|
+
buf.length = 0;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
while (i < lines.length) {
|
|
242
|
+
const line = lines[i];
|
|
243
|
+
if (/^```/.test(line)) {
|
|
244
|
+
flushProse();
|
|
245
|
+
const lang = line.replace(/^```/, '').trim().toLowerCase() || 'text';
|
|
246
|
+
const code: string[] = [];
|
|
247
|
+
i++;
|
|
248
|
+
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
249
|
+
code.push(lines[i]);
|
|
250
|
+
i++;
|
|
251
|
+
}
|
|
252
|
+
i++; // closing fence
|
|
253
|
+
const content = code.join('\n');
|
|
254
|
+
|
|
255
|
+
const block = document.createElement('div');
|
|
256
|
+
block.className = 'nb-md-fence';
|
|
257
|
+
block.innerHTML = `
|
|
258
|
+
<div class="nb-md-fence-head">
|
|
259
|
+
<span class="nb-md-fence-lang">${escapeHtml(lang)}</span>
|
|
260
|
+
<button type="button" class="nb-md-fence-inject">↳ inject</button>
|
|
261
|
+
</div>
|
|
262
|
+
<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(content)}</code></pre>
|
|
263
|
+
`;
|
|
264
|
+
const btn = block.querySelector('.nb-md-fence-inject') as HTMLButtonElement;
|
|
265
|
+
const handler = () => onInject({ lang, content });
|
|
266
|
+
btn.addEventListener('click', handler);
|
|
267
|
+
cleanups.push(() => btn.removeEventListener('click', handler));
|
|
268
|
+
root.appendChild(block);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
buf.push(line);
|
|
272
|
+
i++;
|
|
273
|
+
}
|
|
274
|
+
flushProse();
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
root,
|
|
278
|
+
destroy: () => cleanups.forEach((f) => f()),
|
|
279
|
+
};
|
|
280
|
+
}
|