dsh-coding-sidebar 1.0.2 → 1.0.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 (96) hide show
  1. package/README.md +27 -42
  2. package/lib/client-editor.js +449 -4278
  3. package/lib/client-registry.js +641 -1343
  4. package/lib/client-terminal.js +172 -262
  5. package/lib/client.js +645 -1347
  6. package/lib/index.js +24 -122
  7. package/lib/types/bundle-route.d.ts +1 -1
  8. package/lib/types/client/SideCardSection.d.ts +3 -3
  9. package/lib/types/client/TextEditor.d.ts +20 -10
  10. package/lib/types/client/add-plugin-modal.d.ts +0 -4
  11. package/lib/types/client/api.d.ts +8 -11
  12. package/lib/types/client/builtins/index.d.ts +10 -10
  13. package/lib/types/client/chunk-loader.d.ts +7 -8
  14. package/lib/types/client/locales.d.ts +0 -26
  15. package/lib/types/client/native-avoid.d.ts +2 -0
  16. package/lib/types/client/plugins-shared.d.ts +6 -6
  17. package/lib/types/client/service.d.ts +13 -83
  18. package/lib/types/context-types.d.ts +19 -10
  19. package/lib/types/index.d.ts +1 -1
  20. package/lib/types/prefs-shared.d.ts +1 -24
  21. package/package.json +26 -30
  22. package/src/bundle-route.ts +1 -1
  23. package/src/client/EditorHost.tsx +52 -122
  24. package/src/client/SandboxStatusBar.tsx +12 -9
  25. package/src/client/SideCardSection.module.css +2 -2
  26. package/src/client/SideCardSection.tsx +28 -81
  27. package/src/client/SideChatView.module.css +12 -12
  28. package/src/client/TextEditor.tsx +89 -266
  29. package/src/client/add-plugin-modal.tsx +16 -24
  30. package/src/client/api.ts +1 -25
  31. package/src/client/builtins/index.ts +10 -14
  32. package/src/client/chunk-loader.ts +7 -8
  33. package/src/client/index.tsx +12 -3
  34. package/src/client/locales-ar.ts +0 -6
  35. package/src/client/locales-de.ts +0 -6
  36. package/src/client/locales-fr.ts +0 -6
  37. package/src/client/locales-hi.ts +0 -6
  38. package/src/client/locales-id.ts +0 -6
  39. package/src/client/locales-it.ts +0 -6
  40. package/src/client/locales-ja.ts +0 -6
  41. package/src/client/locales-ko.ts +0 -6
  42. package/src/client/locales-nl.ts +0 -6
  43. package/src/client/locales-pl.ts +0 -6
  44. package/src/client/locales-pt.ts +0 -6
  45. package/src/client/locales-ru.ts +0 -6
  46. package/src/client/locales-sv.ts +0 -6
  47. package/src/client/locales-th.ts +0 -6
  48. package/src/client/locales-tr.ts +0 -6
  49. package/src/client/locales-vi.ts +0 -6
  50. package/src/client/locales-zh-HK.ts +0 -6
  51. package/src/client/locales-zh-MO.ts +0 -6
  52. package/src/client/locales-zh-TW.ts +0 -6
  53. package/src/client/locales.ts +0 -52
  54. package/src/client/native-avoid.ts +155 -0
  55. package/src/client/plugins-shared.ts +6 -6
  56. package/src/client/prefs.ts +0 -7
  57. package/src/client/service.ts +13 -150
  58. package/src/client/sidebar.module.css +1 -425
  59. package/src/config.ts +1 -4
  60. package/src/context-types.ts +12 -7
  61. package/src/index.ts +12 -78
  62. package/src/jobs-routes.ts +3 -2
  63. package/src/prefs-shared.ts +1 -27
  64. package/src/sidechat-routes.ts +13 -8
  65. package/src/subagent-live-route.ts +2 -1
  66. package/lib/client-mermaid.js +0 -200753
  67. package/lib/types/client/MarkdownHtml.d.ts +0 -32
  68. package/lib/types/client/PdfView.d.ts +0 -6
  69. package/lib/types/client/builtins/viewers.d.ts +0 -3
  70. package/lib/types/client/chunks/mermaid.d.ts +0 -10
  71. package/lib/types/client/editor-load.d.ts +0 -66
  72. package/lib/types/client/image-types.d.ts +0 -3
  73. package/lib/types/client/markdown-html.d.ts +0 -96
  74. package/lib/types/client/markdown-images.d.ts +0 -45
  75. package/lib/types/client/md-toc.d.ts +0 -4
  76. package/lib/types/client/mermaid-blocks.d.ts +0 -47
  77. package/lib/types/client/mermaid-sanitize.d.ts +0 -2
  78. package/lib/types/client/mermaid.d.ts +0 -12
  79. package/lib/types/client/pdf-types.d.ts +0 -2
  80. package/lib/types/client/plugins-viewers.d.ts +0 -3
  81. package/lib/types/html-route.d.ts +0 -59
  82. package/src/client/MarkdownHtml.tsx +0 -296
  83. package/src/client/PdfView.tsx +0 -110
  84. package/src/client/builtins/viewers.tsx +0 -125
  85. package/src/client/chunks/mermaid.tsx +0 -10
  86. package/src/client/editor-load.ts +0 -92
  87. package/src/client/image-types.ts +0 -8
  88. package/src/client/markdown-html.ts +0 -331
  89. package/src/client/markdown-images.ts +0 -137
  90. package/src/client/md-toc.tsx +0 -127
  91. package/src/client/mermaid-blocks.ts +0 -110
  92. package/src/client/mermaid-sanitize.ts +0 -93
  93. package/src/client/mermaid.tsx +0 -406
  94. package/src/client/pdf-types.ts +0 -4
  95. package/src/client/plugins-viewers.ts +0 -30
  96. package/src/html-route.ts +0 -106
