mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:00:42 +00:00
fix(export): fix broken template placeholders in session export HTML (#41861)
* fix(export): fix broken template placeholders in session export HTML
The {{MARKED_JS}}, {{HIGHLIGHT_JS}}, and {{JS}} placeholders in the
export HTML template were split across multiple lines by a code
formatter, turning them into JS block statements instead of template
tokens. The generateHtml() function uses .replace('{{MARKED_JS}}', ...)
which requires contiguous strings, so the vendor JS and app code were
never injected — producing a 2MB HTML file that opens with styles and
session data but renders blank (no JS to parse/display the data).
Fix: collapse placeholders to single-line {{TOKEN}} format and add
prettier-ignore comments to prevent re-formatting.
Introduced in 9d403fd.
* fix(export): use function replacers for vendor JS injection
String.replace() interprets $ sequences ($&, $$, $', etc.) in
replacement strings. The minified vendor libraries (highlight.min.js,
marked.min.js) and the template JS contain literal $ characters that
get mutated during injection — e.g. $& becomes the matched placeholder
text, $$ becomes a single $.
Fix: use arrow function replacers for JS content so replacement text
is injected verbatim without $ interpretation. CSS and session data
use string replacers since they don't contain problematic $ patterns.
Flagged by Codex review (P2).
* ci: retrigger checks
* fix(export-session): restore inline export scripts
---------
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp.
|
||||
- Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868.
|
||||
- Control UI/models: request the configured Gateway model-list view so dashboards with only `models.providers.*.models` show those configured models first instead of flooding the picker with the full built-in catalog. Fixes #65405. Thanks @wbyanclaw.
|
||||
- CLI/models: keep default-model and allowlist pickers on explicit `models.providers.*.models` entries when `models.mode` is `replace` instead of loading the full built-in catalog. Fixes #64950. Thanks @mrozentsvayg.
|
||||
|
||||
@@ -19,6 +19,7 @@ const hoisted = await vi.hoisted(async () => {
|
||||
writeFileSyncMock: vi.fn(),
|
||||
mkdirSyncMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => true),
|
||||
exportHtmlTemplateContents: new Map<string, string>(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,8 +55,13 @@ vi.mock("node:fs", async () => {
|
||||
mkdirSync: hoisted.mkdirSyncMock,
|
||||
writeFileSync: hoisted.writeFileSyncMock,
|
||||
readFileSync: vi.fn((filePath: string) => {
|
||||
if (filePath.endsWith("template.html")) {
|
||||
return "<html>{{CSS}}{{JS}}{{SESSION_DATA}}{{MARKED_JS}}{{HIGHLIGHT_JS}}</html>";
|
||||
for (const [suffix, contents] of hoisted.exportHtmlTemplateContents) {
|
||||
if (filePath.endsWith(suffix)) {
|
||||
return contents;
|
||||
}
|
||||
}
|
||||
if (filePath.includes("/export-html/")) {
|
||||
return actual.readFileSync(filePath, "utf8");
|
||||
}
|
||||
return "";
|
||||
}),
|
||||
@@ -124,6 +130,7 @@ describe("buildExportSessionReply", () => {
|
||||
sandboxRuntime: { sandboxed: false, mode: "off" },
|
||||
});
|
||||
hoisted.existsSyncMock.mockReturnValue(true);
|
||||
hoisted.exportHtmlTemplateContents.clear();
|
||||
});
|
||||
|
||||
it("resolves store and transcript paths from the target session agent", async () => {
|
||||
@@ -185,4 +192,60 @@ describe("buildExportSessionReply", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("injects scripts and session data through the real export template", async () => {
|
||||
const { buildExportSessionReply } = await import("./commands-export-session.js");
|
||||
|
||||
await buildExportSessionReply(makeParams());
|
||||
|
||||
const html = hoisted.writeFileSyncMock.mock.calls[0]?.[1];
|
||||
expect(typeof html).toBe("string");
|
||||
expect(html).not.toContain("{{CSS}}");
|
||||
expect(html).not.toContain("{{JS}}");
|
||||
expect(html).not.toContain("{{SESSION_DATA}}");
|
||||
expect(html).not.toContain("{{MARKED_JS}}");
|
||||
expect(html).not.toContain("{{HIGHLIGHT_JS}}");
|
||||
expect(html).not.toContain("data-openclaw-export-placeholder");
|
||||
expect(html).toContain(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
header: null,
|
||||
entries: [],
|
||||
leafId: null,
|
||||
systemPrompt: "system prompt",
|
||||
tools: [],
|
||||
}),
|
||||
).toString("base64"),
|
||||
);
|
||||
expect(html).toContain('const base64 = document.getElementById("session-data").textContent;');
|
||||
});
|
||||
|
||||
it("preserves replacement text with dollar sequences", async () => {
|
||||
const { buildExportSessionReply } = await import("./commands-export-session.js");
|
||||
hoisted.exportHtmlTemplateContents.set(
|
||||
"template.html",
|
||||
[
|
||||
'<style data-openclaw-export-placeholder="CSS"></style>',
|
||||
'<script id="session-data" type="application/json" data-openclaw-export-placeholder="SESSION_DATA"></script>',
|
||||
'<script data-openclaw-export-placeholder="MARKED_JS"></script>',
|
||||
'<script data-openclaw-export-placeholder="HIGHLIGHT_JS"></script>',
|
||||
'<script data-openclaw-export-placeholder="JS"></script>',
|
||||
].join(""),
|
||||
);
|
||||
hoisted.exportHtmlTemplateContents.set("template.css", "/* {{THEME_VARS}} */$&$1");
|
||||
hoisted.exportHtmlTemplateContents.set("template.js", "const marker = '$&$1';");
|
||||
hoisted.exportHtmlTemplateContents.set("vendor/marked.min.js", "const markedMarker = '$&$1';");
|
||||
hoisted.exportHtmlTemplateContents.set(
|
||||
"vendor/highlight.min.js",
|
||||
"const highlightMarker = '$&$1';",
|
||||
);
|
||||
|
||||
await buildExportSessionReply(makeParams());
|
||||
|
||||
const html = hoisted.writeFileSyncMock.mock.calls[0]?.[1];
|
||||
expect(html).toContain("$&$1");
|
||||
expect(html).toContain("const marker = '$&$1';");
|
||||
expect(html).toContain("const markedMarker = '$&$1';");
|
||||
expect(html).toContain("const highlightMarker = '$&$1';");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,25 @@ function loadTemplate(fileName: string): string {
|
||||
return fs.readFileSync(path.join(EXPORT_HTML_DIR, fileName), "utf-8");
|
||||
}
|
||||
|
||||
function replaceHtmlPlaceholder(template: string, name: string, value: string): string {
|
||||
let replaced = false;
|
||||
const placeholder = new RegExp(
|
||||
`(<(?:script|style)\\b(?=[^>]*\\bdata-openclaw-export-placeholder="${name}")[^>]*>)(</(?:script|style)>)`,
|
||||
);
|
||||
const next = template.replace(
|
||||
placeholder,
|
||||
(_match: string, openTag: string, closeTag: string) => {
|
||||
replaced = true;
|
||||
const finalOpenTag = openTag.replace(/\sdata-openclaw-export-placeholder="[^"]*"/, "");
|
||||
return `${finalOpenTag}${value}${closeTag}`;
|
||||
},
|
||||
);
|
||||
if (!replaced) {
|
||||
throw new Error(`Export HTML template missing ${name} placeholder`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function generateHtml(sessionData: SessionData): string {
|
||||
const template = loadTemplate("template.html");
|
||||
const templateCss = loadTemplate("template.css");
|
||||
@@ -88,12 +107,13 @@ function generateHtml(sessionData: SessionData): string {
|
||||
.replace("/* {{CONTAINER_BG_DECL}} */", `--container-bg: ${containerBg};`)
|
||||
.replace("/* {{INFO_BG_DECL}} */", `--info-bg: ${infoBg};`);
|
||||
|
||||
return template
|
||||
.replace("{{CSS}}", css)
|
||||
.replace("{{JS}}", templateJs)
|
||||
.replace("{{SESSION_DATA}}", sessionDataBase64)
|
||||
.replace("{{MARKED_JS}}", markedJs)
|
||||
.replace("{{HIGHLIGHT_JS}}", hljsJs);
|
||||
return [
|
||||
["CSS", css],
|
||||
["SESSION_DATA", sessionDataBase64],
|
||||
["MARKED_JS", markedJs],
|
||||
["HIGHLIGHT_JS", hljsJs],
|
||||
["JS", templateJs],
|
||||
].reduce((html, [name, value]) => replaceHtmlPlaceholder(html, name, value), template);
|
||||
}
|
||||
|
||||
export async function buildExportSessionReply(params: HandleCommandsParams): Promise<ReplyPayload> {
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Session Export</title>
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
<style data-openclaw-export-placeholder="CSS"></style>
|
||||
</head>
|
||||
<body>
|
||||
<button id="hamburger" title="Open sidebar">
|
||||
@@ -54,35 +52,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="session-data" type="application/json">
|
||||
{{SESSION_DATA}}
|
||||
</script>
|
||||
<script
|
||||
id="session-data"
|
||||
type="application/json"
|
||||
data-openclaw-export-placeholder="SESSION_DATA"
|
||||
></script>
|
||||
|
||||
<!-- Vendored libraries -->
|
||||
<script>
|
||||
{
|
||||
{
|
||||
MARKED_JS;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script data-openclaw-export-placeholder="MARKED_JS"></script>
|
||||
|
||||
<!-- highlight.js -->
|
||||
<script>
|
||||
{
|
||||
{
|
||||
HIGHLIGHT_JS;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script data-openclaw-export-placeholder="HIGHLIGHT_JS"></script>
|
||||
|
||||
<!-- Main application code -->
|
||||
<script>
|
||||
{
|
||||
{
|
||||
JS;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script data-openclaw-export-placeholder="JS"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -84,12 +84,23 @@ function installScrollIntoViewStub(document: Document) {
|
||||
}
|
||||
|
||||
async function renderTemplate(sessionData: SessionData) {
|
||||
const html = templateHtml
|
||||
.replace("{{CSS}}", "")
|
||||
.replace("{{SESSION_DATA}}", Buffer.from(JSON.stringify(sessionData), "utf8").toString("base64"))
|
||||
.replace("{{MARKED_JS}}", "")
|
||||
.replace("{{HIGHLIGHT_JS}}", "")
|
||||
.replace("{{JS}}", "");
|
||||
const html = [
|
||||
["CSS", ""],
|
||||
["SESSION_DATA", Buffer.from(JSON.stringify(sessionData), "utf8").toString("base64")],
|
||||
["MARKED_JS", ""],
|
||||
["HIGHLIGHT_JS", ""],
|
||||
["JS", ""],
|
||||
].reduce(
|
||||
(currentHtml, [name, value]) =>
|
||||
currentHtml.replace(
|
||||
new RegExp(
|
||||
`(<(?:script|style)\\b(?=[^>]*\\bdata-openclaw-export-placeholder="${name}")[^>]*>)(</(?:script|style)>)`,
|
||||
),
|
||||
(_match: string, openTag: string, closeTag: string) =>
|
||||
`${openTag.replace(/\sdata-openclaw-export-placeholder="[^"]*"/, "")}${value}${closeTag}`,
|
||||
),
|
||||
templateHtml,
|
||||
);
|
||||
|
||||
const parseHTML = await loadParseHTML();
|
||||
const { document, window } = parseHTML(html);
|
||||
|
||||
Reference in New Issue
Block a user