@khorsheed/dsh-inline-html-render 0.1.13
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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.en.md +79 -0
- package/README.md +79 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +421 -0
- package/lib/index.js +88 -0
- package/lib/invariant.js +25 -0
- package/lib/types/client/bridge.d.ts +28 -0
- package/lib/types/client/bridge.js +110 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +60 -0
- package/lib/types/client/renderer.d.ts +52 -0
- package/lib/types/client/renderer.js +220 -0
- package/lib/types/client/srcdoc.d.ts +30 -0
- package/lib/types/client/srcdoc.js +88 -0
- package/lib/types/index.d.ts +32 -0
- package/lib/types/index.js +90 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +23 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -0
- package/skills/3d-artifact/SKILL.md +52 -0
- package/skills/inline-html-card/SKILL.md +97 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
//#region lib/types/index.js
|
|
5
|
+
/**
|
|
6
|
+
* Inline HTML render host half. Registers the `inline-html-card` and
|
|
7
|
+
* `3d-artifact` skills, which
|
|
8
|
+
* documents the fenced-block protocol the browser half consumes: the agent
|
|
9
|
+
* writes a ` ```dsh-card ```` fenced block (or a `dsh-card <spec>` inline
|
|
10
|
+
* fence) whose body is a fully self-contained HTML document, and the client
|
|
11
|
+
* half swaps the rendered `<pre><code class="language-dsh-card">` for a
|
|
12
|
+
* sandboxed iframe that runs it.
|
|
13
|
+
*
|
|
14
|
+
* This half owns no DOM and no Remote — it only advertises the contract so
|
|
15
|
+
* the agent knows how to author a card. It degrades silently when the
|
|
16
|
+
* `skills` capability is absent (a minimal composition may lack it), exactly
|
|
17
|
+
* without widening the read-only file-preview host's responsibility.
|
|
18
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Read and parse one shipped SKILL.md (`inline-html-card` / `3d-artifact`) into
|
|
22
|
+
* a registration.
|
|
23
|
+
*
|
|
24
|
+
* This skill is content-only — it ships a single self-contained SKILL.md with
|
|
25
|
+
* no sibling scripts/assets/references. Per the capability-catalog registration
|
|
26
|
+
* protocol, a content-only skill OMITS `resourceBase` so the catalog renders it
|
|
27
|
+
* as a virtual single-SKILL.md node (it does NOT point at a directory that
|
|
28
|
+
* holds only SKILL.md). Add `resourceBase: { kind: 'directory', path }` only
|
|
29
|
+
* when a bundle with files beside SKILL.md is introduced later.
|
|
30
|
+
* @param ctx - plugin context (for logging).
|
|
31
|
+
* @returns the parsed skill, or undefined when the file is missing/malformed
|
|
32
|
+
* (a discovery aid must never take a boot down, so each failure warns).
|
|
33
|
+
*/
|
|
34
|
+
function readSkill(ctx, skillName) {
|
|
35
|
+
try {
|
|
36
|
+
const skillDir = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", skillName);
|
|
37
|
+
const skillFile = join(skillDir, "SKILL.md");
|
|
38
|
+
const raw = readFileSync(skillFile, "utf8");
|
|
39
|
+
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(raw);
|
|
40
|
+
const name = /^name: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
|
|
41
|
+
const description = /^description: (.+)$/m.exec(match?.[1] ?? "")?.[1]?.trim();
|
|
42
|
+
const content = match?.[2];
|
|
43
|
+
if (match === null || name === void 0 || description === void 0 || content === void 0) {
|
|
44
|
+
ctx.logger.warn(`inline-html-render: shipped SKILL.md is malformed — the ${skillName} skill is not registered`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
description,
|
|
50
|
+
content,
|
|
51
|
+
source: "runtime",
|
|
52
|
+
provider: "inline-html-render"
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
ctx.logger.warn(`inline-html-render: shipped SKILL.md unreadable (${String(error)}) — the ${skillName} skill is not registered`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** The host half requires no service; `skills` is awaited, never baked into inject. */
|
|
60
|
+
const inject = [];
|
|
61
|
+
/**
|
|
62
|
+
* Plugin body: register the card-authoring skill once the `skills` service is
|
|
63
|
+
* ready. `ctx.inject(['skills'], …)` waits for the service rather than probing
|
|
64
|
+
* it synchronously at apply time, so registration is not skipped when the host
|
|
65
|
+
* mounts this plugin before the skill registry is up. (The file-preview host is
|
|
66
|
+
* a service class instantiated at a later point; this plugin's `apply` runs
|
|
67
|
+
* earlier, so a plain synchronous `ctx.get('skills')` at apply time could see
|
|
68
|
+
* the registry absent and silently skip — which is why inline-html-card did not
|
|
69
|
+
* appear in the catalog while 3d-artifact did.)
|
|
70
|
+
* @param ctx - Cordis context carrying the `skills` service.
|
|
71
|
+
* @returns a disposer unwinding the registration effect.
|
|
72
|
+
*/
|
|
73
|
+
function apply(ctx) {
|
|
74
|
+
const disposers = [];
|
|
75
|
+
ctx.inject(["skills"], (scoped) => {
|
|
76
|
+
const registry = scoped.get("skills");
|
|
77
|
+
if (registry === void 0) return;
|
|
78
|
+
for (const skillName of ["inline-html-card", "3d-artifact"]) {
|
|
79
|
+
const skill = readSkill(scoped, skillName);
|
|
80
|
+
if (skill !== void 0) disposers.push(scoped.effect(() => registry.register(skill)));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return () => {
|
|
84
|
+
for (const disposer of disposers.reverse()) disposer();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { apply, inject };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@khorsheed/dsh-inline-html-render`.
|
|
4
|
+
* @module @khorsheed/dsh-inline-html-render/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@khorsheed/dsh-inline-html-render";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "client-inline-html-render-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the renderer is a pure DOM-layer effect owning its own
|
|
13
|
+
* MutationObserver and a per-document iframe/`<pre>` pair; there is no durable
|
|
14
|
+
* state shared with other packages. Reserved here so the loader knows this
|
|
15
|
+
* package is alive (and so a future invariant can attach without a rename).
|
|
16
|
+
*/
|
|
17
|
+
const install = () => {};
|
|
18
|
+
/**
|
|
19
|
+
* Register this package's invariant companion.
|
|
20
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
21
|
+
* @returns The installed registration's disposer after setup succeeds.
|
|
22
|
+
*/
|
|
23
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
24
|
+
//#endregion
|
|
25
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side capability bridge for an inline card iframe: validates EVERY
|
|
3
|
+
* message — the `message.source` must be the controlled iframe's window, `fn`
|
|
4
|
+
* must be in the whitelist, and args are shape-checked per function — then
|
|
5
|
+
* executes and replies `{ id, ok, result }` back. Unknown or malformed calls
|
|
6
|
+
* get an error reply, never an exception in the host.
|
|
7
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Host capabilities the plugin wires from its context at install time. Every
|
|
11
|
+
* one is optional: absence keeps the plain-browser default.
|
|
12
|
+
*/
|
|
13
|
+
export interface BridgeCapabilities {
|
|
14
|
+
/**
|
|
15
|
+
* Open an https card link. The plugin routes this into the right-Sidebar
|
|
16
|
+
* Browser tab when the host registers that type (0.1.6-alpha.2's
|
|
17
|
+
* ui-sidebar-browser), falling back to a new window otherwise.
|
|
18
|
+
*/
|
|
19
|
+
readonly openLink?: (url: string) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Listen for bridge/height messages from one controlled iframe.
|
|
23
|
+
* @param frame - the card iframe; only its window's messages are honored.
|
|
24
|
+
* @param capabilities - wired host capabilities; absence keeps browser defaults.
|
|
25
|
+
* @returns a disposer removing the listener.
|
|
26
|
+
*/
|
|
27
|
+
export declare function attachBridge(frame: HTMLIFrameElement, capabilities?: BridgeCapabilities): () => void;
|
|
28
|
+
//# sourceMappingURL=bridge.d.ts.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side capability bridge for an inline card iframe: validates EVERY
|
|
3
|
+
* message — the `message.source` must be the controlled iframe's window, `fn`
|
|
4
|
+
* must be in the whitelist, and args are shape-checked per function — then
|
|
5
|
+
* executes and replies `{ id, ok, result }` back. Unknown or malformed calls
|
|
6
|
+
* get an error reply, never an exception in the host.
|
|
7
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
8
|
+
*/
|
|
9
|
+
/** The default link route: a new window, https only enforced by the caller. */
|
|
10
|
+
function openLinkInWindow(url) {
|
|
11
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Each handler runs with validated args and may throw; errors become an error reply.
|
|
15
|
+
* @param capabilities - the install's wired capabilities (openLink falls back to a new window).
|
|
16
|
+
*/
|
|
17
|
+
function handlersFor(capabilities) {
|
|
18
|
+
return {
|
|
19
|
+
// Default: https only, noopener. `javascript:`/`file:`/`data:` are refused.
|
|
20
|
+
openLink: (args) => {
|
|
21
|
+
const url = String(args[0] ?? '');
|
|
22
|
+
if (!/^https:/i.test(url))
|
|
23
|
+
throw new Error(`openLink: only https: targets are allowed (got ${url.slice(0, 40)})`);
|
|
24
|
+
(capabilities.openLink ?? openLinkInWindow)(url);
|
|
25
|
+
},
|
|
26
|
+
copy: async (args) => {
|
|
27
|
+
const text = String(args[0] ?? '');
|
|
28
|
+
if (text.length > 1_000_000)
|
|
29
|
+
throw new Error('copy: text too large');
|
|
30
|
+
try {
|
|
31
|
+
await navigator.clipboard.writeText(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
fallbackCopy(text);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
download: (args) => {
|
|
38
|
+
const name = String(args[0] ?? 'download');
|
|
39
|
+
const dataUrl = String(args[1] ?? '');
|
|
40
|
+
if (!/^data:/i.test(dataUrl))
|
|
41
|
+
throw new Error('download: only data: URLs are allowed');
|
|
42
|
+
const anchor = document.createElement('a');
|
|
43
|
+
anchor.href = dataUrl;
|
|
44
|
+
anchor.download = name;
|
|
45
|
+
anchor.click();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** execCommand copy fallback for contexts without the async clipboard API. */
|
|
50
|
+
function fallbackCopy(text) {
|
|
51
|
+
const area = document.createElement('textarea');
|
|
52
|
+
area.value = text;
|
|
53
|
+
area.setAttribute('readonly', '');
|
|
54
|
+
area.style.position = 'fixed';
|
|
55
|
+
area.style.opacity = '0';
|
|
56
|
+
document.body.appendChild(area);
|
|
57
|
+
area.select();
|
|
58
|
+
try {
|
|
59
|
+
document.execCommand('copy');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
area.remove();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function reply(frame, message) {
|
|
66
|
+
frame.contentWindow?.postMessage(message, '*');
|
|
67
|
+
}
|
|
68
|
+
/** Cap on the reported height, guarding against a runaway or adversarial card. */
|
|
69
|
+
const MAX_FRAME_HEIGHT = 20_000;
|
|
70
|
+
/**
|
|
71
|
+
* Listen for bridge/height messages from one controlled iframe.
|
|
72
|
+
* @param frame - the card iframe; only its window's messages are honored.
|
|
73
|
+
* @param capabilities - wired host capabilities; absence keeps browser defaults.
|
|
74
|
+
* @returns a disposer removing the listener.
|
|
75
|
+
*/
|
|
76
|
+
export function attachBridge(frame, capabilities = {}) {
|
|
77
|
+
const handlers = handlersFor(capabilities);
|
|
78
|
+
const onMessage = (event) => {
|
|
79
|
+
if (event.source !== frame.contentWindow)
|
|
80
|
+
return;
|
|
81
|
+
const data = event.data;
|
|
82
|
+
if (data === null || typeof data !== 'object')
|
|
83
|
+
return;
|
|
84
|
+
const id = data.id;
|
|
85
|
+
const fn = data.fn;
|
|
86
|
+
// The height report is a message without an `id`; it is not a call and gets
|
|
87
|
+
// no reply, only the frame resize.
|
|
88
|
+
if (fn === 'resize') {
|
|
89
|
+
const args = Array.isArray(data.args) ? data.args : [];
|
|
90
|
+
const height = Number(args[0] ?? 0);
|
|
91
|
+
if (Number.isFinite(height) && height > 0) {
|
|
92
|
+
frame.style.height = `${Math.min(Math.ceil(height) + 1, MAX_FRAME_HEIGHT)}px`;
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof id !== 'string' || typeof fn !== 'string')
|
|
97
|
+
return;
|
|
98
|
+
const handler = handlers[fn];
|
|
99
|
+
if (handler === undefined) {
|
|
100
|
+
reply(frame, { id, ok: false, error: `unknown bridge function: ${fn}` });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const args = Array.isArray(data.args) ? data.args : [];
|
|
104
|
+
Promise.resolve()
|
|
105
|
+
.then(() => handler(args))
|
|
106
|
+
.then(() => reply(frame, { id, ok: true, result: undefined }), (error) => reply(frame, { id, ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
107
|
+
};
|
|
108
|
+
addEventListener('message', onMessage);
|
|
109
|
+
return () => removeEventListener('message', onMessage);
|
|
110
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline HTML card client plugin, browser half. Installs a DOM-layer renderer
|
|
3
|
+
* that swaps rendered ` ```dsh-card ```` fenced blocks for sandboxed iframes
|
|
4
|
+
* running the authored HTML, inline in the conversation flow.
|
|
5
|
+
*
|
|
6
|
+
* It owns no slot and no Remote service — the browser half only mutates the
|
|
7
|
+
* already-rendered DOM (the repo-accepted last-resort pattern, like
|
|
8
|
+
* message-tools' dom-hider), so it composes as an independent package with no
|
|
9
|
+
* edits to core packages. Card links open in the right-Sidebar Browser tab
|
|
10
|
+
* when the host ships that type (0.1.6-alpha.2's ui-sidebar-browser), else in
|
|
11
|
+
* a new window — a probe, not a dependency. Without this plugin the fenced
|
|
12
|
+
* block simply renders as a normal code block: it degrades, never crashes.
|
|
13
|
+
* @module @khorsheed/dsh-inline-html-render/client
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
+
/** The client half requires no service: every host capability is probed, never injected. */
|
|
17
|
+
export declare const inject: readonly string[];
|
|
18
|
+
/**
|
|
19
|
+
* The card-link route: host 0.1.6-alpha.2's ui-sidebar-browser registers the
|
|
20
|
+
* `browser` right-Sidebar tab kind, so a card link opens as a Sidebar tab
|
|
21
|
+
* there; any older host or missing piece (service, type, or a registry race
|
|
22
|
+
* on open) falls back to a new window. Probed per open, so a hot-added tab
|
|
23
|
+
* type counts and a hot-removed one just falls back.
|
|
24
|
+
* @param ctx - client root context.
|
|
25
|
+
* @returns the bridge's openLink capability.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createLinkOpener(ctx: Context): (url: string) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Plugin body: install the card renderer for the browser lifetime.
|
|
30
|
+
* @param ctx - client root context (probed for the optional Sidebar Browser route).
|
|
31
|
+
* @returns a disposer tearing the renderer down.
|
|
32
|
+
*/
|
|
33
|
+
export declare function apply(ctx: Context): () => void;
|
|
34
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline HTML card client plugin, browser half. Installs a DOM-layer renderer
|
|
3
|
+
* that swaps rendered ` ```dsh-card ```` fenced blocks for sandboxed iframes
|
|
4
|
+
* running the authored HTML, inline in the conversation flow.
|
|
5
|
+
*
|
|
6
|
+
* It owns no slot and no Remote service — the browser half only mutates the
|
|
7
|
+
* already-rendered DOM (the repo-accepted last-resort pattern, like
|
|
8
|
+
* message-tools' dom-hider), so it composes as an independent package with no
|
|
9
|
+
* edits to core packages. Card links open in the right-Sidebar Browser tab
|
|
10
|
+
* when the host ships that type (0.1.6-alpha.2's ui-sidebar-browser), else in
|
|
11
|
+
* a new window — a probe, not a dependency. Without this plugin the fenced
|
|
12
|
+
* block simply renders as a normal code block: it degrades, never crashes.
|
|
13
|
+
* @module @khorsheed/dsh-inline-html-render/client
|
|
14
|
+
*/
|
|
15
|
+
import { installCardRenderer } from './renderer.js';
|
|
16
|
+
/** The client half requires no service: every host capability is probed, never injected. */
|
|
17
|
+
export const inject = [];
|
|
18
|
+
/**
|
|
19
|
+
* The card-link route: host 0.1.6-alpha.2's ui-sidebar-browser registers the
|
|
20
|
+
* `browser` right-Sidebar tab kind, so a card link opens as a Sidebar tab
|
|
21
|
+
* there; any older host or missing piece (service, type, or a registry race
|
|
22
|
+
* on open) falls back to a new window. Probed per open, so a hot-added tab
|
|
23
|
+
* type counts and a hot-removed one just falls back.
|
|
24
|
+
* @param ctx - client root context.
|
|
25
|
+
* @returns the bridge's openLink capability.
|
|
26
|
+
*/
|
|
27
|
+
export function createLinkOpener(ctx) {
|
|
28
|
+
return (url) => {
|
|
29
|
+
const tabs = ctx.get('sidebarRightTabs');
|
|
30
|
+
const sidebarRight = ctx.get('sidebarRight');
|
|
31
|
+
if (tabs?.get('browser') !== undefined && sidebarRight !== undefined) {
|
|
32
|
+
try {
|
|
33
|
+
sidebarRight.openTab('browser', { params: { url } });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// The kind raced out of the registry between probe and open: fall through.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Plugin body: install the card renderer for the browser lifetime.
|
|
45
|
+
* @param ctx - client root context (probed for the optional Sidebar Browser route).
|
|
46
|
+
* @returns a disposer tearing the renderer down.
|
|
47
|
+
*/
|
|
48
|
+
export function apply(ctx) {
|
|
49
|
+
let renderer;
|
|
50
|
+
// Effect so teardown/re-composition unwinds cleanly; a re-apply replaces the
|
|
51
|
+
// previous renderer.
|
|
52
|
+
const install = () => {
|
|
53
|
+
renderer = installCardRenderer({ openLink: createLinkOpener(ctx) });
|
|
54
|
+
};
|
|
55
|
+
install();
|
|
56
|
+
return () => {
|
|
57
|
+
renderer?.dispose();
|
|
58
|
+
renderer = undefined;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline card renderer: finds the rendered `.md-code-block` whose infostring is
|
|
3
|
+
* `dsh-card` and replaces its visual slot with a sandboxed iframe running the
|
|
4
|
+
* authored HTML, inline in the conversation flow.
|
|
5
|
+
*
|
|
6
|
+
* React owns the markdown tree, so this plugin NEVER removes a node React
|
|
7
|
+
* created — that would make React's reconciliation fight the DOM and could
|
|
8
|
+
* throw or resurrect the block. Instead it inserts the iframe as a SIBLING
|
|
9
|
+
* right after the block and hides the block (`display:none`, a style React
|
|
10
|
+
* does not manage), then re-applies on any mutation. Because React replaces
|
|
11
|
+
* nodes (it does not reuse them across a re-render of changed content), each
|
|
12
|
+
* replacement is detected fresh and re-hidden; the data marker on the *block*
|
|
13
|
+
* keeps one block from being processed twice in a single pass.
|
|
14
|
+
*
|
|
15
|
+
* Streaming gate: while a turn is running, `AssistantMarkdown` sets
|
|
16
|
+
* `data-streaming` on its root and the block content is partial and will be
|
|
17
|
+
* re-rendered. We only swap settled blocks (no `[data-streaming]` ancestor),
|
|
18
|
+
* so a card never mounts on a half-streamed body. On settle, React re-renders
|
|
19
|
+
* the markdown, which removes `data-streaming` and re-creates the block; the
|
|
20
|
+
* observer sees that and swaps then.
|
|
21
|
+
*
|
|
22
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
23
|
+
*/
|
|
24
|
+
import { type BridgeCapabilities } from './bridge.ts';
|
|
25
|
+
/** Info string that marks a fence as an inline card. */
|
|
26
|
+
export declare const CARD_INFO_STRING = "dsh-card";
|
|
27
|
+
/** A mounted card: the block it replaced and the disposer of its bridge. */
|
|
28
|
+
interface MountedCard {
|
|
29
|
+
readonly block: HTMLElement;
|
|
30
|
+
readonly frame: HTMLIFrameElement;
|
|
31
|
+
/** Hides the block and guards duplicate disposal. */
|
|
32
|
+
dispose: () => void;
|
|
33
|
+
}
|
|
34
|
+
/** Find every rendered `dsh-card` block under `root`. */
|
|
35
|
+
export declare function findCardBlocks(root: ParentNode): HTMLElement[];
|
|
36
|
+
/** Reconcile: swap every unprocessed, settled `dsh-card` block under `root`, then drop orphans. */
|
|
37
|
+
export declare function reconcile(root: ParentNode, capabilities?: BridgeCapabilities): MountedCard[];
|
|
38
|
+
/** A live renderer installation: reconcile on mutation, dispose cleanly. */
|
|
39
|
+
export interface CardRenderer {
|
|
40
|
+
/** Manually run a reconcile pass (also fired by the observer). */
|
|
41
|
+
run: () => void;
|
|
42
|
+
/** Remove the observer and every mounted card frame. */
|
|
43
|
+
dispose: () => void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Install the inline-card renderer over the document.
|
|
47
|
+
* @param capabilities - wired host capabilities handed to every card's bridge.
|
|
48
|
+
* @returns a {@link CardRenderer} whose `dispose` tears everything down.
|
|
49
|
+
*/
|
|
50
|
+
export declare function installCardRenderer(capabilities?: BridgeCapabilities): CardRenderer;
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=renderer.d.ts.map
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline card renderer: finds the rendered `.md-code-block` whose infostring is
|
|
3
|
+
* `dsh-card` and replaces its visual slot with a sandboxed iframe running the
|
|
4
|
+
* authored HTML, inline in the conversation flow.
|
|
5
|
+
*
|
|
6
|
+
* React owns the markdown tree, so this plugin NEVER removes a node React
|
|
7
|
+
* created — that would make React's reconciliation fight the DOM and could
|
|
8
|
+
* throw or resurrect the block. Instead it inserts the iframe as a SIBLING
|
|
9
|
+
* right after the block and hides the block (`display:none`, a style React
|
|
10
|
+
* does not manage), then re-applies on any mutation. Because React replaces
|
|
11
|
+
* nodes (it does not reuse them across a re-render of changed content), each
|
|
12
|
+
* replacement is detected fresh and re-hidden; the data marker on the *block*
|
|
13
|
+
* keeps one block from being processed twice in a single pass.
|
|
14
|
+
*
|
|
15
|
+
* Streaming gate: while a turn is running, `AssistantMarkdown` sets
|
|
16
|
+
* `data-streaming` on its root and the block content is partial and will be
|
|
17
|
+
* re-rendered. We only swap settled blocks (no `[data-streaming]` ancestor),
|
|
18
|
+
* so a card never mounts on a half-streamed body. On settle, React re-renders
|
|
19
|
+
* the markdown, which removes `data-streaming` and re-creates the block; the
|
|
20
|
+
* observer sees that and swaps then.
|
|
21
|
+
*
|
|
22
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
23
|
+
*/
|
|
24
|
+
import { buildCardSrcDoc } from './srcdoc.js';
|
|
25
|
+
import { attachBridge } from './bridge.js';
|
|
26
|
+
/** Info string that marks a fence as an inline card. */
|
|
27
|
+
export const CARD_INFO_STRING = 'dsh-card';
|
|
28
|
+
/** Marker set on the block once it has been swapped, making a pass idempotent. */
|
|
29
|
+
const PROCESSED_ATTR = 'data-dsh-card-processed';
|
|
30
|
+
/** The injected iframe's class, for cleanup. */
|
|
31
|
+
const FRAME_CLASS = 'dsh-inline-card-frame';
|
|
32
|
+
/** Attribute used to find the bridge-ready frame and its disposer. */
|
|
33
|
+
const BRIDGE_ATTR = 'data-dsh-bridge';
|
|
34
|
+
/** True when one `.md-code-block` was authored with the `dsh-card` info string.
|
|
35
|
+
*
|
|
36
|
+
* The official `CodeBlock` renders the fence info string in the banner, but the
|
|
37
|
+
* DOM shape is not a stable contract: some builds render a dedicated
|
|
38
|
+
* `.infostring` element, while this release flattens the banner wrap
|
|
39
|
+
* (`_bannerWrap_…`) whose leading text is `<info>复制` (info string first, then
|
|
40
|
+
* the localized copy label), with no `.infostring`. So we match on the block's
|
|
41
|
+
* leading text: the info string is always rendered before the code body, and a
|
|
42
|
+
* normal code block cannot begin with `dsh-card` unless it was authored that
|
|
43
|
+
* way. Prefer the explicit `.infostring` element when present, then fall back
|
|
44
|
+
* to the text prefix.
|
|
45
|
+
*/
|
|
46
|
+
function isCardInfoBlock(block) {
|
|
47
|
+
const explicit = block.querySelector('.infostring');
|
|
48
|
+
if (explicit !== null)
|
|
49
|
+
return explicit.textContent?.trim() === CARD_INFO_STRING;
|
|
50
|
+
// `textContent` begins with the info string baked before the copy label.
|
|
51
|
+
return block.textContent.trimStart().startsWith(CARD_INFO_STRING);
|
|
52
|
+
}
|
|
53
|
+
/** Find every rendered `dsh-card` block under `root`. */
|
|
54
|
+
export function findCardBlocks(root) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const node of root.querySelectorAll('.md-code-block')) {
|
|
57
|
+
if (isCardInfoBlock(node))
|
|
58
|
+
out.push(node);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** True when the block (or an ancestor) is inside a still-streaming message. */
|
|
63
|
+
function isStreaming(node) {
|
|
64
|
+
return node.closest('[data-streaming]') !== null;
|
|
65
|
+
}
|
|
66
|
+
/** Extract the authored HTML from a card block's `<pre>` text body. */
|
|
67
|
+
function readCardHtml(block) {
|
|
68
|
+
const pre = block.querySelector('pre');
|
|
69
|
+
return (pre?.textContent ?? '').replace(/\n$/, '');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render one card block: read the HTML, build a sandboxed srcDoc, insert the
|
|
73
|
+
* iframe after the block, hide the block, and attach the capability bridge.
|
|
74
|
+
* @param block - the rendered `dsh-card` block.
|
|
75
|
+
* @param capabilities - wired host capabilities handed to the bridge.
|
|
76
|
+
* @returns a {@link MountedCard} or null when the block had no content.
|
|
77
|
+
*/
|
|
78
|
+
function mountCard(block, capabilities) {
|
|
79
|
+
const html = readCardHtml(block);
|
|
80
|
+
if (html.trim() === '')
|
|
81
|
+
return null;
|
|
82
|
+
const frame = document.createElement('iframe');
|
|
83
|
+
frame.className = FRAME_CLASS;
|
|
84
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
85
|
+
frame.setAttribute('title', 'inline card');
|
|
86
|
+
// A fresh container each mount: the frame is opaque-origin so nothing can
|
|
87
|
+
// spoof it, and setting srcDoc after insertion is fine (load fires async).
|
|
88
|
+
frame.srcdoc = buildCardSrcDoc(html);
|
|
89
|
+
frame.style.display = 'block';
|
|
90
|
+
frame.style.width = '100%';
|
|
91
|
+
// A small default so an unloaded frame is not collapsed; the bridge inside
|
|
92
|
+
// the frame posts the real content height on load/resize (see srcdoc.ts),
|
|
93
|
+
// because the parent cannot read contentDocument on an opaque-origin frame.
|
|
94
|
+
frame.style.height = 'auto';
|
|
95
|
+
frame.style.border = 'none';
|
|
96
|
+
block.parentNode?.insertBefore(frame, block.nextSibling);
|
|
97
|
+
// Hide the block with a style React does not reconcile (React only manages
|
|
98
|
+
// the class/style it rendered; this element's `style` is set once by us).
|
|
99
|
+
block.style.display = 'none';
|
|
100
|
+
block.setAttribute(PROCESSED_ATTR, '');
|
|
101
|
+
const disposeBridge = attachBridge(frame, capabilities);
|
|
102
|
+
frame.setAttribute(BRIDGE_ATTR, '');
|
|
103
|
+
const dispose = () => {
|
|
104
|
+
disposeBridge();
|
|
105
|
+
frame.remove();
|
|
106
|
+
block.removeAttribute(PROCESSED_ATTR);
|
|
107
|
+
// Do NOT clear block.style.display — React may not have re-rendered; a
|
|
108
|
+
// cleared style would flash the code block back. Cleaning up the frame is
|
|
109
|
+
// enough; the block node itself belongs to React.
|
|
110
|
+
};
|
|
111
|
+
return { block, frame, dispose };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Drop frames whose owning block is gone. React may replace a processed block
|
|
115
|
+
* with a fresh node (a re-render of the settled markdown); the old block's
|
|
116
|
+
* frame then has no matching block before it and would sit orphaned. A frame
|
|
117
|
+
* is owned by the immediately-preceding `.md-code-block` carrying the
|
|
118
|
+
* processed marker that paired with it — so a frame not preceded by exactly
|
|
119
|
+
* that is stale. Reactive re-scanning keeps the two in lockstep.
|
|
120
|
+
*/
|
|
121
|
+
function collectCardFrames(root) {
|
|
122
|
+
return [...root.querySelectorAll(`.${FRAME_CLASS}`)];
|
|
123
|
+
}
|
|
124
|
+
/** Remove every card frame whose predecessor block is no longer its partner. */
|
|
125
|
+
function dropOrphanFrames(root, mounted) {
|
|
126
|
+
for (const frame of collectCardFrames(root)) {
|
|
127
|
+
const prev = frame.previousElementSibling;
|
|
128
|
+
const owned = prev instanceof HTMLElement && prev.classList.contains('md-code-block')
|
|
129
|
+
&& prev.hasAttribute(PROCESSED_ATTR);
|
|
130
|
+
if (owned)
|
|
131
|
+
continue;
|
|
132
|
+
frame.remove();
|
|
133
|
+
// Best-effort detach from the mounted registry (the handle holds the same
|
|
134
|
+
// node, so a later dispose of the registry still finds it already gone).
|
|
135
|
+
for (const card of mounted) {
|
|
136
|
+
if (card.frame === frame) {
|
|
137
|
+
mounted.delete(card);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Reconcile: swap every unprocessed, settled `dsh-card` block under `root`, then drop orphans. */
|
|
144
|
+
export function reconcile(root, capabilities = {}) {
|
|
145
|
+
const mounted = [];
|
|
146
|
+
for (const block of findCardBlocks(root)) {
|
|
147
|
+
if (block.hasAttribute(PROCESSED_ATTR))
|
|
148
|
+
continue;
|
|
149
|
+
if (isStreaming(block))
|
|
150
|
+
continue;
|
|
151
|
+
const card = mountCard(block, capabilities);
|
|
152
|
+
if (card !== null)
|
|
153
|
+
mounted.push(card);
|
|
154
|
+
}
|
|
155
|
+
return mounted;
|
|
156
|
+
}
|
|
157
|
+
const CHAT_ROOT = '[data-conversation-scroll]';
|
|
158
|
+
/**
|
|
159
|
+
* Install the inline-card renderer over the document.
|
|
160
|
+
* @param capabilities - wired host capabilities handed to every card's bridge.
|
|
161
|
+
* @returns a {@link CardRenderer} whose `dispose` tears everything down.
|
|
162
|
+
*/
|
|
163
|
+
export function installCardRenderer(capabilities = {}) {
|
|
164
|
+
// jsdom lacks requestAnimationFrame; fall back to a macrotask so the plugin
|
|
165
|
+
// never throws headless (and tests run synchronously via `run`).
|
|
166
|
+
const nextFrame = typeof requestAnimationFrame === 'function'
|
|
167
|
+
? requestAnimationFrame
|
|
168
|
+
: (cb) => { setTimeout(cb, 0); };
|
|
169
|
+
const cancelFrame = typeof cancelAnimationFrame === 'function'
|
|
170
|
+
? cancelAnimationFrame
|
|
171
|
+
: (id) => { clearTimeout(id); };
|
|
172
|
+
// Track mounted cards so dispose can take every frame down. Set keeps one
|
|
173
|
+
// entry per frame; re-runs of reconcile mount new frames and re-insert here.
|
|
174
|
+
const mounted = new Set();
|
|
175
|
+
const run = () => {
|
|
176
|
+
// Scope to the chat pane when present; fall back to the whole document so
|
|
177
|
+
// a not-yet-mounted chat (or headless test) still sees a first pass.
|
|
178
|
+
const root = document.querySelector(CHAT_ROOT) ?? document.body;
|
|
179
|
+
for (const card of reconcile(root, capabilities))
|
|
180
|
+
mounted.add(card);
|
|
181
|
+
dropOrphanFrames(root, mounted);
|
|
182
|
+
};
|
|
183
|
+
// Re-scan when the observed subtree mutates. Chat is the hot path; the full
|
|
184
|
+
// document observer would be noisy, so a chat bound improves cost — but the
|
|
185
|
+
// chat pane may not be mounted yet, so a document fallback keeps discovery
|
|
186
|
+
// working. Both observers coalesce via `nextFrame`.
|
|
187
|
+
let raf = 0;
|
|
188
|
+
const schedule = () => {
|
|
189
|
+
if (raf !== 0)
|
|
190
|
+
return;
|
|
191
|
+
raf = nextFrame(() => {
|
|
192
|
+
raf = 0;
|
|
193
|
+
run();
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
const observer = new MutationObserver(schedule);
|
|
197
|
+
observer.observe(document.body, {
|
|
198
|
+
childList: true,
|
|
199
|
+
subtree: true,
|
|
200
|
+
attributes: true,
|
|
201
|
+
attributeFilter: ['data-streaming'],
|
|
202
|
+
});
|
|
203
|
+
// First pass right away. `nextFrame` so the observer itself is not spammed
|
|
204
|
+
// before the first commit; a settled render only needs a microtask, so a
|
|
205
|
+
// caller wanting deterministic behavior calls `run()` directly.
|
|
206
|
+
schedule();
|
|
207
|
+
return {
|
|
208
|
+
run,
|
|
209
|
+
dispose: () => {
|
|
210
|
+
if (raf !== 0) {
|
|
211
|
+
cancelFrame(raf);
|
|
212
|
+
raf = 0;
|
|
213
|
+
}
|
|
214
|
+
observer.disconnect();
|
|
215
|
+
for (const card of mounted)
|
|
216
|
+
card.dispose();
|
|
217
|
+
mounted.clear();
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed srcDoc builder for inline HTML cards.
|
|
3
|
+
*
|
|
4
|
+
* The parent web shell has no Content-Security-Policy of its own and an
|
|
5
|
+
* iframe's `srcdoc` is not served as an HTTP response, so CSP inheritance
|
|
6
|
+
* cannot be relied on — the policy must be embedded IN the document. This
|
|
7
|
+
* helper guarantees exactly that for both shapes of input:
|
|
8
|
+
*
|
|
9
|
+
* - a bare fragment (no `<html>`/`<head>`) is wrapped into a full document
|
|
10
|
+
* whose head carries the CSP and the capability bridge;
|
|
11
|
+
* - a full document keeps its own structure and gets the CSP meta injected
|
|
12
|
+
* right after its `<head …>` tag (skipped when it already declares one,
|
|
13
|
+
* e.g. a skill-compliant output that embeds the same policy).
|
|
14
|
+
*
|
|
15
|
+
* The sandbox is `allow-scripts`, NEVER `allow-same-origin` — the opaque
|
|
16
|
+
* origin is what keeps the frame's scripts from touching the host, and the
|
|
17
|
+
* CSP is the network/capability boundary. This is deliberately stricter than
|
|
18
|
+
* file-preview's Tier1 (no whitelisted CDNs): an inline card should never
|
|
19
|
+
* reach the network at all.
|
|
20
|
+
* @module @khorsheed/dsh-inline-html-render
|
|
21
|
+
*/
|
|
22
|
+
/** Strict sandbox CSP: no network, no navigation, scripts only inline. */
|
|
23
|
+
export declare const CARD_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; worker-src blob:; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
24
|
+
/**
|
|
25
|
+
* Wrap or annotate an untrusted HTML document so it runs inside the sandbox CSP.
|
|
26
|
+
* @param content - the card HTML, as authored inside the fenced block.
|
|
27
|
+
* @returns a complete document carrying the strict CSP and the bridge.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildCardSrcDoc(content: string): string;
|
|
30
|
+
//# sourceMappingURL=srcdoc.d.ts.map
|