dsh-codinput 0.3.9 → 0.4.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 (37) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/NOTICE +37 -4
  3. package/README.en.md +26 -12
  4. package/README.md +26 -12
  5. package/TESTING.md +86 -6
  6. package/assets/donate-qr.jpg +0 -0
  7. package/lib/client.js +2875 -98
  8. package/package.json +5 -2
  9. package/src/client/cm.ts +19 -1
  10. package/src/client/command.ts +39 -8
  11. package/src/client/components/CodinputSurface.tsx +80 -27
  12. package/src/client/components/EditorPane.tsx +3 -1
  13. package/src/client/components/InputSlotSeat.tsx +56 -1
  14. package/src/client/components/SlashMenu.tsx +294 -41
  15. package/src/client/components/StatsRow.tsx +40 -23
  16. package/src/client/components/Toolbar.tsx +284 -48
  17. package/src/client/components/UserMessageMarkdown.tsx +158 -115
  18. package/src/client/components/VoiceActivity.tsx +30 -69
  19. package/src/client/components/slotEntry.tsx +37 -0
  20. package/src/client/host-types.ts +11 -3
  21. package/src/client/hotkey.ts +38 -0
  22. package/src/client/i18n.ts +18 -4
  23. package/src/client/index.tsx +9 -0
  24. package/src/client/markdown/code.ts +281 -0
  25. package/src/client/markdown/extensions.ts +576 -0
  26. package/src/client/markdown/heavy-assets.d.ts +11 -0
  27. package/src/client/markdown/heavy-shared.ts +50 -0
  28. package/src/client/markdown/heavy.ts +159 -0
  29. package/src/client/markdown/index.ts +289 -0
  30. package/src/client/markdown/math.ts +134 -0
  31. package/src/client/markdown/mermaid.ts +358 -0
  32. package/src/client/markdown/sanitize.ts +103 -0
  33. package/src/client/markdown/styles.ts +171 -0
  34. package/src/client/prefs.ts +206 -23
  35. package/src/client/settings.tsx +7 -0
  36. package/src/client/stats.ts +92 -20
  37. package/src/client/styles.ts +55 -30
