@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,351 @@
1
+ /**
2
+ * A compact markdown renderer for the preview panel: headings, paragraphs,
3
+ * fenced + inline code, bold/italic, links/images, lists, blockquotes, hr,
4
+ * and tables. All HTML is escaped before transformation — the output only
5
+ * ever contains the renderer's own tags. Pure and exported for tests.
6
+ * @module dsh-filemgr/client/preview/markdown
7
+ */
8
+
9
+ /** Escape HTML special characters. */
10
+ export function escapeHtml(text: string): string {
11
+ return text
12
+ .replace(/&/g, '&')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#39;')
17
+ }
18
+
19
+ /** How one image src resolves against the markdown file's location. */
20
+ export type MarkdownImageResolution =
21
+ /** Scheme URL or fragment: the browser resolves it as-is. */
22
+ | { kind: 'absolute' }
23
+ /** Workspace-relative target: resolved path plus any ?query#fragment suffix. */
24
+ | { kind: 'relative'; path: string; suffix: string }
25
+ /** `..` escaped the project root: the image must be dropped. */
26
+ | { kind: 'escape' }
27
+
28
+ /** Directory of a workspace-relative file path ('' when at the root). */
29
+ function dirOf(filePath: string): string {
30
+ const slash = filePath.lastIndexOf('/')
31
+ return slash === -1 ? '' : filePath.slice(0, slash)
32
+ }
33
+
34
+ /** Collapse . and .. segments; null when .. escapes the base. */
35
+ function normalizeRelPath(rel: string): string | null {
36
+ const out: string[] = []
37
+ for (const part of rel.split('/')) {
38
+ if (part === '' || part === '.') continue
39
+ if (part === '..') {
40
+ if (out.length === 0) return null
41
+ out.pop()
42
+ continue
43
+ }
44
+ out.push(part)
45
+ }
46
+ return out.join('/')
47
+ }
48
+
49
+ /** Percent-decode a path portion (best effort; never throws). */
50
+ function decodePathPart(raw: string): string {
51
+ try {
52
+ return decodeURIComponent(raw)
53
+ } catch {
54
+ return raw
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Resolve one markdown image src against the markdown file's location:
60
+ * - Absolute URLs (http/https/data:/...) and fragment-only srcs are left to
61
+ * the browser ('absolute').
62
+ * - Root-relative srcs (/img.png) resolve from the project root; other
63
+ * relative srcs resolve against the file's directory. `..` escaping the
64
+ * project root is rejected ('escape').
65
+ * - The path portion is percent-decoded (markdown authors encode spaces in
66
+ * filenames) and any ?query#fragment suffix is preserved verbatim, so
67
+ * cache-busting srcs like ./img.png?v=2 still fetch img.png.
68
+ */
69
+ export function resolveMarkdownImage(filePath: string, src: string): MarkdownImageResolution {
70
+ const trimmed = src.trim()
71
+ if (trimmed === '' || trimmed.startsWith('#')) return { kind: 'absolute' }
72
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return { kind: 'absolute' }
73
+ const decoded = decodePathPart(trimmed)
74
+ const q = decoded.indexOf('?')
75
+ const h = decoded.indexOf('#')
76
+ let cut = decoded.length
77
+ if (q !== -1) cut = Math.min(cut, q)
78
+ if (h !== -1) cut = Math.min(cut, h)
79
+ const pathPart = decoded.slice(0, cut)
80
+ const suffix = decoded.slice(cut)
81
+ const base = pathPart.startsWith('/') ? '' : dirOf(filePath)
82
+ const joined = base === '' ? pathPart : `${base}/${pathPart}`
83
+ const normalized = normalizeRelPath(joined)
84
+ if (normalized === null) return { kind: 'escape' }
85
+ return { kind: 'relative', path: normalized, suffix }
86
+ }
87
+
88
+ /** Options controlling markdown rendering. */
89
+ export interface MarkdownRenderOptions {
90
+ /**
91
+ * Rewrite image srcs before they are emitted. Return the URL to use, or
92
+ * null to drop the image (alt text only). Relative workspace paths are
93
+ * typically resolved to absolute URLs here.
94
+ */
95
+ resolveImageSrc?: (src: string) => string | null
96
+ }
97
+
98
+ /**
99
+ * Guard a raw link/image target against dangerous protocols. Returns the
100
+ * (trimmed) raw string when safe, else null. Only these schemes are allowed:
101
+ * http:, https:, mailto: and fragment anchors (#...). Scheme-less relative
102
+ * paths (./ ../ / and plain filenames) pass through unchanged. Anything with
103
+ * a scheme outside the allow-list — javascript:, data:, vbscript:, etc. —
104
+ * is rejected so the value never reaches dangerouslySetInnerHTML.
105
+ */
106
+ export function safeUrl(raw: string): string | null {
107
+ const trimmed = raw.trim()
108
+ if (trimmed === '') return null
109
+ if (trimmed.startsWith('#')) return trimmed
110
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
111
+ if (scheme === null) return trimmed
112
+ const name = scheme[1].toLowerCase()
113
+ return name === 'http' || name === 'https' || name === 'mailto' ? trimmed : null
114
+ }
115
+
116
+ /** Inline pass: code spans, bold, italic, images, links. */
117
+ export function renderInline(text: string, options?: MarkdownRenderOptions): string {
118
+ let out = ''
119
+ let i = 0
120
+ const n = text.length
121
+ while (i < n) {
122
+ const char = text[i]
123
+ // Fenced inline code first.
124
+ if (char === '`') {
125
+ const end = text.indexOf('`', i + 1)
126
+ if (end !== -1) {
127
+ out += `<code>${escapeHtml(text.slice(i + 1, end))}</code>`
128
+ i = end + 1
129
+ continue
130
+ }
131
+ }
132
+ // Image ![alt](src)
133
+ if (char === '!' && text[i + 1] === '[') {
134
+ const close = text.indexOf('](', i + 2)
135
+ if (close !== -1) {
136
+ const parenEnd = text.indexOf(')', close + 2)
137
+ if (parenEnd !== -1) {
138
+ const alt = text.slice(i + 2, close)
139
+ const src = text.slice(close + 2, parenEnd)
140
+ const safe = safeUrl(src)
141
+ if (safe === null) {
142
+ // Unsafe image target: drop the img, keep the alt text.
143
+ out += escapeHtml(alt)
144
+ } else {
145
+ let target: string | null = safe
146
+ if (options?.resolveImageSrc !== undefined) {
147
+ target = options.resolveImageSrc(safe)
148
+ }
149
+ if (target === null) {
150
+ // The resolver dropped the image (unresolvable path): alt only.
151
+ out += escapeHtml(alt)
152
+ } else {
153
+ const srcEsc = escapeHtml(target).replace(/\s+/g, '%20')
154
+ out += `<img alt="${escapeHtml(alt)}" src="${srcEsc}" />`
155
+ }
156
+ }
157
+ i = parenEnd + 1
158
+ continue
159
+ }
160
+ }
161
+ }
162
+ // Link [text](href)
163
+ if (char === '[') {
164
+ const close = text.indexOf('](', i + 1)
165
+ if (close !== -1) {
166
+ const parenEnd = text.indexOf(')', close + 2)
167
+ if (parenEnd !== -1) {
168
+ const label = text.slice(i + 1, close)
169
+ const href = text.slice(close + 2, parenEnd)
170
+ const safe = safeUrl(href)
171
+ if (safe === null) {
172
+ // Unsafe link target: render the label as plain text, no <a>.
173
+ out += renderInline(label, options)
174
+ } else {
175
+ out += `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer">${renderInline(label, options)}</a>`
176
+ }
177
+ i = parenEnd + 1
178
+ continue
179
+ }
180
+ }
181
+ }
182
+ // Bold **text**
183
+ if (char === '*' && text[i + 1] === '*') {
184
+ const end = text.indexOf('**', i + 2)
185
+ if (end !== -1) {
186
+ out += `<strong>${renderInline(text.slice(i + 2, end), options)}</strong>`
187
+ i = end + 2
188
+ continue
189
+ }
190
+ }
191
+ // Italic *text*
192
+ if (char === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
193
+ const end = text.indexOf('*', i + 1)
194
+ if (end !== -1 && text[end + 1] !== '*') {
195
+ out += `<em>${renderInline(text.slice(i + 1, end), options)}</em>`
196
+ i = end + 1
197
+ continue
198
+ }
199
+ }
200
+ // Strikethrough ~~text~~
201
+ if (char === '~' && text[i + 1] === '~') {
202
+ const end = text.indexOf('~~', i + 2)
203
+ if (end !== -1) {
204
+ out += `<del>${renderInline(text.slice(i + 2, end), options)}</del>`
205
+ i = end + 2
206
+ continue
207
+ }
208
+ }
209
+ out += escapeHtml(char)
210
+ i += 1
211
+ }
212
+ return out
213
+ }
214
+
215
+ /** Render a markdown document to HTML (block pass). */
216
+ export function renderMarkdown(source: string, options?: MarkdownRenderOptions): string {
217
+ const lines = source.replace(/\r\n/g, '\n').split('\n')
218
+ const out: string[] = []
219
+ let i = 0
220
+ const n = lines.length
221
+
222
+ const flushParagraph = (buffer: string[]): void => {
223
+ if (buffer.length === 0) return
224
+ out.push(`<p>${renderInline(buffer.join("\n"), options)}</p>`)
225
+ buffer.length = 0
226
+ }
227
+
228
+ let paragraph: string[] = []
229
+ while (i < n) {
230
+ const line = lines[i]
231
+
232
+ // Fenced code block.
233
+ const fence = /^```([\w+-]*)\s*$/.exec(line)
234
+ if (fence !== null) {
235
+ flushParagraph(paragraph)
236
+ const lang = fence[1] ?? ''
237
+ i += 1
238
+ const code: string[] = []
239
+ while (i < n && !/^```\s*$/.test(lines[i])) {
240
+ code.push(lines[i])
241
+ i += 1
242
+ }
243
+ i += 1 // closing fence
244
+ const langAttr = lang === '' ? '' : ` class="language-${escapeHtml(lang)}"`
245
+ out.push(`<pre${langAttr}><code>${escapeHtml(code.join('\n'))}</code></pre>`)
246
+ continue
247
+ }
248
+
249
+ // Heading.
250
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
251
+ if (heading !== null) {
252
+ flushParagraph(paragraph)
253
+ const level = heading[1].length
254
+ out.push(`<h${level}>${renderInline(heading[2] ?? '', options)}</h${level}>`)
255
+ i += 1
256
+ continue
257
+ }
258
+
259
+ // Horizontal rule.
260
+ if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
261
+ flushParagraph(paragraph)
262
+ out.push('<hr />')
263
+ i += 1
264
+ continue
265
+ }
266
+
267
+ // Table: header row then separator row.
268
+ if (line.includes('|') && i + 1 < n && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
269
+ flushParagraph(paragraph)
270
+ const headerCells = splitTableRow(line)
271
+ i += 2
272
+ const rows: string[][] = []
273
+ while (i < n && lines[i].includes('|')) {
274
+ rows.push(splitTableRow(lines[i]))
275
+ i += 1
276
+ }
277
+ out.push('<table>')
278
+ out.push(`<thead><tr>${headerCells.map((cell) => `<th>${renderInline(cell, options)}</th>`).join('')}</tr></thead>`)
279
+ if (rows.length > 0) {
280
+ out.push(`<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, options)}</td>`).join('')}</tr>`).join('')}</tbody>`)
281
+ }
282
+ out.push('</table>')
283
+ continue
284
+ }
285
+
286
+ // Blockquote (one level).
287
+ const quote = /^>\s?(.*)$/.exec(line)
288
+ if (quote !== null) {
289
+ flushParagraph(paragraph)
290
+ const body: string[] = []
291
+ while (i < n) {
292
+ const q = /^>\s?(.*)$/.exec(lines[i])
293
+ if (q === null) break
294
+ body.push(q[1] ?? '')
295
+ i += 1
296
+ }
297
+ out.push(`<blockquote><p>${body.map((line) => renderInline(line, options)).join('<br />')}</p></blockquote>`)
298
+ continue
299
+ }
300
+
301
+ // Unordered list.
302
+ const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
303
+ if (ul !== null) {
304
+ flushParagraph(paragraph)
305
+ const items: string[] = []
306
+ while (i < n) {
307
+ const item = /^\s*([-*+])\s+(.*)$/.exec(lines[i])
308
+ if (item === null) break
309
+ items.push(`<li>${renderInline(item[2] ?? '', options)}</li>`)
310
+ i += 1
311
+ }
312
+ out.push(`<ul>${items.join('')}</ul>`)
313
+ continue
314
+ }
315
+
316
+ // Ordered list.
317
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line)
318
+ if (ol !== null) {
319
+ flushParagraph(paragraph)
320
+ const items: string[] = []
321
+ while (i < n) {
322
+ const item = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i])
323
+ if (item === null) break
324
+ items.push(`<li>${renderInline(item[1] ?? '', options)}</li>`)
325
+ i += 1
326
+ }
327
+ out.push(`<ol>${items.join('')}</ol>`)
328
+ continue
329
+ }
330
+
331
+ // Blank line: flush the paragraph.
332
+ if (line.trim() === '') {
333
+ flushParagraph(paragraph)
334
+ i += 1
335
+ continue
336
+ }
337
+
338
+ paragraph.push(line)
339
+ i += 1
340
+ }
341
+ flushParagraph(paragraph)
342
+ return out.join('\n')
343
+ }
344
+
345
+ /** Split one table row into cells (respecting the leading/trailing pipes). */
346
+ function splitTableRow(line: string): string[] {
347
+ const trimmed = line.trim()
348
+ const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
349
+ const withoutTrailing = inner.endsWith('|') ? inner.slice(0, -1) : inner
350
+ return withoutTrailing.split('|').map((cell) => cell.trim())
351
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Mermaid diagram enhancement for markdown surfaces: lazily loads the
3
+ * mermaid runtime from the host vendor route (same origin, no CDN), renders
4
+ * every fenced ```mermaid code block in place, and re-renders on theme
5
+ * flips. Framework-free so both the preview panel (React effect) and the
6
+ * chat transcript observer can drive it over disjoint DOM scopes.
7
+ *
8
+ * Failure policy: any load/render failure leaves the original code block
9
+ * untouched (or restores it verbatim); nothing here throws to the caller.
10
+ * @module dsh-filemgr/client/preview/mermaid
11
+ */
12
+
13
+ /** Minimal structural type of the mermaid runtime this module consumes. */
14
+ interface MermaidRuntime {
15
+ initialize: (config: Record<string, unknown>) => void
16
+ render: (id: string, text: string, container?: HTMLElement) => Promise<{ svg: string }>
17
+ }
18
+
19
+ /** Host-served mermaid IIFE bundle (lib/assets/mermaid.min.js behind the route). */
20
+ export const MERMAID_VENDOR_URL = '/filemgr/vendor/mermaid.js'
21
+
22
+ /** Lifecycle state stamped on diagram containers (`pending`/`rendering`/`done`). */
23
+ const DATA_STATE = 'data-mermaid-state'
24
+
25
+ /** State stamped on a code block once its container exists (`claimed`). */
26
+ const DATA_CLAIMED = 'data-mermaid-claimed'
27
+
28
+ /** The verbatim diagram source kept on the container for theme re-renders. */
29
+ const DATA_SOURCE = 'data-mermaid-source'
30
+
31
+ /** Marker the preview viewer stamps on its own subtree (chat enhancement skips it). */
32
+ export const DATA_MD_SCOPE = 'data-filemgr-md-scope'
33
+
34
+ let loadPromise: Promise<MermaidRuntime> | undefined
35
+
36
+ /**
37
+ * Resolve the mermaid global left by the vendor IIFE bundle, or null while
38
+ * absent. Narrow and defensive: the bundle is a third-party artifact.
39
+ */
40
+ function mermaidGlobal(): MermaidRuntime | null {
41
+ const candidate = (globalThis as Record<string, unknown>).mermaid
42
+ if (typeof candidate !== 'object' || candidate === null) return null
43
+ const checked = candidate as Record<string, unknown>
44
+ if (typeof checked.initialize !== 'function' || typeof checked.render !== 'function') return null
45
+ return checked as unknown as MermaidRuntime
46
+ }
47
+
48
+ /**
49
+ * Load the mermaid runtime once per page: injects a <script> for the host
50
+ * vendor route and resolves with the runtime. Concurrent callers share one
51
+ * injection; a failure clears the cache so a later surface can retry.
52
+ */
53
+ export function loadMermaidLibrary(): Promise<MermaidRuntime> {
54
+ const existing = mermaidGlobal()
55
+ if (existing !== null) return Promise.resolve(existing)
56
+ if (loadPromise !== undefined) return loadPromise
57
+ loadPromise = new Promise<MermaidRuntime>((resolve, reject) => {
58
+ const script = document.createElement('script')
59
+ script.src = MERMAID_VENDOR_URL
60
+ script.async = true
61
+ script.onload = () => {
62
+ const runtime = mermaidGlobal()
63
+ if (runtime === null) {
64
+ loadPromise = undefined
65
+ reject(new Error('mermaid vendor script loaded but window.mermaid is missing'))
66
+ return
67
+ }
68
+ resolve(runtime)
69
+ }
70
+ script.onerror = () => {
71
+ loadPromise = undefined
72
+ reject(new Error(`failed to load ${MERMAID_VENDOR_URL}`))
73
+ }
74
+ document.head.appendChild(script)
75
+ })
76
+ return loadPromise
77
+ }
78
+
79
+ /** Mermaid theme name for the shell theme marker (`default` or `dark`). */
80
+ export function mermaidTheme(isDark: boolean): 'default' | 'dark' {
81
+ return isDark ? 'dark' : 'default'
82
+ }
83
+
84
+ /** Whether the shell currently carries the dark marker attribute. */
85
+ export function shellIsDark(): boolean {
86
+ return document.body.hasAttribute('data-ds-dark-theme')
87
+ }
88
+
89
+ /** Monotonic id source for render calls (mermaid keys its <svg> by id). */
90
+ let renderSeq = 0
91
+
92
+ /**
93
+ * Configure the mermaid runtime for the current theme. Called once per
94
+ * render batch (enhance or retheme), not per diagram, so a surface with
95
+ * many diagrams initializes the runtime a single time.
96
+ */
97
+ function initializeRuntime(runtime: MermaidRuntime, theme: string): void {
98
+ runtime.initialize({
99
+ startOnLoad: false,
100
+ theme,
101
+ securityLevel: 'strict',
102
+ fontFamily: '"trebuchet ms", verdana, arial, sans-serif',
103
+ })
104
+ }
105
+
106
+ /** Render one diagram source to SVG with the already-initialized runtime. */
107
+ async function renderSvg(runtime: MermaidRuntime, source: string): Promise<string> {
108
+ const { svg } = await runtime.render(`filemgr-mermaid-${(renderSeq += 1)}`, source)
109
+ return svg
110
+ }
111
+
112
+ /** Disallowed elements removed from mermaid SVG output before innerHTML. */
113
+ const DISALLOWED_ELEMENTS = ['script', 'foreignObject', 'iframe', 'object', 'embed']
114
+
115
+ /** Whether an attribute name is an { on* } event-handler (case-insensitive). */
116
+ function isEventHandler(name: string): boolean {
117
+ return /^on/i.test(name)
118
+ }
119
+
120
+ /** Whether an href/xlink:href value carries an executable javascript: URL. */
121
+ function isDangerousHref(value: string): boolean {
122
+ return /^javascript:/i.test(value.trim())
123
+ }
124
+
125
+ /**
126
+ * Application-level defense-in-depth on top of mermaid's own strict-mode
127
+ * escaping: parse the rendered SVG in a detached container, remove disallowed
128
+ * elements and dangerous attributes, and return the serialized cleaned markup.
129
+ * Throws when the input cannot be parsed as markup or still carries dangerous
130
+ * raw tokens, so callers fall back to their failure path.
131
+ */
132
+ export function sanitizeSvg(svg: string): string {
133
+ const template = document.createElement('template')
134
+ template.innerHTML = svg
135
+ const root = template.content
136
+
137
+ // Remove disallowed elements; loop because removals can expose nested ones.
138
+ for (let found = true; found; ) {
139
+ found = false
140
+ for (const el of Array.from(root.querySelectorAll('*'))) {
141
+ if (DISALLOWED_ELEMENTS.some((tag) => el.tagName.toLowerCase() === tag.toLowerCase())) {
142
+ el.remove()
143
+ found = true
144
+ }
145
+ }
146
+ }
147
+
148
+ // Strip event-handler attributes and javascript: hrefs from every element.
149
+ for (const el of Array.from(root.querySelectorAll('*'))) {
150
+ for (const attr of Array.from(el.attributes)) {
151
+ if (isEventHandler(attr.name) || isDangerousHref(attr.value)) el.removeAttribute(attr.name)
152
+ }
153
+ }
154
+
155
+ const cleaned = template.innerHTML
156
+ const lower = cleaned.toLowerCase()
157
+ if (lower.includes('<script') || lower.includes('javascript:')) {
158
+ throw new Error('mermaid SVG still contains dangerous tokens after sanitization')
159
+ }
160
+ return cleaned
161
+ }
162
+
163
+ /**
164
+ * Collect the still-unclaimed fenced mermaid code blocks under one scope.
165
+ * Both shapes are found: the panel renderer's `pre.language-mermaid` and
166
+ * the chat renderer's `pre > code.language-mermaid` (the claim always
167
+ * targets the <pre>). Empty blocks and blocks another driver already
168
+ * claimed are skipped. Pure (DOM-read only) so tests can drive it in jsdom.
169
+ */
170
+ export function findMermaidCodeBlocks(scope: ParentNode): HTMLPreElement[] {
171
+ const found: HTMLPreElement[] = []
172
+ const seen = new Set<Element>()
173
+ for (const el of Array.from(scope.querySelectorAll('pre.language-mermaid, code.language-mermaid'))) {
174
+ const pre = el instanceof HTMLPreElement ? el : el.parentElement
175
+ if (pre === null || !(pre instanceof HTMLPreElement)) continue
176
+ if (seen.has(pre)) continue
177
+ seen.add(pre)
178
+ if (pre.hasAttribute(DATA_CLAIMED)) continue
179
+ if ((pre.textContent ?? '').trim() === '') continue
180
+ found.push(pre)
181
+ }
182
+ return found
183
+ }
184
+
185
+ /**
186
+ * Swap one code block for a diagram container. The original <pre> stays in
187
+ * the tree (hidden once the render lands) so a failure can restore it
188
+ * verbatim; the container carries the source for theme re-renders.
189
+ */
190
+ function claimBlock(pre: HTMLPreElement, className: string): HTMLElement {
191
+ pre.setAttribute(DATA_CLAIMED, '1')
192
+ const container = document.createElement('div')
193
+ container.className = className
194
+ container.setAttribute(DATA_STATE, 'pending')
195
+ container.setAttribute(DATA_SOURCE, pre.textContent ?? '')
196
+ pre.insertAdjacentElement('afterend', container)
197
+ return container
198
+ }
199
+
200
+ /** Options for {@link enhanceMermaidBlocks}. */
201
+ export interface EnhanceOptions {
202
+ /** Class for the diagram container (a CSS module export). */
203
+ className: string
204
+ /** Resolved mermaid theme name. */
205
+ theme: string
206
+ /** Optional extra exclusion for scopes another driver owns. */
207
+ skip?: (pre: HTMLPreElement) => boolean
208
+ }
209
+
210
+ /**
211
+ * Render every unclaimed ```mermaid block under `scope` into an inline SVG
212
+ * diagram. Idempotent per block across drivers (claimed blocks are skipped);
213
+ * failures restore the original code block. Never rejects.
214
+ */
215
+ export async function enhanceMermaidBlocks(scope: ParentNode, options: EnhanceOptions): Promise<void> {
216
+ let runtime: MermaidRuntime
217
+ try {
218
+ runtime = await loadMermaidLibrary()
219
+ } catch {
220
+ return // no vendor route (asset missing): keep plain code blocks
221
+ }
222
+ initializeRuntime(runtime, options.theme)
223
+ const jobs: Array<Promise<void>> = []
224
+ for (const pre of findMermaidCodeBlocks(scope)) {
225
+ if (options.skip?.(pre) === true) continue
226
+ const container = claimBlock(pre, options.className)
227
+ jobs.push((async () => {
228
+ try {
229
+ container.setAttribute(DATA_STATE, 'rendering')
230
+ const source = container.getAttribute(DATA_SOURCE) ?? ''
231
+ const svg = await renderSvg(runtime, source)
232
+ container.innerHTML = sanitizeSvg(svg)
233
+ container.setAttribute(DATA_STATE, 'done')
234
+ pre.style.display = 'none'
235
+ } catch {
236
+ // Syntax error or render failure: restore the untouched code block.
237
+ container.remove()
238
+ pre.removeAttribute(DATA_CLAIMED)
239
+ }
240
+ })())
241
+ }
242
+ await Promise.all(jobs)
243
+ }
244
+
245
+ /**
246
+ * Re-render every completed diagram container under `scope` after a theme
247
+ * flip (stored sources re-render with the new theme). Containers not in the
248
+ * `done` state are skipped; a failure keeps the previous render.
249
+ */
250
+ export async function rethemeMermaidBlocks(scope: ParentNode, options: { theme: string }): Promise<void> {
251
+ const runtime = mermaidGlobal()
252
+ if (runtime === null) return
253
+ initializeRuntime(runtime, options.theme)
254
+ const containers = Array.from(scope.querySelectorAll<HTMLElement>('[data-mermaid-state="done"]'))
255
+ await Promise.all(containers.map(async (container) => {
256
+ const source = container.getAttribute(DATA_SOURCE) ?? ''
257
+ try {
258
+ const svg = await renderSvg(runtime, source)
259
+ container.innerHTML = sanitizeSvg(svg)
260
+ } catch {
261
+ // Keep the previous render; a theme flip must not blank diagrams.
262
+ }
263
+ }))
264
+ }
265
+
266
+ /**
267
+ * One dark-marker watcher per surface: fires on body attribute flips so the
268
+ * caller can retheme. Returns the disposer.
269
+ */
270
+ export function watchShellTheme(onChange: (isDark: boolean) => void): () => void {
271
+ const observer = new MutationObserver(() => { onChange(shellIsDark()) })
272
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
273
+ return () => { observer.disconnect() }
274
+ }