dsh-doc-preview 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 +69 -0
- package/cordis.patch.yml +17 -0
- package/lib/index.js +334 -0
- package/lib/types/index.d.ts +49 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek Harness plugin authors
|
|
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,69 @@
|
|
|
1
|
+
# dsh-doc-preview
|
|
2
|
+
|
|
3
|
+
在 DeepSeek Harness Web 里**按工作区预览文档**的 dsh bundle 路由插件。
|
|
4
|
+
|
|
5
|
+
它复用 dsh-web-app 已挂载的 `ctx.webServer` 服务注册 `/docs` 前缀路由,并用 `ctx.workspaceRegistry`
|
|
6
|
+
读取 **dsh 真实登记的工作区**,把 `/docs/<工作区id>/…` 映射到对应工作区目录。每个工作区独立命名空间,
|
|
7
|
+
文档不会跨工作区混杂,也无法越界访问系统目录。
|
|
8
|
+
|
|
9
|
+
## 地址规则
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
http://<host>/docs -> 所有已登记工作区的索引页
|
|
13
|
+
http://<host>/docs/<工作区id>/ -> 该工作区根目录列表
|
|
14
|
+
http://<host>/docs/<工作区id>/<相对路径>.md -> 渲染该工作区内的文档
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`<工作区id>` = 工作区真实目录路径去掉前导 `/`、`/` 改 `-`。例如:
|
|
18
|
+
|
|
19
|
+
| 工作区目录 | 工作区 id | 示例地址 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `/home/lc/deepseek-no1` | `home-lc-deepseek-no1` | `/docs/home-lc-deepseek-no1/docs-design/调研.md` |
|
|
22
|
+
| `/root/documents` | `root-documents` | `/docs/root-documents/报告.md` |
|
|
23
|
+
|
|
24
|
+
- 工作区列表来自 `ctx.workspaceRegistry.list()`(持久化于 `storages/workspace.json`),**新登记的工作区自动出现**,无需改插件。
|
|
25
|
+
- **隔离**:请求被限制在对应工作区目录内;未知工作区、路径穿越(`..`)、畸形多斜杠一律 404。
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dsh plugin --profile web add /path/to/docs-preview-plugin
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
依赖:必须运行在**已加载 `ctx.webServer` 与 `ctx.workspaceRegistry`** 的 profile(即 `web` 形态,
|
|
34
|
+
dsh-web-app 自带这两个服务)。headless / sdk 这类没有 web host 的 profile 不适用。
|
|
35
|
+
|
|
36
|
+
## 配置
|
|
37
|
+
|
|
38
|
+
无需配置即可工作。可选字段(`cordis.patch.yml` 的 `config`):
|
|
39
|
+
|
|
40
|
+
| 字段 | 说明 |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `base` | 保留的旧字面路径兜底根目录,默认 `/`(有 workspaceRegistry 时一般用不到) |
|
|
43
|
+
|
|
44
|
+
## 文件结构
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/index.ts # 插件源(name/inject/apply,路由 + md 渲染)
|
|
48
|
+
lib/index.js # 构建产物(Loader 实际加载)
|
|
49
|
+
cordis.patch.yml # 插入 doc-preview 行,inject webServer + workspaceRegistry
|
|
50
|
+
tests/drive-route.mjs # 路由自测(mock workspaceRegistry)
|
|
51
|
+
tests/render-test.mjs # md 渲染自测
|
|
52
|
+
tests/logic-test.mjs # 渲染边界 + 穿越防护自测
|
|
53
|
+
README.md / ENABLE.md
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 自测
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
node tests/drive-route.mjs # 工作区映射/隔离/404
|
|
60
|
+
tsx --tsconfig tsconfig.json tests/render-test.mjs
|
|
61
|
+
tsx --tsconfig tsconfig.json tests/logic-test.mjs
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 说明 / 限制
|
|
65
|
+
|
|
66
|
+
- 这是**宿主端 HTTP 路由**,`/docs` 无会话上下文;工作区由 URL 首段显式指定,索引页列出全部登记工作区。
|
|
67
|
+
- 渲染器为精简 Markdown 实现(标题/列表/表格/代码块/引用/粗斜体/行内代码/链接/复选框/分隔线)。
|
|
68
|
+
- 依赖 `workspaceRegistry`(`@deepseek-ai/dsh-workspace`,web profile 默认携带)。
|
|
69
|
+
- 当前为 `private: true` 的本地 bundle;如需分发给他人,可去掉 `private` 并补齐 `files`,或直接用 `dsh plugin add <路径|git地址>`。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# dsh-doc-preview bundle patch: add a /docs route that previews local docs.
|
|
2
|
+
# It ONLY adds a new row; it does not redefine the existing `webserver` row
|
|
3
|
+
# (that is owned by dsh-web-app). The plugin injects the already-present
|
|
4
|
+
# `ctx.webServer` service and registers a prefix route on it.
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: doc-preview
|
|
8
|
+
name: 'dsh-doc-preview'
|
|
9
|
+
inject: [webServer, workspaceRegistry]
|
|
10
|
+
config:
|
|
11
|
+
# /docs routes by real workspace, resolved from ctx.workspaceRegistry
|
|
12
|
+
# (the dsh-host-workspace service, present in every web profile).
|
|
13
|
+
# /docs/ -> index of registered workspaces
|
|
14
|
+
# /docs/<workspaceId>/<rel> -> confined to that workspace directory
|
|
15
|
+
# workspaceId = workspace path with leading "/" dropped and "/" -> "-",
|
|
16
|
+
# e.g. /home/lc/deepseek-no1 -> home-lc-deepseek-no1.
|
|
17
|
+
# Example: /docs/home-lc-deepseek-no1/docs-design/调研.md
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-doc-preview — serve & preview local Markdown/HTML documents in the
|
|
3
|
+
* Web GUI through a /docs prefix route on ctx.webServer.
|
|
4
|
+
*
|
|
5
|
+
* This is a function plugin: it exports `name`, `inject`, `Config` and `apply`.
|
|
6
|
+
* It registers a `prefix` HTTP route on the already-present `ctx.webServer`
|
|
7
|
+
* service (mounted by dsh-web-app), so it composes into the `web` profile
|
|
8
|
+
* without redefining the webserver row.
|
|
9
|
+
*
|
|
10
|
+
* Route behavior under /docs:
|
|
11
|
+
* /docs -> directory listing (HTML)
|
|
12
|
+
* /docs/<x>.md -> rendered HTML page
|
|
13
|
+
* /docs/<x>.html -> served raw
|
|
14
|
+
* /docs/<x> (any other file) -> served raw with an inferred content type
|
|
15
|
+
* Traversal outside `root` is rejected (404).
|
|
16
|
+
*/
|
|
17
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { basename, extname, resolve, sep } from 'node:path';
|
|
19
|
+
export const name = 'doc-preview';
|
|
20
|
+
export const inject = ['webServer', 'workspaceRegistry'];
|
|
21
|
+
const MIME = {
|
|
22
|
+
'.html': 'text/html; charset=utf-8',
|
|
23
|
+
'.md': 'text/html; charset=utf-8',
|
|
24
|
+
'.markdown': 'text/html; charset=utf-8',
|
|
25
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
26
|
+
'.json': 'application/json; charset=utf-8',
|
|
27
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
28
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
29
|
+
'.cjs': 'text/javascript; charset=utf-8',
|
|
30
|
+
'.css': 'text/css; charset=utf-8',
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.svg': 'image/svg+xml',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.woff2': 'font/woff2',
|
|
38
|
+
'.yaml': 'text/plain; charset=utf-8',
|
|
39
|
+
'.yml': 'text/plain; charset=utf-8',
|
|
40
|
+
};
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Minimal dependency-free Markdown -> HTML. Rooms for headings, lists, tables,
|
|
43
|
+
// code fences, blockquote, bold/italic, inline code, links, hr, checkboxes.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
function esc(s) {
|
|
46
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
47
|
+
}
|
|
48
|
+
function inline(s) {
|
|
49
|
+
return s
|
|
50
|
+
.replace(/`([^`]+)`/g, (_, c) => `<code>${esc(c)}</code>`)
|
|
51
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, b) => `<strong>${esc(b)}</strong>`)
|
|
52
|
+
.replace(/\*([^*]+)\*/g, (_, i) => `<em>${esc(i)}</em>`)
|
|
53
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `<a href="${esc(u)}">${esc(t)}</a>`);
|
|
54
|
+
}
|
|
55
|
+
function renderTable(rows) {
|
|
56
|
+
const head = rows[0] ?? [];
|
|
57
|
+
const body = rows.slice(1);
|
|
58
|
+
let h = '<table><thead><tr>' + head.map(c => `<th>${c}</th>`).join('') + '</tr></thead><tbody>';
|
|
59
|
+
for (const r of body)
|
|
60
|
+
h += '<tr>' + r.map(c => `<td>${c}</td>`).join('') + '</tr>';
|
|
61
|
+
return h + '</tbody></table>';
|
|
62
|
+
}
|
|
63
|
+
export function markdownToHtml(md) {
|
|
64
|
+
const lines = md.split('\n');
|
|
65
|
+
const out = [];
|
|
66
|
+
let i = 0;
|
|
67
|
+
let inCode = false;
|
|
68
|
+
let codeBuf = [];
|
|
69
|
+
let inTable = false;
|
|
70
|
+
let tableRows = [];
|
|
71
|
+
let listStack = [];
|
|
72
|
+
let paraBuf = [];
|
|
73
|
+
const flushPara = () => {
|
|
74
|
+
if (paraBuf.length) {
|
|
75
|
+
out.push(`<p>${paraBuf.join(' ')}</p>`);
|
|
76
|
+
paraBuf = [];
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const closeLists = () => { while (listStack.length)
|
|
80
|
+
out.push(`</${listStack.pop()}>`); };
|
|
81
|
+
const closeTable = () => { if (inTable) {
|
|
82
|
+
out.push(renderTable(tableRows));
|
|
83
|
+
tableRows = [];
|
|
84
|
+
inTable = false;
|
|
85
|
+
} };
|
|
86
|
+
for (; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
if (inCode) {
|
|
89
|
+
if (/^```/.test(line)) {
|
|
90
|
+
inCode = false;
|
|
91
|
+
out.push(`<pre><code>${codeBuf.join('\n')}</code></pre>`);
|
|
92
|
+
codeBuf = [];
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
codeBuf.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (/^```/.test(line)) {
|
|
99
|
+
inCode = true;
|
|
100
|
+
codeBuf = [];
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (line.trim() === '') {
|
|
104
|
+
flushPara();
|
|
105
|
+
closeTable();
|
|
106
|
+
if (listStack.length && !/^\s*(?:[-*]|\d+\.)\s/.test(lines[i + 1] ?? ''))
|
|
107
|
+
closeLists();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (/^\s*(?:---|\*\*\*)\s*$/.test(line)) {
|
|
111
|
+
flushPara();
|
|
112
|
+
closeTable();
|
|
113
|
+
closeLists();
|
|
114
|
+
out.push('<hr>');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (/^\s*\|/.test(line)) {
|
|
118
|
+
flushPara();
|
|
119
|
+
const parse = (l) => l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => inline(c.trim()));
|
|
120
|
+
tableRows.push(parse(line));
|
|
121
|
+
inTable = true;
|
|
122
|
+
while (i + 1 < lines.length && /^\s*\|/.test(lines[i + 1])) {
|
|
123
|
+
i++;
|
|
124
|
+
if (/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i]) && tableRows.length >= 1)
|
|
125
|
+
continue;
|
|
126
|
+
tableRows.push(parse(lines[i]));
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
131
|
+
if (h) {
|
|
132
|
+
flushPara();
|
|
133
|
+
closeTable();
|
|
134
|
+
closeLists();
|
|
135
|
+
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (/^>\s?/.test(line)) {
|
|
139
|
+
flushPara();
|
|
140
|
+
closeTable();
|
|
141
|
+
out.push(`<blockquote>${inline(line.replace(/^>\s?/, ''))}</blockquote>`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const ul = /^\s*[-*]\s+(.*)$/.exec(line);
|
|
145
|
+
if (ul) {
|
|
146
|
+
flushPara();
|
|
147
|
+
closeTable();
|
|
148
|
+
if (listStack[listStack.length - 1] !== 'ul') {
|
|
149
|
+
out.push('<ul>');
|
|
150
|
+
listStack.push('ul');
|
|
151
|
+
}
|
|
152
|
+
const item = ul[1];
|
|
153
|
+
const cb = /^\[([ xX])\]\s+(.*)$/.exec(item);
|
|
154
|
+
if (cb)
|
|
155
|
+
out.push(`<li><input type="checkbox" ${cb[1].toLowerCase() === 'x' ? 'checked' : ''} disabled> ${inline(cb[2])}</li>`);
|
|
156
|
+
else
|
|
157
|
+
out.push(`<li>${inline(item)}</li>`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const ol = /^\s*(\d+)\.\s+(.*)$/.exec(line);
|
|
161
|
+
if (ol) {
|
|
162
|
+
flushPara();
|
|
163
|
+
closeTable();
|
|
164
|
+
if (listStack[listStack.length - 1] !== 'ol') {
|
|
165
|
+
out.push('<ol>');
|
|
166
|
+
listStack.push('ol');
|
|
167
|
+
}
|
|
168
|
+
out.push(`<li>${inline(ol[2])}</li>`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
paraBuf.push(inline(line));
|
|
172
|
+
}
|
|
173
|
+
flushPara();
|
|
174
|
+
closeTable();
|
|
175
|
+
closeLists();
|
|
176
|
+
if (inCode)
|
|
177
|
+
out.push(`<pre><code>${codeBuf.join('\n')}</code></pre>`);
|
|
178
|
+
return out.join('\n');
|
|
179
|
+
}
|
|
180
|
+
function page(title, body) {
|
|
181
|
+
return `<!DOCTYPE html>
|
|
182
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
183
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
184
|
+
<title>${esc(title)}</title>
|
|
185
|
+
<style>
|
|
186
|
+
:root{--bg:#fafaf8;--fg:#1a1a1a;--accent:#1e6fd9;--code:#f1f1ee;--border:#e2e2dd}
|
|
187
|
+
*{box-sizing:border-box}
|
|
188
|
+
body{font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--fg);line-height:1.7;margin:0;padding:24px}
|
|
189
|
+
main{max-width:900px;margin:0 auto;background:#fff;border:1px solid var(--border);border-radius:10px;padding:24px 32px}
|
|
190
|
+
h1,h2,h3{line-height:1.3}
|
|
191
|
+
h2{border-bottom:1px solid var(--border);padding-bottom:6px}
|
|
192
|
+
code{background:var(--code);border-radius:4px;padding:1px 5px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.9em}
|
|
193
|
+
pre{background:var(--code);border-radius:8px;padding:14px;overflow-x:auto}
|
|
194
|
+
pre code{background:none;padding:0}
|
|
195
|
+
table{border-collapse:collapse;width:100%;margin:14px 0;font-size:14px}
|
|
196
|
+
th,td{border:1px solid var(--border);padding:7px 10px;text-align:left;vertical-align:top}
|
|
197
|
+
th{background:#f3f3f0}
|
|
198
|
+
blockquote{border-left:4px solid var(--accent);background:#f5f8fd;padding:4px 14px;margin:12px 0}
|
|
199
|
+
a{color:var(--accent)}
|
|
200
|
+
ul,ol{padding-left:26px}
|
|
201
|
+
.breadcrumb{color:#666;font-size:13px;margin-bottom:12px}
|
|
202
|
+
.breadcrumb a{text-decoration:none}
|
|
203
|
+
</style></head><body><main>${body}</main></body></html>`;
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Route plumbing
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
/**
|
|
209
|
+
* Workspace-namespaced routing over the real dsh workspace registry.
|
|
210
|
+
* /docs/ -> index of all registered workspaces
|
|
211
|
+
* /docs/<workspaceId>/ -> directory listing of that workspace root
|
|
212
|
+
* /docs/<workspaceId>/<rel>.md -> rendered doc
|
|
213
|
+
* `<workspaceId>` is the workspace path with the leading "/" removed and "/"
|
|
214
|
+
* replaced by "-" (e.g. /home/lc/deepseek-no1 -> home-lc-deepseek-no1),
|
|
215
|
+
* derived from ctx.workspaceRegistry.list(). Only registered workspaces
|
|
216
|
+
* resolve; each request is confined to that workspace's directory.
|
|
217
|
+
*/
|
|
218
|
+
function decodeSeg(p) {
|
|
219
|
+
try {
|
|
220
|
+
return decodeURIComponent(p);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return p;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function slugOf(path) {
|
|
227
|
+
return path.replace(/^\/+/, '').replace(/\//g, '-');
|
|
228
|
+
}
|
|
229
|
+
function splitWsRel(pathname) {
|
|
230
|
+
let raw = decodeSeg(pathname.replace(/^\/docs/, ''));
|
|
231
|
+
if (raw.startsWith('/'))
|
|
232
|
+
raw = raw.slice(1); // normalize the single "/" after /docs
|
|
233
|
+
if (raw.startsWith('/'))
|
|
234
|
+
return { slug: '\u0000', rel: '' }; // repeated slash -> malformed
|
|
235
|
+
if (raw === '')
|
|
236
|
+
return { slug: '', rel: '' };
|
|
237
|
+
const first = raw.indexOf('/');
|
|
238
|
+
if (first === -1)
|
|
239
|
+
return { slug: raw, rel: '' };
|
|
240
|
+
return { slug: raw.slice(0, first), rel: raw.slice(first + 1) };
|
|
241
|
+
}
|
|
242
|
+
function safeJoin(basePath, rel) {
|
|
243
|
+
if (rel === '')
|
|
244
|
+
return resolve(basePath);
|
|
245
|
+
const target = resolve(basePath, rel);
|
|
246
|
+
const baseResolved = resolve(basePath);
|
|
247
|
+
if (target === baseResolved)
|
|
248
|
+
return target;
|
|
249
|
+
const prefix = baseResolved.endsWith(sep) ? baseResolved : baseResolved + sep;
|
|
250
|
+
if (!target.startsWith(prefix))
|
|
251
|
+
return null;
|
|
252
|
+
return target;
|
|
253
|
+
}
|
|
254
|
+
/** Build the absolute href for an entry inside the current directory path. */
|
|
255
|
+
function relHref(dirPath, entry) {
|
|
256
|
+
const base = (dirPath === '/docs' || dirPath === '' ? '/docs' : dirPath.replace(/\/$/, ''));
|
|
257
|
+
return `${base}/${entry}`;
|
|
258
|
+
}
|
|
259
|
+
function listWorkspaces(wsRegistry) {
|
|
260
|
+
try {
|
|
261
|
+
return (wsRegistry?.list?.() ?? []).map(w => ({
|
|
262
|
+
slug: slugOf(w.path),
|
|
263
|
+
path: w.path,
|
|
264
|
+
title: w.title || w.path,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function serve(req, res, config, wsRegistry) {
|
|
272
|
+
const pathname = req.url.split('?')[0] ?? '/docs/';
|
|
273
|
+
const { slug, rel } = splitWsRel(pathname);
|
|
274
|
+
const workspaces = listWorkspaces(wsRegistry);
|
|
275
|
+
if (slug === '') {
|
|
276
|
+
const items = workspaces.length
|
|
277
|
+
? workspaces.map(w => `<li><a href="/docs/${esc(w.slug)}/">${esc(w.title)} <code>(${esc(w.path)})</code></a></li>`).join('\n')
|
|
278
|
+
: '<li><em>(未登记任何工作区)</em></li>';
|
|
279
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
280
|
+
res.end(page('Document workspaces', `<h1>Document workspaces</h1><ul>${items}</ul>`));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const ws = workspaces.find(w => w.slug === slug);
|
|
284
|
+
if (ws === undefined) {
|
|
285
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
286
|
+
res.end('Unknown workspace');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const target = safeJoin(ws.path, rel);
|
|
290
|
+
if (target === null) {
|
|
291
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
292
|
+
res.end('Not found');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const info = await stat(target);
|
|
297
|
+
if (info.isDirectory()) {
|
|
298
|
+
const entries = await readdir(target);
|
|
299
|
+
entries.sort();
|
|
300
|
+
const items = entries
|
|
301
|
+
.map(e => `<li><a href="${relHref(pathname, e)}">${esc(e)}${e.indexOf('.') === -1 ? '/' : ''}</a></li>`)
|
|
302
|
+
.join('\n');
|
|
303
|
+
const up = `<div class="breadcrumb"><a href="/docs/">workspaces</a> / <a href="/docs/${esc(slug)}/">${esc(slug)}</a>${rel ? ' / ' + esc(rel) : ''}</div>`;
|
|
304
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
305
|
+
res.end(page(`Directory: ${slug}/${rel}`, `${up}<h1>${esc(slug)}/${esc(rel)}</h1><ul>${items}</ul>`));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const ext = extname(target).toLowerCase();
|
|
309
|
+
const raw = await readFile(target);
|
|
310
|
+
if (ext === '.md' || ext === '.markdown') {
|
|
311
|
+
const html = markdownToHtml(raw.toString('utf8'));
|
|
312
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
313
|
+
res.end(page(basename(target), html));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
res.writeHead(200, {
|
|
317
|
+
'content-type': MIME[ext] ?? 'application/octet-stream',
|
|
318
|
+
'content-length': raw.byteLength,
|
|
319
|
+
});
|
|
320
|
+
res.end(raw);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
324
|
+
res.end('Not found');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
export function apply(ctx, config) {
|
|
328
|
+
const wsRegistry = ctx.workspaceRegistry;
|
|
329
|
+
ctx.effect(() => ctx.webServer.register({
|
|
330
|
+
kind: 'prefix',
|
|
331
|
+
path: '/docs',
|
|
332
|
+
handler: (req, res) => serve(req, res, config ?? {}, wsRegistry),
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-doc-preview public type surface.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free: the `Context` type here is a narrow structural shape so
|
|
5
|
+
* consumers do not need to resolve `@deepseek-ai/cordis` just to type-check
|
|
6
|
+
* imports of this package. At runtime nothing from this file is required.
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
|
|
10
|
+
/** A workspace projected from ctx.workspaceRegistry.list(). */
|
|
11
|
+
export interface WorkspaceLike {
|
|
12
|
+
readonly path: string
|
|
13
|
+
readonly title?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Route registration surface this plugin consumes from ctx.webServer. */
|
|
17
|
+
declare interface WebServerLike {
|
|
18
|
+
register(route: {
|
|
19
|
+
kind: 'exact' | 'prefix'
|
|
20
|
+
path: string
|
|
21
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
22
|
+
}): () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal cordis Context shape this plugin depends on. */
|
|
26
|
+
export interface ContextLike {
|
|
27
|
+
webServer: WebServerLike
|
|
28
|
+
workspaceRegistry: { list(): WorkspaceLike[] }
|
|
29
|
+
effect(fn: () => void): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Plugin row identity. */
|
|
33
|
+
export declare const name: 'doc-preview'
|
|
34
|
+
/** Cordis services this plugin injects. */
|
|
35
|
+
export declare const inject: ['webServer', 'workspaceRegistry']
|
|
36
|
+
|
|
37
|
+
export interface Config {
|
|
38
|
+
/**
|
|
39
|
+
* Reserved legacy literal-path fallback root (default "/"). Ignored when the
|
|
40
|
+
* workspace registry is available.
|
|
41
|
+
*/
|
|
42
|
+
base?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Render Markdown to an HTML fragment (headings/lists/tables/code/quote/links). */
|
|
46
|
+
export declare function markdownToHtml(markdown: string): string
|
|
47
|
+
|
|
48
|
+
/** Cordis plugin entry point. */
|
|
49
|
+
export declare function apply(ctx: ContextLike, config?: Config): void
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-doc-preview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A dsh bundle plugin: preview local Markdown/HTML documents per workspace in the Web GUI via a /docs route on ctx.webServer + ctx.workspaceRegistry.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/types/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib/index.js",
|
|
18
|
+
"lib/types/**/*.d.ts",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"deepseek",
|
|
26
|
+
"deepseek-harness",
|
|
27
|
+
"dsh",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"plugin",
|
|
30
|
+
"document-preview",
|
|
31
|
+
"markdown",
|
|
32
|
+
"web"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22.6"
|
|
36
|
+
},
|
|
37
|
+
"author": "DeepSeek Harness plugin authors",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/cong543/dsh-doc-preview.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/cong543/dsh-doc-preview/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/cong543/dsh-doc-preview#readme",
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "*"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@deepseek-ai/cordis": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"dsh": {
|
|
55
|
+
"bundle": {
|
|
56
|
+
"patch": "./cordis.patch.yml"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "node scripts/build.mjs",
|
|
61
|
+
"prepack": "node scripts/build.mjs",
|
|
62
|
+
"prepublishOnly": "node scripts/build.mjs",
|
|
63
|
+
"render:test": "tsx --tsconfig ./tsconfig.json tests/render-test.mjs",
|
|
64
|
+
"logic:test": "tsx --tsconfig ./tsconfig.json tests/logic-test.mjs",
|
|
65
|
+
"route:test": "node tests/drive-route.mjs",
|
|
66
|
+
"test": "node scripts/build.mjs && node tests/drive-route.mjs && tsx --tsconfig ./tsconfig.json tests/render-test.mjs && tsx --tsconfig ./tsconfig.json tests/logic-test.mjs"
|
|
67
|
+
}
|
|
68
|
+
}
|