dsh-codinput 0.3.8 → 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.
- package/CHANGELOG.md +71 -0
- package/NOTICE +37 -4
- package/README.en.md +28 -13
- package/README.md +28 -13
- package/TESTING.md +96 -9
- package/assets/donate-qr.jpg +0 -0
- package/lib/client.js +2875 -98
- package/package.json +5 -2
- package/src/client/cm.ts +19 -1
- package/src/client/command.ts +39 -8
- package/src/client/components/CodinputSurface.tsx +80 -27
- package/src/client/components/EditorPane.tsx +3 -1
- package/src/client/components/InputSlotSeat.tsx +56 -1
- package/src/client/components/MenuSurface.tsx +64 -12
- package/src/client/components/ModelMenu.tsx +5 -17
- package/src/client/components/PermissionMenu.tsx +8 -3
- package/src/client/components/SlashMenu.tsx +294 -41
- package/src/client/components/StatsRow.tsx +40 -23
- package/src/client/components/Toolbar.tsx +284 -48
- package/src/client/components/UserMessageMarkdown.tsx +158 -115
- package/src/client/components/VoiceActivity.tsx +30 -69
- package/src/client/components/slotEntry.tsx +37 -0
- package/src/client/host-types.ts +11 -3
- package/src/client/hotkey.ts +38 -0
- package/src/client/i18n.ts +18 -4
- package/src/client/index.tsx +9 -0
- package/src/client/markdown/code.ts +281 -0
- package/src/client/markdown/extensions.ts +576 -0
- package/src/client/markdown/heavy-assets.d.ts +11 -0
- package/src/client/markdown/heavy-shared.ts +50 -0
- package/src/client/markdown/heavy.ts +159 -0
- package/src/client/markdown/index.ts +289 -0
- package/src/client/markdown/math.ts +134 -0
- package/src/client/markdown/mermaid.ts +358 -0
- package/src/client/markdown/sanitize.ts +103 -0
- package/src/client/markdown/styles.ts +171 -0
- package/src/client/prefs.ts +206 -23
- package/src/client/settings.tsx +15 -3
- package/src/client/stats.ts +92 -20
- package/src/client/styles.ts +55 -30
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展渲染 Heavy 扩展的统一注册入口 + 自有 CSS。
|
|
3
|
+
*
|
|
4
|
+
* 三个 registry 扩展(基础渲染 `registerMarkdownExtension`):
|
|
5
|
+
* - `dci-heavy-code` —— marked.renderer.code 覆盖(highlight.js 高亮 + 语言标签
|
|
6
|
+
* + 复制按钮),activate 里挂复制事件委托;
|
|
7
|
+
* - `dci-heavy-math` —— marked 行内/块级 `$...$` 占位扩展,activate 里同步
|
|
8
|
+
* `katex.renderToString` 替换(字体 CSS 全内置);
|
|
9
|
+
* - `dci-heavy-mermaid` —— 只做 allowAttrs(占位属性)+ activate 异步渲染 SVG。
|
|
10
|
+
*
|
|
11
|
+
* 调用点:`src/client/index.tsx` 的 apply() 里调一次(幂等,返回注销函数)。
|
|
12
|
+
* CSS 由 `src/client/styles.ts` 追加拼接(本模块只导出字符串,不自己注入全局样式,
|
|
13
|
+
* 避免与 基础渲染的样式注入顺序打架);KaTeX 自带 CSS 在首次用到公式时懒注入。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { registerMarkdownExtension } from './index';
|
|
17
|
+
import { activateCodeBlocks, renderCodeBlock } from './code';
|
|
18
|
+
import { activateMath, mathMarkedExtensions } from './math';
|
|
19
|
+
import { activateMermaid } from './mermaid';
|
|
20
|
+
|
|
21
|
+
let disposer: (() => void) | null = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 注册 扩展渲染的三个渲染扩展(幂等)。
|
|
25
|
+
* @returns 注销函数(再次注册会先复用同一个注册集)。
|
|
26
|
+
*/
|
|
27
|
+
export function registerHeavyMarkdown(): () => void {
|
|
28
|
+
if (disposer) return disposer;
|
|
29
|
+
const offs: (() => void)[] = [];
|
|
30
|
+
try {
|
|
31
|
+
offs.push(
|
|
32
|
+
registerMarkdownExtension({
|
|
33
|
+
name: 'dci-heavy-code',
|
|
34
|
+
marked: { renderer: { code: renderCodeBlock } },
|
|
35
|
+
activate: activateCodeBlocks,
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
offs.push(
|
|
39
|
+
registerMarkdownExtension({
|
|
40
|
+
name: 'dci-heavy-math',
|
|
41
|
+
marked: { extensions: mathMarkedExtensions },
|
|
42
|
+
allowAttrs: ['data-dci-md-math', 'data-dci-md-display'],
|
|
43
|
+
activate: activateMath,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
offs.push(
|
|
47
|
+
registerMarkdownExtension({
|
|
48
|
+
name: 'dci-heavy-mermaid',
|
|
49
|
+
allowAttrs: ['data-dci-md-mermaid', 'data-dci-md-state'],
|
|
50
|
+
activate: activateMermaid,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
for (const off of offs) {
|
|
55
|
+
try {
|
|
56
|
+
off();
|
|
57
|
+
} catch {
|
|
58
|
+
// 回滚失败不掩盖原始错误。
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
disposer = () => {
|
|
64
|
+
for (const off of offs) {
|
|
65
|
+
try {
|
|
66
|
+
off();
|
|
67
|
+
} catch {
|
|
68
|
+
// 注销异常不应中断组件卸载。
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
disposer = null;
|
|
72
|
+
};
|
|
73
|
+
return disposer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 扩展渲染 自有样式(追加在 基础渲染 markdownStyles 之后,不动既有段)。 */
|
|
77
|
+
export const heavyMarkdownStyles = `
|
|
78
|
+
/* ===== 扩展渲染:代码块(highlight.js + 语言标签 + 复制按钮)===== */
|
|
79
|
+
.dci-md .dci-md-codeblock { margin: 0.7em 0; border-radius: var(--dsw-radius-sm, 8px); overflow: hidden;
|
|
80
|
+
background: var(--dsw-alias-markdown-code-block, color-mix(in srgb, currentColor 6%, transparent));
|
|
81
|
+
border: 0.5px solid var(--dsw-alias-border-l1, color-mix(in srgb, currentColor 12%, transparent)); }
|
|
82
|
+
.dci-md .dci-md-codebar { display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
83
|
+
padding: 3px 8px 3px 10px; border-bottom: 0.5px solid var(--dsw-alias-border-l1, color-mix(in srgb, currentColor 10%, transparent));
|
|
84
|
+
font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary, color-mix(in srgb, currentColor 62%, transparent)); }
|
|
85
|
+
.dci-md .dci-md-codelang { font-family: var(--ds-font-family-code, ui-monospace, monospace); letter-spacing: 0.02em; }
|
|
86
|
+
.dci-md .dci-md-copy { display: inline-flex; align-items: center; gap: 4px; padding: 1px 6px; border: none; border-radius: 5px;
|
|
87
|
+
background: transparent; color: inherit; font: inherit; cursor: pointer; transition: color 0.12s, background-color 0.12s; }
|
|
88
|
+
.dci-md .dci-md-copy:hover { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 10%, transparent));
|
|
89
|
+
color: var(--dsw-alias-label-primary, currentColor); }
|
|
90
|
+
.dci-md .dci-md-copy:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary, #4c8dff)); outline-offset: 1px; }
|
|
91
|
+
.dci-md .dci-md-copy-glyph { width: 12px; height: 12px; flex: none; background-color: currentColor;
|
|
92
|
+
-webkit-mask: var(--dci-md-icon-copy) center / 12px 12px no-repeat; mask: var(--dci-md-icon-copy) center / 12px 12px no-repeat; }
|
|
93
|
+
.dci-md .dci-md-copy[data-dci-copied='true'] { color: var(--dsw-alias-state-success-primary, #2f9e6e); }
|
|
94
|
+
.dci-md .dci-md-copy[data-dci-copied='true'] .dci-md-copy-glyph {
|
|
95
|
+
-webkit-mask-image: var(--dci-md-icon-check); mask-image: var(--dci-md-icon-check); }
|
|
96
|
+
.dci-md .dci-md-copy-text { white-space: nowrap; }
|
|
97
|
+
.dci-md .dci-md-pre { margin: 0; padding: 10px 12px; overflow-x: auto; overflow-y: hidden; background: transparent;
|
|
98
|
+
border: 0; border-radius: 0; font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 12px; line-height: 1.55; }
|
|
99
|
+
.dci-md .dci-md-pre code { padding: 0; border: 0; background: transparent; font: inherit; white-space: pre; }
|
|
100
|
+
|
|
101
|
+
/* highlight.js token 配色:全部走宿主 --dsw-* 语义 token,浅/深色自动适配;
|
|
102
|
+
token 缺失时用 currentColor 混合回退,保证可读。 */
|
|
103
|
+
.dci-md .hljs { color: var(--dsw-alias-label-primary, currentColor); background: transparent; }
|
|
104
|
+
.dci-md .hljs-comment, .dci-md .hljs-quote { color: var(--dsw-alias-label-tertiary, color-mix(in srgb, currentColor 55%, transparent)); font-style: italic; }
|
|
105
|
+
.dci-md .hljs-keyword, .dci-md .hljs-selector-tag, .dci-md .hljs-literal, .dci-md .hljs-doctag,
|
|
106
|
+
.dci-md .hljs-name, .dci-md .hljs-tag { color: var(--dsw-alias-state-business-primary, #79a7ff); }
|
|
107
|
+
.dci-md .hljs-string, .dci-md .hljs-regexp, .dci-md .hljs-addition, .dci-md .hljs-attribute,
|
|
108
|
+
.dci-md .hljs-meta .hljs-string, .dci-md .hljs-template-tag { color: var(--dsw-alias-state-success-primary, #6fcf97); }
|
|
109
|
+
.dci-md .hljs-number, .dci-md .hljs-symbol, .dci-md .hljs-bullet, .dci-md .hljs-variable,
|
|
110
|
+
.dci-md .hljs-template-variable, .dci-md .hljs-selector-attr, .dci-md .hljs-selector-pseudo,
|
|
111
|
+
.dci-md .hljs-link { color: var(--dsw-alias-state-warn-primary, #e5b567); }
|
|
112
|
+
.dci-md .hljs-title, .dci-md .hljs-title.class_, .dci-md .hljs-title.function_, .dci-md .hljs-section,
|
|
113
|
+
.dci-md .hljs-selector-id { color: var(--dsw-alias-state-business-primary, #79a7ff); font-weight: 600; }
|
|
114
|
+
.dci-md .hljs-attr, .dci-md .hljs-property, .dci-md .hljs-params, .dci-md .hljs-subst { color: var(--dsw-alias-label-primary, currentColor); }
|
|
115
|
+
.dci-md .hljs-built_in, .dci-md .hljs-type, .dci-md .hljs-class .hljs-title { color: var(--dsw-alias-state-success-primary, #6fcf97); }
|
|
116
|
+
.dci-md .hljs-meta { color: var(--dsw-alias-label-tertiary, color-mix(in srgb, currentColor 58%, transparent)); }
|
|
117
|
+
.dci-md .hljs-deletion { color: var(--dsw-alias-state-error-primary, #e5484d); }
|
|
118
|
+
.dci-md .hljs-emphasis { font-style: italic; }
|
|
119
|
+
.dci-md .hljs-strong { font-weight: 700; }
|
|
120
|
+
|
|
121
|
+
/* ===== 扩展渲染:KaTeX 数学(公式 CSS 本体懒注入,这里只做容器与主题微调)===== */
|
|
122
|
+
.dci-md .dci-md-math-inline { display: inline; }
|
|
123
|
+
.dci-md .dci-md-math-block { display: block; margin: 0.65em 0; text-align: center; overflow-x: auto; overflow-y: hidden; }
|
|
124
|
+
.dci-md .katex { font-size: 1.06em; }
|
|
125
|
+
.dci-md .katex-display { margin: 0.35em 0; }
|
|
126
|
+
.dci-md .dci-md-math-error { display: inline-block; max-width: 100%; padding: 1px 5px; border-radius: 4px;
|
|
127
|
+
color: var(--dsw-alias-state-error-primary, #e5484d);
|
|
128
|
+
background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e5484d) 10%, transparent);
|
|
129
|
+
font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 0.92em; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
130
|
+
.dci-md .dci-md-math-block.dci-md-math-error { display: block; text-align: left; padding: 6px 8px; }
|
|
131
|
+
|
|
132
|
+
/* ===== 扩展渲染:Mermaid(占位源码 → 异步 SVG / 错误回退)===== */
|
|
133
|
+
.dci-md .dci-md-mermaid { position: relative; margin: 0.7em 0; border-radius: var(--dsw-radius-sm, 8px);
|
|
134
|
+
border: 0.5px solid var(--dsw-alias-border-l1, color-mix(in srgb, currentColor 12%, transparent));
|
|
135
|
+
background: var(--dsw-alias-markdown-code-block, color-mix(in srgb, currentColor 4%, transparent));
|
|
136
|
+
overflow-x: auto; overflow-y: hidden; }
|
|
137
|
+
.dci-md .dci-md-mermaid[data-dci-md-state='rendered'] { display: flex; justify-content: center; padding: 6px 8px;
|
|
138
|
+
border-color: transparent; background: transparent; }
|
|
139
|
+
.dci-md .dci-md-mermaid[data-dci-md-state='rendered'] svg { max-width: 100%; height: auto; }
|
|
140
|
+
.dci-md .dci-md-mermaid[data-dci-md-state='rendered'] .dci-md-mermaid-pending { display: none; }
|
|
141
|
+
.dci-md .dci-md-mermaid[data-dci-md-state='error'] { padding: 8px 10px;
|
|
142
|
+
border-left: 3px solid var(--dsw-alias-state-error-primary, #e5484d); }
|
|
143
|
+
.dci-md .dci-md-mermaid-badge { display: inline-block; margin: 0 0 2px 10px; padding: 1px 6px; border-radius: 999px;
|
|
144
|
+
font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 10px; line-height: 14px;
|
|
145
|
+
color: var(--dsw-alias-state-business-primary, #79a7ff);
|
|
146
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #79a7ff) 14%, transparent); }
|
|
147
|
+
.dci-md .dci-md-mermaid-pending .dci-md-pre { padding-top: 4px; }
|
|
148
|
+
.dci-md .dci-md-mermaid[data-dci-md-state='rendering'] .dci-md-mermaid-badge { opacity: 0.65; }
|
|
149
|
+
.dci-md .dci-md-mermaid-error { color: var(--dsw-alias-label-primary, currentColor); }
|
|
150
|
+
.dci-md .dci-md-mermaid-error-head { font-size: 12px; font-weight: 600; line-height: 18px;
|
|
151
|
+
color: var(--dsw-alias-state-error-primary, #e5484d); }
|
|
152
|
+
.dci-md .dci-md-mermaid-error-msg { margin: 2px 0 4px; font-size: 11px; line-height: 16px; white-space: pre-wrap;
|
|
153
|
+
overflow-wrap: anywhere; color: var(--dsw-alias-label-tertiary, color-mix(in srgb, currentColor 60%, transparent)); }
|
|
154
|
+
.dci-md .dci-md-mermaid-error .dci-md-pre { padding-left: 0; padding-right: 0; }
|
|
155
|
+
|
|
156
|
+
/* 复制/成功图标(CSS mask + data URI,不往 DOM 里塞 SVG,净化白名单保持收敛)。 */
|
|
157
|
+
.dci-md { --dci-md-icon-copy: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%20fill='none'%20stroke='black'%20stroke-width='1.4'%3E%3Crect%20x='5.75'%20y='5.75'%20width='7.5'%20height='7.5'%20rx='1.6'/%3E%3Cpath%20d='M10.25%205.75V4.1A1.6%201.6%200%200%200%208.65%202.5H4.1A1.6%201.6%200%200%200%202.5%204.1v4.55a1.6%201.6%200%200%200%201.6%201.6h1.65'/%3E%3C/svg%3E");
|
|
158
|
+
--dci-md-icon-check: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%20fill='none'%20stroke='black'%20stroke-width='1.8'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M2.8%208.6l3.2%203.1%207.2-7.4'/%3E%3C/svg%3E"); }
|
|
159
|
+
`;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一 Markdown 渲染管线的**唯一入口**。
|
|
3
|
+
*
|
|
4
|
+
* 预览面板(CodinputSurface)与用户消息(UserMessageMarkdown)都只调
|
|
5
|
+
* `renderMarkdown(text)`:同步、返回已净化 HTML,两处输出逐字节一致。
|
|
6
|
+
* 渲染之外的 DOM 交互(图片灯箱、wikilink 复制、扩展渲染的异步激活)由本模块
|
|
7
|
+
* 的辅助函数接管,组件侧只挂一个 onClick + 一个 activateMarkdown effect。
|
|
8
|
+
*
|
|
9
|
+
* ## 对外接口
|
|
10
|
+
* - `renderMarkdown(text, options?)` → sanitized HTML(同步)
|
|
11
|
+
* - `markdownStyles` → 本管线新增 CSS(由 `src/client/styles.ts` 拼接注入)
|
|
12
|
+
* - `registerMarkdownExtension(ext)` → 轻量 registry(扩展渲染 高亮/KaTeX/Mermaid)
|
|
13
|
+
* - `activateMarkdown(root, surface?)` → innerHTML 落地后的扩展激活(可返回清理函数)
|
|
14
|
+
* - `handleMarkdownClick(event, { onImage })` → 图片点击开灯箱 / wikilink 点击复制
|
|
15
|
+
* - `copyTextToClipboard(text)` → 剪贴板(含 execCommand 兜底)
|
|
16
|
+
*
|
|
17
|
+
* ## registry 约定(扩展渲染 契约)
|
|
18
|
+
* ```ts
|
|
19
|
+
* const off = registerMarkdownExtension({
|
|
20
|
+
* name: 'katex', // 唯一名;重复注册覆盖旧项
|
|
21
|
+
* marked: { extensions: [...], renderer: { ... } }, // marked 扩展(必须同步)
|
|
22
|
+
* allowTags: ['math', 'annotation'], // 增量放行 DOMPurify 白名单
|
|
23
|
+
* allowAttrs: ['data-katex'],
|
|
24
|
+
* // 异步面(Mermaid):innerHTML 落地后由 activateMarkdown 调,返回清理函数
|
|
25
|
+
* activate(root, { surface }) { ...; return () => cleanup(); },
|
|
26
|
+
* });
|
|
27
|
+
* off(); // 注销并重建解析器
|
|
28
|
+
* ```
|
|
29
|
+
* 约束:`marked` 扩展不得设 `async: true`(renderMarkdown 是同步契约);异步渲染
|
|
30
|
+
* 请放 `activate`(宿主 DOM 已就绪),需要的高度变化由调用方/ResizeObserver 处理。
|
|
31
|
+
* `renderer` 扩展返回 `false` 可回退上一实现(marked 约定)。
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { Marked, type MarkedExtension } from 'marked';
|
|
35
|
+
import { diagError } from '../diag';
|
|
36
|
+
import { escapeHtml, sanitizeMarkdown, type SanitizeExtras } from './sanitize';
|
|
37
|
+
import {
|
|
38
|
+
beginFootnoteRender,
|
|
39
|
+
buildFootnotesHtml,
|
|
40
|
+
coreMarkedOptions,
|
|
41
|
+
endFootnoteRender,
|
|
42
|
+
extractFootnotes,
|
|
43
|
+
} from './extensions';
|
|
44
|
+
import { markdownStyles } from './styles';
|
|
45
|
+
|
|
46
|
+
export { markdownStyles };
|
|
47
|
+
|
|
48
|
+
/** 渲染面:预览面板 / 用户消息气泡(扩展渲染 可据此做差异化激活)。 */
|
|
49
|
+
export type MarkdownSurface = 'preview' | 'user-message';
|
|
50
|
+
|
|
51
|
+
/** 激活钩子拿到的上下文。 */
|
|
52
|
+
export interface MarkdownRenderContext {
|
|
53
|
+
surface: MarkdownSurface;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* registry 扩展项。基础渲染 只用到基础面;扩展渲染的代码高亮/KaTeX 走 `marked`,
|
|
58
|
+
* Mermaid 走 `activate`(异步、DOM 就绪后)。
|
|
59
|
+
*/
|
|
60
|
+
export interface MarkdownExtension {
|
|
61
|
+
/** 唯一标识;同名重复注册以最后一次为准。 */
|
|
62
|
+
name: string;
|
|
63
|
+
/** marked 扩展(tokenizer/renderer/hooks)。不得声明 async。 */
|
|
64
|
+
marked?: MarkedExtension;
|
|
65
|
+
/** 追加放行的 DOMPurify 标签(如 KaTeX 的 math/annotation)。 */
|
|
66
|
+
allowTags?: readonly string[];
|
|
67
|
+
/** 追加放行的 DOMPurify 属性(如 data-katex)。 */
|
|
68
|
+
allowAttrs?: readonly string[];
|
|
69
|
+
/**
|
|
70
|
+
* innerHTML 落地后调用(组件在 useEffect 里调 `activateMarkdown`)。
|
|
71
|
+
* 返回清理函数,会在下次渲染/卸载前执行。只应在已净化的根节点上工作。
|
|
72
|
+
*/
|
|
73
|
+
activate?: (root: HTMLElement, context: MarkdownRenderContext) => void | (() => void);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RenderMarkdownOptions {
|
|
77
|
+
/** 渲染面标识;基础渲染 只作激活上下文透传,不改变输出。 */
|
|
78
|
+
surface?: MarkdownSurface;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ---------- 解析器 / registry ---------- */
|
|
82
|
+
|
|
83
|
+
let extensions: MarkdownExtension[] = [];
|
|
84
|
+
let parser: Marked = buildParser();
|
|
85
|
+
let sanitizeExtras: SanitizeExtras | undefined;
|
|
86
|
+
|
|
87
|
+
function buildParser(): Marked {
|
|
88
|
+
const packs: MarkedExtension[] = [coreMarkedOptions];
|
|
89
|
+
for (const extension of extensions) {
|
|
90
|
+
if (extension.marked) packs.push(extension.marked);
|
|
91
|
+
}
|
|
92
|
+
// marked 的 use() 对后注册的 tokenizer 前插:registry 扩展天然覆盖核心扩展。
|
|
93
|
+
return new Marked(...packs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rebuild(): void {
|
|
97
|
+
parser = buildParser();
|
|
98
|
+
const tags: string[] = [];
|
|
99
|
+
const attrs: string[] = [];
|
|
100
|
+
for (const extension of extensions) {
|
|
101
|
+
if (extension.allowTags) tags.push(...extension.allowTags);
|
|
102
|
+
if (extension.allowAttrs) attrs.push(...extension.allowAttrs);
|
|
103
|
+
}
|
|
104
|
+
sanitizeExtras = tags.length > 0 || attrs.length > 0 ? { tags, attrs } : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 注册一个渲染扩展(扩展渲染 接高亮/KaTeX/Mermaid 用)。返回注销函数。
|
|
109
|
+
* 注册后立即重建解析器;同名覆盖旧项。
|
|
110
|
+
*/
|
|
111
|
+
export function registerMarkdownExtension(extension: MarkdownExtension): () => void {
|
|
112
|
+
if (!extension || typeof extension.name !== 'string' || extension.name === '') {
|
|
113
|
+
throw new Error('markdown: registerMarkdownExtension requires a non-empty name');
|
|
114
|
+
}
|
|
115
|
+
extensions = [...extensions.filter((item) => item.name !== extension.name), extension];
|
|
116
|
+
rebuild();
|
|
117
|
+
return () => {
|
|
118
|
+
const next = extensions.filter((item) => item !== extension);
|
|
119
|
+
if (next.length === extensions.length) return;
|
|
120
|
+
extensions = next;
|
|
121
|
+
rebuild();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 当前已注册的扩展名(诊断 / QA 用)。 */
|
|
126
|
+
export function registeredMarkdownExtensions(): string[] {
|
|
127
|
+
return extensions.map((extension) => extension.name);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ---------- 渲染 ---------- */
|
|
131
|
+
|
|
132
|
+
let renderSeq = 0;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 把 Markdown 渲染成可安全 `dangerouslySetInnerHTML` 的 HTML(同步)。
|
|
136
|
+
* 空串返回空串(组件据此显示占位);任何异常降级为「原文转义 + <br>」,
|
|
137
|
+
* 错误进 `__dshCodinputErrs`(不把未净化 HTML 塞进 DOM)。
|
|
138
|
+
*/
|
|
139
|
+
export function renderMarkdown(text: string, _options: RenderMarkdownOptions = {}): string {
|
|
140
|
+
const source = typeof text === 'string' ? text : '';
|
|
141
|
+
if (source.trim() === '') return '';
|
|
142
|
+
const renderId = ++renderSeq;
|
|
143
|
+
try {
|
|
144
|
+
const { text: body, definitions } = extractFootnotes(source);
|
|
145
|
+
const numbers = new Map(definitions.map((definition) => [definition.id, definition.number] as const));
|
|
146
|
+
beginFootnoteRender({ prefix: `dci-md-${renderId}-`, numbers, shownRefs: new Map() });
|
|
147
|
+
const parsed = parser.parse(body, { async: false }) as unknown;
|
|
148
|
+
if (typeof parsed !== 'string') {
|
|
149
|
+
throw new Error('markdown: async marked extensions are not supported by renderMarkdown()');
|
|
150
|
+
}
|
|
151
|
+
let html = parsed;
|
|
152
|
+
if (definitions.length > 0) {
|
|
153
|
+
html += buildFootnotesHtml(definitions, (markdown) => parser.parse(markdown, { async: false }));
|
|
154
|
+
}
|
|
155
|
+
return sanitizeMarkdown(html, sanitizeExtras);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
diagError('markdown.render', error);
|
|
158
|
+
try {
|
|
159
|
+
return sanitizeMarkdown(`<p>${escapeHtml(source).replace(/\n/gu, '<br>')}</p>`, sanitizeExtras);
|
|
160
|
+
} catch {
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
endFootnoteRender();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* innerHTML 落地后激活已注册扩展(扩展渲染的 Mermaid 异步渲染走这里)。
|
|
170
|
+
* 返回清理函数;组件在 useEffect 里 `return activateMarkdown(ref.current, surface)`。
|
|
171
|
+
* 没有注册扩展时是零开销 no-op。
|
|
172
|
+
*/
|
|
173
|
+
export function activateMarkdown(root: HTMLElement | null, surface: MarkdownSurface = 'preview'): () => void {
|
|
174
|
+
if (!root || extensions.length === 0) return () => {};
|
|
175
|
+
const context: MarkdownRenderContext = { surface };
|
|
176
|
+
const cleanups: (() => void)[] = [];
|
|
177
|
+
for (const extension of extensions) {
|
|
178
|
+
if (typeof extension.activate !== 'function') continue;
|
|
179
|
+
try {
|
|
180
|
+
const cleanup = extension.activate(root, context);
|
|
181
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
diagError('markdown.activate', error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return () => {
|
|
187
|
+
for (const cleanup of cleanups) {
|
|
188
|
+
try {
|
|
189
|
+
cleanup();
|
|
190
|
+
} catch (error) {
|
|
191
|
+
diagError('markdown.activate', error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---------- DOM 交互(图片灯箱 / wikilink 复制) ---------- */
|
|
198
|
+
|
|
199
|
+
export interface MarkdownImageTarget {
|
|
200
|
+
src: string;
|
|
201
|
+
alt: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface MarkdownActivation {
|
|
205
|
+
image?: MarkdownImageTarget;
|
|
206
|
+
wikilink?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 从点击目标解析出「Markdown 根内的图片 / wikilink」。 */
|
|
210
|
+
function activationFromEvent(event: { target: EventTarget | null }): MarkdownActivation | null {
|
|
211
|
+
const target = event.target;
|
|
212
|
+
if (!(target instanceof Element)) return null;
|
|
213
|
+
const root = target.closest('.dci-md');
|
|
214
|
+
if (!root) return null;
|
|
215
|
+
const image = target.closest('img');
|
|
216
|
+
if (image && root.contains(image)) {
|
|
217
|
+
const src = image.getAttribute('src') ?? '';
|
|
218
|
+
if (/^https?:\/\//i.test(src)) return { image: { src, alt: image.getAttribute('alt') ?? '' } };
|
|
219
|
+
}
|
|
220
|
+
const wikilink = target.closest('[data-dci-wikilink]');
|
|
221
|
+
if (wikilink && root.contains(wikilink)) {
|
|
222
|
+
const value = wikilink.getAttribute('data-dci-wikilink') ?? '';
|
|
223
|
+
if (value !== '') return { wikilink: value };
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 剪贴板写入(含旧 API 兜底;保留官方复制钮同款实现)。 */
|
|
229
|
+
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
await navigator.clipboard.writeText(text);
|
|
232
|
+
return true;
|
|
233
|
+
} catch {
|
|
234
|
+
try {
|
|
235
|
+
const area = document.createElement('textarea');
|
|
236
|
+
area.value = text;
|
|
237
|
+
area.style.position = 'fixed';
|
|
238
|
+
area.style.opacity = '0';
|
|
239
|
+
document.body.appendChild(area);
|
|
240
|
+
area.select();
|
|
241
|
+
const ok = document.execCommand('copy');
|
|
242
|
+
area.remove();
|
|
243
|
+
return ok;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function flashWikilinkCopied(element: HTMLElement): void {
|
|
251
|
+
element.dataset.dciCopied = 'true';
|
|
252
|
+
window.setTimeout(() => {
|
|
253
|
+
delete element.dataset.dciCopied;
|
|
254
|
+
}, 900);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface MarkdownClickHandlers {
|
|
258
|
+
/** Markdown 内的 http(s) 图片被点击(组件接灯箱)。 */
|
|
259
|
+
onImage?(image: MarkdownImageTarget): void;
|
|
260
|
+
/** wikilink 目标已复制成功。 */
|
|
261
|
+
onWikilink?(target: string): void;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Markdown 根的点击委派:图片 → `onImage`(组件开灯箱);wikilink → 复制目标名。
|
|
266
|
+
* 返回是否已处理(组件可据此阻止默认行为/冒泡)。
|
|
267
|
+
*/
|
|
268
|
+
export function handleMarkdownClick(
|
|
269
|
+
event: { target: EventTarget | null; preventDefault?: () => void },
|
|
270
|
+
handlers: MarkdownClickHandlers = {},
|
|
271
|
+
): boolean {
|
|
272
|
+
const activation = activationFromEvent(event);
|
|
273
|
+
if (!activation) return false;
|
|
274
|
+
if (activation.image) {
|
|
275
|
+
event.preventDefault?.();
|
|
276
|
+
handlers.onImage?.(activation.image);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
const target = activation.wikilink as string;
|
|
280
|
+
event.preventDefault?.();
|
|
281
|
+
const element =
|
|
282
|
+
event.target instanceof Element ? event.target.closest<HTMLElement>('[data-dci-wikilink]') : null;
|
|
283
|
+
void copyTextToClipboard(target).then((ok) => {
|
|
284
|
+
if (!ok || !element) return;
|
|
285
|
+
flashWikilinkCopied(element);
|
|
286
|
+
handlers.onWikilink?.(target);
|
|
287
|
+
});
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展渲染:KaTeX 数学公式(`$...$` 行内 / `$$...$$` 块级;字体全内置离线)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么是「占位符 + activate 替换」而不是直接出 KaTeX HTML
|
|
5
|
+
* KaTeX 的 HTML 输出依赖大量 inline style(strut 高度、分式/上下标定位),而
|
|
6
|
+
* 基础渲染的净化白名单为了安全硬性 `FORBID_ATTR: ['style']`(见 sanitize.ts)。
|
|
7
|
+
* 因此这里让 marked 只产出**空占位元素**(源码以 base64 放 data 属性,经
|
|
8
|
+
* registry 的 `allowAttrs` 增量放行),`activateMath` 在 innerHTML 落地后同步
|
|
9
|
+
* 调 `katex.renderToString` 替换 innerHTML —— KaTeX 输出由库生成、输入 escape、
|
|
10
|
+
* `trust:false`,不经过 DOMPurify 也不会引入用户可控 HTML。
|
|
11
|
+
*
|
|
12
|
+
* 失败(`throwOnError:true` → 抛错)回退「原样公式源码 + 错误样式」,不抛到页面。
|
|
13
|
+
*
|
|
14
|
+
* ## 行内 `$...$` 的防误伤规则(对齐 KaTeX auto-render 思路)
|
|
15
|
+
* - 开 `$` 后不能是空白或另一个 `$`(`$$` 是块级);
|
|
16
|
+
* - 闭 `$` 前不能是空白/反斜杠(`\$` 是字面量);
|
|
17
|
+
* - 闭 `$` 后不能是数字或 `$`(`$5 and $10` 这类货币文本不会命中);
|
|
18
|
+
* - 内容不允许换行;转义字符按 `\x` 整体吞掉。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import katex from 'katex';
|
|
22
|
+
import type { TokenizerAndRendererExtension, Tokens } from 'marked';
|
|
23
|
+
import katexCss from 'virtual:katex-css';
|
|
24
|
+
import { decodeBase64Utf8, encodeBase64Utf8 } from './heavy-shared';
|
|
25
|
+
|
|
26
|
+
const KATEX_STYLE_ID = 'dci-md-katex-css';
|
|
27
|
+
let katexCssInjected = false;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 首次出现公式时注入 KaTeX CSS(字体是 CSS 内嵌的 data URI,零网络请求)。
|
|
31
|
+
* 必须插在本插件 `<style id="dsh-codinput-styles">` **之前**:KaTeX 默认样式
|
|
32
|
+
* 先落地,我们在 heavyMarkdownStyles 里的 `.dci-md .katex` 覆盖才能生效。
|
|
33
|
+
*/
|
|
34
|
+
export function ensureKatexCss(): void {
|
|
35
|
+
if (katexCssInjected || typeof document === 'undefined') return;
|
|
36
|
+
katexCssInjected = true;
|
|
37
|
+
if (document.getElementById(KATEX_STYLE_ID)) return;
|
|
38
|
+
if (typeof katexCss !== 'string' || katexCss === '') return;
|
|
39
|
+
const style = document.createElement('style');
|
|
40
|
+
style.id = KATEX_STYLE_ID;
|
|
41
|
+
style.textContent = katexCss;
|
|
42
|
+
const pluginStyle = document.getElementById('dsh-codinput-styles');
|
|
43
|
+
if (pluginStyle && pluginStyle.parentNode) pluginStyle.parentNode.insertBefore(style, pluginStyle);
|
|
44
|
+
else document.head.appendChild(style);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* ---------- marked 侧:占位元素 ---------- */
|
|
48
|
+
|
|
49
|
+
function placeholder(tex: string, display: boolean): string {
|
|
50
|
+
const encoded = encodeBase64Utf8(tex);
|
|
51
|
+
const tag = display ? 'div' : 'span';
|
|
52
|
+
const className = display ? 'dci-md-math-block' : 'dci-md-math-inline';
|
|
53
|
+
return `<${tag} class="${className}" data-dci-md-math="${encoded}" data-dci-md-display="${display ? '1' : '0'}"></${tag}>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 块级 `$$ ... $$`(可跨行;要求独占块边界)。 */
|
|
57
|
+
const blockMathExtension: TokenizerAndRendererExtension = {
|
|
58
|
+
name: 'dciBlockMath',
|
|
59
|
+
level: 'block',
|
|
60
|
+
start(src: string): number | undefined {
|
|
61
|
+
const index = src.indexOf('$$');
|
|
62
|
+
return index < 0 ? undefined : index;
|
|
63
|
+
},
|
|
64
|
+
tokenizer(src: string): Tokens.Generic | undefined {
|
|
65
|
+
const match = /^\$\$[ \t]*\n?([\s\S]+?)\n?[ \t]*\$\$[ \t]*(?:\n|$)/u.exec(src);
|
|
66
|
+
if (!match) return undefined;
|
|
67
|
+
const tex = match[1].trim();
|
|
68
|
+
if (tex === '') return undefined;
|
|
69
|
+
return { type: 'dciBlockMath', raw: match[0], text: tex };
|
|
70
|
+
},
|
|
71
|
+
renderer(token: Tokens.Generic): string {
|
|
72
|
+
return `${placeholder(String(token['text'] ?? ''), true)}\n`;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** 行内 `$ ... $`(规则见文件头注释)。 */
|
|
77
|
+
const inlineMathExtension: TokenizerAndRendererExtension = {
|
|
78
|
+
name: 'dciInlineMath',
|
|
79
|
+
level: 'inline',
|
|
80
|
+
start(src: string): number | undefined {
|
|
81
|
+
const index = src.indexOf('$');
|
|
82
|
+
return index < 0 ? undefined : index;
|
|
83
|
+
},
|
|
84
|
+
tokenizer(src: string): Tokens.Generic | undefined {
|
|
85
|
+
const match = /^\$(?![\s$])((?:\\.|[^$\\\n])+?)(?<![\s\\])\$(?![\d$])/u.exec(src);
|
|
86
|
+
if (!match) return undefined;
|
|
87
|
+
const tex = match[1];
|
|
88
|
+
if (tex.trim() === '') return undefined;
|
|
89
|
+
return { type: 'dciInlineMath', raw: match[0], text: tex };
|
|
90
|
+
},
|
|
91
|
+
renderer(token: Tokens.Generic): string {
|
|
92
|
+
return placeholder(String(token['text'] ?? ''), false);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** 交给 registry 的 `marked.extensions`。 */
|
|
97
|
+
export const mathMarkedExtensions: TokenizerAndRendererExtension[] = [blockMathExtension, inlineMathExtension];
|
|
98
|
+
|
|
99
|
+
/* ---------- 激活期:同步替换为 KaTeX HTML ---------- */
|
|
100
|
+
|
|
101
|
+
function renderMathNode(node: HTMLElement): void {
|
|
102
|
+
const encoded = node.getAttribute('data-dci-md-math') ?? '';
|
|
103
|
+
const display = node.getAttribute('data-dci-md-display') === '1';
|
|
104
|
+
const tex = decodeBase64Utf8(encoded);
|
|
105
|
+
try {
|
|
106
|
+
const html = katex.renderToString(tex, {
|
|
107
|
+
displayMode: display,
|
|
108
|
+
throwOnError: true,
|
|
109
|
+
trust: false,
|
|
110
|
+
strict: false,
|
|
111
|
+
output: 'htmlAndMathml',
|
|
112
|
+
});
|
|
113
|
+
node.innerHTML = html;
|
|
114
|
+
node.classList.add('dci-md-math-rendered');
|
|
115
|
+
} catch (error) {
|
|
116
|
+
node.classList.add('dci-md-math-error');
|
|
117
|
+
node.textContent = tex;
|
|
118
|
+
node.setAttribute('title', error instanceof Error ? error.message : String(error));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** innerHTML 落地后的公式渲染(同步;基础渲染 `activateMarkdown` 调用)。 */
|
|
123
|
+
export function activateMath(root: HTMLElement): void {
|
|
124
|
+
const nodes = root.querySelectorAll<HTMLElement>('[data-dci-md-math]');
|
|
125
|
+
if (nodes.length === 0) return;
|
|
126
|
+
ensureKatexCss();
|
|
127
|
+
for (const node of nodes) {
|
|
128
|
+
try {
|
|
129
|
+
renderMathNode(node);
|
|
130
|
+
} catch {
|
|
131
|
+
// renderMathNode 已处理 KaTeX 解析错误;这里兜底任何意外(不抛给 React)。
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|