@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,274 +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
|
-
}
|
|
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
|
+
}
|