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.
Files changed (40) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/NOTICE +37 -4
  3. package/README.en.md +28 -13
  4. package/README.md +28 -13
  5. package/TESTING.md +96 -9
  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/MenuSurface.tsx +64 -12
  15. package/src/client/components/ModelMenu.tsx +5 -17
  16. package/src/client/components/PermissionMenu.tsx +8 -3
  17. package/src/client/components/SlashMenu.tsx +294 -41
  18. package/src/client/components/StatsRow.tsx +40 -23
  19. package/src/client/components/Toolbar.tsx +284 -48
  20. package/src/client/components/UserMessageMarkdown.tsx +158 -115
  21. package/src/client/components/VoiceActivity.tsx +30 -69
  22. package/src/client/components/slotEntry.tsx +37 -0
  23. package/src/client/host-types.ts +11 -3
  24. package/src/client/hotkey.ts +38 -0
  25. package/src/client/i18n.ts +18 -4
  26. package/src/client/index.tsx +9 -0
  27. package/src/client/markdown/code.ts +281 -0
  28. package/src/client/markdown/extensions.ts +576 -0
  29. package/src/client/markdown/heavy-assets.d.ts +11 -0
  30. package/src/client/markdown/heavy-shared.ts +50 -0
  31. package/src/client/markdown/heavy.ts +159 -0
  32. package/src/client/markdown/index.ts +289 -0
  33. package/src/client/markdown/math.ts +134 -0
  34. package/src/client/markdown/mermaid.ts +358 -0
  35. package/src/client/markdown/sanitize.ts +103 -0
  36. package/src/client/markdown/styles.ts +171 -0
  37. package/src/client/prefs.ts +206 -23
  38. package/src/client/settings.tsx +15 -3
  39. package/src/client/stats.ts +92 -20
  40. package/src/client/styles.ts +55 -30