@@ -0,0 +1,358 @@
1
+ /**
2
+ * 扩展渲染:Mermaid 图表(异步渲染为 SVG;全内置、离线;仅内置 13 种常用图类型)。
3
+ *
4
+ * 契约分工:
5
+ * - marked 侧(`renderMermaidBlock`)只产出占位 div:源码 base64 进
6
+ * `data-dci-md-mermaid`,内部先放一份「源码 + mermaid 标签」的可读降级内容
7
+ * (JS 还没激活时也能读源码,视觉上是普通代码块);
8
+ * - `activateMermaid`(基础渲染 `activateMarkdown` 调用,DOM 就绪后):
9
+ * 懒加载 mermaid(dynamic import,esbuild 内联进单文件;首次真正需要时才执行
10
+ * mermaid 模块初始化的开销,首屏不受影响)→ IntersectionObserver 可见时排队
11
+ * 渲染 → `mermaid.render` → DOMPurify(SVG profile) 净化 → 替换 innerHTML。
12
+ * - `securityLevel: 'strict'`(mermaid 自己也会净化 label)、`theme` 跟随宿主
13
+ * 浅/深色并监听主题变化重渲染;失败回退「源码 + 错误样式」,不抛到页面。
14
+ * - 构建期裁剪见 `scripts/build.mjs`:未内置的图类型被 stub,渲染时抛错走回退;
15
+ * ELK/cose 布局被移除(请求 ELK 时 mermaid 官方逻辑自动 fallback dagre)。
16
+ *
17
+ * 高度/滚动:SVG 由 CSS 约束 `max-width:100%; height:auto`;容器高度变化交给
18
+ * 宿主滚动容器自然响应(不做滚动同步的额外干预,避免破坏预览分屏同步)。
19
+ */
20
+
21
+ import * as DOMPurifyModule from 'dompurify';
22
+ import { decodeBase64Utf8, encodeBase64Utf8, escapeHtml } from './heavy-shared';
23
+
24
+ type DomPurifyLike = {
25
+ sanitize(html: string, options?: Record<string, unknown>): string;
26
+ isSupported?: boolean;
27
+ };
28
+
29
+ /** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容(同 基础渲染)。 */
30
+ const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
31
+ DOMPurifyModule) as unknown as DomPurifyLike;
32
+
33
+ type MermaidModule = typeof import('mermaid');
34
+ type MermaidApi = MermaidModule['default'];
35
+ type MermaidRenderResult = Awaited<ReturnType<MermaidApi['render']>>;
36
+ type MermaidTheme = 'default' | 'dark';
37
+
38
+ /* ---------- marked 侧:占位元素 ---------- */
39
+
40
+ /** ```mermaid 代码块 → 占位容器(源码 base64 保存在属性里,等 activate 渲染)。 */
41
+ export function renderMermaidBlock(source: string): string {
42
+ const encoded = encodeBase64Utf8(source);
43
+ return [
44
+ '<div class="dci-md-mermaid" data-dci-md-mermaid="',
45
+ encoded,
46
+ '" data-dci-md-state="pending">',
47
+ '<div class="dci-md-mermaid-pending">',
48
+ '<div class="dci-md-mermaid-badge">mermaid</div>',
49
+ `<pre class="dci-md-pre"><code>${escapeHtml(source)}</code></pre>`,
50
+ '</div>',
51
+ '</div>\n',
52
+ ].join('');
53
+ }
54
+
55
+ /* ---------- 懒加载 / 配置 ---------- */
56
+
57
+ let mermaidPromise: Promise<MermaidApi> | null = null;
58
+ let activeTheme: MermaidTheme | null = null;
59
+ let parseErrorSilenced = false;
60
+ let renderSeq = 0;
61
+ let queue: Promise<void> = Promise.resolve();
62
+
63
+ /** 首次需要时 import('mermaid')(esbuild 打包成单文件内的惰性模块初始化)。 */
64
+ function loadMermaid(): Promise<MermaidApi> {
65
+ mermaidPromise ??= import('mermaid').then((module) => module.default);
66
+ return mermaidPromise;
67
+ }
68
+
69
+ /** 串行队列:mermaid 内部有执行队列,这里再串一层避免并发 render 互相踩临时容器。 */
70
+ function enqueue(task: () => Promise<void>): void {
71
+ queue = queue.then(task, task).catch(() => {
72
+ // 单个图表失败已在 renderInto 内回退,队列本身不因异常中断。
73
+ });
74
+ }
75
+
76
+ function nextRenderId(): string {
77
+ renderSeq += 1;
78
+ return `dci-md-mermaid-${renderSeq.toString(36)}`;
79
+ }
80
+
81
+ /** 让 mermaid 的 parse 错误静默(错误由我们自己的回退块呈现,不污染 console)。 */
82
+ function silenceParseErrors(api: MermaidApi): void {
83
+ if (parseErrorSilenced) return;
84
+ parseErrorSilenced = true;
85
+ try {
86
+ api.setParseErrorHandler?.(() => {});
87
+ } catch {
88
+ // 旧版本没有该 API 时忽略。
89
+ }
90
+ }
91
+
92
+ async function ensureMermaid(theme: MermaidTheme): Promise<MermaidApi> {
93
+ const api = await loadMermaid();
94
+ silenceParseErrors(api);
95
+ if (activeTheme !== theme) {
96
+ api.initialize({
97
+ startOnLoad: false,
98
+ securityLevel: 'strict',
99
+ theme,
100
+ logLevel: 'fatal',
101
+ fontFamily: 'inherit',
102
+ // mermaid 12 起 `flowchart.htmlLabels` 已废弃:必须用**全局 htmlLabels:false**
103
+ // 才会输出 SVG `<text>` 标签。否则节点文字落在 foreignObject 里(DOMPurify
104
+ // SVG 净化会剥掉其 HTML 子节点 → 空框;本地 harness 实测)。
105
+ htmlLabels: false,
106
+ flowchart: { useMaxWidth: true },
107
+ sequence: { useMaxWidth: true },
108
+ gantt: { useMaxWidth: true },
109
+ mindmap: { useMaxWidth: true },
110
+ timeline: { useMaxWidth: true },
111
+ });
112
+ activeTheme = theme;
113
+ }
114
+ return api;
115
+ }
116
+
117
+ /* ---------- 主题检测 ---------- */
118
+
119
+ interface Rgb {
120
+ r: number;
121
+ g: number;
122
+ b: number;
123
+ }
124
+
125
+ function parseCssColor(value: string): Rgb | null {
126
+ const text = value.trim().toLowerCase();
127
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(text);
128
+ if (hex) {
129
+ const raw = hex[1] as string;
130
+ const full =
131
+ raw.length === 3
132
+ ? raw
133
+ .split('')
134
+ .map((char) => char + char)
135
+ .join('')
136
+ : raw;
137
+ return {
138
+ r: Number.parseInt(full.slice(0, 2), 16),
139
+ g: Number.parseInt(full.slice(2, 4), 16),
140
+ b: Number.parseInt(full.slice(4, 6), 16),
141
+ };
142
+ }
143
+ const rgb = /^rgba?\(([^)]+)\)$/u.exec(text);
144
+ if (rgb) {
145
+ const parts = (rgb[1] as string)
146
+ .split(/[,/\s]+/u)
147
+ .filter(Boolean)
148
+ .map((part) => Number.parseFloat(part));
149
+ if (parts.length >= 3 && parts.slice(0, 3).every((part) => Number.isFinite(part))) {
150
+ return { r: parts[0] as number, g: parts[1] as number, b: parts[2] as number };
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ function relativeLuminance({ r, g, b }: Rgb): number {
157
+ const channel = (value: number): number => {
158
+ const scaled = value / 255;
159
+ return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
160
+ };
161
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
162
+ }
163
+
164
+ /**
165
+ * 宿主浅/深色判定(按可靠性降级):
166
+ * 1. `<html data-theme|data-dsw-theme|data-color-scheme>`(主题插件/宿主约定);
167
+ * 2. `<html class="dark|theme-dark">`;
168
+ * 3. `--dsw-alias-label-primary` 的亮度(深色主题下正文色是浅色);
169
+ * 4. `prefers-color-scheme`。
170
+ */
171
+ export function detectHostTheme(): MermaidTheme {
172
+ if (typeof document === 'undefined') return 'default';
173
+ const root = document.documentElement;
174
+ const attr =
175
+ root.getAttribute('data-theme') ??
176
+ root.getAttribute('data-dsw-theme') ??
177
+ root.getAttribute('data-color-scheme') ??
178
+ '';
179
+ if (/dark/iu.test(attr)) return 'dark';
180
+ if (/light/iu.test(attr)) return 'default';
181
+ if (root.classList.contains('dark') || root.classList.contains('theme-dark')) return 'dark';
182
+ try {
183
+ const probe = getComputedStyle(root).getPropertyValue('--dsw-alias-label-primary').trim();
184
+ const color = probe === '' ? null : parseCssColor(probe);
185
+ if (color) return relativeLuminance(color) > 0.55 ? 'dark' : 'default';
186
+ } catch {
187
+ // 取样式失败(离屏/测试环境)继续降级。
188
+ }
189
+ try {
190
+ return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default';
191
+ } catch {
192
+ return 'default';
193
+ }
194
+ }
195
+
196
+ /* ---------- SVG 净化 ---------- */
197
+
198
+ /**
199
+ * Mermaid 生成的 SVG 走 DOMPurify 的 SVG profile 净化(禁脚本 / 事件属性)。
200
+ * 标签文字已通过全局 `htmlLabels:false` 落成 SVG `<text>`,所以这里继续禁
201
+ * `foreignObject`(零 HTML 注入面;本地 harness 已断言 flowchart/sequence 标签正常)。
202
+ */
203
+ const SVG_SANITIZE_OPTIONS: Record<string, unknown> = {
204
+ USE_PROFILES: { svg: true, svgFilters: true },
205
+ FORBID_TAGS: ['script', 'foreignObject'],
206
+ FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onfocus', 'onbegin'],
207
+ };
208
+
209
+ function sanitizeSvg(svg: string): string {
210
+ if (typeof DOMPurify.sanitize !== 'function') return svg;
211
+ try {
212
+ return DOMPurify.sanitize(svg, SVG_SANITIZE_OPTIONS);
213
+ } catch {
214
+ return '';
215
+ }
216
+ }
217
+
218
+ /* ---------- 渲染 ---------- */
219
+
220
+ function errorMarkup(source: string, error: unknown): string {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ return [
223
+ '<div class="dci-md-mermaid-error">',
224
+ '<div class="dci-md-mermaid-error-head">Mermaid 渲染失败</div>',
225
+ `<div class="dci-md-mermaid-error-msg">${escapeHtml(message)}</div>`,
226
+ `<pre class="dci-md-pre"><code>${escapeHtml(source)}</code></pre>`,
227
+ '</div>',
228
+ ].join('');
229
+ }
230
+
231
+ /** mermaid.render 会在 body 里建临时容器;无论成败都清掉本轮 id 的残留节点。 */
232
+ function cleanupTempNodes(id: string): void {
233
+ if (typeof document === 'undefined') return;
234
+ for (const candidate of [id, `d${id}`]) {
235
+ const node = document.getElementById(candidate);
236
+ if (node && node.parentElement === document.body) node.remove();
237
+ }
238
+ }
239
+
240
+ async function renderInto(element: HTMLElement): Promise<void> {
241
+ if (!element.isConnected) return;
242
+ const source = decodeBase64Utf8(element.getAttribute('data-dci-md-mermaid') ?? '');
243
+ if (source.trim() === '') {
244
+ element.setAttribute('data-dci-md-state', 'error');
245
+ element.innerHTML = errorMarkup(source, new Error('empty mermaid source'));
246
+ return;
247
+ }
248
+ element.setAttribute('data-dci-md-state', 'rendering');
249
+ const id = nextRenderId();
250
+ try {
251
+ const api = await ensureMermaid(detectHostTheme());
252
+ const result: MermaidRenderResult = await api.render(id, source);
253
+ if (!element.isConnected) return;
254
+ const svg = sanitizeSvg(result.svg ?? '');
255
+ if (svg === '') throw new Error('mermaid: empty svg');
256
+ element.innerHTML = svg;
257
+ element.setAttribute('data-dci-md-state', 'rendered');
258
+ try {
259
+ result.bindFunctions?.(element);
260
+ } catch {
261
+ // 交互绑定失败不影响静态 SVG 展示。
262
+ }
263
+ } catch (error) {
264
+ if (!element.isConnected) return;
265
+ element.setAttribute('data-dci-md-state', 'error');
266
+ element.innerHTML = errorMarkup(source, error);
267
+ } finally {
268
+ cleanupTempNodes(id);
269
+ }
270
+ }
271
+
272
+ /* ---------- 主题跟随 ---------- */
273
+
274
+ /** 监听宿主主题变化;变化后重新 initialize 并重渲染本根内已渲染的图表。 */
275
+ function watchTheme(root: HTMLElement, isDisposed: () => boolean): () => void {
276
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined') return () => {};
277
+ let scheduled = false;
278
+ const rerender = (): void => {
279
+ if (scheduled || isDisposed()) return;
280
+ scheduled = true;
281
+ const run = (): void => {
282
+ scheduled = false;
283
+ if (isDisposed()) return;
284
+ const theme = detectHostTheme();
285
+ if (theme === activeTheme) return;
286
+ for (const node of root.querySelectorAll<HTMLElement>('[data-dci-md-state="rendered"]')) {
287
+ if (!node.hasAttribute('data-dci-md-mermaid')) continue;
288
+ node.setAttribute('data-dci-md-state', 'pending');
289
+ enqueue(() => (isDisposed() ? Promise.resolve() : renderInto(node)));
290
+ }
291
+ };
292
+ // 等宿主把 theme 属性/class 全部落定再读 computed style:延迟一拍再查。
293
+ // 用 setTimeout 而不是 rAF:无头/后台标签页里 rAF 可能被节流甚至不调度,
294
+ // 主题重渲染会因此丢失(本地 harness 实测);16ms 定时器在两种环境下都稳。
295
+ window.setTimeout(run, 16);
296
+ };
297
+ const observer = new MutationObserver(rerender);
298
+ // 等主题属性/class 全部落定后再读 computed style(rAF 已延迟一拍)。
299
+ observer.observe(document.documentElement, {
300
+ attributes: true,
301
+ attributeFilter: ['class', 'data-theme', 'data-dsw-theme', 'data-color-scheme'],
302
+ });
303
+ let media: MediaQueryList | null = null;
304
+ try {
305
+ media = matchMedia('(prefers-color-scheme: dark)');
306
+ } catch {
307
+ media = null;
308
+ }
309
+ const onMediaChange = (): void => rerender();
310
+ media?.addEventListener?.('change', onMediaChange);
311
+ return () => {
312
+ observer.disconnect();
313
+ media?.removeEventListener?.('change', onMediaChange);
314
+ };
315
+ }
316
+
317
+ /* ---------- 激活期入口 ---------- */
318
+
319
+ /**
320
+ * innerHTML 落地后的 Mermaid 激活:可见时懒渲染 + 主题跟随。
321
+ * 返回 cleanup(组件下次渲染/卸载时调用):置 disposed、断开 observer;
322
+ * 已排队但未开始的渲染会因 disposed 被丢弃(mermaid.render 本身不可中断)。
323
+ */
324
+ export function activateMermaid(root: HTMLElement): (() => void) | undefined {
325
+ const nodes = root.querySelectorAll<HTMLElement>('[data-dci-md-mermaid]');
326
+ if (nodes.length === 0) return undefined;
327
+ let disposed = false;
328
+ const renderNode = (element: HTMLElement): void => {
329
+ if (disposed) return;
330
+ if (element.getAttribute('data-dci-md-state') !== 'pending') return;
331
+ enqueue(() => (disposed ? Promise.resolve() : renderInto(element)));
332
+ };
333
+
334
+ let observer: IntersectionObserver | null = null;
335
+ if (typeof IntersectionObserver === 'function') {
336
+ observer = new IntersectionObserver(
337
+ (entries) => {
338
+ for (const entry of entries) {
339
+ if (!entry.isIntersecting) continue;
340
+ observer?.unobserve(entry.target);
341
+ renderNode(entry.target as HTMLElement);
342
+ }
343
+ },
344
+ // 提前 320px 开始渲染:滚动接近时 SVG 已就绪,减少可见的高度跳变。
345
+ { rootMargin: '320px 0px' },
346
+ );
347
+ for (const node of nodes) observer.observe(node);
348
+ } else {
349
+ for (const node of nodes) renderNode(node);
350
+ }
351
+
352
+ const stopWatchingTheme = watchTheme(root, () => disposed);
353
+ return () => {
354
+ disposed = true;
355
+ observer?.disconnect();
356
+ stopWatchingTheme();
357
+ };
358
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Markdown 输出净化(DOMPurify 兜底)。
3
+ *
4
+ * 安全模型(B5 结论不变):**原始 HTML 一律在 marked 的 renderer.html 里转义**
5
+ * (见 `extensions.ts`),所以任何 `data-dci-*` 属性只可能由本模块自己的扩展
6
+ * 产生;手写 `<span data-dci-wire="x">` 只会显示成文本。DOMPurify 这里是第二道
7
+ * 闸:白名单收敛到 Markdown 产出面,URI 只放行 http(s)/mailto/页内锚点,
8
+ * 行内 style 一律不进 DOM(`<style>` 一旦落地会全局生效)。
9
+ *
10
+ * 白名单由「基础面 + registry 扩展追加」组成:扩展渲染的 KaTeX/Mermaid 通过
11
+ * `registerMarkdownExtension({ allowTags, allowAttrs })` 增量放行自己的输出标签,
12
+ * 不需要改本文件。
13
+ */
14
+
15
+ import * as DOMPurifyModule from 'dompurify';
16
+
17
+ /** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
18
+ const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
19
+ DOMPurifyModule) as unknown as {
20
+ sanitize(html: string, options?: Record<string, unknown>): string;
21
+ isSupported?: boolean;
22
+ };
23
+
24
+ /** HTML 文本转义(属性值与文本节点共用)。 */
25
+ export function escapeHtml(text: string): string {
26
+ return text.replace(
27
+ /[&<>"']/g,
28
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string,
29
+ );
30
+ }
31
+
32
+ /** Markdown 结构 + 本批扩展(callout / 脚注 / 任务清单 / wikilink / 折叠块)。 */
33
+ const BASE_TAGS: readonly string[] = [
34
+ 'p', 'br', 'strong', 'em', 'del', 'mark', 'code', 'pre', 'blockquote',
35
+ 'ul', 'ol', 'li', 'a', 'img',
36
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
37
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
38
+ 'input', 'span', 'div', 'section', 'sup', 'sub', 'small', 'button',
39
+ 'details', 'summary',
40
+ ];
41
+
42
+ /** 属性面。data-* 只在下面显式列出(ALLOW_DATA_ATTR=false),保持 B5 结论。 */
43
+ const BASE_ATTRS: readonly string[] = [
44
+ 'href', 'src', 'alt', 'title', 'target', 'rel',
45
+ 'type', 'checked', 'disabled', 'start',
46
+ 'align', 'colspan', 'rowspan', 'class', 'id',
47
+ 'width', 'height', 'loading', 'decoding', 'open',
48
+ 'data-dci-wire', 'data-callout', 'data-family', 'data-icon',
49
+ 'data-dci-wikilink', 'data-dci-fn', 'data-dci-footnotes',
50
+ ];
51
+
52
+ /** 链接/图片 URI:页内锚点(脚注回跳)、http(s)、mailto;javascript:/data: 等全部拦截。 */
53
+ const ALLOWED_URI = /^(?:https?:|mailto:|#)/i;
54
+
55
+ /**
56
+ * 非 URL 属性(值不做 URI 协议检查)。
57
+ *
58
+ * DOMPurify 的 `ALLOWED_URI_REGEXP` 会对**所有非「URI-safe」属性**的取值做匹配,
59
+ * 不匹配就整条剥掉——所以 `target="_blank"`、`align="center"`、`width="300"`、
60
+ * `data-callout="note"` 这类非 URL 取值会被误杀。标准解法是把它们登记进
61
+ * `ADD_URI_SAFE_ATTR`(与默认 URI-safe 集合并),只留 href/src 走协议白名单。
62
+ */
63
+ const NON_URI_ATTRS: readonly string[] = [
64
+ 'alt', 'title', 'target', 'rel', 'type', 'checked', 'disabled', 'start',
65
+ 'align', 'colspan', 'rowspan', 'class', 'id',
66
+ 'width', 'height', 'loading', 'decoding', 'open',
67
+ 'data-dci-wire', 'data-callout', 'data-family', 'data-icon',
68
+ 'data-dci-wikilink', 'data-dci-fn', 'data-dci-footnotes',
69
+ ];
70
+
71
+ export interface SanitizeExtras {
72
+ /** registry 扩展追加放行的标签(如 KaTeX 的 span/自定义节点)。 */
73
+ tags?: readonly string[];
74
+ /** registry 扩展追加放行的属性(如 KaTeX 的 style-less 数据属性)。 */
75
+ attrs?: readonly string[];
76
+ }
77
+
78
+ function dedupe(values: readonly string[]): string[] {
79
+ return [...new Set(values)];
80
+ }
81
+
82
+ function sanitizeOptions(extras?: SanitizeExtras): Record<string, unknown> {
83
+ return {
84
+ ALLOWED_TAGS: dedupe([...BASE_TAGS, ...(extras?.tags ?? [])]),
85
+ ALLOWED_ATTR: dedupe([...BASE_ATTRS, ...(extras?.attrs ?? [])]),
86
+ ADD_URI_SAFE_ATTR: dedupe([...NON_URI_ATTRS, ...(extras?.attrs ?? [])]),
87
+ ALLOWED_URI_REGEXP: ALLOWED_URI,
88
+ ALLOW_DATA_ATTR: false,
89
+ FORBID_ATTR: ['style'],
90
+ };
91
+ }
92
+
93
+ /**
94
+ * 净化 marked 产出的 HTML。
95
+ * DOMPurify 不可用(无 DOM 的极端环境)时宁可把 HTML 当文本全转义,
96
+ * 也不把未净化的标记塞进 DOM。
97
+ */
98
+ export function sanitizeMarkdown(html: string, extras?: SanitizeExtras): string {
99
+ if (typeof DOMPurify.sanitize !== 'function' || DOMPurify.isSupported === false) {
100
+ return `<p>${escapeHtml(html).replace(/\n/g, '<br>')}</p>`;
101
+ }
102
+ return DOMPurify.sanitize(html, sanitizeOptions(extras));
103
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Markdown 管线样式:由 `src/client/styles.ts` import 后拼接注入一次。
3
+ *
4
+ * 两段:
5
+ * A. 预览面板排版 —— 自 `src/client/styles.ts` 的 `.dci-preview` 段迁入
6
+ * (逐条保持原视觉,另补表格对齐/表头底色)。
7
+ * B. 统一扩展样式 —— 以 `.dci-md` 为根(预览内层 div 与用户消息
8
+ * `.dci-umsg-md` 同挂该类):callout / 脚注 / `==高亮==` / wikilink /
9
+ * 任务清单只读勾选框 / 图片放大光标 / 表格对齐 / 会话引用占位。
10
+ * 用户消息的正文排版仍由官方对齐的 `.dci-umsg-md` 规则承担,这里只加
11
+ * 扩展块自己的规则(新类名,不覆盖既有排版)。
12
+ *
13
+ * 主题:颜色一律走宿主 `--dsw-*` token + color-mix;token 缺失时回退
14
+ * currentColor 混合或中性色,浅色/深色都可读。callout 图标用 CSS mask +
15
+ * 内联 data URI(24 视框 lucide 线性路径),不往 DOM 里塞 SVG——
16
+ * 净化白名单保持 Markdown 产出面,不额外放行 svg。
17
+ */
18
+ export const markdownStyles = `
19
+ /* ===== A. 预览面板排版(迁自 styles.ts,视觉逐条保持) ===== */
20
+ .dci-preview { flex: 1 1 100%; min-width: 0; box-sizing: border-box; overflow: auto; padding: 8px 14px 10px; font-size: 13px; line-height: 1.65; }
21
+ .dci-preview h1, .dci-preview h2, .dci-preview h3, .dci-preview h4 { margin: 0.7em 0 0.35em; line-height: 1.3; }
22
+ .dci-preview h1 { font-size: 1.35em; } .dci-preview h2 { font-size: 1.2em; } .dci-preview h3 { font-size: 1.08em; }
23
+ .dci-preview p { margin: 0.45em 0; } .dci-preview ul, .dci-preview ol { margin: 0.45em 0; padding-left: 1.5em; }
24
+ .dci-preview code { font-family: inherit; background: color-mix(in srgb, currentColor 9%, transparent);
25
+ border-radius: 4px; padding: 0.1em 0.35em; font-size: 0.92em; }
26
+ .dci-preview pre { background: color-mix(in srgb, currentColor 7%, transparent); border-radius: 8px;
27
+ padding: 10px 12px; overflow-x: auto; } .dci-preview pre code { background: transparent; padding: 0; }
28
+ .dci-preview blockquote { margin: 0.5em 0; padding: 0.1em 0.9em; border-left: 3px solid color-mix(in srgb, currentColor 22%, transparent); opacity: 0.85; }
29
+ .dci-preview table { border-collapse: collapse; margin: 0.6em 0; }
30
+ .dci-preview th, .dci-preview td { border: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding: 4px 9px; }
31
+ .dci-preview th { background: color-mix(in srgb, currentColor 5%, transparent); font-weight: 600; }
32
+ .dci-preview :is(th, td)[align='center'] { text-align: center; }
33
+ /* 预览链接走官方 markdown 同款 alias-link token(官方 _markdown_ a 逐值核对)。 */
34
+ .dci-preview a { color: var(--dsw-alias-link, #4c8dff); } .dci-preview img { max-width: 100%; } .dci-preview hr { border: none;
35
+ border-top: 1px solid color-mix(in srgb, currentColor 14%, transparent); }
36
+
37
+ /* ===== B. 统一扩展样式根 ===== */
38
+ .dci-md { min-width: 0; }
39
+ /* 方案 A:Markdown 正文等宽连字字体(设置 → Markdown 等宽连字,默认开)。
40
+ 目标:正文与行内代码里的 -> / => / // 等靠字体 calt/liga 连字显示为箭头/连字,
41
+ DOM 文本仍是原文(不做字符替换);只影响 Markdown 显示面,编辑器保持字面。
42
+ - font-variant-ligatures / font-feature-settings 显式打开,避免被宿主全局样式关掉;
43
+ - 关闭态(根上没有 data-mono)不写任何字体/连字覆盖 → 恢复宿主阅读字体;
44
+ - 代码块/嵌入块保持代码字体并复位为默认连字设置(用户取舍:代码块不动);
45
+ 行内代码按用户要求也显示连字箭头(见下方 preview/umsg 两条规则)。 */
46
+ .dci-md[data-mono='true'] {
47
+ font-family: 'JetBrains Mono', var(--ds-font-family-code, ui-monospace, monospace);
48
+ font-variant-ligatures: normal;
49
+ font-feature-settings: 'calt' 1, 'liga' 1;
50
+ }
51
+ .dci-md[data-mono='true'] :is(.dci-md-codeblock, pre, .dci-md-embed) {
52
+ font-variant-ligatures: normal;
53
+ font-feature-settings: normal;
54
+ }
55
+ /* 围栏代码块固定走宿主代码字体(与 扩展渲染的 .dci-md-pre 同值),避免正文等宽字体
56
+ 泄漏进代码面。 */
57
+ .dci-md[data-mono='true'] pre { font-family: var(--ds-font-family-code, ui-monospace, monospace); }
58
+ /* 行内代码:用户取舍「右侧预览两处(正文 + 行内代码)都要连字箭头」,故 mono 态下
59
+ 预览/用户消息行内代码都走 JBM(回退代码字体 token)并显式打开 calt/liga;关闭态
60
+ 不命中本规则,预览行内代码恢复 0.6 的阅读字体继承行为。代码块不在此列(不动)。 */
61
+ .dci-preview .dci-md[data-mono='true'] :not(pre) > code,
62
+ .dci-umsg-md[data-mono='true'] :not(pre) > code {
63
+ font-family: 'JetBrains Mono', var(--ds-font-family-code, ui-monospace, monospace);
64
+ font-variant-ligatures: normal;
65
+ font-feature-settings: 'calt' 1, 'liga' 1;
66
+ }
67
+ /* 用户消息 legacy 排版(src/client/styles.ts)用 font: 简写定义标题/表格单元格,
68
+ 简写会重置 font-family 且元素级优先级高于继承 → mono 态根字体到不了这些元素。
69
+ 这里显式补 family + 连字(字号/字重/行高仍由原 font 简写决定,不改观感);只影响
70
+ 用户消息根,预览侧这些元素没有 font 简写、本来就继承根字体。代码面各自的代码字体
71
+ 规则不受影响(行内代码/代码块未命中本选择器)。 */
72
+ .dci-umsg-md[data-mono='true'] :is(h1, h2, h3, h4, h5, h6),
73
+ .dci-umsg-md[data-mono='true'] .dci-umsg-tscroll :is(th, td) {
74
+ font-family: 'JetBrains Mono', var(--ds-font-family-code, ui-monospace, monospace);
75
+ font-variant-ligatures: normal;
76
+ font-feature-settings: 'calt' 1, 'liga' 1;
77
+ }
78
+ /* 图片:点击开灯箱(交互在 markdown 模块的 handleMarkdownClick)。 */
79
+ .dci-md img { max-width: 100%; height: auto; cursor: zoom-in; }
80
+ /* 任务清单:只读勾选框(marked 产出 input[disabled],装饰期保证不可编辑)。 */
81
+ .dci-md li:has(> input[type='checkbox']) { list-style: none; }
82
+ .dci-md li > input[type='checkbox'] { width: 14px; height: 14px; margin: 0 6px 0 0; vertical-align: -2px;
83
+ accent-color: var(--dsw-alias-label-secondary, currentColor); }
84
+ .dci-md ul:has(> li > input[type='checkbox']) { padding-left: 4px; }
85
+ /* GFM 表格对齐(marked 产出 align 属性;官方用户消息表样式把 text-align 定死为 start,这里在属性面回补)。 */
86
+ .dci-md :is(th, td)[align='center'] { text-align: center; }
87
+ .dci-md :is(th, td)[align='right'] { text-align: right; }
88
+ .dci-md :is(th, td)[align='left'] { text-align: left; }
89
+
90
+ /* ==高亮== */
91
+ .dci-md mark { background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #d9a400) 34%, transparent);
92
+ color: inherit; border-radius: 3px; padding: 0 2px; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
93
+
94
+ /* [[wikilink]]:无宿主导航目标 → 样式化 chip,点击复制目标名(复制态短暂加深)。 */
95
+ .dci-md .dci-md-wikilink { display: inline; max-width: 100%; margin: 0 2px; padding: 0 4px; font: inherit; font-weight: 500;
96
+ color: var(--dsw-alias-state-business-primary, #4c8dff); cursor: copy; white-space: normal; overflow-wrap: anywhere;
97
+ border: 0.5px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #4c8dff) 32%, transparent);
98
+ border-radius: var(--dsw-radius-sm, 6px);
99
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4c8dff) 10%, transparent); }
100
+ .dci-md .dci-md-wikilink:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4c8dff) 18%, transparent); }
101
+ .dci-md .dci-md-wikilink:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary, #4c8dff)); outline-offset: 2px; }
102
+ .dci-md .dci-md-wikilink[data-dci-copied='true'] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4c8dff) 26%, transparent); }
103
+ /* ![[图片.png]]:无法解析仓库文件 → 样式化文本(代码面),不崩不留破图。 */
104
+ .dci-md .dci-md-embed { display: inline; padding: 0 4px; font-family: var(--ds-font-family-code, monospace); font-size: 0.9em;
105
+ color: var(--dsw-alias-markdown-placeholder, var(--dsw-alias-label-tertiary, currentColor));
106
+ background: var(--dsw-alias-markdown-code-block, color-mix(in srgb, currentColor 8%, transparent));
107
+ border-radius: var(--dsw-radius-sm, 6px); }
108
+ /* 会话引用线格式占位(预览面纯展示;用户消息侧装饰成官方引用 chip)。 */
109
+ .dci-md span[data-dci-wire] { color: var(--dsw-alias-state-business-primary, #4c8dff); font-weight: 500; }
110
+
111
+ /* Obsidian callout:左色条 + 同色浅底 + 图标/标题;普通 blockquote 不受影响。
112
+ accent 必须**从前景色派生**:家族 token 只提供色相,再与宿主
113
+ --dsw-alias-label-primary 按 55/45 混合——浅色主题前景近黑、混出深 accent,
114
+ 深色主题前景近白、混出浅 accent,于是 title/图标/左边框在两个主题都保持可读
115
+ (QA 实测:浅色 5.4–9.2、深色 8.3–11.8,阈值 3.0)。
116
+ 不能直接拿 state-* / markdown-citation 之类「背景/状态语义」色当前景:
117
+ 浅色下 success=2.1、warning=2.0,citation 是背景色(浅 1.15 / 深 1.47)会糊底。 */
118
+ .dci-md .dci-md-callout { --dci-md-accent-hue: var(--dsw-alias-state-business-primary, #4c8dff);
119
+ --dci-md-accent: color-mix(in srgb, var(--dci-md-accent-hue) 55%, var(--dsw-alias-label-primary, currentColor) 45%);
120
+ margin: 14px 0; padding: 8px 12px; border-left: 3px solid var(--dci-md-accent);
121
+ border-radius: var(--dsw-radius-sm, 6px);
122
+ background: color-mix(in srgb, var(--dci-md-accent) 9%, transparent);
123
+ color: var(--dsw-alias-label-primary, inherit); }
124
+ .dci-md .dci-md-callout[data-family='green'] { --dci-md-accent-hue: var(--dsw-alias-state-success-primary, #2f9e6e); }
125
+ .dci-md .dci-md-callout[data-family='amber'] { --dci-md-accent-hue: var(--dsw-alias-state-warn-primary, #d9a400); }
126
+ .dci-md .dci-md-callout[data-family='red'] { --dci-md-accent-hue: var(--dsw-alias-state-error-primary, #e5484d); }
127
+ /* purple 无对应语义 state token:固定紫只作色相源,再按主题混向前景
128
+ (--dsw-alias-markdown-citation 是背景色语义,不能当 accent)。 */
129
+ .dci-md .dci-md-callout[data-family='purple'] { --dci-md-accent-hue: #8b7cf6; }
130
+ .dci-md .dci-md-callout[data-family='gray'] { --dci-md-accent-hue: var(--dsw-alias-label-secondary, currentColor); }
131
+ .dci-md .dci-md-callout-head { display: flex; align-items: center; gap: 6px; min-width: 0;
132
+ color: var(--dci-md-accent); font-weight: 600; line-height: 24px; }
133
+ .dci-md .dci-md-callout-label { min-width: 0; overflow-wrap: anywhere; }
134
+ .dci-md .dci-md-callout-body { margin-top: 2px; }
135
+ .dci-md .dci-md-callout-body > :first-child { margin-top: 0; }
136
+ .dci-md .dci-md-callout-body > :last-child { margin-bottom: 0; }
137
+ .dci-md details.dci-md-callout > summary { list-style: none; cursor: pointer; }
138
+ .dci-md details.dci-md-callout > summary::-webkit-details-marker { display: none; }
139
+ /* callout 图标:mask + 内联 SVG(描边形状,颜色由 background-color/currentColor 给)。 */
140
+ .dci-md .dci-md-callout-icon { --dci-md-icon: none; flex: none; width: 16px; height: 16px; background-color: currentColor;
141
+ -webkit-mask-image: var(--dci-md-icon); mask-image: var(--dci-md-icon);
142
+ -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
143
+ -webkit-mask-position: center; mask-position: center;
144
+ -webkit-mask-size: 16px 16px; mask-size: 16px 16px; }
145
+ .dci-md .dci-md-callout-icon[data-icon='pencil'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z'/%3E%3Cpath d='m15 5 4 4'/%3E%3C/svg%3E"); }
146
+ .dci-md .dci-md-callout-icon[data-icon='info'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E"); }
147
+ .dci-md .dci-md-callout-icon[data-icon='bulb'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5'/%3E%3Cpath d='M9 18h6'/%3E%3Cpath d='M10 22h4'/%3E%3C/svg%3E"); }
148
+ .dci-md .dci-md-callout-icon[data-icon='check-circle'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21.801 10A10 10 0 1 1 17 3.335'/%3E%3Cpath d='m9 11 3 3L22 4'/%3E%3C/svg%3E"); }
149
+ .dci-md .dci-md-callout-icon[data-icon='check-square'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 11 3 3L22 4'/%3E%3Cpath d='M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11'/%3E%3C/svg%3E"); }
150
+ .dci-md .dci-md-callout-icon[data-icon='alert-triangle'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3'/%3E%3Cpath d='M12 9v4'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E"); }
151
+ .dci-md .dci-md-callout-icon[data-icon='x-octagon'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z'/%3E%3Cpath d='m15 9-6 6'/%3E%3Cpath d='m9 9 6 6'/%3E%3C/svg%3E"); }
152
+ .dci-md .dci-md-callout-icon[data-icon='help-circle'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E"); }
153
+ .dci-md .dci-md-callout-icon[data-icon='quote'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z'/%3E%3Cpath d='M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z'/%3E%3C/svg%3E"); }
154
+ .dci-md .dci-md-callout-icon[data-icon='list'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h.01'/%3E%3Cpath d='M3 12h.01'/%3E%3Cpath d='M3 18h.01'/%3E%3Cpath d='M8 6h13'/%3E%3Cpath d='M8 12h13'/%3E%3Cpath d='M8 18h13'/%3E%3C/svg%3E"); }
155
+ .dci-md .dci-md-callout-icon[data-icon='flame'] { --dci-md-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z'/%3E%3C/svg%3E"); }
156
+
157
+ /* 脚注:正文上标 + 文末列表(可回跳)。 */
158
+ .dci-md .dci-md-fnref { font-size: 0.72em; line-height: 0; vertical-align: super; }
159
+ .dci-md .dci-md-fnref a { padding: 0 1px; color: var(--dsw-alias-state-business-primary, #4c8dff);
160
+ font-weight: 600; text-decoration: none; }
161
+ .dci-md .dci-md-fnref a:hover { text-decoration: underline; }
162
+ .dci-md .dci-md-footnotes { margin-top: 20px; padding-top: 10px;
163
+ border-top: 0.5px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 14%, transparent));
164
+ font-size: 0.9em; color: var(--dsw-alias-label-secondary, inherit); }
165
+ .dci-md .dci-md-fnlist { margin: 0; padding: 0; list-style: none; }
166
+ .dci-md .dci-md-fnitem { position: relative; margin: 6px 0; }
167
+ .dci-md .dci-md-fnitem > :is(p, div) { display: inline; margin: 0; }
168
+ .dci-md .dci-md-fnback { margin-left: 4px; color: var(--dsw-alias-state-business-primary, #4c8dff);
169
+ font-size: 0.9em; text-decoration: none; }
170
+ .dci-md .dci-md-fnback:hover { text-decoration: underline; }
171
+ `;