@vibe-x/dsh-html-workbench 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/src/client.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * HTML Workbench DSH plugin — Client half (function body).
3
+ *
4
+ * This file is the plain-JavaScript function body consumed by DeepSeek
5
+ * Harness's dynamic Cordis plugin loader: pass this exact text as `code.client`
6
+ * to `cordis_define`. The static client bundle wraps this same body in
7
+ * `window.__ModuleLoader__.load(...)` at build time.
8
+ *
9
+ * Responsibilities (runs in the browser):
10
+ * - Register a right-side panel + corner trigger in `shell.overlay`.
11
+ * - Preview takes the full panel height; the HTML-asset picker is a compact,
12
+ * collapsible dock at the bottom (a <select> switcher + expandable list).
13
+ * - Open a file by calling Host `open`, then embed the workbench URL in an
14
+ * iframe. Left edge is draggable to resize the panel width.
15
+ */
16
+
17
+ return {
18
+ inject: ['timer'],
19
+ apply(ctx) {
20
+ const slots = ctx.get('slots')
21
+ if (slots === undefined) return
22
+
23
+ styles.insert(`
24
+ html #root { margin-right: var(--hwb-panel-width, 0px); transition: margin-right var(--ds-transition-duration-slow, 200ms) ease; }
25
+ body[data-hwb-dragging] #root { transition: none; }
26
+ body[data-hwb-dragging] { user-select: none; }
27
+ .hwb-panel, .hwb-panel * { box-sizing: border-box; }
28
+ .hwb-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 820px; max-width: calc(100vw - 24px); display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); border-left: 1px solid var(--dsw-alias-border-l1); box-shadow: var(--dsw-shadow-lv2); z-index: 9999; font-family: var(--dsw-font-family, system-ui, sans-serif); font-size: 13px; line-height: 1.5; pointer-events: auto; }
29
+ .hwb-resize { position: absolute; left: -4px; top: 0; bottom: 0; width: 9px; cursor: col-resize; z-index: 3; touch-action: none; }
30
+ .hwb-resize::after { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; width: 2px; background: transparent; transition: background .15s; }
31
+ .hwb-resize:hover::after, .hwb-resize:active::after { background: var(--dsw-alias-interactive-bg-hover-accent); }
32
+ .hwb-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); flex: none; }
33
+ .hwb-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); flex: none; }
34
+ .hwb-dot.on { background: var(--dsw-alias-state-success-primary); }
35
+ .hwb-titlewrap { min-width: 0; display: flex; flex-direction: column; }
36
+ .hwb-title { font-weight: 600; font-size: 13px; line-height: 1.2; white-space: nowrap; }
37
+ .hwb-sub { font-size: 11px; color: var(--dsw-alias-label-tertiary); line-height: 1.2; }
38
+ .hwb-spacer { flex: 1; }
39
+ .hwb-close { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; border-radius: 6px; flex: none; }
40
+ .hwb-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
41
+ .hwb-preview { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: #fff; }
42
+ .hwb-frame { flex: 1; border: 0; width: 100%; }
43
+ .hwb-hint { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); text-align: center; padding: 16px; }
44
+ .hwb-error { padding: 10px 14px; color: var(--dsw-alias-state-error-primary); font-size: 12px; }
45
+ .hwb-dock { flex: none; border-top: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); display: flex; flex-direction: column; }
46
+ .hwb-dockbar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; }
47
+ .hwb-select { flex: 1; min-width: 0; height: 28px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 0 8px; cursor: pointer; }
48
+ .hwb-count { font-size: 11px; color: var(--dsw-alias-label-tertiary); flex: none; white-space: nowrap; }
49
+ .hwb-docktoggle { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; border-radius: 6px; flex: none; }
50
+ .hwb-docktoggle:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
51
+ .hwb-docklist { flex: none; max-height: 38vh; overflow-y: auto; border-top: 1px solid var(--dsw-alias-border-l2); }
52
+ .hwb-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--dsw-alias-border-l2); cursor: pointer; }
53
+ .hwb-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
54
+ .hwb-item.active { background: var(--dsw-alias-interactive-bg-active); }
55
+ .hwb-item-main { flex: 1; min-width: 0; }
56
+ .hwb-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
57
+ .hwb-item-path { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
58
+ .hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
59
+ .hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
60
+ .hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
61
+ .hwb-trigger { position: fixed; top: 10px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
62
+ .hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
63
+ `)
64
+
65
+ const DEFAULT_WIDTH = 820
66
+ const MIN_WIDTH = 480
67
+
68
+ const store = {
69
+ open: false,
70
+ panelWidth: DEFAULT_WIDTH,
71
+ expanded: false,
72
+ assets: [],
73
+ running: false,
74
+ current: null,
75
+ loading: false,
76
+ error: null,
77
+ listeners: [],
78
+ subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
79
+ set(p) { Object.assign(this, p); this.listeners.forEach((fn) => { try { fn() } catch (e) {} }) },
80
+ }
81
+
82
+ const useStore = () => {
83
+ const [, force] = React.useState(0)
84
+ React.useEffect(() => store.subscribe(() => force((x) => x + 1)), [])
85
+ return store
86
+ }
87
+
88
+ const basename = (p) => {
89
+ const parts = String(p).split('/')
90
+ return parts[parts.length - 1] || p
91
+ }
92
+
93
+ const refresh = () => {
94
+ host.call('list').then((res) => {
95
+ if (res && res.ok) store.set({ assets: res.assets || [], running: !!res.running })
96
+ else store.set({ error: (res && res.error) || 'list failed' })
97
+ }).catch((e) => store.set({ error: String(e && e.message ? e.message : e) }))
98
+ }
99
+
100
+ const openFile = (path) => {
101
+ store.set({ loading: true, current: { path: path, url: null }, error: null })
102
+ host.call('open', { file: path }).then((res) => {
103
+ if (res && res.ok) store.set({ loading: false, current: { path: path, url: res.url }, running: true })
104
+ else store.set({ loading: false, error: (res && res.error) || 'open failed' })
105
+ }).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
106
+ }
107
+
108
+ const startResize = (e) => {
109
+ e.preventDefault()
110
+ document.body.setAttribute('data-hwb-dragging', '')
111
+ const maxW = Math.max(MIN_WIDTH, window.innerWidth - 60)
112
+ const onMove = (ev) => {
113
+ const w = window.innerWidth - ev.clientX
114
+ store.set({ panelWidth: Math.max(MIN_WIDTH, Math.min(w, maxW)) })
115
+ }
116
+ const onUp = () => {
117
+ document.body.removeAttribute('data-hwb-dragging')
118
+ document.removeEventListener('mousemove', onMove)
119
+ document.removeEventListener('mouseup', onUp)
120
+ }
121
+ document.addEventListener('mousemove', onMove)
122
+ document.addEventListener('mouseup', onUp)
123
+ }
124
+
125
+ const chevronUp = React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
126
+ React.createElement('path', { d: 'M18 15l-6-6-6 6' }))
127
+ const chevronDown = React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
128
+ React.createElement('path', { d: 'M6 9l6 6 6-6' }))
129
+
130
+ const Panel = () => {
131
+ const s = useStore()
132
+ React.useEffect(() => {
133
+ const root = document.documentElement
134
+ root.style.setProperty('--hwb-panel-width', s.open ? (s.panelWidth + 'px') : '0px')
135
+ return () => { root.style.setProperty('--hwb-panel-width', '0px') }
136
+ }, [s.open, s.panelWidth])
137
+ React.useEffect(() => {
138
+ if (!s.open) return
139
+ refresh()
140
+ const dispose = ctx.interval(refresh, 3000)
141
+ return () => { if (dispose) dispose() }
142
+ }, [s.open])
143
+ if (!s.open) return null
144
+
145
+ const assets = s.assets || []
146
+ const currentPath = s.current && s.current.path ? s.current.path : ''
147
+ const opts = assets.map((a) => React.createElement('option', { key: a.id || a.path, value: a.path }, basename(a.path)))
148
+ const items = assets.map((a) => {
149
+ const active = currentPath === a.path
150
+ return React.createElement('div', { key: a.id || a.path, className: 'hwb-item' + (active ? ' active' : '') },
151
+ React.createElement('div', { className: 'hwb-item-main' },
152
+ React.createElement('div', { className: 'hwb-item-name' }, basename(a.path)),
153
+ React.createElement('div', { className: 'hwb-item-path', title: a.path }, a.path),
154
+ ),
155
+ React.createElement('button', { type: 'button', className: 'hwb-item-btn', onClick: () => openFile(a.path) }, '打开'),
156
+ )
157
+ })
158
+
159
+ return React.createElement('div', { className: 'hwb-panel', style: { width: s.panelWidth + 'px' }, role: 'dialog', 'aria-label': 'HTML Workbench' },
160
+ React.createElement('div', { className: 'hwb-resize', title: '拖动调整宽度', onMouseDown: startResize }),
161
+ React.createElement('div', { className: 'hwb-head' },
162
+ React.createElement('span', { className: 'hwb-dot' + (s.running ? ' on' : '') }),
163
+ React.createElement('div', { className: 'hwb-titlewrap' },
164
+ React.createElement('div', { className: 'hwb-title' }, 'HTML Workbench'),
165
+ React.createElement('div', { className: 'hwb-sub' }, s.running ? '服务运行中 · 拖动左边缘调整宽度' : '服务未就绪'),
166
+ ),
167
+ React.createElement('span', { className: 'hwb-spacer' }),
168
+ React.createElement('button', { type: 'button', className: 'hwb-close', title: '关闭', onClick: () => store.set({ open: false }) },
169
+ React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' },
170
+ React.createElement('path', { d: 'M18 6L6 18' }),
171
+ React.createElement('path', { d: 'M6 6l12 12' }),
172
+ ),
173
+ ),
174
+ ),
175
+ React.createElement('div', { className: 'hwb-preview' },
176
+ s.loading ? React.createElement('div', { className: 'hwb-hint' }, '正在启动 Workbench…')
177
+ : s.current && s.current.url ? React.createElement('iframe', { className: 'hwb-frame', src: s.current.url, title: 'HTML Workbench' })
178
+ : React.createElement('div', { className: 'hwb-hint' }, '从下方选择一个 HTML 文件开始编辑与预览'),
179
+ ),
180
+ React.createElement('div', { className: 'hwb-dock' },
181
+ React.createElement('div', { className: 'hwb-dockbar' },
182
+ React.createElement('select', {
183
+ className: 'hwb-select',
184
+ value: currentPath,
185
+ onChange: (e) => { const v = e.target.value; if (v) openFile(v) },
186
+ },
187
+ React.createElement('option', { value: '', disabled: true }, assets.length ? '选择 HTML 文件…' : '暂无 HTML 产物'),
188
+ opts,
189
+ ),
190
+ React.createElement('span', { className: 'hwb-count' }, assets.length + ' 个'),
191
+ React.createElement('button', { type: 'button', className: 'hwb-docktoggle', title: s.expanded ? '收起列表' : '展开列表', onClick: () => store.set({ expanded: !s.expanded }) },
192
+ s.expanded ? chevronDown : chevronUp,
193
+ ),
194
+ ),
195
+ s.expanded ? React.createElement('div', { className: 'hwb-docklist' },
196
+ items.length ? items : React.createElement('div', { className: 'hwb-dockempty' }, '暂无 HTML 产物 — 代理 write/edit 的 .html 文件会出现在这里。'),
197
+ s.error ? React.createElement('div', { className: 'hwb-error' }, s.error) : null,
198
+ ) : null,
199
+ ),
200
+ )
201
+ }
202
+
203
+ const Trigger = () => {
204
+ const s = useStore()
205
+ if (s.open) return null
206
+ return React.createElement('button', {
207
+ type: 'button',
208
+ className: 'hwb-trigger',
209
+ title: 'HTML Workbench',
210
+ 'aria-expanded': s.open,
211
+ onClick: () => { store.set({ open: true }); refresh() },
212
+ }, React.createElement('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
213
+ React.createElement('rect', { x: 2, y: 4, width: 20, height: 14, rx: 2 }),
214
+ React.createElement('path', { d: 'M8 21h8' }),
215
+ React.createElement('path', { d: 'M12 18v3' }),
216
+ ))
217
+ }
218
+
219
+ slots.inject('shell.overlay', () => slots.register(
220
+ { name: 'shell.overlay', id: 'html-workbench-panel', order: 60, label: 'HTML Workbench' },
221
+ () => React.createElement(Panel),
222
+ ))
223
+
224
+ slots.inject('shell.overlay', () => slots.register(
225
+ { name: 'shell.overlay', id: 'html-workbench-trigger', order: 59, label: 'HTML Workbench 入口' },
226
+ () => React.createElement(Trigger),
227
+ ))
228
+ },
229
+ }
package/src/host.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * HTML Workbench DSH plugin — Host half (function body).
3
+ *
4
+ * This file is the plain-JavaScript function body consumed by DeepSeek
5
+ * Harness's dynamic Cordis plugin loader: pass this exact text as `code.host`
6
+ * to `cordis_define`. The static host entry `src/index.js` evaluates this same
7
+ * body via `new Function` and re-exports it, so it runs unchanged in both
8
+ * modes. `harness` usage is guarded (`typeof harness !== 'undefined'`) so the
9
+ * body also runs as a static bundle where that symbol does not exist.
10
+ *
11
+ * Responsibilities (runs in the DSH Node.js process):
12
+ * - Start / reuse the local `html-workbench` Python service on registration
13
+ * and stop the process it started when the plugin unloads (side-effect
14
+ * lifecycle via ctx.effect).
15
+ * - Track HTML files produced by the `write` / `edit` tools via `tools/result`.
16
+ * - Expose the same three operations through two transports:
17
+ * * dynamic: `harness.handle` Package-private RPC (Client `host.call`);
18
+ * * static: `webServer.register` HTTP routes under `/html-workbench/*`.
19
+ */
20
+
21
+ return {
22
+ inject: ['shell', 'timer'],
23
+ apply(ctx, config) {
24
+ // 静态/NPM 装载时由 src/index.js 相对插件自身解析 scripts/workbench.py 并注入 config.script;
25
+ // 动态装载(cordis_define)无 config → script 为空,服务不自动拉起(仅注册传输层)。
26
+ // editorRoot 可选:缺省不传 --editor-root,由 workbench.py 自行回退到用户家目录。
27
+ const opts = config || {}
28
+ const PORT = opts.port || 4317
29
+ const SCRIPT = opts.script || null
30
+ const EDITOR_ROOT = opts.editorRoot || null
31
+
32
+ let assets = []
33
+ let seq = 0
34
+ let ownedProcess = null
35
+ let serviceRunning = false
36
+ let startError = null
37
+ let disposed = false
38
+
39
+ const shell = ctx.get('shell')
40
+
41
+ const runCli = async (args, timeoutMs) => {
42
+ if (!shell || !SCRIPT) return null
43
+ try {
44
+ const spec = shell.resolve({
45
+ command: 'python3 "' + SCRIPT + '" ' + args,
46
+ timeoutMs: timeoutMs || 15000,
47
+ stdoutMaxBytes: 64 * 1024,
48
+ })
49
+ return await shell.run(spec)
50
+ } catch (e) {
51
+ return null
52
+ }
53
+ }
54
+
55
+ const health = async () => {
56
+ const r = await runCli('health --port ' + PORT, 8000)
57
+ if (!r || r.exitCode !== 0) return null
58
+ try {
59
+ const p = JSON.parse((r.stdout && r.stdout.text) || '')
60
+ return p && p.ok === true ? p : null
61
+ } catch (e) {
62
+ return null
63
+ }
64
+ }
65
+
66
+ const startService = async () => {
67
+ if (disposed) return null
68
+ if (serviceRunning) return health()
69
+ const existing = await health()
70
+ if (existing) { serviceRunning = true; startError = null; return existing }
71
+ if (!shell) { startError = 'shell service unavailable'; return null }
72
+ if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; return null }
73
+ let proc = null
74
+ try {
75
+ const editorRootArg = EDITOR_ROOT ? ' --editor-root "' + EDITOR_ROOT + '"' : ''
76
+ const spec = shell.resolve({
77
+ command: 'python3 "' + SCRIPT + '" serve --port ' + PORT + editorRootArg,
78
+ stdoutMaxBytes: 64 * 1024,
79
+ })
80
+ proc = shell.start(spec)
81
+ if (disposed) { try { proc.kill() } catch (e) {} return null }
82
+ ownedProcess = proc
83
+ for (let i = 0; i < 240; i += 1) {
84
+ const h = await health()
85
+ if (h) { serviceRunning = true; startError = null; return h }
86
+ await ctx.timeout(250)
87
+ }
88
+ startError = 'service did not become healthy'
89
+ return null
90
+ } catch (e) {
91
+ startError = e && e.message ? String(e.message) : String(e)
92
+ if (proc) { try { proc.kill() } catch (e2) {} }
93
+ ownedProcess = null
94
+ return null
95
+ }
96
+ }
97
+
98
+ // 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
99
+ ctx.effect(() => () => {
100
+ disposed = true
101
+ const proc = ownedProcess
102
+ ownedProcess = null
103
+ serviceRunning = false
104
+ if (proc) { try { proc.kill() } catch (e) { console.error('[html-workbench] stop failed', e) } }
105
+ })
106
+
107
+ const recordAsset = (path, kind) => {
108
+ const existing = assets.find((a) => a.path === path)
109
+ if (existing) {
110
+ existing.kind = kind
111
+ existing.at = Date.now()
112
+ existing.seq = ++seq
113
+ } else {
114
+ assets.push({ id: 'w' + (++seq), path: path, kind: kind, at: Date.now(), seq: seq })
115
+ if (assets.length > 500) assets = assets.slice(-500)
116
+ }
117
+ }
118
+
119
+ ctx.on('tools/result', (exec, result) => {
120
+ try {
121
+ if (!exec || !result || result.isError === true) return
122
+ if (exec.name !== 'write' && exec.name !== 'edit') return
123
+ const args = exec.arguments || {}
124
+ const path = args.file_path
125
+ if (typeof path !== 'string' || !path) return
126
+ if (!/\.html?$/i.test(path)) return
127
+ recordAsset(path, exec.name === 'write' ? 'create' : 'edit')
128
+ } catch (e) {
129
+ console.error('[html-workbench] track failed', e)
130
+ }
131
+ })
132
+
133
+ const snapshot = () => assets.slice().sort((a, b) => b.seq - a.seq)
134
+
135
+ const openFile = async (file) => {
136
+ if (typeof file !== 'string' || !file) return { ok: false, error: 'file is required' }
137
+ if (!/\.html?$/i.test(file)) return { ok: false, error: 'only .html files are supported' }
138
+ if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置' }
139
+ const wasUp = await health()
140
+ if (!wasUp) await startService()
141
+ const h = await health()
142
+ if (!h) return { ok: false, error: startError || 'workbench service unavailable' }
143
+ return {
144
+ ok: true,
145
+ url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
146
+ reused: !!wasUp,
147
+ port: PORT,
148
+ }
149
+ }
150
+
151
+ // 动态传输:Package-private RPC(仅动态运行时存在 harness)。
152
+ if (typeof harness !== 'undefined') {
153
+ harness.handle('list', () => ({ ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }))
154
+ harness.handle('status', async () => { const h = await health(); return { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null } })
155
+ harness.handle('open', async (args) => openFile(args && args.file))
156
+ }
157
+
158
+ // 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
159
+ const webServer = ctx.get('webServer')
160
+ if (webServer) {
161
+ const sendJson = (res, status, body) => {
162
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
163
+ res.end(JSON.stringify(body))
164
+ }
165
+ const parseQuery = (req) => {
166
+ const qs = (req.url || '').split('?')[1] || ''
167
+ const out = {}
168
+ qs.split('&').forEach((pair) => {
169
+ if (!pair) return
170
+ const eq = pair.indexOf('=')
171
+ const k = decodeURIComponent(eq < 0 ? pair : pair.slice(0, eq))
172
+ const v = decodeURIComponent(eq < 0 ? '' : pair.slice(eq + 1))
173
+ out[k] = v
174
+ })
175
+ return out
176
+ }
177
+ ctx.effect(() => webServer.register({
178
+ kind: 'exact',
179
+ path: '/html-workbench/list',
180
+ handler: (req, res) => sendJson(res, 200, { ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }),
181
+ }), 'html-workbench: list route')
182
+ ctx.effect(() => webServer.register({
183
+ kind: 'exact',
184
+ path: '/html-workbench/status',
185
+ handler: async (req, res) => { const h = await health(); sendJson(res, 200, { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null }) },
186
+ }), 'html-workbench: status route')
187
+ ctx.effect(() => webServer.register({
188
+ kind: 'exact',
189
+ path: '/html-workbench/open',
190
+ handler: async (req, res) => {
191
+ const file = parseQuery(req).file
192
+ const out = await openFile(file)
193
+ sendJson(res, out.ok ? 200 : 400, out)
194
+ },
195
+ }), 'html-workbench: open route')
196
+ }
197
+
198
+ // 注册时主动拉起服务(异步,不阻塞 apply)。
199
+ void startService()
200
+ },
201
+ }
package/src/index.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * HTML Workbench DSH plugin — static host entry.
3
+ *
4
+ * Evaluates the canonical plugin body in `src/host.js` (the very same text
5
+ * passed to `cordis_define` as `code.host`) and re-exports it for the Cordis
6
+ * loader. The bundled Python service ships next to this package
7
+ * (`scripts/workbench.py` + `assets/workbench.html`), so its absolute path is
8
+ * resolved relative to THIS module (`import.meta.url`) — never hard-coded to a
9
+ * specific machine — and handed to the shared body as `config.script`.
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url))
16
+ const scriptPath = resolve(here, '..', 'scripts', 'workbench.py')
17
+
18
+ // The body is a `return { ... }` statement; wrap it in a function and call it.
19
+ // eslint-disable-next-line no-new-func
20
+ const makePlugin = new Function(readFileSync(new URL('./host.js', import.meta.url), 'utf8'))
21
+ const plugin = makePlugin()
22
+
23
+ export const name = 'html-workbench'
24
+ export const inject = plugin.inject ?? []
25
+ // Inject the portably-resolved script path; a deployment-provided config may
26
+ // still override `script` / `editorRoot` / `port`.
27
+ export const apply = (ctx, config) => plugin.apply(ctx, { script: scriptPath, ...(config || {}) })