dsh-coding-sidebar 1.0.0
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 +21 -0
- package/README.md +524 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +48229 -0
- package/lib/client-mermaid.js +200745 -0
- package/lib/client-registry.js +21867 -0
- package/lib/client-terminal.js +9361 -0
- package/lib/client.js +21867 -0
- package/lib/index.js +4556 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-opens.d.ts +65 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +39 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +13 -0
- package/lib/types/client/FileTree.d.ts +38 -0
- package/lib/types/client/FreeWindow.d.ts +29 -0
- package/lib/types/client/GitView.d.ts +10 -0
- package/lib/types/client/MarkdownHtml.d.ts +32 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +54 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +24 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +275 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +17 -0
- package/lib/types/client/browser.d.ts +60 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +131 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +59 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +25 -0
- package/lib/types/client/intercept.d.ts +46 -0
- package/lib/types/client/lang.d.ts +14 -0
- package/lib/types/client/layout-push.d.ts +15 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales-ar.d.ts +18 -0
- package/lib/types/client/locales-de.d.ts +4 -0
- package/lib/types/client/locales-fr.d.ts +10 -0
- package/lib/types/client/locales-hi.d.ts +17 -0
- package/lib/types/client/locales-id.d.ts +15 -0
- package/lib/types/client/locales-it.d.ts +8 -0
- package/lib/types/client/locales-ja.d.ts +17 -0
- package/lib/types/client/locales-ko.d.ts +9 -0
- package/lib/types/client/locales-nl.d.ts +16 -0
- package/lib/types/client/locales-pl.d.ts +19 -0
- package/lib/types/client/locales-pt.d.ts +1 -0
- package/lib/types/client/locales-ru.d.ts +13 -0
- package/lib/types/client/locales-sv.d.ts +1 -0
- package/lib/types/client/locales-th.d.ts +17 -0
- package/lib/types/client/locales-tr.d.ts +17 -0
- package/lib/types/client/locales-vi.d.ts +17 -0
- package/lib/types/client/locales-zh-HK.d.ts +32 -0
- package/lib/types/client/locales-zh-MO.d.ts +32 -0
- package/lib/types/client/locales-zh-TW.d.ts +32 -0
- package/lib/types/client/locales.d.ts +452 -0
- package/lib/types/client/markdown-html.d.ts +96 -0
- package/lib/types/client/markdown-images.d.ts +45 -0
- package/lib/types/client/markdown-labels.d.ts +38 -0
- package/lib/types/client/md-toc.d.ts +4 -0
- package/lib/types/client/mermaid-blocks.d.ts +47 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +85 -0
- package/lib/types/client/paths.d.ts +43 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/pinned.d.ts +86 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +29 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +439 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +36 -0
- package/lib/types/client/state.d.ts +432 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +40 -0
- package/lib/types/client/terminal-font.d.ts +78 -0
- package/lib/types/client/terminal-links.d.ts +119 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +605 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +124 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/path-security.d.ts +20 -0
- package/lib/types/prefs-shared.d.ts +236 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +34 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +172 -0
- package/scripts/install.ps1 +306 -0
- package/scripts/install.sh +287 -0
- package/src/agent-opens.ts +265 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +289 -0
- package/src/client/DiffTab.tsx +114 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +558 -0
- package/src/client/FileTree.tsx +669 -0
- package/src/client/FreeWindow.tsx +337 -0
- package/src/client/GitView.tsx +717 -0
- package/src/client/MarkdownHtml.tsx +296 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1163 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +645 -0
- package/src/client/Sidebar.tsx +1370 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +365 -0
- package/src/client/TerminalView.tsx +424 -0
- package/src/client/TextEditor.tsx +468 -0
- package/src/client/TreePanel.tsx +275 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +357 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +61 -0
- package/src/client/browser.ts +167 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +340 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +358 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +152 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +427 -0
- package/src/client/intercept.tsx +188 -0
- package/src/client/lang.ts +225 -0
- package/src/client/layout-push.ts +24 -0
- package/src/client/layout.css +90 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales-ar.ts +368 -0
- package/src/client/locales-de.ts +353 -0
- package/src/client/locales-fr.ts +360 -0
- package/src/client/locales-hi.ts +367 -0
- package/src/client/locales-id.ts +365 -0
- package/src/client/locales-it.ts +358 -0
- package/src/client/locales-ja.ts +367 -0
- package/src/client/locales-ko.ts +359 -0
- package/src/client/locales-nl.ts +365 -0
- package/src/client/locales-pl.ts +369 -0
- package/src/client/locales-pt.ts +350 -0
- package/src/client/locales-ru.ts +365 -0
- package/src/client/locales-sv.ts +350 -0
- package/src/client/locales-th.ts +367 -0
- package/src/client/locales-tr.ts +367 -0
- package/src/client/locales-vi.ts +367 -0
- package/src/client/locales-zh-HK.ts +382 -0
- package/src/client/locales-zh-MO.ts +382 -0
- package/src/client/locales-zh-TW.ts +382 -0
- package/src/client/locales.ts +894 -0
- package/src/client/markdown-html.ts +331 -0
- package/src/client/markdown-images.ts +137 -0
- package/src/client/markdown-labels.tsx +52 -0
- package/src/client/md-toc.tsx +127 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +406 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +143 -0
- package/src/client/paths.ts +63 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/pinned.ts +183 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +96 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +210 -0
- package/src/client/produced-files.ts +120 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +859 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2788 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +331 -0
- package/src/client/state.ts +1497 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +55 -0
- package/src/client/terminal-font.ts +224 -0
- package/src/client/terminal-links.ts +194 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +154 -0
- package/src/context-types.ts +611 -0
- package/src/fs-operations.ts +95 -0
- package/src/fs-search.ts +113 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +446 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1291 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/path-security.ts +75 -0
- package/src/prefs-shared.ts +280 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +482 -0
- package/src/trust-fence.ts +84 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,331 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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` (``, 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 ``
|
|
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 `})`
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy-button / chrome labels for DSH's shared `MarkdownText`, shaped for
|
|
3
|
+
* BOTH prop generations the plugin supports:
|
|
4
|
+
*
|
|
5
|
+
* - 0.1.1-rc.x: optional flat prop `codeLabels` — the renderer reads
|
|
6
|
+
* `labels.copyLabel` / `labels.copiedLabel` directly.
|
|
7
|
+
* - 0.1.2-alpha.1+: renamed to a REQUIRED `labels` prop with a nested shape
|
|
8
|
+
* (`labels.code.copyLabel` + a screen-reader-only `labels.footnotes`
|
|
9
|
+
* heading). Passing only the old prop crashes the fence render with
|
|
10
|
+
* "Cannot read properties of undefined (reading 'code')" — the exact
|
|
11
|
+
* regression the mount lane caught on a real 0.1.2-alpha.1 host.
|
|
12
|
+
*
|
|
13
|
+
* The union object satisfies both readers, and {@link markdownTextProps}
|
|
14
|
+
* passes it under BOTH prop names — each host ignores the one it does not
|
|
15
|
+
* know. `footnotes` is left empty (the heading is sr-only; give it a real
|
|
16
|
+
* string only if a locale key ever earns its place in all 19 dictionaries).
|
|
17
|
+
*/
|
|
18
|
+
import type { ComponentProps } from 'react'
|
|
19
|
+
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
20
|
+
|
|
21
|
+
/** The flat copy-button pair the plugin threads through its own props
|
|
22
|
+
* (e.g. MermaidMarkdownProps.codeLabels — the chunk contract stays put). */
|
|
23
|
+
export interface MarkdownCopyLabels {
|
|
24
|
+
copyLabel: string
|
|
25
|
+
copiedLabel: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The dual-generation chrome labels object (see module doc). */
|
|
29
|
+
export interface MarkdownChromeLabels extends MarkdownCopyLabels {
|
|
30
|
+
/** 0.1.2-alpha.1+ nested reads. */
|
|
31
|
+
code: MarkdownCopyLabels
|
|
32
|
+
/** 0.1.2-alpha.1+ sr-only footnotes heading. */
|
|
33
|
+
footnotes: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build the dual-shape chrome labels from a flat copy-button pair. */
|
|
37
|
+
export function markdownChromeLabels(labels: MarkdownCopyLabels): MarkdownChromeLabels {
|
|
38
|
+
return {
|
|
39
|
+
copyLabel: labels.copyLabel,
|
|
40
|
+
copiedLabel: labels.copiedLabel,
|
|
41
|
+
code: { copyLabel: labels.copyLabel, copiedLabel: labels.copiedLabel },
|
|
42
|
+
footnotes: '',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** MarkdownText props carrying the labels under BOTH prop names. The cast is
|
|
47
|
+
* load-bearing: the plugin builds against the 0.1.1-rc.x declaration, where
|
|
48
|
+
* `labels` does not exist yet (and vice versa on a 0.1.2-alpha.1+ host). */
|
|
49
|
+
export function markdownTextProps(text: string, labels: MarkdownCopyLabels): ComponentProps<typeof MarkdownText> {
|
|
50
|
+
const chrome = markdownChromeLabels(labels)
|
|
51
|
+
return { text, codeLabels: chrome, labels: chrome } as unknown as ComponentProps<typeof MarkdownText>
|
|
52
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|