@@ -0,0 +1,576 @@
1
+ /**
2
+ * 统一 Markdown 管线的 marked 扩展层。
3
+ *
4
+ * 本模块只产出 HTML 片段,不做净化(净化在 `sanitize.ts`)也不做 DOM 交互
5
+ * (灯箱 / wikilink 复制在 `index.ts` 的交互辅助里)。核心扩展:
6
+ *
7
+ * - GFM 增强:表格/任务清单/删除线/自动链接由 marked 的 gfm 能力提供;这里
8
+ * 接管 link/image 渲染 —— 外链补 `target=_blank rel=noopener noreferrer`、
9
+ * 只放行 http(s)/mailto/页内锚点,图片支持 Obsidian 尺寸语法 `![alt|300](url)`
10
+ * 且非 http(s) 图降级为 alt 文本(不破图、不执行)。
11
+ * - 脚注:`[^1]` / `[^1]: ...`(定义在解析前抽出,正文上标 + 文末 footnote 区,
12
+ * 可回跳;编号按首次引用顺序,渲染 id 带本轮回数前缀避免多消息撞 id)。
13
+ * - Obsidian callout:`> [!note] Title`(含 `+`/`-` 折叠),图标/配色走
14
+ * `markdownStyles` 里的主题 token。
15
+ * - Obsidian 语法:`==高亮==`、`%%注释%%`(整行/多行/行内都隐藏)、
16
+ * `[[目标|别名]]`(可点复制目标的 chip)、`![[图片.png]]`(不可解析 → 样式化
17
+ * 文本,不崩)。
18
+ * - 会话引用线格式 `@[标签](dsh-session:…)`:产出 `<span data-dci-wire>` 占位,
19
+ * 由用户消息侧装饰成官方引用 chip(B5:该属性只可能由本扩展产生)。
20
+ * - 原始 HTML:`renderer.html` 一律转义(顶层渲染器见 `coreRenderer`)。
21
+ */
22
+
23
+ import type {
24
+ MarkedExtension,
25
+ RendererObject,
26
+ RendererThis,
27
+ Token,
28
+ Tokens,
29
+ TokenizerAndRendererExtension,
30
+ TokenizerThis,
31
+ } from 'marked';
32
+ import { escapeHtml } from './sanitize';
33
+
34
+ /* =====================================================================
35
+ * 脚注:解析前抽取定义 + 渲染态(编号 / 前缀 / 引用计数)
36
+ * ===================================================================== */
37
+
38
+ export interface FootnoteDefinition {
39
+ id: string;
40
+ /** 从 1 起的正文显示编号(按首次引用顺序)。 */
41
+ number: number;
42
+ /** 定义原文(已去掉 `[^id]: ` 前缀与续行缩进,可含多段)。 */
43
+ body: string;
44
+ }
45
+
46
+ export interface ExtractedFootnotes {
47
+ /** 移除脚注定义行后的 Markdown。 */
48
+ text: string;
49
+ /** 被引用到的定义(按首次引用顺序编号)。 */
50
+ definitions: FootnoteDefinition[];
51
+ }
52
+
53
+ export interface FootnoteRenderState {
54
+ /** 本轮渲染的 DOM id 前缀(如 `dci-md-7-`):同一页面多次渲染不撞 id。 */
55
+ prefix: string;
56
+ /** id → 显示编号。 */
57
+ numbers: Map<string, number>;
58
+ /** 编号 → 已出现引用次数(重复引用生成唯一 id)。 */
59
+ shownRefs: Map<number, number>;
60
+ }
61
+
62
+ let footnoteState: FootnoteRenderState | null = null;
63
+
64
+ /** 每次 renderMarkdown 同步执行:begin → parse → buildFootnotes → end。 */
65
+ export function beginFootnoteRender(state: FootnoteRenderState): void {
66
+ footnoteState = state;
67
+ }
68
+
69
+ export function endFootnoteRender(): void {
70
+ footnoteState = null;
71
+ }
72
+
73
+ /** 文末脚注区;`renderBlock` 把定义 Markdown 渲染成块级 HTML。 */
74
+ export function buildFootnotesHtml(
75
+ definitions: readonly FootnoteDefinition[],
76
+ renderBlock: (markdown: string) => string,
77
+ ): string {
78
+ if (definitions.length === 0) return '';
79
+ const prefix = footnoteState?.prefix ?? '';
80
+ const items = definitions.map((def) => {
81
+ const body = renderBlock(def.body);
82
+ const back = `<a class="dci-md-fnback" href="#${prefix}fnref-${def.number}" data-dci-fn="back" title="↩">↩</a>`;
83
+ return `<li id="${prefix}fn-${def.number}" class="dci-md-fnitem">${body}${back}</li>`;
84
+ });
85
+ return `<section class="dci-md-footnotes" data-dci-footnotes="true"><ol class="dci-md-fnlist">${items.join('')}</ol></section>\n`;
86
+ }
87
+
88
+ /** 代码围栏状态(``` / ~~~,长度 ≥3 且闭合不短于开启)。 */
89
+ interface FenceState {
90
+ ch: string;
91
+ len: number;
92
+ }
93
+
94
+ function fenceOpen(line: string): FenceState | null {
95
+ const m = /^\s{0,3}(`{3,}|~{3,})/u.exec(line);
96
+ if (!m) return null;
97
+ return { ch: m[1][0], len: m[1].length };
98
+ }
99
+
100
+ function fenceClose(line: string, open: FenceState): boolean {
101
+ const m = /^\s{0,3}(`{3,}|~{3,})\s*$/u.exec(line);
102
+ return m !== null && m[1][0] === open.ch && m[1].length >= open.len;
103
+ }
104
+
105
+ /** 忽略围栏代码 + 行内代码后的脚注引用顺序(编号 = 首次出现顺序)。 */
106
+ function collectFootnoteRefs(text: string): string[] {
107
+ const ids: string[] = [];
108
+ const seen = new Set<string>();
109
+ let fence: FenceState | null = null;
110
+ for (const line of text.split('\n')) {
111
+ if (fence) {
112
+ if (fenceClose(line, fence)) fence = null;
113
+ continue;
114
+ }
115
+ const open = fenceOpen(line);
116
+ if (open) {
117
+ fence = open;
118
+ continue;
119
+ }
120
+ const masked = line.replace(/`[^`\n]*`/gu, (m) => ' '.repeat(m.length));
121
+ const re = /\[\^([^\]\s]+)\]/gu;
122
+ let m: RegExpExecArray | null;
123
+ while ((m = re.exec(masked)) !== null) {
124
+ if (seen.has(m[1])) continue;
125
+ seen.add(m[1]);
126
+ ids.push(m[1]);
127
+ }
128
+ }
129
+ return ids;
130
+ }
131
+
132
+ /**
133
+ * 从 Markdown 源里抽出 `[^id]: ...` 定义行(含 2+ 空格 / Tab 缩进的续行),
134
+ * 并把被引用的定义按首次引用顺序编号。未被引用的定义随定义行一起丢弃
135
+ * (与 Obsidian / Pandoc 一致)。
136
+ *
137
+ * 只识别块首(≤3 空格缩进)的定义行;围栏代码里的 `[^x]:` 不动。
138
+ */
139
+ export function extractFootnotes(source: string): ExtractedFootnotes {
140
+ const lines = source.split('\n');
141
+ const kept: string[] = [];
142
+ const rawDefs = new Map<string, string>();
143
+ let current: { id: string; lines: string[] } | null = null;
144
+ let pendingBlanks: string[] = [];
145
+ let fence: FenceState | null = null;
146
+
147
+ const flush = (): void => {
148
+ if (!current) return;
149
+ const body = current.lines.join('\n').replace(/[\s\u00a0]+$/u, '');
150
+ rawDefs.set(current.id, body);
151
+ current = null;
152
+ };
153
+ const dropPendingBlanks = (): void => {
154
+ pendingBlanks = [];
155
+ };
156
+ const keepPendingBlanks = (): void => {
157
+ kept.push(...pendingBlanks);
158
+ pendingBlanks = [];
159
+ };
160
+
161
+ for (const line of lines) {
162
+ if (fence) {
163
+ kept.push(line);
164
+ if (fenceClose(line, fence)) fence = null;
165
+ continue;
166
+ }
167
+ if (current) {
168
+ if (line.trim() === '') {
169
+ pendingBlanks.push(line);
170
+ continue;
171
+ }
172
+ // 定义续行:Tab 或 2+ 空格缩进(Obsidian 沿用「缩进即续行」的手感)。
173
+ if (/^(?: {2,}|\t)/u.test(line)) {
174
+ for (const blank of pendingBlanks) current.lines.push(blank);
175
+ dropPendingBlanks();
176
+ current.lines.push(line.replace(/^(?: {1,4}|\t)/u, ''));
177
+ continue;
178
+ }
179
+ flush();
180
+ keepPendingBlanks();
181
+ }
182
+ const open = fenceOpen(line);
183
+ if (open) {
184
+ fence = open;
185
+ kept.push(line);
186
+ continue;
187
+ }
188
+ const def = /^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/u.exec(line);
189
+ if (def) {
190
+ current = { id: def[1], lines: [def[2]] };
191
+ continue;
192
+ }
193
+ kept.push(line);
194
+ }
195
+ if (current) {
196
+ flush();
197
+ keepPendingBlanks();
198
+ }
199
+ const text = kept.join('\n');
200
+ const definitions: FootnoteDefinition[] = [];
201
+ let number = 0;
202
+ for (const id of collectFootnoteRefs(text)) {
203
+ const body = rawDefs.get(id);
204
+ if (body === undefined) continue;
205
+ definitions.push({ id, number: ++number, body });
206
+ }
207
+ return { text, definitions };
208
+ }
209
+
210
+ /* =====================================================================
211
+ * 内联扩展
212
+ * ===================================================================== */
213
+
214
+ interface FootnoteRefToken extends Tokens.Generic {
215
+ type: 'dciFootnoteRef';
216
+ number: number;
217
+ refIndex: number;
218
+ }
219
+
220
+ const footnoteRefExtension: TokenizerAndRendererExtension = {
221
+ name: 'dciFootnoteRef',
222
+ level: 'inline',
223
+ start(src: string): number | undefined {
224
+ const i = src.indexOf('[^');
225
+ return i < 0 ? undefined : i;
226
+ },
227
+ tokenizer(this: TokenizerThis, src: string): Tokens.Generic | undefined {
228
+ const m = /^\[\^([^\]\s]+)\]/u.exec(src);
229
+ if (!m || !footnoteState) return undefined;
230
+ const number = footnoteState.numbers.get(m[1]);
231
+ if (number === undefined) return undefined;
232
+ const shown = footnoteState.shownRefs.get(number) ?? 0;
233
+ footnoteState.shownRefs.set(number, shown + 1);
234
+ return { type: 'dciFootnoteRef', raw: m[0], number, refIndex: shown + 1 } as FootnoteRefToken;
235
+ },
236
+ renderer(this: RendererThis, token: Tokens.Generic): string {
237
+ const ref = token as FootnoteRefToken;
238
+ const prefix = footnoteState?.prefix ?? '';
239
+ const refId =
240
+ ref.refIndex === 1 ? `${prefix}fnref-${ref.number}` : `${prefix}fnref-${ref.number}-${ref.refIndex}`;
241
+ return `<sup class="dci-md-fnref"><a href="#${prefix}fn-${ref.number}" id="${refId}" data-dci-fn="ref">${ref.number}</a></sup>`;
242
+ },
243
+ };
244
+
245
+ interface HighlightToken extends Tokens.Generic {
246
+ type: 'dciHighlight';
247
+ tokens: Token[];
248
+ }
249
+
250
+ /** `==高亮==` → `<mark>`(不跨行,避免吞掉整段)。 */
251
+ const highlightExtension: TokenizerAndRendererExtension = {
252
+ name: 'dciHighlight',
253
+ level: 'inline',
254
+ // `=` 不是 marked 内联扫描的天然停点,给 start 让 inlineText 在 `==` 前切开,
255
+ // 否则整段会先被吃成一个 text token,扩展永远看不到高亮语法。
256
+ start(src: string): number | undefined {
257
+ const i = src.indexOf('==');
258
+ return i < 0 ? undefined : i;
259
+ },
260
+ tokenizer(this: TokenizerThis, src: string): Tokens.Generic | undefined {
261
+ const m = /^==(?=\S)([^\n]*?\S)==/u.exec(src);
262
+ if (!m) return undefined;
263
+ return { type: 'dciHighlight', raw: m[0], tokens: this.lexer.inlineTokens(m[1], []) } as HighlightToken;
264
+ },
265
+ childTokens: ['tokens'],
266
+ renderer(this: RendererThis, token: Tokens.Generic): string {
267
+ const highlight = token as HighlightToken;
268
+ return `<mark class="dci-md-mark">${this.parser.parseInline(highlight.tokens ?? [])}</mark>`;
269
+ },
270
+ };
271
+
272
+ /** 行内注释 `%%…%%`:整段消失(不渲染空白占位)。 */
273
+ const commentInlineExtension: TokenizerAndRendererExtension = {
274
+ name: 'dciCommentInline',
275
+ level: 'inline',
276
+ // 同 highlight:`%` 不是天然停点,靠 start 把文本 cut 在 `%%` 之前。
277
+ start(src: string): number | undefined {
278
+ const i = src.indexOf('%%');
279
+ return i < 0 ? undefined : i;
280
+ },
281
+ tokenizer(src: string): Tokens.Generic | undefined {
282
+ const m = /^%%[^\n]*?%%/u.exec(src);
283
+ if (!m) return undefined;
284
+ // marked 按 token.type 找扩展 renderer,而 renderer 是挂在扩展 name 上的:
285
+ // 这里 type 必须与 name 完全一致,否则渲染器查不到、原文会漏出来。
286
+ return { type: 'dciCommentInline', raw: m[0] };
287
+ },
288
+ renderer(): string {
289
+ return '';
290
+ },
291
+ };
292
+
293
+ /** 多行注释块:块首 `%%` 起、后续任意行内再次出现 `%%` 收(单行注释交给 inline)。 */
294
+ const commentBlockExtension: TokenizerAndRendererExtension = {
295
+ name: 'dciCommentBlock',
296
+ level: 'block',
297
+ tokenizer(src: string): Tokens.Generic | undefined {
298
+ const lines = src.split('\n');
299
+ const first = lines[0] ?? '';
300
+ if (!/^ {0,3}%%/u.test(first)) return undefined;
301
+ const open = /^ {0,3}%%/u.exec(first);
302
+ if (!open || first.slice(open[0].length).includes('%%')) return undefined;
303
+ let close = -1;
304
+ for (let i = 1; i < lines.length; i++) {
305
+ if (lines[i].includes('%%')) {
306
+ close = i;
307
+ break;
308
+ }
309
+ }
310
+ if (close < 0) return undefined;
311
+ let raw = lines.slice(0, close + 1).join('\n');
312
+ if (close + 1 < lines.length) raw += '\n';
313
+ return { type: 'dciCommentBlock', raw };
314
+ },
315
+ renderer(): string {
316
+ return '';
317
+ },
318
+ };
319
+
320
+ interface WikilinkToken extends Tokens.Generic {
321
+ type: 'dciWikilink';
322
+ target: string;
323
+ alias: string;
324
+ }
325
+
326
+ /** `[[目标]]` / `[[目标|别名]]`:样式化 chip,点击复制目标名(交互见 index.ts)。 */
327
+ const wikilinkExtension: TokenizerAndRendererExtension = {
328
+ name: 'dciWikilink',
329
+ level: 'inline',
330
+ start(src: string): number | undefined {
331
+ const i = src.indexOf('[[');
332
+ return i < 0 ? undefined : i;
333
+ },
334
+ tokenizer(src: string): Tokens.Generic | undefined {
335
+ const m = /^\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]/u.exec(src);
336
+ if (!m) return undefined;
337
+ const target = m[1].trim();
338
+ if (target === '') return undefined;
339
+ return { type: 'dciWikilink', raw: m[0], target, alias: (m[2] ?? '').trim() } as WikilinkToken;
340
+ },
341
+ renderer(token: Tokens.Generic): string {
342
+ const link = token as WikilinkToken;
343
+ const label = link.alias !== '' ? link.alias : link.target;
344
+ return `<button type="button" class="dci-md-wikilink" data-dci-wikilink="${escapeHtml(link.target)}" title="${escapeHtml(`[[${link.target}]]`)}">${escapeHtml(label)}</button>`;
345
+ },
346
+ };
347
+
348
+ interface EmbedToken extends Tokens.Generic {
349
+ type: 'dciEmbed';
350
+ target: string;
351
+ alias: string;
352
+ }
353
+
354
+ /** `![[图片.png]]`:宿主没有仓库解析面 → 样式化文本;正则整段吃掉,不崩。 */
355
+ const embedExtension: TokenizerAndRendererExtension = {
356
+ name: 'dciEmbed',
357
+ level: 'inline',
358
+ start(src: string): number | undefined {
359
+ const i = src.indexOf('![[');
360
+ return i < 0 ? undefined : i;
361
+ },
362
+ tokenizer(src: string): Tokens.Generic | undefined {
363
+ const m = /^!\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]/u.exec(src);
364
+ if (!m) return undefined;
365
+ const target = m[1].trim();
366
+ if (target === '') return undefined;
367
+ return { type: 'dciEmbed', raw: m[0], target, alias: (m[2] ?? '').trim() } as EmbedToken;
368
+ },
369
+ renderer(token: Tokens.Generic): string {
370
+ const embed = token as EmbedToken;
371
+ const label = embed.alias !== '' ? embed.alias : embed.target;
372
+ return `<span class="dci-md-embed" title="${escapeHtml(embed.target)}">${escapeHtml(label)}</span>`;
373
+ },
374
+ };
375
+
376
+ /* =====================================================================
377
+ * 块级扩展:Obsidian callout
378
+ * ===================================================================== */
379
+
380
+ type CalloutFamily = 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'gray';
381
+
382
+ interface CalloutMeta {
383
+ label: string;
384
+ family: CalloutFamily;
385
+ /** markdownStyles 里的 mask 图标名。 */
386
+ icon: string;
387
+ }
388
+
389
+ /** Obsidian 官方 callout 类型 + 常见别名;未知类型按 note 家族渲染(标题用原名)。 */
390
+ const CALLOUT_KINDS: Record<string, CalloutMeta> = {
391
+ note: { label: 'Note', family: 'blue', icon: 'pencil' },
392
+ abstract: { label: 'Abstract', family: 'blue', icon: 'list' },
393
+ summary: { label: 'Summary', family: 'blue', icon: 'list' },
394
+ tldr: { label: 'TL;DR', family: 'blue', icon: 'list' },
395
+ info: { label: 'Info', family: 'blue', icon: 'info' },
396
+ todo: { label: 'Todo', family: 'blue', icon: 'check-square' },
397
+ tip: { label: 'Tip', family: 'green', icon: 'bulb' },
398
+ hint: { label: 'Hint', family: 'green', icon: 'bulb' },
399
+ important: { label: 'Important', family: 'amber', icon: 'flame' },
400
+ success: { label: 'Success', family: 'green', icon: 'check-circle' },
401
+ check: { label: 'Check', family: 'green', icon: 'check-circle' },
402
+ done: { label: 'Done', family: 'green', icon: 'check-circle' },
403
+ question: { label: 'Question', family: 'purple', icon: 'help-circle' },
404
+ help: { label: 'Help', family: 'purple', icon: 'help-circle' },
405
+ faq: { label: 'FAQ', family: 'purple', icon: 'help-circle' },
406
+ warning: { label: 'Warning', family: 'amber', icon: 'alert-triangle' },
407
+ caution: { label: 'Caution', family: 'amber', icon: 'alert-triangle' },
408
+ attention: { label: 'Attention', family: 'amber', icon: 'alert-triangle' },
409
+ danger: { label: 'Danger', family: 'red', icon: 'x-octagon' },
410
+ error: { label: 'Error', family: 'red', icon: 'x-octagon' },
411
+ failure: { label: 'Failure', family: 'red', icon: 'x-octagon' },
412
+ fail: { label: 'Fail', family: 'red', icon: 'x-octagon' },
413
+ missing: { label: 'Missing', family: 'red', icon: 'x-octagon' },
414
+ bug: { label: 'Bug', family: 'red', icon: 'x-octagon' },
415
+ example: { label: 'Example', family: 'purple', icon: 'list' },
416
+ quote: { label: 'Quote', family: 'gray', icon: 'quote' },
417
+ cite: { label: 'Cite', family: 'gray', icon: 'quote' },
418
+ };
419
+
420
+ function calloutMeta(kind: string): CalloutMeta {
421
+ const known = CALLOUT_KINDS[kind];
422
+ if (known) return known;
423
+ const label = kind === '' ? 'Note' : kind.charAt(0).toUpperCase() + kind.slice(1);
424
+ return { label, family: 'blue', icon: 'info' };
425
+ }
426
+
427
+ interface CalloutToken extends Tokens.Generic {
428
+ type: 'dciCallout';
429
+ kind: string;
430
+ fold: string;
431
+ title: string;
432
+ tokens: Token[];
433
+ }
434
+
435
+ const CALLOUT_HEAD = /^ {0,3}>[ \t]?\[!([a-zA-Z][a-zA-Z0-9_-]*)\]([+-]?)[ \t]*(.*)$/u;
436
+
437
+ /** `> [!note] 标题` + 后续 `>` 行 → 带图标/配色的 callout 块(`+`/`-` 折叠)。 */
438
+ const calloutExtension: TokenizerAndRendererExtension = {
439
+ name: 'dciCallout',
440
+ level: 'block',
441
+ tokenizer(this: TokenizerThis, src: string): Tokens.Generic | undefined {
442
+ const lines = src.split('\n');
443
+ const head = CALLOUT_HEAD.exec(lines[0] ?? '');
444
+ if (!head) return undefined;
445
+ const rawLines: string[] = [lines[0]];
446
+ const bodyLines: string[] = [];
447
+ for (let i = 1; i < lines.length; i++) {
448
+ const line = lines[i];
449
+ // 只有继续带 `>` 的行才属于本 blockquote/callout;空行(未带 `>`)结束本块
450
+ // ——`> a\n\n> b` 在 CommonMark/Obsidian 里是两个块,不能吞并成一次 callout。
451
+ if (!/^ {0,3}>/u.test(line)) break;
452
+ rawLines.push(line);
453
+ bodyLines.push(line.replace(/^ {0,3}>[ \t]?/u, ''));
454
+ }
455
+ let raw = rawLines.join('\n');
456
+ if (rawLines.length < lines.length) raw += '\n';
457
+ const body = bodyLines.join('\n');
458
+ const prevTop = this.lexer.state.top;
459
+ this.lexer.state.top = true;
460
+ let tokens: Token[];
461
+ try {
462
+ tokens = this.lexer.blockTokens(body, []);
463
+ } finally {
464
+ this.lexer.state.top = prevTop;
465
+ }
466
+ return { type: 'dciCallout', raw, kind: head[1].toLowerCase(), fold: head[2], title: head[3].trim(), tokens } as CalloutToken;
467
+ },
468
+ renderer(this: RendererThis, token: Tokens.Generic): string {
469
+ const callout = token as CalloutToken;
470
+ const meta = calloutMeta(callout.kind);
471
+ const label = callout.title !== '' ? callout.title : meta.label;
472
+ const body = this.parser.parse(callout.tokens ?? []);
473
+ const head = `<span class="dci-md-callout-icon" data-icon="${meta.icon}"></span><span class="dci-md-callout-label">${escapeHtml(label)}</span>`;
474
+ const attrs = `class="dci-md-callout" data-callout="${escapeHtml(callout.kind)}" data-family="${meta.family}"`;
475
+ if (callout.fold === '-' || callout.fold === '+') {
476
+ const open = callout.fold === '+' ? ' open' : '';
477
+ return `<details ${attrs}${open}><summary class="dci-md-callout-head">${head}</summary><div class="dci-md-callout-body">${body}</div></details>\n`;
478
+ }
479
+ return `<div ${attrs}><div class="dci-md-callout-head">${head}</div><div class="dci-md-callout-body">${body}</div></div>\n`;
480
+ },
481
+ };
482
+
483
+ /* =====================================================================
484
+ * 会话引用线格式(用户消息 chip 的唯一起源,B5)
485
+ * ===================================================================== */
486
+
487
+ /** 会话引用的落库线格式:@[标签](dsh-session:…)(官方 SESSION_WIRE_RE)。 */
488
+ const SESSION_WIRE_SRC = /^@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/u;
489
+
490
+ const sessionWireExtension: TokenizerAndRendererExtension = {
491
+ name: 'dshSessionWire',
492
+ level: 'inline',
493
+ start(src: string): number | undefined {
494
+ const i = src.indexOf('@[');
495
+ return i < 0 ? undefined : i;
496
+ },
497
+ tokenizer(src: string): Tokens.Generic | undefined {
498
+ const m = SESSION_WIRE_SRC.exec(src);
499
+ if (!m) return undefined;
500
+ return { type: 'dshSessionWire', raw: m[0], label: m[1] };
501
+ },
502
+ renderer(token: Tokens.Generic): string {
503
+ const label = String((token as { label?: unknown }).label ?? '');
504
+ return `<span data-dci-wire="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
505
+ },
506
+ };
507
+
508
+ /* =====================================================================
509
+ * 顶层渲染器(原始 HTML 转义 / 链接与图片白名单)
510
+ * ===================================================================== */
511
+
512
+ /** 本管线放行的链接协议(页内锚点给脚注回跳用)。 */
513
+ const SAFE_HREF = /^(?:https?:|mailto:|#)/i;
514
+
515
+ export const coreRenderer: RendererObject = {
516
+ /**
517
+ * B5(安全):marked 默认把原始 HTML 原样透传。用户消息是不可信面——
518
+ * 手写 `<span data-dci-wire="x">x</span>` 会被装饰期当成会话 chip(伪造),
519
+ * `<style>` 等还会污染整页。这里把原始 HTML 一律按文本转义:`data-dci-*`
520
+ * 从此只能由本模块的扩展产生;DOMPurify 白名单继续作为兜底。
521
+ */
522
+ html({ text }) {
523
+ return escapeHtml(text);
524
+ },
525
+ link({ href, title, tokens }) {
526
+ const text = this.parser.parseInline(tokens ?? []);
527
+ const raw = typeof href === 'string' ? href.trim() : '';
528
+ if (!SAFE_HREF.test(raw)) return text;
529
+ const external = /^https?:/i.test(raw);
530
+ const target = external ? ' target="_blank"' : '';
531
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
532
+ return `<a href="${escapeHtml(raw)}"${titleAttr}${target} rel="noopener noreferrer">${text}</a>`;
533
+ },
534
+ image({ href, title, text }) {
535
+ const rawHref = typeof href === 'string' ? href.trim() : '';
536
+ const rawAlt = typeof text === 'string' ? text : '';
537
+ // Obsidian 尺寸语法:![alt|300](url) / ![alt|300x200](url)(宽度生效,高度随比例)。
538
+ const sized = /^(.*)\|(\d+)(?:x\d+)?$/u.exec(rawAlt.trim());
539
+ const altSource = (sized ? sized[1] : rawAlt).trim();
540
+ const alt = altSource.replace(/\\([\\`*_{}[\]()#+\-.!|>~])/gu, '$1').replace(/[*_`~]/gu, '');
541
+ // 非 http(s) 图片(相对路径 / data: / javascript:)降级为 alt 文本,不留破图。
542
+ if (!/^https?:\/\//i.test(rawHref)) return escapeHtml(alt);
543
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
544
+ const width = sized ? ` width="${sized[2]}"` : '';
545
+ return `<img src="${escapeHtml(rawHref)}" alt="${escapeHtml(alt)}"${titleAttr}${width} loading="lazy" decoding="async">`;
546
+ },
547
+ };
548
+
549
+ /* =====================================================================
550
+ * 扩展清单
551
+ * ===================================================================== */
552
+
553
+ /** marked 侧核心扩展(顺序即注册顺序;marked 内部对同层 tokenizer 前插,语义上互不重叠)。 */
554
+ export const coreExtensions: TokenizerAndRendererExtension[] = [
555
+ sessionWireExtension,
556
+ footnoteRefExtension,
557
+ highlightExtension,
558
+ commentInlineExtension,
559
+ commentBlockExtension,
560
+ wikilinkExtension,
561
+ embedExtension,
562
+ calloutExtension,
563
+ ];
564
+
565
+ /** 供 index.ts 组装 marked 实例用(GFM + 换行 + 核心扩展 + 核心渲染器)。 */
566
+ export const coreMarkedOptions: MarkedExtension = {
567
+ gfm: true,
568
+ breaks: true,
569
+ extensions: coreExtensions,
570
+ renderer: coreRenderer,
571
+ };
572
+
573
+ /** 渲染期才能确定的脚注状态访问口(供 index.ts 组装 state)。 */
574
+ export function footnoteRenderState(): FootnoteRenderState | null {
575
+ return footnoteState;
576
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 扩展渲染 构建期虚拟模块的类型声明。
3
+ *
4
+ * `virtual:katex-css` 由 `scripts/build.mjs` 的 `katex-css-inline` plugin 生成:
5
+ * KaTeX 的 CSS 字符串,其中 `url(fonts/*.woff2)` 已替换为 data URI(字体全内置,
6
+ * 运行时零网络请求),woff/ttf 回退项已删除。
7
+ */
8
+ declare module 'virtual:katex-css' {
9
+ const css: string;
10
+ export default css;
11
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Markdown 扩展渲染(heavy)共用的小工具。
3
+ *
4
+ * 为什么要 base64 传源码:KaTeX / Mermaid 的「占位元素」要经过 基础渲染的
5
+ * DOMPurify 白名单(那里有意禁止 style、原始 HTML 一律转义)。把公式/图表源码
6
+ * 编码进 `data-dci-*` 属性,既不会被 HTML 解析阶段破坏(引号/换行/尖括号都安全),
7
+ * 也不会把「看起来像 HTML」的源码交给净化器解释;activate 阶段再解码还原。
8
+ *
9
+ * 只做无 DOM 依赖的纯函数(renderMarkdown 在同步契约里也会调用编码)。
10
+ */
11
+
12
+ import { escapeHtml } from './sanitize';
13
+
14
+ export { escapeHtml };
15
+
16
+ /** 一次 fromCharCode 展开的字节数(远小于调用栈上限,长公式也不会爆栈)。 */
17
+ const BASE64_CHUNK = 0x8000;
18
+
19
+ /** UTF-8 文本 → base64(无 btoa/TextEncoder 的极端环境下返回空串)。 */
20
+ export function encodeBase64Utf8(text: string): string {
21
+ if (typeof btoa !== 'function' || typeof TextEncoder === 'undefined') return '';
22
+ try {
23
+ const bytes = new TextEncoder().encode(text);
24
+ let binary = '';
25
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK) {
26
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK));
27
+ }
28
+ return btoa(binary);
29
+ } catch {
30
+ return '';
31
+ }
32
+ }
33
+
34
+ /** base64 → UTF-8 文本(脏数据返回空串,绝不抛错)。 */
35
+ export function decodeBase64Utf8(encoded: string): string {
36
+ if (encoded === '' || typeof atob !== 'function' || typeof TextDecoder === 'undefined') return '';
37
+ try {
38
+ const binary = atob(encoded);
39
+ const bytes = new Uint8Array(binary.length);
40
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
41
+ return new TextDecoder().decode(bytes);
42
+ } catch {
43
+ return '';
44
+ }
45
+ }
46
+
47
+ /** 语言名只放行安全字符后进 class;非法名返回空串(未知语言不崩)。 */
48
+ export function safeLanguageClass(language: string): string {
49
+ return /^[a-z0-9_+#.-]{1,40}$/iu.test(language) ? language.toLowerCase() : '';
50
+ }