dsh-paste-code-block 0.1.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 +78 -0
- package/README.zh-CN.md +82 -0
- package/cordis.patch.yml +4 -0
- package/docs/design.md +93 -0
- package/package.json +66 -0
- package/src/client.js +681 -0
- package/src/index.js +11 -0
- package/src/parse.js +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-paste-code-block contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# dsh-paste-code-block
|
|
4
|
+
|
|
5
|
+
**Cherry Studio‑style text/code block pasting for DeepSeek Harness Web**
|
|
6
|
+
|
|
7
|
+
Paste text/code into the DSH Web composer → it collapses into a **bordered, collapsible, language-tagged card** instead of a long flat string; on send it restores the original content as a fenced code block.
|
|
8
|
+
|
|
9
|
+
`type: module` · `runtime: host` · `client: web` · MIT
|
|
10
|
+
|
|
11
|
+
**[中文](./README.zh-CN.md) | English**
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- 📋 **Smart detection** — pasted content with ``` fences, newlines, or long indented code becomes a card; ordinary short prose pastes as-is.
|
|
20
|
+
- 🏷️ **Language tags** — auto-detects Python / JS / JSON / YAML / SQL / bash / HTML·XML / C++ / Go / Ruby (incl. shebang). Plain text blocks are tagged `text`.
|
|
21
|
+
- 📂 **Collapsible card** — collapsed by default showing a one-line preview; click to expand full code (scrollable, up to 260px).
|
|
22
|
+
- ✏️ **In‑card editing** — edit the block content right in the detail card; the send path uses your edited text.
|
|
23
|
+
- 📤 **Restore on send** — each card is restored to a ```` ```lang … ``` ```` fenced block; a failed send auto-fills it back into the input.
|
|
24
|
+
- 🔢 **Smart numbering** — code and text are counted apart; deleting a block frees its number for reuse (smallest-free allocation).
|
|
25
|
+
- 📱 **All surfaces** — pure client plugin; works on PC & mobile Web, adapts to light/dark themes.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Requires DSH `0.1.x` (current developer preview — APIs may change).
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# Local install from this directory
|
|
33
|
+
dsh plugin --profile web add file:/path/to/dsh-paste-code-block
|
|
34
|
+
|
|
35
|
+
# Or after publishing
|
|
36
|
+
# dsh plugin --profile web add dsh-paste-code-block
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Restart `dsh web`** after installing.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
1. Copy a block of code or a multi-line block of text anywhere.
|
|
44
|
+
2. Paste it into the DSH input → it becomes a code-block card.
|
|
45
|
+
3. Expand / copy / edit / remove as you like; press Enter or send and it's restored as a fenced code block.
|
|
46
|
+
|
|
47
|
+
## Notes & boundaries
|
|
48
|
+
|
|
49
|
+
- Blocks are **appended at the end of the draft** (reusing DSH's hidden-reference semantics), keeping cursor/undo stable; ordinary short text is not intercepted.
|
|
50
|
+
- Plain-text blocks are wrapped in ```` ```text ``` ```` on send so they don't smear into one line.
|
|
51
|
+
- Detection threshold: **contains newline / ≥2 lines / length ≥96 / indented or fenced** — any one triggers the card.
|
|
52
|
+
|
|
53
|
+
## Project layout
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
dsh-paste-code-block/
|
|
57
|
+
├── src/
|
|
58
|
+
│ ├── client.js # Web (browser) half — paste capture, chip styling, detail card
|
|
59
|
+
│ ├── index.js # Host (node) half — intentionally empty (pure UI plugin)
|
|
60
|
+
│ └── parse.js # Pure block-parsing logic (canonical, unit-tested)
|
|
61
|
+
├── tests/
|
|
62
|
+
│ └── parse.test.js # node:test unit tests for src/parse.js
|
|
63
|
+
├── docs/
|
|
64
|
+
│ └── design.md # Design rationale (numbering, anchor stability, codec)
|
|
65
|
+
├── cordis.patch.yml # DSH bundle patch declaring the host plugin
|
|
66
|
+
├── package.json # Package + DSH plugin manifest
|
|
67
|
+
├── README.md # English
|
|
68
|
+
└── README.zh-CN.md # 简体中文
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npm run check # syntax-check both JS halves
|
|
75
|
+
npm test # run the unit tests (node:test)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
MIT — see [LICENSE](./LICENSE).
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# dsh-paste-code-block
|
|
4
|
+
|
|
5
|
+
**Cherry Studio 式的文本块/代码块粘贴效果(DeepSeek Harness Web)**
|
|
6
|
+
|
|
7
|
+
把复制的**文本块 / 代码块**粘到 DSH Web 输入框时,不再是一长串普通文字,而是收成一张**带边框、可折叠、带语言标签的代码块卡片**;发送时自动还原成真正的 ```` ```lang … ``` ```` 围栏代码块发送。
|
|
8
|
+
|
|
9
|
+
纯客户端插件(浏览器侧),PC 与手机 Web 通用,实时适配深浅主题。
|
|
10
|
+
|
|
11
|
+
`type: module` · `runtime: host` · `client: web` · MIT
|
|
12
|
+
|
|
13
|
+
**[English](./README.md) | 中文**
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 功能
|
|
20
|
+
|
|
21
|
+
- 📋 **智能识别**:粘贴内容已带 ``` 围栏、含换行、或是较长的缩进代码 → 收成卡片;普通短文本照常粘贴。
|
|
22
|
+
- 🏷️ **语言标签**:自动识别 Python / JS / JSON / YAML / SQL / bash / HTML·XML / C++ / Go / Ruby 等常见语言(可识别 shebang)。纯文本块标为 `text`。
|
|
23
|
+
- 📂 **可折叠卡片**:默认收起显示首行预览,点击展开完整代码(最多 260px 高可滚动)。
|
|
24
|
+
- ✏️ **卡片内编辑**:在详情卡片里直接编辑代码块内容,发送时使用编辑后的文本。
|
|
25
|
+
- 📤 **发送还原**:发出时把每个卡片还原成 ```` ```lang ``` ```` 围栏代码块;发送失败自动回填回输入框。
|
|
26
|
+
- 🔢 **智能编号**:代码与文本分开计数,从最小可用号分配;删除某块即释放其编号供复用。
|
|
27
|
+
- 📱 **全端适配**:纯客户端插件,PC 与手机 Web 通用,实时适配深浅主题。
|
|
28
|
+
|
|
29
|
+
## 安装
|
|
30
|
+
|
|
31
|
+
需要 DSH `0.1.x`(当前为开发者预览版,接口可能变化)。
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
# 从本目录本地安装
|
|
35
|
+
dsh plugin --profile web add file:/path/to/dsh-paste-code-block
|
|
36
|
+
|
|
37
|
+
# 或打包发布后
|
|
38
|
+
# dsh plugin --profile web add dsh-paste-code-block
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
安装后**重启 `dsh web`** 生效。
|
|
42
|
+
|
|
43
|
+
## 使用
|
|
44
|
+
|
|
45
|
+
1. 在任意地方复制一段代码 / 一段带换行的文本。
|
|
46
|
+
2. 在 DSH 输入框粘贴 → 变成一张代码块卡片。
|
|
47
|
+
3. 可展开 / 复制 / 编辑 / 移除;回车或发送即还原为围栏代码块发出。
|
|
48
|
+
|
|
49
|
+
## 说明与边界
|
|
50
|
+
|
|
51
|
+
- 默认**追加在草稿末尾**(复用 DSH 隐藏引用按文件/上传的既有语义),保证光标/撤销稳定;短普通文本不拦截。
|
|
52
|
+
- 纯文本块发送时用 ```` ```text ``` ```` 围栏包裹,避免在对话里糊成一行。
|
|
53
|
+
- 识别阈值:**含换行 / 行数 ≥2 / 长度 ≥96 / 有缩进或围栏**其一即视为块。
|
|
54
|
+
|
|
55
|
+
## 目录结构
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
dsh-paste-code-block/
|
|
59
|
+
├── src/
|
|
60
|
+
│ ├── client.js # Web(浏览器侧)半端 —— 粘贴拦截、徽章样式、详情卡片
|
|
61
|
+
│ ├── index.js # Host(Node 侧)半端 —— 有意为空(纯 UI 插件)
|
|
62
|
+
│ └── parse.js # 纯文本块解析逻辑(规范版,含单元测试)
|
|
63
|
+
├── tests/
|
|
64
|
+
│ └── parse.test.js # node:test 单元测试(针对 src/parse.js)
|
|
65
|
+
├── docs/
|
|
66
|
+
│ └── design.md # 设计说明(编号复用、锚点稳定、codec)
|
|
67
|
+
├── cordis.patch.yml # DSH bundle patch,声明宿主插件
|
|
68
|
+
├── package.json # 包与 DSH 插件清单
|
|
69
|
+
├── README.md # English
|
|
70
|
+
└── README.zh-CN.md # 简体中文
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 开发
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npm run check # 语法检查两个 JS 半端
|
|
77
|
+
npm test # 运行单元测试(node:test)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT — 见 [LICENSE](./LICENSE)。
|
package/cordis.patch.yml
ADDED
package/docs/design.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Design notes — dsh-paste-code-block
|
|
2
|
+
|
|
3
|
+
This document captures the non-obvious design decisions behind the plugin, so future
|
|
4
|
+
contributors understand *why* the code is the way it is. It is not a usage guide — see
|
|
5
|
+
[README.md](../README.md) for that.
|
|
6
|
+
|
|
7
|
+
## 1. One plugin, two halves
|
|
8
|
+
|
|
9
|
+
A DSH plugin can ship a **host** (node) half and a **client** (browser) half. This plugin is a
|
|
10
|
+
*pure UI* plugin: all behavior lives on the browser side.
|
|
11
|
+
|
|
12
|
+
- `src/index.js` — host half. **Intentionally empty.** It exists only so the package resolves
|
|
13
|
+
as a DSH plugin and so the `dsh.client` declaration in `package.json` is discovered.
|
|
14
|
+
- `src/client.js` — browser half. A self-contained UMD bundle loaded via
|
|
15
|
+
`window.__ModuleLoader__.load({ id, factory })`. It captures pastes, restyles DSH's inline
|
|
16
|
+
reference chips into boxed badges, and renders an editing detail card above the composer.
|
|
17
|
+
|
|
18
|
+
Because the client is loaded by DSH's module loader — which resolves *package names*, not
|
|
19
|
+
relative ESM files — `client.js` **cannot** `import` a sibling module. Its parsing logic is
|
|
20
|
+
therefore **embedded** in the bundle. To keep that logic unit-testable, the same functions are
|
|
21
|
+
extracted into `src/parse.js` (a plain, dependency-free ESM module) which the test suite
|
|
22
|
+
exercises. **Keep `src/parse.js` and the embedded copy in `src/client.js` in sync.**
|
|
23
|
+
|
|
24
|
+
## 2. How a paste becomes a card (the pipeline)
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
paste event
|
|
28
|
+
└─ parseBlock(text) → Block | null (pure logic, in src/parse.js)
|
|
29
|
+
└─ is short plain prose? → null → paste as-is (don't intercept)
|
|
30
|
+
└─ else → { lang, content, lines, isCode }
|
|
31
|
+
└─ controller.attach(sid, block)
|
|
32
|
+
├─ nextLabel(sid, type) → smallest free number (code/text counted apart)
|
|
33
|
+
├─ insertReference(...) → DSH 'slash/input-insert-reference' with clipboardText '\u200B'
|
|
34
|
+
└─ publish(sid) → re-render the dock
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 3. The hidden-reference codec
|
|
38
|
+
|
|
39
|
+
The card is rendered by DSH's existing **hidden-reference (chip)** machinery — the same
|
|
40
|
+
mechanism that renders file/upload references inline. The plugin registers a source under the
|
|
41
|
+
`@` input trigger:
|
|
42
|
+
|
|
43
|
+
- `trigger: '@'`, `name: 'paste-code-block'`
|
|
44
|
+
- `codec.clipboardText` → `''` (the chip leaves no visible text on copy)
|
|
45
|
+
- `codec.serialize(ref)` → the block's fenced ```` ```lang … ``` ```` text
|
|
46
|
+
|
|
47
|
+
The chip label is the human-readable badge (`复制代码块N` / `复制文本块N`); clicking it opens the
|
|
48
|
+
detail card.
|
|
49
|
+
|
|
50
|
+
## 4. Why `clipboardText` is U+200B (zero-width space)
|
|
51
|
+
|
|
52
|
+
`insertReference` passes `clipboardText: '\u200B'`. This is deliberate:
|
|
53
|
+
|
|
54
|
+
- Each chip contributes exactly **one character** to the editor's detect text.
|
|
55
|
+
- `String.prototype.trim()` does **not** strip U+200B, so the composer's empty check
|
|
56
|
+
(`draft.trim() === ""`) sees a draft with only chips as *non-empty*, keeping the send button
|
|
57
|
+
enabled even when the user pasted a block with no surrounding text.
|
|
58
|
+
- Because `draft.length` then equals the detect length, anchoring insertions at `draft.length`
|
|
59
|
+
keeps chips strictly ordered — fixing the earlier "every 3rd block shifts forward by 1" bug.
|
|
60
|
+
|
|
61
|
+
## 5. Numbering and reuse
|
|
62
|
+
|
|
63
|
+
Numbers are tracked **per session, split by type** (`code` vs `text`):
|
|
64
|
+
|
|
65
|
+
- `nextLabel` always allocates the **smallest free integer** (`while (used.has(n)) n++`).
|
|
66
|
+
- Deleting a chip frees its number; the next paste reuses it.
|
|
67
|
+
|
|
68
|
+
So `复制代码块1` … `复制代码块N` and `复制文本块1` … `复制文本块M` each count independently and
|
|
69
|
+
stay dense after removals.
|
|
70
|
+
|
|
71
|
+
## 6. Markdown vs. code detection
|
|
72
|
+
|
|
73
|
+
The trickiest heuristic is deciding whether pasted text is *code* or *markdown prose*. The
|
|
74
|
+
signal counter `countMarkdownSignals` scores structural markdown (headings, blockquotes, lists,
|
|
75
|
+
links, bold, tables, hr). A lone `#` comment in code scores 1 and stays code; a real markdown
|
|
76
|
+
document scores ≥2 and is treated as text. Fenced snippets always win as code.
|
|
77
|
+
|
|
78
|
+
## 7. Anchor stability and reconcile
|
|
79
|
+
|
|
80
|
+
Blocks are **appended at the end of the draft**, reusing DSH's hidden-reference semantics so
|
|
81
|
+
cursor/undo stay stable. During a submit the chips may transiently vanish; `reconcile` defers
|
|
82
|
+
pruning while `serializing` is setatch, and `restoreFailed` re-surfaces in-flight blocks if the
|
|
83
|
+
send fails — so a failed send auto-refills the input.
|
|
84
|
+
|
|
85
|
+
## 8. Testing strategy
|
|
86
|
+
|
|
87
|
+
`tests/parse.test.js` exercises the pure parsing module (`src/parse.js`) with the built-in
|
|
88
|
+
`node:test` runner (no test framework dependency). The DOM/controller glue is not unit-tested
|
|
89
|
+
because it depends on the live DSH client runtime; it is verified manually in the browser.
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npm test
|
|
93
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-paste-code-block",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cherry Studio-style paste: pasted text/code blocks into the DSH Web composer render as a bordered, collapsible, language-tagged card instead of a long flat string; the original text is restored as a fenced block on send.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "DSH paste-code-block contributors",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"plugin",
|
|
11
|
+
"paste",
|
|
12
|
+
"code-block",
|
|
13
|
+
"cherry-studio",
|
|
14
|
+
"ui"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "./src/index.js",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.js",
|
|
20
|
+
"./client": "./src/client.js",
|
|
21
|
+
"./parse": "./src/parse.js",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src/",
|
|
26
|
+
"docs/",
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"README.md",
|
|
29
|
+
"README.zh-CN.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"dsh": {
|
|
33
|
+
"runtime": "host",
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"platform": "web",
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-input-trigger",
|
|
42
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-conversation": "*",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "*",
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-slots": "*",
|
|
51
|
+
"react": "^18.2.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-conversation": { "optional": true },
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": { "optional": true },
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-slots": { "optional": true },
|
|
57
|
+
"react": { "optional": true }
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"check": "node --check src/index.js && node --check src/client.js",
|
|
64
|
+
"test": "node --test 'tests/**/*.test.js'"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-paste-code-block',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const module = { exports: {} }
|
|
5
|
+
const exports = module.exports
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
7
|
+
|
|
8
|
+
const React = require('react')
|
|
9
|
+
|
|
10
|
+
// ===== Constants =========================================================
|
|
11
|
+
const SOURCE = 'paste-code-block'
|
|
12
|
+
// Inline chip: DSH renders each reference as a Lexical chip whose host span
|
|
13
|
+
// carries data-composer-chip="<source>" and, inside it, a decorator span with
|
|
14
|
+
// the label as title/text. We restyle that host and hide its "@" marker.
|
|
15
|
+
const MARK = 'data-dsh-pcb'
|
|
16
|
+
const MARK_SESSION = 'data-dsh-pcb-session'
|
|
17
|
+
const DETAIL_NS = 'dsh-pcb-detail'
|
|
18
|
+
|
|
19
|
+
// ===== Block parsing =====================================================
|
|
20
|
+
const SHEBANG_LANG = {
|
|
21
|
+
python: 'python', python3: 'python', node: 'javascript', nodejs: 'javascript',
|
|
22
|
+
ruby: 'ruby', bash: 'bash', sh: 'bash', zsh: 'bash', dash: 'bash',
|
|
23
|
+
perl: 'perl', php: 'php', go: 'go', rust: 'rust', deno: 'javascript',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function detectLang(text) {
|
|
27
|
+
const t = text.trim()
|
|
28
|
+
const shebang = /^\s*#!\s*(?:\/usr\/(?:bin|local\/bin|sbin)\/env\s+)?([\w.-]+)/.exec(text)
|
|
29
|
+
if (shebang) {
|
|
30
|
+
const base = shebang[1].split('.')[0]
|
|
31
|
+
if (SHEBANG_LANG[base]) return SHEBANG_LANG[base]
|
|
32
|
+
}
|
|
33
|
+
if (/^\s*\{(?:\s|")|\n?\s*"[\w-]+"\s*:[ \t]/.test(text) || /(?:{|\[)\s*$/.test(t)) return 'json'
|
|
34
|
+
if (/^\s*<\?xml|^\s*<!DOCTYPE/.test(text)) return 'html'
|
|
35
|
+
if (/^\s*</.test(t)) return 'xml'
|
|
36
|
+
if (/^\s*(let|const|var)\s+\w+\s*=|function\s*\w*\s*\(|=>|class\s+\w+\s*\{/.test(t)) return 'javascript'
|
|
37
|
+
if (/^\s*(import|from|export)\s+/.test(t) && /(?:;|=>|\(\))/.test(t)) return 'javascript'
|
|
38
|
+
if (/^\s*(def |class \w+:|import \w+$|from \w+ import|async def )/.test(t)) return 'python'
|
|
39
|
+
if (/^\s*(SELECT|INSERT INTO|CREATE TABLE|UPDATE |DELETE FROM|ALTER TABLE)\b/i.test(t)) return 'sql'
|
|
40
|
+
if (/^\s*---\s*$|^\s*[a-z0-9_-]+\s*:\s*/i.test(t) && text.indexOf('\n') >= 0 && t.indexOf('=') < 0) return 'yaml'
|
|
41
|
+
if (/^\s*(docker run|docker compose|FROM\s|RUN |npm run|pip install)/.test(t)) return 'bash'
|
|
42
|
+
if (/^\s*(#include|int main|std::)/.test(t)) return 'cpp'
|
|
43
|
+
if (/^\s*(package |import .*\n.*func )/.test(t)) return 'go'
|
|
44
|
+
if (/^\s*(BEGIN|DECLARE)/i.test(t)) return 'sql'
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse pasted text into a block, or null when it is ordinary short prose
|
|
50
|
+
* that should paste plainly. `isCode` drives both the chip wording
|
|
51
|
+
* ("复制代码块N" vs "复制文本块N") and the fenced language on send.
|
|
52
|
+
*/
|
|
53
|
+
function parseBlock(raw) {
|
|
54
|
+
const text = String(raw || '').replace(/^\uFEFF/, '')
|
|
55
|
+
if (!text) return null
|
|
56
|
+
|
|
57
|
+
const fm = /^\s*```([\w+-]*)[^\n]*\n([\s\S]*?)\n?\s*```\s*$/.exec(text)
|
|
58
|
+
if (fm) {
|
|
59
|
+
const lang = (fm[1] || '').trim()
|
|
60
|
+
const content = fm[2].replace(/\n+$/, '')
|
|
61
|
+
if (!content) return null
|
|
62
|
+
return { lang, content, lines: content.split(/\r?\n/), isCode: true }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const multi = /\r?\n/.test(text)
|
|
66
|
+
const long = text.length >= 96
|
|
67
|
+
const fenced = /(^|\n)\s*```/m.test(text)
|
|
68
|
+
if (!multi && !long && !fenced) return null
|
|
69
|
+
|
|
70
|
+
// Markdown documents (headings, blockquotes, lists, tables, links, bold,
|
|
71
|
+
// hr, task lists) read as *text*, not code. A lone "#" alone is treated as
|
|
72
|
+
// a comment/code line, so only a combination of markdown signals flips it.
|
|
73
|
+
const mdCount = countMarkdownSignals(text)
|
|
74
|
+
|
|
75
|
+
const indent = text.split(/\r?\n/).some((l) => /^\s{2,}/.test(l))
|
|
76
|
+
const codeKw = /^(def |function |class |const |let |var |import |from |if\b|elif\b|else\b|for\b|while\b|return\b|#include|#!|SELECT |INSERT |CREATE TABLE|UPDATE |DELETE FROM|void |public |private |package |type |enum |interface )/m.test(text)
|
|
77
|
+
const dense = (text.match(/[{}()[\];]/g) || []).length >= 5
|
|
78
|
+
const strongCode = fenced || codeKw || (indent && dense)
|
|
79
|
+
|
|
80
|
+
// Markdown prose wins unless it is an actual fenced code snippet.
|
|
81
|
+
const isMarkdownProse = !fenced && mdCount >= 2
|
|
82
|
+
const isCode = !isMarkdownProse && strongCode
|
|
83
|
+
|
|
84
|
+
const lang = isCode ? detectLang(text) : ''
|
|
85
|
+
const content = text.replace(/\n+$/, '')
|
|
86
|
+
return { lang, content, lines: content.split(/\r?\n/), isCode }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Count distinct markdown structural signals. A markdown document usually
|
|
91
|
+
* carries several (e.g. headings + lists + links); code with a lone "#"
|
|
92
|
+
* comment carries just one and is therefore kept as code.
|
|
93
|
+
*/
|
|
94
|
+
function countMarkdownSignals(text) {
|
|
95
|
+
let n = 0
|
|
96
|
+
if (/^\s{0,3}#{1,6}\s+\S/m.test(text)) n += 1 // ATX heading
|
|
97
|
+
if (/^\s{0,3}>\s+/m.test(text)) n += 1 // blockquote
|
|
98
|
+
if (/^\s{0,3}([-*+]|\d{1,3}\.)\s+/m.test(text)) n += 1 // list / ordered list
|
|
99
|
+
if (/\[[^\]]+\]\([^)]*\)/.test(text)) n += 1 // link
|
|
100
|
+
if (/\*\*[^*\n]+\*\*|__[^_\n]+__/.test(text)) n += 1 // bold
|
|
101
|
+
if (/^\s*\|.*\|\s*$/m.test(text)) n += 1 // table row
|
|
102
|
+
if (/^\s*-{3,}\s*$/m.test(text)) n += 1 // horizontal rule
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function serializeBlock(block) {
|
|
107
|
+
const lang = block.isCode ? (block.lang || '') : 'text'
|
|
108
|
+
return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ===== Draft controller ==================================================
|
|
112
|
+
class BlockController {
|
|
113
|
+
constructor(ctx) {
|
|
114
|
+
this.ctx = ctx
|
|
115
|
+
this.list = new Map() // sessionId -> Block[]
|
|
116
|
+
this.selected = new Map() // sessionId -> ref
|
|
117
|
+
this.used = new Map() // sessionId -> {code:Set, text:Set}
|
|
118
|
+
this.refIndex = new Map() // ref -> {sessionId, block}
|
|
119
|
+
this.labelIndex = new Map()// label -> ref
|
|
120
|
+
this.inFlight = new Map()
|
|
121
|
+
this.expiry = new Map()
|
|
122
|
+
this.serializing = new Set()
|
|
123
|
+
this.listeners = new Map()
|
|
124
|
+
this.counter = 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Per-session sets of the label numbers currently in use, split by type
|
|
128
|
+
// (code vs text). Numbering always picks the smallest free integer, so
|
|
129
|
+
// deleting a block frees its number for reuse (text/code counted apart).
|
|
130
|
+
usedFor(sessionId) {
|
|
131
|
+
const k = String(sessionId)
|
|
132
|
+
let u = this.used.get(k)
|
|
133
|
+
if (!u) { u = { code: new Set(), text: new Set() }; this.used.set(k, u) }
|
|
134
|
+
return u
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
nextLabel(sessionId, type) {
|
|
138
|
+
const used = this.usedFor(sessionId)[type]
|
|
139
|
+
let n = 1
|
|
140
|
+
while (used.has(n)) n += 1
|
|
141
|
+
used.add(n)
|
|
142
|
+
return n
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
freeLabel(sessionId, type, n) {
|
|
146
|
+
const k = String(sessionId)
|
|
147
|
+
const used = this.used.get(k)
|
|
148
|
+
if (used) used[type]?.delete(n)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
labelFor(block, n) {
|
|
152
|
+
return block.isCode ? `复制代码块${n}` : `复制文本块${n}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
listFor(sessionId) { return this.list.get(String(sessionId)) || [] }
|
|
156
|
+
selectedFor(sessionId) { return this.selected.get(String(sessionId)) }
|
|
157
|
+
|
|
158
|
+
subscribe(sessionId, fn) {
|
|
159
|
+
const k = String(sessionId)
|
|
160
|
+
let s = this.listeners.get(k)
|
|
161
|
+
if (!s) { s = new Set(); this.listeners.set(k, s) }
|
|
162
|
+
s.add(fn)
|
|
163
|
+
return () => { s.delete(fn); if (s.size === 0) this.listeners.delete(k) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
publish(sessionId) {
|
|
167
|
+
const s = this.listeners.get(String(sessionId))
|
|
168
|
+
if (!s) return
|
|
169
|
+
for (const fn of s) fn()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
scope(sessionId) {
|
|
173
|
+
const actx = this.ctx.sessions.scope(sessionId)
|
|
174
|
+
if (!actx) throw new Error('paste-code-block: session not ready')
|
|
175
|
+
return { actx, shell: this.ctx.conversation.input.for(actx) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
insertReference(sessionId, block) {
|
|
179
|
+
const { actx, shell } = this.scope(sessionId)
|
|
180
|
+
const input = shell.snapshot
|
|
181
|
+
if (!input || input.phase !== 'plain') return false
|
|
182
|
+
// Each chip contributes exactly one char to the editor's detect text
|
|
183
|
+
// (the "" placeholder) and one char to the plain `draft` projection
|
|
184
|
+
// (clipboardText = zero-width space, chosen because String#trim() does
|
|
185
|
+
// NOT strip U+200B — so the composer's empty check `draft.trim() === ""`
|
|
186
|
+
// sees the draft as non-empty and the send button stays enabled even when
|
|
187
|
+
// only a block is pasted with no typed text). Hence draft.length already
|
|
188
|
+
// equals the detect length; anchoring there keeps blocks strictly ordered.
|
|
189
|
+
const end = input.draft ? input.draft.length : 0
|
|
190
|
+
return actx.bail(actx, 'slash/input-insert-reference', {
|
|
191
|
+
reference: {
|
|
192
|
+
source: SOURCE,
|
|
193
|
+
ref: block.id,
|
|
194
|
+
label: block.label,
|
|
195
|
+
clipboardText: '\u200B',
|
|
196
|
+
},
|
|
197
|
+
span: { start: end, end, draftRev: input.draftRev },
|
|
198
|
+
}) === true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
attach(sessionId, block) {
|
|
202
|
+
const k = String(sessionId)
|
|
203
|
+
this.clearInFlight(k)
|
|
204
|
+
block.id = `${k}-${++this.counter}`
|
|
205
|
+
const type = block.isCode ? 'code' : 'text'
|
|
206
|
+
const n = this.nextLabel(sessionId, type)
|
|
207
|
+
block.labelNumber = n
|
|
208
|
+
block.type = type
|
|
209
|
+
block.label = this.labelFor(block, n)
|
|
210
|
+
if (!this.insertReference(sessionId, block)) {
|
|
211
|
+
// release the reserved label on failure
|
|
212
|
+
this.freeLabel(sessionId, type, n)
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
const next = [...this.listFor(k), block]
|
|
216
|
+
this.list.set(k, next)
|
|
217
|
+
this.refIndex.set(block.id, { sessionId: k, block })
|
|
218
|
+
this.labelIndex.set(block.label, block.id)
|
|
219
|
+
this.publish(sessionId)
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
blockForLabel(label) {
|
|
224
|
+
const ref = this.labelIndex.get(label)
|
|
225
|
+
return ref ? this.refIndex.get(ref)?.block : undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
select(sessionId, ref) {
|
|
229
|
+
const k = String(sessionId)
|
|
230
|
+
const cur = this.selected.get(k)
|
|
231
|
+
if (cur === ref) this.selected.delete(k)
|
|
232
|
+
else this.selected.set(k, ref)
|
|
233
|
+
this.publish(sessionId)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
remove(sessionId, ref) {
|
|
237
|
+
const k = String(sessionId)
|
|
238
|
+
const entries = this.listFor(k)
|
|
239
|
+
const entry = entries.find((b) => b.id === ref)
|
|
240
|
+
if (!entry) return
|
|
241
|
+
if (this.selected.get(k) === ref) this.selected.delete(k)
|
|
242
|
+
const { shell } = this.scope(sessionId)
|
|
243
|
+
const input = shell.snapshot
|
|
244
|
+
const occurrence = (input.occurrences || []).find((o) => o.source === SOURCE && o.ref === ref)
|
|
245
|
+
if (occurrence) {
|
|
246
|
+
shell.setDraft(input.draft.slice(0, occurrence.offset) + input.draft.slice(occurrence.offset + 1))
|
|
247
|
+
}
|
|
248
|
+
const next = entries.filter((b) => b.id !== ref)
|
|
249
|
+
if (next.length > 0) this.list.set(k, next)
|
|
250
|
+
else this.list.delete(k)
|
|
251
|
+
this.refIndex.delete(ref)
|
|
252
|
+
for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
|
|
253
|
+
this.freeLabel(sessionId, entry.type, entry.labelNumber)
|
|
254
|
+
this.publish(sessionId)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Update a block's content while editing it in the detail card. */
|
|
258
|
+
updateContent(sessionId, ref, content) {
|
|
259
|
+
const record = this.refIndex.get(ref)
|
|
260
|
+
if (!record) return
|
|
261
|
+
record.block.content = content
|
|
262
|
+
record.block.lines = content.split(/\r?\n/)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
clearInFlight(sessionId) {
|
|
266
|
+
const k = String(sessionId)
|
|
267
|
+
const expiry = this.expiry.get(k)
|
|
268
|
+
if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
|
|
269
|
+
for (const b of this.inFlight.get(k) || []) {
|
|
270
|
+
this.refIndex.delete(b.id)
|
|
271
|
+
this.freeLabel(sessionId, b.type, b.labelNumber)
|
|
272
|
+
for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
|
|
273
|
+
}
|
|
274
|
+
this.inFlight.delete(k)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
markSerializing(ref) {
|
|
278
|
+
const record = this.refIndex.get(ref)
|
|
279
|
+
if (record && !this.serializing.has(record.sessionId)) this.serializing.add(record.sessionId)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async serialize(ref) {
|
|
283
|
+
const record = this.refIndex.get(ref)
|
|
284
|
+
if (!record) throw new Error('代码块已失效,请重新粘贴')
|
|
285
|
+
this.markSerializing(ref)
|
|
286
|
+
return serializeBlock(record.block)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Keep only blocks whose inline chip is still present in the draft (the
|
|
291
|
+
* user may delete a chip with Backspace). During a submit the chips may
|
|
292
|
+
* transiently vanish, so prune is deferred there.
|
|
293
|
+
*/
|
|
294
|
+
reconcile(sessionId, occurrences, phase) {
|
|
295
|
+
const k = String(sessionId)
|
|
296
|
+
const entries = this.listFor(k)
|
|
297
|
+
if (entries.length === 0) return
|
|
298
|
+
const refs = new Set((occurrences || []).filter((o) => o.source === SOURCE).map((o) => o.ref))
|
|
299
|
+
const missing = entries.filter((entry) => !refs.has(entry.id))
|
|
300
|
+
if (missing.length === 0) return
|
|
301
|
+
|
|
302
|
+
if (this.serializing.delete(k)) {
|
|
303
|
+
this.list.delete(k)
|
|
304
|
+
this.inFlight.set(k, entries)
|
|
305
|
+
const prev = this.expiry.get(k)
|
|
306
|
+
if (prev) clearTimeout(prev)
|
|
307
|
+
this.expiry.set(k, setTimeout(() => this.clearInFlight(k), 20_000))
|
|
308
|
+
this.publish(sessionId)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (phase === 'claimed' || phase === 'settling') return
|
|
313
|
+
|
|
314
|
+
for (const entry of missing) {
|
|
315
|
+
this.refIndex.delete(entry.id)
|
|
316
|
+
for (const [label, r] of this.labelIndex) if (r === entry.id) this.labelIndex.delete(label)
|
|
317
|
+
if (this.selected.get(k) === entry.id) this.selected.delete(k)
|
|
318
|
+
this.freeLabel(sessionId, entry.type, entry.labelNumber)
|
|
319
|
+
}
|
|
320
|
+
const next = entries.filter((entry) => refs.has(entry.id))
|
|
321
|
+
if (next.length > 0) this.list.set(k, next)
|
|
322
|
+
else this.list.delete(k)
|
|
323
|
+
this.publish(sessionId)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
restoreFailed(sessionId) {
|
|
327
|
+
// Best-effort: on a failed submit, simply surface in-flight blocks again.
|
|
328
|
+
// The draft chips are usually untouched on failure, so reconciliation
|
|
329
|
+
// keeps them; we only re-insert markers that were consumed.
|
|
330
|
+
const k = String(sessionId)
|
|
331
|
+
const entries = this.inFlight.get(k) || []
|
|
332
|
+
const expiry = this.expiry.get(k)
|
|
333
|
+
if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
|
|
334
|
+
this.inFlight.delete(k)
|
|
335
|
+
const survived = []
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
this.refIndex.set(entry.id, { sessionId: k, block: entry })
|
|
338
|
+
this.labelIndex.set(entry.label, entry.id)
|
|
339
|
+
// A chip still present in the draft is enough; skip re-insert attempt
|
|
340
|
+
// if the shell can't resolve (rare). Reconcile will re-prune as needed.
|
|
341
|
+
survived.push(entry)
|
|
342
|
+
}
|
|
343
|
+
if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
|
|
344
|
+
this.publish(sessionId)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
dispose() {
|
|
348
|
+
for (const t of this.expiry.values()) clearTimeout(t)
|
|
349
|
+
this.expiry.clear(); this.list.clear(); this.selected.clear()
|
|
350
|
+
this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
|
|
351
|
+
this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ===== React glue ========================================================
|
|
356
|
+
function useBlocks(controller, sessionId) {
|
|
357
|
+
const [, render] = React.useState(0)
|
|
358
|
+
React.useEffect(
|
|
359
|
+
() => controller.subscribe(sessionId, () => render((v) => v + 1)),
|
|
360
|
+
[controller, sessionId],
|
|
361
|
+
)
|
|
362
|
+
return controller.listFor(sessionId)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function useSelectedBlock(controller, sessionId) {
|
|
366
|
+
const [, render] = React.useState(0)
|
|
367
|
+
React.useEffect(
|
|
368
|
+
() => controller.subscribe(sessionId, () => render((v) => v + 1)),
|
|
369
|
+
[controller, sessionId],
|
|
370
|
+
)
|
|
371
|
+
const ref = controller.selectedFor(sessionId)
|
|
372
|
+
if (!ref) return null
|
|
373
|
+
const record = controller.refIndex.get(ref)
|
|
374
|
+
return record ? record.block : null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function useComposerMark(rootRef, sessionId, draftRev, active) {
|
|
378
|
+
React.useLayoutEffect(() => {
|
|
379
|
+
const root = rootRef.current
|
|
380
|
+
if (!root) return
|
|
381
|
+
let el = root.parentElement
|
|
382
|
+
while (el) {
|
|
383
|
+
if (el.nodeType === 1 && el.querySelector && el.querySelector('textarea,[contenteditable],[role="textbox"]')) break
|
|
384
|
+
el = el.parentElement
|
|
385
|
+
}
|
|
386
|
+
if (!el) return
|
|
387
|
+
el.setAttribute(MARK, '1')
|
|
388
|
+
el.setAttribute(MARK_SESSION, String(sessionId))
|
|
389
|
+
// Invisible block markers can leave the draft reading as "empty", so the
|
|
390
|
+
// composer's own placeholder would otherwise sit on top of the chips.
|
|
391
|
+
el.setAttribute('data-dsh-pcb-active', active ? '1' : '0')
|
|
392
|
+
}, [sessionId, draftRev, active])
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ===== Render: the small inline chips are DSH's own; we style them via CSS. =====
|
|
396
|
+
// This dock renders a **detail card only for the selected block** (clicked chip).
|
|
397
|
+
function BlockDock(props) {
|
|
398
|
+
const input = props.useInput((state) => state) || {}
|
|
399
|
+
const promptError = props.useSession((session) => session.promptError) || null
|
|
400
|
+
const blocks = useBlocks(props.controller, props.sessionId)
|
|
401
|
+
const selected = useSelectedBlock(props.controller, props.sessionId)
|
|
402
|
+
const railRef = React.useRef(null)
|
|
403
|
+
|
|
404
|
+
useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
|
|
405
|
+
|
|
406
|
+
React.useEffect(() => {
|
|
407
|
+
props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
|
|
408
|
+
}, [props.controller, props.sessionId, input.draftRev, input.phase])
|
|
409
|
+
|
|
410
|
+
React.useEffect(() => {
|
|
411
|
+
if (promptError && props.controller.inFlight.has(String(props.sessionId))) {
|
|
412
|
+
props.controller.restoreFailed(props.sessionId)
|
|
413
|
+
}
|
|
414
|
+
}, [props.controller, props.sessionId, promptError])
|
|
415
|
+
|
|
416
|
+
const empty = !selected
|
|
417
|
+
return React.createElement(
|
|
418
|
+
'div',
|
|
419
|
+
{
|
|
420
|
+
ref: railRef,
|
|
421
|
+
className: `dsh-pcb-dock${empty ? ' dsh-pcb-dock--empty' : ''}`,
|
|
422
|
+
'data-dsh-pcb-rail': '',
|
|
423
|
+
'data-dsh-pcb-session': String(props.sessionId),
|
|
424
|
+
'aria-label': '代码块详情',
|
|
425
|
+
},
|
|
426
|
+
selected
|
|
427
|
+
? React.createElement(BlockDetail, {
|
|
428
|
+
block: selected,
|
|
429
|
+
controller: props.controller,
|
|
430
|
+
sessionId: props.sessionId,
|
|
431
|
+
})
|
|
432
|
+
: null,
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function BlockDetail(props) {
|
|
437
|
+
const { block, controller, sessionId } = props
|
|
438
|
+
const [collapsed, setCollapsed] = React.useState(false)
|
|
439
|
+
const [copied, setCopied] = React.useState(false)
|
|
440
|
+
// Editable draft: lets the user edit the block's content in the detail
|
|
441
|
+
// card; kept in local state (so the caret isn't disturbed) and mirrored to
|
|
442
|
+
// the controller so the serialized send uses the edited text.
|
|
443
|
+
const [draft, setDraft] = React.useState(block.content)
|
|
444
|
+
|
|
445
|
+
React.useEffect(() => {
|
|
446
|
+
setDraft(block.content)
|
|
447
|
+
}, [block.id])
|
|
448
|
+
|
|
449
|
+
function onEdit(ev) {
|
|
450
|
+
const value = ev.target.value
|
|
451
|
+
setDraft(value)
|
|
452
|
+
controller.updateContent(sessionId, block.id, value)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function copy() {
|
|
456
|
+
navigator.clipboard?.writeText(block.content)?.then(() => {
|
|
457
|
+
setCopied(true)
|
|
458
|
+
setTimeout(() => setCopied(false), 1200)
|
|
459
|
+
}).catch(() => {})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function hide() {
|
|
463
|
+
controller.select(sessionId, block.id) // toggles off (same id)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return React.createElement(
|
|
467
|
+
'div',
|
|
468
|
+
{ className: `dsh-pcb-detail${collapsed ? ' dsh-pcb-detail--collapsed' : ''}` },
|
|
469
|
+
React.createElement(
|
|
470
|
+
'div',
|
|
471
|
+
{ className: 'dsh-pcb-head' },
|
|
472
|
+
React.createElement('span', { className: 'dsh-pcb-lang' }, block.lang || 'text'),
|
|
473
|
+
React.createElement('span', { className: 'dsh-pcb-lines' }, `${block.lines.length} 行`),
|
|
474
|
+
React.createElement('span', { className: 'dsh-pcb-spacer' }),
|
|
475
|
+
collapsed ? React.createElement('button', {
|
|
476
|
+
type: 'button', className: 'dsh-pcb-btn', title: '展开', 'aria-label': '展开',
|
|
477
|
+
onClick: () => setCollapsed(false),
|
|
478
|
+
}, '▾') : React.createElement('button', {
|
|
479
|
+
type: 'button', className: 'dsh-pcb-btn', title: '折叠', 'aria-label': '折叠',
|
|
480
|
+
onClick: () => setCollapsed(true),
|
|
481
|
+
}, '▸'),
|
|
482
|
+
React.createElement('button', {
|
|
483
|
+
type: 'button', className: 'dsh-pcb-btn', title: '复制', 'aria-label': '复制',
|
|
484
|
+
onClick: copy,
|
|
485
|
+
}, copied ? '✓' : '⎘'),
|
|
486
|
+
React.createElement('button', {
|
|
487
|
+
type: 'button', className: 'dsh-pcb-btn', title: '收起', 'aria-label': '收起',
|
|
488
|
+
onClick: hide,
|
|
489
|
+
}, '⟨'),
|
|
490
|
+
React.createElement('button', {
|
|
491
|
+
type: 'button', className: 'dsh-pcb-btn dsh-pcb-btn--danger', title: '移除', 'aria-label': '移除',
|
|
492
|
+
onClick: () => controller.remove(sessionId, block.id),
|
|
493
|
+
}, '×'),
|
|
494
|
+
),
|
|
495
|
+
collapsed
|
|
496
|
+
? React.createElement('div', { className: 'dsh-pcb-preview' }, block.lines.slice(0, 1).join('\n'))
|
|
497
|
+
: React.createElement('textarea', {
|
|
498
|
+
className: 'dsh-pcb-body dsh-pcb-body--edit',
|
|
499
|
+
value: draft,
|
|
500
|
+
onChange: onEdit,
|
|
501
|
+
spellCheck: false,
|
|
502
|
+
rows: Math.min(Math.max(block.lines.length, 2), 12),
|
|
503
|
+
'aria-label': '编辑代码块内容',
|
|
504
|
+
}),
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ===== Styles ============================================================
|
|
509
|
+
const CSS = `
|
|
510
|
+
/* --- restyle DSH's inline reference chip into a boxed badge --- */
|
|
511
|
+
[data-composer-chip="${SOURCE}"] > span{
|
|
512
|
+
box-sizing:border-box!important;display:inline-flex!important;align-items:center;
|
|
513
|
+
gap:4px!important;vertical-align:middle;margin:0 2px;padding:1px 8px!important;
|
|
514
|
+
border:1px solid var(--dsw-alias-border-l3)!important;border-radius:7px!important;
|
|
515
|
+
background:var(--dsw-specific-input-major,var(--dsw-alias-interactive-bg-hover))!important;
|
|
516
|
+
color:var(--dsw-alias-label-primary)!important;
|
|
517
|
+
font-family:var(--dsw-font-family)!important;font-size:12px!important;line-height:18px!important;
|
|
518
|
+
cursor:pointer;box-shadow:var(--dsw-shadow-lv1);min-width:0;max-width:100%;
|
|
519
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap
|
|
520
|
+
}
|
|
521
|
+
[data-composer-chip="${SOURCE}"]:hover > span{
|
|
522
|
+
border-color:var(--dsw-alias-state-business-primary)!important;
|
|
523
|
+
background:var(--dsw-alias-interactive-bg-hover)!important
|
|
524
|
+
}
|
|
525
|
+
/* hide the "@" marker that DSH renders when the chip has no appearance glyph */
|
|
526
|
+
[data-composer-chip="${SOURCE}"] > span > [aria-hidden="true"]{display:none!important}
|
|
527
|
+
/* the label text */
|
|
528
|
+
[data-composer-chip="${SOURCE}"] > span > span:last-child{font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
529
|
+
|
|
530
|
+
/* --- dock / detail card above the input --- */
|
|
531
|
+
.dsh-pcb-dock{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width);margin:0 auto 6px;padding:0 var(--dsh-composer-side-clearance,16px);display:flex;flex-direction:column;gap:8px}
|
|
532
|
+
.dsh-pcb-dock--empty{display:none}
|
|
533
|
+
.dsh-pcb-detail{box-sizing:border-box;width:100%;min-width:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary);overflow:hidden}
|
|
534
|
+
.dsh-pcb-head{box-sizing:border-box;height:32px;flex:none;display:flex;align-items:center;gap:8px;padding:0 6px 0 10px;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
535
|
+
.dsh-pcb-lang{flex:none;max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-state-business-primary,#4c6ef5);background:var(--dsw-alias-interactive-bg-hover);border-radius:6px;padding:1px 7px}
|
|
536
|
+
.dsh-pcb-lines{flex:none;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
|
|
537
|
+
.dsh-pcb-spacer{flex:1;min-width:0}
|
|
538
|
+
.dsh-pcb-btn{box-sizing:border-box;width:26px;height:26px;flex:none;display:grid;place-items:center;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1;cursor:pointer}
|
|
539
|
+
.dsh-pcb-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
540
|
+
.dsh-pcb-btn--danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
|
|
541
|
+
.dsh-pcb-preview{box-sizing:border-box;padding:6px 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
|
|
542
|
+
.dsh-pcb-body{box-sizing:border-box;margin:0;max-height:260px;overflow:auto;padding:8px 12px;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:1.5;white-space:pre;word-break:break-all;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
|
|
543
|
+
.dsh-pcb-body--edit{display:block;width:100%;min-height:56px;max-height:300px;resize:vertical;border:0;outline:none;border-radius:0;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);white-space:pre;overflow:auto;cursor:text}
|
|
544
|
+
.dsh-pcb-body--edit:focus{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:-1px}
|
|
545
|
+
|
|
546
|
+
/* --- hide the composer's own placeholder while chips/cards are active --- */
|
|
547
|
+
[data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
|
|
548
|
+
|
|
549
|
+
@media (max-width:640px){
|
|
550
|
+
.dsh-pcb-head{height:34px}
|
|
551
|
+
.dsh-pcb-lang{max-width:55%}
|
|
552
|
+
}
|
|
553
|
+
`
|
|
554
|
+
|
|
555
|
+
// ===== Client plugin body ===============================================
|
|
556
|
+
const inject = ['slots', 'sessions', 'conversation', 'inputTriggers']
|
|
557
|
+
|
|
558
|
+
function apply(ctx) {
|
|
559
|
+
try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
|
|
560
|
+
const controller = new BlockController(ctx)
|
|
561
|
+
ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
|
|
562
|
+
|
|
563
|
+
ctx.effect(() => ctx.inputTriggers.registerSource({
|
|
564
|
+
trigger: '@',
|
|
565
|
+
name: SOURCE,
|
|
566
|
+
order: 20_000,
|
|
567
|
+
candidates: async () => [],
|
|
568
|
+
onPick: () => undefined,
|
|
569
|
+
codec: {
|
|
570
|
+
clipboardText: () => '',
|
|
571
|
+
serialize: async (ref) => controller.serialize(ref),
|
|
572
|
+
},
|
|
573
|
+
}), 'paste-code-block: hidden reference codec')
|
|
574
|
+
|
|
575
|
+
ctx.effect(() => {
|
|
576
|
+
const style = document.createElement('style')
|
|
577
|
+
style.dataset.plugin = 'dsh-paste-code-block'
|
|
578
|
+
style.textContent = CSS
|
|
579
|
+
document.head.appendChild(style)
|
|
580
|
+
return () => style.remove()
|
|
581
|
+
}, 'paste-code-block: styles')
|
|
582
|
+
|
|
583
|
+
// Resolve the active session for an event inside the composer seat.
|
|
584
|
+
function sessionFor(target) {
|
|
585
|
+
const seat = target.closest(`[${MARK}]`)
|
|
586
|
+
let sid = seat && seat.getAttribute(MARK_SESSION)
|
|
587
|
+
if (!sid && target.parentElement) {
|
|
588
|
+
let el = target.parentElement
|
|
589
|
+
while (el) {
|
|
590
|
+
const rail = el.querySelector && el.querySelector('[data-dsh-pcb-rail]')
|
|
591
|
+
if (rail) { sid = rail.getAttribute('data-dsh-pcb-session') || sid; break }
|
|
592
|
+
el = el.parentElement
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return sid
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Turn qualifying pastes into boxed inline chips.
|
|
599
|
+
const onPaste = (ev) => {
|
|
600
|
+
const clip = ev.clipboardData
|
|
601
|
+
if (!clip) return
|
|
602
|
+
const text = clip.getData('text/plain')
|
|
603
|
+
if (!text || text.length === 0) return
|
|
604
|
+
const target = document.activeElement
|
|
605
|
+
if (!target || !(target instanceof Element)) return
|
|
606
|
+
const sid = sessionFor(target)
|
|
607
|
+
if (!sid) return
|
|
608
|
+
const block = parseBlock(text)
|
|
609
|
+
if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
|
|
610
|
+
console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang)
|
|
611
|
+
ev.preventDefault()
|
|
612
|
+
ev.stopPropagation()
|
|
613
|
+
try {
|
|
614
|
+
if (!controller.attach(sid, block)) {
|
|
615
|
+
console.log('[dsh-paste-code-block] attach failed; plain insert instead')
|
|
616
|
+
const { actx, shell } = controller.scope(sid)
|
|
617
|
+
const input = shell.snapshot
|
|
618
|
+
if (input && actx.bail(actx, 'slash/input-insert-text', {
|
|
619
|
+
text: text + (text.endsWith('\n') ? '' : '\n'),
|
|
620
|
+
span: {
|
|
621
|
+
start: input.draft ? input.draft.length : 0,
|
|
622
|
+
end: input.draft ? input.draft.length : 0,
|
|
623
|
+
draftRev: input.draftRev,
|
|
624
|
+
},
|
|
625
|
+
}) !== true) {
|
|
626
|
+
console.warn('[dsh-paste-code-block] could not insert block text')
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.error('[paste-code-block] attach failed:', err)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
ctx.effect(() => {
|
|
634
|
+
document.addEventListener('paste', onPaste, true)
|
|
635
|
+
return () => document.removeEventListener('paste', onPaste, true)
|
|
636
|
+
}, 'paste-code-block: paste capture')
|
|
637
|
+
|
|
638
|
+
// Clicking an inline chip toggles its detail card.
|
|
639
|
+
const onChipPointer = (ev) => {
|
|
640
|
+
const target = ev.target
|
|
641
|
+
if (!(target instanceof Element)) return
|
|
642
|
+
// Clicking a chip toggles its detail card.
|
|
643
|
+
const host = target.closest(`[data-composer-chip="${SOURCE}"]`)
|
|
644
|
+
if (host) {
|
|
645
|
+
const labelEl = host.querySelector(':scope > span[title]') || host.querySelector('span[title]')
|
|
646
|
+
const label = labelEl && labelEl.getAttribute('title')
|
|
647
|
+
const sid = sessionFor(target)
|
|
648
|
+
if (!sid || !label) return
|
|
649
|
+
const block = controller.blockForLabel(label)
|
|
650
|
+
if (!block) return
|
|
651
|
+
ev.preventDefault()
|
|
652
|
+
ev.stopPropagation()
|
|
653
|
+
controller.select(sid, block.id)
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
// Clicking anywhere OUTSIDE the open detail card closes it.
|
|
657
|
+
const withinDetail = target.closest('.dsh-pcb-detail')
|
|
658
|
+
if (withinDetail) return
|
|
659
|
+
for (const [k, ref] of controller.selected) {
|
|
660
|
+
controller.selected.delete(k)
|
|
661
|
+
controller.publish(k)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
ctx.effect(() => {
|
|
665
|
+
document.addEventListener('pointerdown', onChipPointer, true)
|
|
666
|
+
return () => document.removeEventListener('pointerdown', onChipPointer, true)
|
|
667
|
+
}, 'paste-code-block: chip pointer')
|
|
668
|
+
|
|
669
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
670
|
+
name: 'conversation.input.dock',
|
|
671
|
+
id: DETAIL_NS,
|
|
672
|
+
order: 90,
|
|
673
|
+
label: '粘贴代码块详情',
|
|
674
|
+
}, (props) => React.createElement(BlockDock, { ...props, controller })))
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
exports.apply = apply
|
|
678
|
+
exports.inject = inject
|
|
679
|
+
return module.exports
|
|
680
|
+
},
|
|
681
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-paste-code-block — host (node) half.
|
|
3
|
+
*
|
|
4
|
+
* Pure UI plugin: the host body is intentionally empty. The browser half ships
|
|
5
|
+
* via exports["./client"], discovered through the package.json `dsh.client`
|
|
6
|
+
* declaration and mounted into the Web composer by the host layer.
|
|
7
|
+
*/
|
|
8
|
+
export const name = 'dsh-paste-code-block'
|
|
9
|
+
|
|
10
|
+
/** Host plugin body — no host-side behavior. */
|
|
11
|
+
export function apply() {}
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-paste-code-block — pure block-parsing logic (no DOM, no DSH deps).
|
|
3
|
+
*
|
|
4
|
+
* Canonical source for how a pasted text/code snippet is turned into a block
|
|
5
|
+
* object. The browser half (`client.js`) embeds its own copy of this logic so
|
|
6
|
+
* it can run inside DSH's client module loader (which cannot `require` relative
|
|
7
|
+
* ESM files); this module is the single place where that logic is unit-tested.
|
|
8
|
+
*
|
|
9
|
+
* Keep it framework-free and side-effect-free so it is trivially testable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SHEBANG_LANG = {
|
|
13
|
+
python: 'python', python3: 'python', node: 'javascript', nodejs: 'javascript',
|
|
14
|
+
ruby: 'ruby', bash: 'bash', sh: 'bash', zsh: 'bash', dash: 'bash',
|
|
15
|
+
perl: 'perl', php: 'php', go: 'go', rust: 'rust', deno: 'javascript',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function detectLang(text) {
|
|
19
|
+
const t = text.trim()
|
|
20
|
+
const shebang = /^\s*#!\s*(?:\/usr\/(?:bin|local\/bin|sbin)\/env\s+)?([\w.-]+)/.exec(text)
|
|
21
|
+
if (shebang) {
|
|
22
|
+
const base = shebang[1].split('.')[0]
|
|
23
|
+
if (SHEBANG_LANG[base]) return SHEBANG_LANG[base]
|
|
24
|
+
}
|
|
25
|
+
if (/^\s*\{(?:\s|")|\n?\s*"[\w-]+"\s*:[ \t]/.test(text) || /(?:{|\[)\s*$/.test(t)) return 'json'
|
|
26
|
+
if (/^\s*<\?xml|^\s*<!DOCTYPE/.test(text)) return 'html'
|
|
27
|
+
if (/^\s*</.test(t)) return 'xml'
|
|
28
|
+
if (/^\s*(let|const|var)\s+\w+\s*=|function\s*\w*\s*\(|=>|class\s+\w+\s*\{/.test(t)) return 'javascript'
|
|
29
|
+
if (/^\s*(import|from|export)\s+/.test(t) && /(?:;|=>|\(\))/.test(t)) return 'javascript'
|
|
30
|
+
if (/^\s*(def |class \w+:|import \w+$|from \w+ import|async def )/.test(t)) return 'python'
|
|
31
|
+
if (/^\s*(SELECT|INSERT INTO|CREATE TABLE|UPDATE |DELETE FROM|ALTER TABLE)\b/i.test(t)) return 'sql'
|
|
32
|
+
if (/^\s*---\s*$|^\s*[a-z0-9_-]+\s*:\s*/i.test(t) && text.indexOf('\n') >= 0 && t.indexOf('=') < 0) return 'yaml'
|
|
33
|
+
if (/^\s*(docker run|docker compose|FROM\s|RUN |npm run|pip install)/.test(t)) return 'bash'
|
|
34
|
+
if (/^\s*(#include|int main|std::)/.test(t)) return 'cpp'
|
|
35
|
+
if (/^\s*(package |import .*\n.*func )/.test(t)) return 'go'
|
|
36
|
+
if (/^\s*(BEGIN|DECLARE)/i.test(t)) return 'sql'
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse pasted text into a block, or null when it is ordinary short prose
|
|
42
|
+
* that should paste plainly. `isCode` drives both the chip wording
|
|
43
|
+
* ("复制代码块N" vs "复制文本块N") and the fenced language on send.
|
|
44
|
+
*/
|
|
45
|
+
export function parseBlock(raw) {
|
|
46
|
+
const text = String(raw || '').replace(/^\uFEFF/, '')
|
|
47
|
+
if (!text) return null
|
|
48
|
+
|
|
49
|
+
const fm = /^\s*```([\w+-]*)[^\n]*\n([\s\S]*?)\n?\s*```\s*$/.exec(text)
|
|
50
|
+
if (fm) {
|
|
51
|
+
const lang = (fm[1] || '').trim()
|
|
52
|
+
const content = fm[2].replace(/\n+$/, '')
|
|
53
|
+
if (!content) return null
|
|
54
|
+
return { lang, content, lines: content.split(/\r?\n/), isCode: true }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const multi = /\r?\n/.test(text)
|
|
58
|
+
const long = text.length >= 96
|
|
59
|
+
const fenced = /(^|\n)\s*```/m.test(text)
|
|
60
|
+
if (!multi && !long && !fenced) return null
|
|
61
|
+
|
|
62
|
+
// Markdown documents (headings, blockquotes, lists, tables, links, bold,
|
|
63
|
+
// hr, task lists) read as *text*, not code. A lone "#" alone is treated as
|
|
64
|
+
// a comment/code line, so only a combination of markdown signals flips it.
|
|
65
|
+
const mdCount = countMarkdownSignals(text)
|
|
66
|
+
|
|
67
|
+
const indent = text.split(/\r?\n/).some((l) => /^\s{2,}/.test(l))
|
|
68
|
+
const codeKw = /^(def |function |class |const |let |var |import |from |if\b|elif\b|else\b|for\b|while\b|return\b|#include|#!|SELECT |INSERT |CREATE TABLE|UPDATE |DELETE FROM|void |public |private |package |type |enum |interface )/m.test(text)
|
|
69
|
+
const dense = (text.match(/[{}()[\];]/g) || []).length >= 5
|
|
70
|
+
const strongCode = fenced || codeKw || (indent && dense)
|
|
71
|
+
|
|
72
|
+
// Markdown prose wins unless it is an actual fenced code snippet.
|
|
73
|
+
const isMarkdownProse = !fenced && mdCount >= 2
|
|
74
|
+
const isCode = !isMarkdownProse && strongCode
|
|
75
|
+
|
|
76
|
+
const lang = isCode ? detectLang(text) : ''
|
|
77
|
+
const content = text.replace(/\n+$/, '')
|
|
78
|
+
return { lang, content, lines: content.split(/\r?\n/), isCode }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Count distinct markdown structural signals. A markdown document usually
|
|
83
|
+
* carries several (e.g. headings + lists + links); code with a lone "#"
|
|
84
|
+
* comment carries just one and is therefore kept as code.
|
|
85
|
+
*/
|
|
86
|
+
export function countMarkdownSignals(text) {
|
|
87
|
+
let n = 0
|
|
88
|
+
if (/^\s{0,3}#{1,6}\s+\S/m.test(text)) n += 1 // ATX heading
|
|
89
|
+
if (/^\s{0,3}>\s+/m.test(text)) n += 1 // blockquote
|
|
90
|
+
if (/^\s{0,3}([-*+]|\d{1,3}\.)\s+/m.test(text)) n += 1 // list / ordered list
|
|
91
|
+
if (/\[[^\]]+\]\([^)]*\)/.test(text)) n += 1 // link
|
|
92
|
+
if (/\*\*[^*\n]+\*\*|__[^_\n]+__/.test(text)) n += 1 // bold
|
|
93
|
+
if (/^\s*\|.*\|\s*$/m.test(text)) n += 1 // table row
|
|
94
|
+
if (/^\s*-{3,}\s*$/m.test(text)) n += 1 // horizontal rule
|
|
95
|
+
return n
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function serializeBlock(block) {
|
|
99
|
+
const lang = block.isCode ? (block.lang || '') : 'text'
|
|
100
|
+
return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
|
|
101
|
+
}
|