@@ -1,331 +0,0 @@
1
- /**
2
- * Raw-HTML block detection for the markdown preview. The shared `MarkdownText`
3
- * renders raw HTML as literal text (a chat-security stance), so a GitHub-style
4
- * README (`<div align="center">` badge walls, `<details>` collapsibles with
5
- * markdown inside, table cells full of inline tags) previews as source soup.
6
- * This module's pure splitter lifts those HTML runs OUT of the markdown stream
7
- * before rendering: markdown runs keep flowing through `MarkdownText` (shiki /
8
- * KaTeX / GFM intact, mermaid chunk path unchanged) while HTML runs render as
9
- * sanitized DOM (see markdown-html.tsx).
10
- *
11
- * Splitting follows CommonMark's shape closely enough for real-world READMEs:
12
- * a line outside code fences that starts with a block-level tag (type-6 list
13
- * below) or `<!--` opens an HTML run that extends to the next blank line
14
- * (comments end at the line containing `-->`). Inline-only tags (`<b>`, `<br>`,
15
- * `<a>`…) never open a run — they stay in the markdown stream and are handled
16
- * by the inline pass instead. Unclosed block tags (`<details>` … markdown …
17
- * `</details>`) are surfaced by {@link analyzeHtmlSegment} as ordered parts so
18
- * the renderer can nest the in-between markdown inside the open element, the
19
- * way GitHub's linear HTML output nests.
20
- */
21
-
22
- import { CLOSE_FENCE_RE, OPEN_FENCE_RE, fenceInfo } from './mermaid-blocks.ts'
23
-
24
- /** A run of raw HTML lines lifted out of the markdown stream. */
25
- export interface HtmlSegment {
26
- kind: 'html'
27
- text: string
28
- }
29
-
30
- /** A run of markdown source (may contain non-mermaid fences, inline HTML…). */
31
- export interface MarkdownHtmlSegment {
32
- kind: 'markdown'
33
- text: string
34
- }
35
-
36
- export type MdHtmlSegment = MarkdownHtmlSegment | HtmlSegment
37
-
38
- /** The doc-wide analysis the preview consumes (all pure, unit-tested here). */
39
- export interface AnalyzedMarkdownHtml {
40
- /** The document split into markdown / html runs (empty input → []). */
41
- segments: MdHtmlSegment[]
42
- /** True when at least one html run was lifted out. */
43
- hasBlockHtml: boolean
44
- /** True when the source contains any tag-like text (block OR inline). */
45
- hasInlineHtml: boolean
46
- /** Every reference definition found in the markdown runs (`[label]: dest`
47
- * lines), joined — appended to each markdown run so `[text][id]` keeps
48
- * resolving across the lifted HTML runs (first-match wins makes the
49
- * appended copy inert inside runs that already define it). */
50
- referenceDefinitions: string
51
- }
52
-
53
- /**
54
- * CommonMark HTML-block type-6 tag names (block-level elements), lowercased.
55
- * A line starting with one of these (open or close) outside a fence begins an
56
- * HTML run. `<summary>` is CommonMark-inline but intentionally included: it is
57
- * the idiomatic first child of a `<details>` run in GitHub-flavored READMEs.
58
- */
59
- export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
60
- 'address', 'article', 'aside', 'base', 'basefont', 'blockquote', 'body',
61
- 'caption', 'center', 'col', 'colgroup', 'dd', 'details', 'dialog', 'dir',
62
- 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
63
- 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header',
64
- 'hr', 'html', 'legend', 'li', 'link', 'main', 'menu', 'menuitem', 'nav',
65
- 'noframes', 'ol', 'optgroup', 'option', 'p', 'param', 'picture', 'pre',
66
- 'section', 'source', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th',
67
- 'thead', 'title', 'tr', 'track', 'ul', 'video',
68
- ])
69
-
70
- /** HTML void elements — never pushed on the balance stack. */
71
- const VOID_TAGS: ReadonlySet<string> = new Set([
72
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
73
- 'param', 'source', 'track', 'wbr',
74
- ])
75
-
76
- /** A block-tag line start: indent + `<` or `</` + tag name + space/`/`/`>`. */
77
- const HTML_BLOCK_START_RE = /^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)[\s/>]/
78
-
79
- /** A comment-open line start (CommonMark type 2). */
80
- const COMMENT_START_RE = /^ {0,3}<!--/
81
-
82
- /** Tag-like text anywhere — the cheap gate for the inline pass. */
83
- const TAGLIKE_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
84
-
85
- /** One HTML tag token (open/close/void) in a lifted HTML run. */
86
- interface HtmlToken {
87
- kind: 'open' | 'close' | 'void'
88
- tag: string
89
- /** Raw attribute source of an open tag (between the name and `>`/`/>`). */
90
- attrs: string
91
- start: number
92
- end: number
93
- }
94
-
95
- /** A comment range within a run — tags inside comments are not tokens. */
96
- interface CommentRange {
97
- start: number
98
- end: number
99
- }
100
-
101
- /** A full tag match including its `>` (token regex; quotes guard `>` inside attrs). */
102
- const TAG_TOKEN_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
103
- const COMMENT_RE = /<!--[\s\S]*?-->/g
104
-
105
- /**
106
- * Tokenize a lifted HTML run into tags (comments are located first so tags
107
- * inside them are skipped). Text between tokens is not tokenized — it stays
108
- * part of the raw `html` spans the part analysis slices out.
109
- */
110
- function tokenizeHtml(source: string): HtmlToken[] {
111
- const comments: CommentRange[] = []
112
- for (const match of source.matchAll(COMMENT_RE)) {
113
- comments.push({ start: match.index, end: match.index + match[0].length })
114
- }
115
- const inComment = (index: number): boolean =>
116
- comments.some((range) => index >= range.start && index < range.end)
117
- const tokens: HtmlToken[] = []
118
- for (const match of source.matchAll(TAG_TOKEN_RE)) {
119
- if (inComment(match.index)) continue
120
- const closing = match[1] === '/'
121
- const tag = match[2]!.toLowerCase()
122
- let attrs = match[3] ?? ''
123
- // Self-closing syntax (`<div/>`) behaves as void regardless of the name.
124
- const selfClosing = /\/\s*$/.test(attrs)
125
- if (selfClosing) attrs = attrs.replace(/\/\s*$/, '')
126
- tokens.push({
127
- kind: closing ? 'close' : selfClosing || VOID_TAGS.has(tag) ? 'void' : 'open',
128
- tag,
129
- attrs,
130
- start: match.index,
131
- end: match.index + match[0].length,
132
- })
133
- }
134
- return tokens
135
- }
136
-
137
- /** One structural piece of a lifted HTML run, in document order. */
138
- export type HtmlPart =
139
- | { kind: 'html'; html: string }
140
- | { kind: 'open'; tag: string; attrs: string }
141
- | { kind: 'close'; tag: string }
142
-
143
- /** The run's structure relative to the surrounding document nesting. */
144
- export interface HtmlSegmentShape {
145
- parts: HtmlPart[]
146
- }
147
-
148
- /**
149
- * Reduce a lifted HTML run to ordered parts: balanced spans become `html`
150
- * leaves, unclosed open tags become `open` (a wrapper the renderer lowers
151
- * following markdown runs into), unmatched closes become `close` (pops one
152
- * wrapper level). A mismatched close pops through the matching open — the
153
- * HTML parser's implicit-close behavior. Runs with no structural tags reduce
154
- * to a single `html` part.
155
- */
156
- export function analyzeHtmlSegment(source: string): HtmlSegmentShape {
157
- const tokens = tokenizeHtml(source)
158
- const stack: { token: HtmlToken }[] = []
159
- const unmatchedCloses: HtmlToken[] = []
160
-
161
- for (let index = 0; index < tokens.length; index += 1) {
162
- const token = tokens[index]!
163
- if (token.kind === 'void') continue
164
- if (token.kind === 'open') {
165
- stack.push({ token })
166
- continue
167
- }
168
- // Close: pop through the nearest matching open (implicit closes inside).
169
- let matchAt = -1
170
- for (let depth = stack.length - 1; depth >= 0; depth -= 1) {
171
- if (stack[depth]!.token.tag === token.tag) { matchAt = depth; break }
172
- }
173
- if (matchAt === -1) {
174
- unmatchedCloses.push(token)
175
- continue
176
- }
177
- stack.length = matchAt
178
- }
179
-
180
- // Structural tokens in source order: unmatched closes + surviving opens.
181
- const structural: Array<{ token: HtmlToken; kind: 'open' | 'close' }> = []
182
- for (const token of unmatchedCloses) structural.push({ token, kind: 'close' })
183
- for (const entry of stack) structural.push({ token: entry.token, kind: 'open' })
184
- structural.sort((a, b) => a.token.start - b.token.start)
185
-
186
- if (structural.length === 0) return { parts: [{ kind: 'html', html: source }] }
187
-
188
- const parts: HtmlPart[] = []
189
- let cursor = 0
190
- for (const { token, kind } of structural) {
191
- if (token.start > cursor) parts.push({ kind: 'html', html: source.slice(cursor, token.start) })
192
- parts.push(kind === 'open'
193
- ? { kind: 'open', tag: token.tag, attrs: token.attrs }
194
- : { kind: 'close', tag: token.tag })
195
- cursor = token.end
196
- }
197
- if (cursor < source.length) parts.push({ kind: 'html', html: source.slice(cursor) })
198
- return { parts }
199
- }
200
-
201
- /**
202
- * Split markdown source into markdown / html runs (fence-aware: an HTML-looking
203
- * line inside any fenced code block is content, not a run start). Blank lines
204
- * terminate HTML runs and are dropped between segments (they carry no markdown
205
- * semantics the preview needs); everything else stays byte-identical.
206
- */
207
- export function splitHtmlBlocks(text: string): MdHtmlSegment[] {
208
- if (text === '') return []
209
- const lines = text.split('\n')
210
- const segments: MdHtmlSegment[] = []
211
- let markdown: string[] = []
212
- let html: string[] = []
213
- /** Fence state: the char + length of the currently open fence, if any. */
214
- let openFence: { char: string; length: number } | null = null
215
- /** Comment state: an HTML comment run ends at the line containing `-->`. */
216
- let inComment = false
217
- /** True while accumulating a blank-line-terminated HTML run. */
218
- let inHtmlRun = false
219
-
220
- const flushMarkdown = (): void => {
221
- // Separator blank lines around HTML runs carry no markdown semantics.
222
- while (markdown.length > 0 && isBlank(markdown[markdown.length - 1] ?? '')) markdown.pop()
223
- if (markdown.length === 0) return
224
- segments.push({ kind: 'markdown', text: markdown.join('\n') })
225
- markdown = []
226
- }
227
- const flushHtml = (): void => {
228
- if (html.length === 0) return
229
- segments.push({ kind: 'html', text: html.join('\n') })
230
- html = []
231
- inHtmlRun = false
232
- inComment = false
233
- }
234
- const isBlank = (line: string): boolean => /^[ \t]*$/.test(line)
235
-
236
- for (const line of lines) {
237
- if (openFence !== null) {
238
- const close = CLOSE_FENCE_RE.exec(line)
239
- if (close !== null && close[1]!.charAt(0) === openFence.char && close[1]!.length >= openFence.length) {
240
- openFence = null
241
- }
242
- markdown.push(line)
243
- continue
244
- }
245
- const fenceMatch = OPEN_FENCE_RE.exec(line)
246
- if (fenceMatch !== null) {
247
- const fence = fenceMatch[1]!
248
- const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
249
- const info = fenceInfo(rest, fence)
250
- if (info !== null) {
251
- openFence = { char: fence.charAt(0), length: fence.length }
252
- markdown.push(line)
253
- continue
254
- }
255
- // Not a valid fence (backtick info containing backticks): plain text.
256
- markdown.push(line)
257
- continue
258
- }
259
-
260
- if (inComment) {
261
- html.push(line)
262
- if (line.includes('-->')) flushHtml()
263
- continue
264
- }
265
- if (inHtmlRun) {
266
- if (isBlank(line)) {
267
- flushHtml()
268
- continue
269
- }
270
- html.push(line)
271
- continue
272
- }
273
-
274
- if (COMMENT_START_RE.test(line)) {
275
- flushMarkdown()
276
- html.push(line)
277
- inComment = true
278
- if (line.includes('-->')) flushHtml()
279
- continue
280
- }
281
- const htmlMatch = HTML_BLOCK_START_RE.exec(line)
282
- const tag = htmlMatch?.[1]?.toLowerCase()
283
- if (htmlMatch !== null && tag !== undefined && HTML_BLOCK_TAGS.has(tag)) {
284
- flushMarkdown()
285
- html.push(line)
286
- inHtmlRun = true
287
- continue
288
- }
289
- // Blank lines only survive inside a markdown run (paragraph separators);
290
- // at a run boundary they are separators and are dropped.
291
- if (isBlank(line) && markdown.length === 0) continue
292
- markdown.push(line)
293
- }
294
- flushMarkdown()
295
- flushHtml()
296
- return segments
297
- }
298
-
299
- /** A reference definition line: `[label]: destination` (up to 3 spaces indent). */
300
- const REFERENCE_DEF_RE = /^ {0,3}\[((?:[^\][]|\[[^\]]*\])*)\]:\s*(?:<([^<>]*)>|(\S+))/
301
-
302
- /**
303
- * Collect the reference definitions of every markdown run (HTML runs cannot
304
- * define them), in document order, newline-joined for appending.
305
- */
306
- export function collectReferenceDefinitions(segments: readonly MdHtmlSegment[]): string {
307
- const defs: string[] = []
308
- for (const segment of segments) {
309
- if (segment.kind !== 'markdown') continue
310
- for (const line of segment.text.split('\n')) {
311
- if (REFERENCE_DEF_RE.test(line)) defs.push(line)
312
- }
313
- }
314
- return defs.join('\n')
315
- }
316
-
317
- /**
318
- * The whole-document gate + split the preview consumes. `hasInlineHtml` is a
319
- * cheap source-level regex (code-fence content may false-positive; the inline
320
- * pass skips rendered code blocks anyway, so a false positive only costs the
321
- * enhanced render path, never a behavior change).
322
- */
323
- export function analyzeMarkdownHtml(text: string): AnalyzedMarkdownHtml {
324
- const segments = splitHtmlBlocks(text)
325
- return {
326
- segments,
327
- hasBlockHtml: segments.some((segment) => segment.kind === 'html'),
328
- hasInlineHtml: TAGLIKE_RE.test(text),
329
- referenceDefinitions: collectReferenceDefinitions(segments),
330
- }
331
- }
@@ -1,137 +0,0 @@
1
- /**
2
- * Markdown-preview local-image resolution. The shared `MarkdownText` (from
3
- * @deepseek-ai/dsh-client-ui-primitives) only renders absolute http(s) image
4
- * URLs — relative links are disabled for chat security — so a local image in
5
- * a previewed `.md` (`![alt](./img.png)`, an absolute `/cwd/img.png`, or a
6
- * reference definition) would otherwise fall back to its alt text. This
7
- * dependency-free helper rewrites those destinations into absolute
8
- * `/sidebar/file` media URLs (prefixed with the GUI's own origin) so
9
- * `MarkdownText` accepts them; the host media route then serves the bytes,
10
- * still restricted to files under the session cwd.
11
- */
12
-
13
- import type { SessionScope } from './api.ts'
14
- import { isAbsolutePath } from './paths.ts'
15
-
16
- /**
17
- * True for a destination that is a remote URL — an absolute `scheme:` URL
18
- * that is not a Windows drive path (`C:\...`). http/https/data/mailto etc.
19
- * all match here and are handed back to `MarkdownText` untouched.
20
- */
21
- function isRemoteUrl(dest: string): boolean {
22
- return /^[a-z][a-z0-9+.-]*:/i.test(dest) && !/^[A-Za-z]:[\\/]/.test(dest)
23
- }
24
-
25
- /**
26
- * Collapse `.`/`..` segments of an absolute local path, preserving its root
27
- * (POSIX `/`), its Windows drive (`C:\`), or its UNC `\\server\share`
28
- * prefix. The host's `requireAbsolute` (`path.resolve`) normalizes anyway,
29
- * but producing a canonical path here keeps the `/sidebar/file` URL clean.
30
- */
31
- function normalizeLocalPath(path: string): string {
32
- const drive = /^([A-Za-z]:)[\\/]/.exec(path)?.[1]
33
- const body = drive !== undefined ? path.slice(drive.length) : path
34
- const parts = body.split(/[\\/]+/).filter((segment) => segment !== '' && segment !== '.')
35
- const out: string[] = []
36
- for (const part of parts) {
37
- if (part === '..') { out.pop(); continue }
38
- out.push(part)
39
- }
40
- if (drive !== undefined) return `${drive}\\${out.join('\\')}`
41
- const separator = path.startsWith('\\') ? '\\' : '/'
42
- const root = path.startsWith('/') ? '/' : path.startsWith('\\') ? '\\\\' : ''
43
- return `${root}${out.join(separator)}`
44
- }
45
-
46
- /**
47
- * Rewrite markdown image destinations that point at local files into
48
- * absolute `/sidebar/file` media URLs. Relative destinations resolve against
49
- * the opened file's directory (normalizing `.`/`..` segments); absolute
50
- * local paths pass through. Remote (http/https/data/mailto) and `#`-anchor
51
- * destinations are left untouched for `MarkdownText`. Reference-style images
52
- * (`![x][id]` + `[id]: url`) are covered by rewriting their definition lines.
53
- *
54
- * Code spans (`` `...` ``) and fenced code blocks (``` ```...``` ```) are
55
- * masked before rewriting so documentation that demonstrates `![alt](./img.png)`
56
- * is not mutated into a `/sidebar/file` URL. Reference definitions are only
57
- * rewritten when their label is actually referenced by an image (collapsed
58
- * `[![][id]]`, full `![alt][id]`, or shortcut `![]` referencing the next
59
- * definition) — a plain link `[text][id]` must not have its destination
60
- * redirected to the media route.
61
- * @param text - The raw markdown source (inline + reference images).
62
- * @param scope - The session scope (sessionId + cwd) for the media route.
63
- * @param filePath - The absolute path of the opened `.md` file.
64
- * @param origin - The GUI's own origin (`window.location.origin`); injected
65
- * so the core rewrite stays pure and unit-testable.
66
- * @returns The markdown with local image destinations rewritten in place.
67
- */
68
- /**
69
- * Resolve one media destination against the session's media route: local
70
- * (relative or absolute) paths become absolute `/sidebar/file` URLs (prefixed
71
- * with the GUI's own origin so the shared MarkdownText http(s) allowlist
72
- * accepts them), while remote URLs, `#`-anchors and empty destinations are
73
- * returned untouched. Shared by the markdown image rewriter below and by the
74
- * preview's raw-HTML sanitizer (`markdown-html.tsx`, which meets the same
75
- * allowlist when rendering `<img src="./x.png">` inside HTML blocks).
76
- */
77
- export function resolveLocalMediaDest(
78
- dest: string,
79
- scope: SessionScope,
80
- filePath: string,
81
- origin: string,
82
- ): string {
83
- const trimmed = dest.trim()
84
- if (trimmed === '' || trimmed.startsWith('#')) return dest
85
- if (isRemoteUrl(trimmed)) return dest
86
- const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
87
- const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
88
- const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
89
- // Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
90
- // absolute so the shared MarkdownText http(s) allowlist accepts it.
91
- const params = new URLSearchParams({ sessionId: scope.sessionId, path: normalizeLocalPath(candidate) })
92
- if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
93
- return `${origin}/sidebar/file?${params.toString()}`
94
- }
95
-
96
- export function rewriteLocalImageUrls(
97
- text: string,
98
- scope: SessionScope,
99
- filePath: string,
100
- origin: string,
101
- ): string {
102
- const resolve = (dest: string): string => resolveLocalMediaDest(dest, scope, filePath, origin)
103
-
104
- // Mask fenced code blocks and inline code spans so image-looking text
105
- // inside documentation examples is never rewritten. The sentinel uses a
106
- // character unlikely to appear in prose; the original spans are restored
107
- // after the image rewrite.
108
- const masks: string[] = []
109
- const masked = text
110
- .replace(/```[\s\S]*?```/g, (block) => { masks.push(block); return `\u0000${masks.length - 1}\u0000` })
111
- .replace(/`[^`\n]*`/g, (span) => { masks.push(span); return `\u0000${masks.length - 1}\u0000` })
112
-
113
- const inline = masked.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, dest) => {
114
- return `![${alt}](${resolve(dest)})`
115
- })
116
-
117
- // Collect labels referenced by image syntax (full `![alt][id]` and
118
- // collapsed `![][id]`) so only those reference definitions are rewritten.
119
- // A shortcut reference (`![alt]` with no `[id]`) resolves to the label
120
- // text `alt` itself; include it too. Plain links `[text][id]` never match
121
- // the leading `!` and are left untouched.
122
- const imageLabels = new Set<string>()
123
- const labelRe = /!\[([^\]]*)\](?:\[((?:[^\][]|\[[^\]]*\])*)\])?/g
124
- let labelMatch: RegExpExecArray | null
125
- while ((labelMatch = labelRe.exec(inline)) !== null) {
126
- const alt = labelMatch[1] ?? ''
127
- const ref = labelMatch[2]
128
- imageLabels.add(ref !== undefined && ref !== '' ? ref.toLowerCase() : alt.toLowerCase())
129
- }
130
-
131
- const refsRewritten = inline.replace(/^(\s*\[([^\]]+)\]:\s*)(<[^>]+>|[^\s]+)/gm, (match, head: string, label: string, dest: string) => {
132
- if (!imageLabels.has(label.toLowerCase())) return match
133
- return `${head}${resolve(dest.replace(/^<|>$/g, ''))}`
134
- })
135
-
136
- return refsRewritten.replace(/\u0000(\d+)\u0000/g, (_m, index: string) => masks[Number(index)] ?? '')
137
- }
@@ -1,127 +0,0 @@
1
- /**
2
- * The markdown preview's table of contents: a sticky zero-height bar keeping
3
- * a small "outline" button pinned at the top-right of the preview's scroll
4
- * container, visible once the document has at least {@link TOC_MIN_HEADINGS}
5
- * headings. The button opens a popover outline (h1–h6 collected from the
6
- * rendered DOM — so headings inside HTML runs count too); clicking an entry
7
- * expands any collapsed `<details>` ancestor, smooth-scrolls the heading into
8
- * view and flashes it. The scan re-runs through a MutationObserver so late
9
- * content (lazy mermaid chunk, shiki highlighting) is picked up.
10
- *
11
- * Mount contract: render as a DIRECT child of the scroll container it
12
- * outlines. The container is discovered through the bar's own DOM position
13
- * (`parentElement`), not a passed ref — React attaches a parent host
14
- * element's ref only after its children's layout effects have run, so a
15
- * ref-based container read here would see null at mount and never re-run.
16
- * The bar itself renders unconditionally (zero height, pointer-events
17
- * none), so it never affects the preview layout.
18
- */
19
- import { useLayoutEffect, useRef, useState } from 'react'
20
- import type { ReactNode } from 'react'
21
- import { IconListPenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
22
- import { t } from './locales.ts'
23
- import css from './sidebar.module.css'
24
-
25
- /** Fewer headings than this and the button stays hidden (no outline value). */
26
- export const TOC_MIN_HEADINGS = 3
27
-
28
- /** One collected outline entry; `el` is the live rendered heading element. */
29
- interface TocEntry {
30
- level: number
31
- text: string
32
- el: HTMLElement
33
- }
34
-
35
- /** The signature used to skip no-op rescans (identity-safe setState guard). */
36
- function signatureOf(entries: readonly TocEntry[]): string {
37
- return entries.map((entry) => `${entry.level}:${entry.text}`).join('\n')
38
- }
39
-
40
- export function MdToc(): ReactNode {
41
- const barRef = useRef<HTMLDivElement>(null)
42
- const [entries, setEntries] = useState<TocEntry[]>([])
43
- const [open, setOpen] = useState(false)
44
- const signatureRef = useRef('')
45
-
46
- useLayoutEffect(() => {
47
- // The bar's own host ref is attached before this effect runs; the scroll
48
- // container is its parent by the mount contract above.
49
- const container = barRef.current?.parentElement ?? null
50
- if (container === null) return
51
- const scan = (): void => {
52
- const found: TocEntry[] = []
53
- for (const el of container.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')) {
54
- const text = (el.textContent ?? '').trim()
55
- if (text === '') continue
56
- found.push({ level: Number(el.tagName.charAt(1)), text, el })
57
- }
58
- const signature = signatureOf(found)
59
- if (signature === signatureRef.current) return
60
- signatureRef.current = signature
61
- setEntries(found)
62
- }
63
- scan()
64
- const observer = new MutationObserver(() => { scan() })
65
- observer.observe(container, { childList: true, subtree: true })
66
- return () => { observer.disconnect() }
67
- }, [])
68
-
69
- // Esc closes the popover (the panel is a sibling of the button, so focus
70
- // stays wherever the user came from — a document listener is the simplest).
71
- useLayoutEffect(() => {
72
- if (!open) return
73
- const onKey = (event: KeyboardEvent): void => {
74
- if (event.key === 'Escape') setOpen(false)
75
- }
76
- document.addEventListener('keydown', onKey)
77
- return () => { document.removeEventListener('keydown', onKey) }
78
- }, [open])
79
-
80
- const jump = (entry: TocEntry): void => {
81
- entry.el.closest('details:not([open])')?.setAttribute('open', '')
82
- entry.el.scrollIntoView({ behavior: 'smooth', block: 'start' })
83
- const el = entry.el
84
- const flash = css.tocFlash
85
- if (flash !== undefined) {
86
- el.classList.add(flash)
87
- window.setTimeout(() => { el.classList.remove(flash) }, 1200)
88
- }
89
- setOpen(false)
90
- }
91
-
92
- const showOutline = entries.length >= TOC_MIN_HEADINGS
93
- return (
94
- <div className={css.tocBar} ref={barRef}>
95
- {open && showOutline && (
96
- <div className={css.tocPanel} data-dsh-md-toc-panel>
97
- {entries.map((entry, index) => (
98
- <button
99
- key={index}
100
- type="button"
101
- className={css.tocItem}
102
- data-level={entry.level}
103
- title={entry.text}
104
- onClick={() => { jump(entry) }}
105
- >
106
- <span className={css.tocItemLevel}>{entry.level}</span>
107
- <span className={css.tocItemText}>{entry.text}</span>
108
- </button>
109
- ))}
110
- </div>
111
- )}
112
- {showOutline && (
113
- <button
114
- type="button"
115
- className={css.tocButton}
116
- data-dsh-md-toc=""
117
- aria-label={t('toc')}
118
- title={t('toc')}
119
- aria-expanded={open}
120
- onClick={() => { setOpen(!open) }}
121
- >
122
- <IconListPenOutline16 />
123
- </button>
124
- )}
125
- </div>
126
- )
127
- }