@lijian-ui/dsh-file-manager 0.2.2 → 0.2.4

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 (101) hide show
  1. package/LICENSE +38 -38
  2. package/README.i18n.yaml +6 -6
  3. package/README.md +66 -66
  4. package/README.zh.md +66 -66
  5. package/cordis.patch.yml +13 -13
  6. package/lib/client.js +27354 -355
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +474 -42
  9. package/lib/tsconfig.client.tsbuildinfo +1 -1
  10. package/lib/tsconfig.host.tsbuildinfo +1 -1
  11. package/lib/types/client/file-source.d.ts +10 -0
  12. package/lib/types/client/file-source.d.ts.map +1 -1
  13. package/lib/types/client/fileType.d.ts +19 -4
  14. package/lib/types/client/fileType.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts +0 -1
  16. package/lib/types/client/index.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +3 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/mount.d.ts +1 -1
  20. package/lib/types/client/mount.d.ts.map +1 -1
  21. package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
  22. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
  23. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  24. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  25. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  26. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  27. package/lib/types/client/preview/content.d.ts +3 -1
  28. package/lib/types/client/preview/content.d.ts.map +1 -1
  29. package/lib/types/client/preview/office.d.ts +23 -0
  30. package/lib/types/client/preview/office.d.ts.map +1 -0
  31. package/lib/types/client/reference.d.ts +23 -10
  32. package/lib/types/client/reference.d.ts.map +1 -1
  33. package/lib/types/client/store.d.ts.map +1 -1
  34. package/lib/types/host/fs-service.d.ts.map +1 -1
  35. package/lib/types/host/office-preview.d.ts +13 -0
  36. package/lib/types/host/office-preview.d.ts.map +1 -0
  37. package/lib/types/host/routes.d.ts.map +1 -1
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/package.json +19 -4
  40. package/src/client/DockItem.tsx +1 -1
  41. package/src/client/FileManagerSettingsCard.tsx +117 -117
  42. package/src/client/PluginSettingsCard.tsx +337 -337
  43. package/src/client/chat/file-ref.ts +144 -144
  44. package/src/client/chat/mermaid-chat.tsx +102 -102
  45. package/src/client/chat/placeholder-hint.tsx +46 -46
  46. package/src/client/components/ExplorerPanel.tsx +534 -534
  47. package/src/client/components/FileIcon.tsx +46 -46
  48. package/src/client/components/ScmPanel.tsx +480 -480
  49. package/src/client/components/a11y.ts +20 -20
  50. package/src/client/components/icons.tsx +274 -274
  51. package/src/client/components/overlay.tsx +225 -225
  52. package/src/client/drag/DragFileInlay.tsx +89 -89
  53. package/src/client/drag/file-drag.ts +67 -67
  54. package/src/client/drag.ts +154 -154
  55. package/src/client/file-source.ts +34 -0
  56. package/src/client/fileType.ts +32 -5
  57. package/src/client/hooks/useResizableSplit.ts +91 -91
  58. package/src/client/hooks/useStore.ts +15 -15
  59. package/src/client/index.ts +45 -17
  60. package/src/client/layout.ts +523 -523
  61. package/src/client/locales.ts +6 -0
  62. package/src/client/maximize.ts +34 -34
  63. package/src/client/mount.tsx +2 -1
  64. package/src/client/persist.ts +194 -194
  65. package/src/client/picker/FilePickerModal.tsx +526 -507
  66. package/src/client/picker/file-picker.ts +86 -86
  67. package/src/client/preview/PreviewPanel.tsx +8 -1
  68. package/src/client/preview/PreviewTabs.tsx +159 -159
  69. package/src/client/preview/PreviewToolbar.tsx +3 -2
  70. package/src/client/preview/content.tsx +7 -2
  71. package/src/client/preview/markdown.ts +351 -351
  72. package/src/client/preview/mermaid.ts +274 -274
  73. package/src/client/preview/office.tsx +321 -0
  74. package/src/client/reference.ts +62 -8
  75. package/src/client/settings-card.module.css +316 -316
  76. package/src/client/settings-form.ts +451 -451
  77. package/src/client/store.ts +17 -12
  78. package/src/client/styles/drag.module.css +30 -30
  79. package/src/client/styles/explorer.module.css +371 -371
  80. package/src/client/styles/picker.module.css +344 -344
  81. package/src/client/styles/preview.module.css +18 -11
  82. package/src/client/styles/scm.module.css +380 -380
  83. package/src/client/styles/tokens.module.css +376 -367
  84. package/src/core/types.ts +162 -162
  85. package/src/host/fs-service.ts +7 -1
  86. package/src/host/gate.ts +73 -73
  87. package/src/host/git-runner.ts +116 -116
  88. package/src/host/git-service.ts +395 -395
  89. package/src/host/loopback.ts +63 -63
  90. package/src/host/office-preview.ts +399 -0
  91. package/src/host/poll-guard.ts +109 -109
  92. package/src/host/routes.ts +72 -1
  93. package/src/index.ts +3 -1
  94. package/src/mount-once.ts +48 -48
  95. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  96. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  97. package/lib/types/client/floating.d.ts +0 -27
  98. package/lib/types/client/floating.d.ts.map +0 -1
  99. package/lib/types/client/mention.d.ts +0 -44
  100. package/lib/types/client/mention.d.ts.map +0 -1
  101. package/src/client/styles/chip.module.css +0 -38
@@ -1,351 +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
- }
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, '&amp;')
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
+ }