@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.
- package/LICENSE +38 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27354 -355
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +0 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +23 -10
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/DockItem.tsx +1 -1
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +45 -17
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -507
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +8 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +62 -8
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +18 -11
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -367
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- package/lib/types/client/mention.d.ts.map +0 -1
- 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, '<')
|
|
14
|
-
.replace(/>/g, '>')
|
|
15
|
-
.replace(/"/g, '"')
|
|
16
|
-
.replace(/'/g, ''')
|
|
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 
|
|
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, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''')
|
|
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 
|
|
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
|
+
}
|