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:
Brian Newman
2026-04-27 21:34:20 -07:00
committed by GitHub
parent 5826774076
commit 055127425f
5 changed files with 118 additions and 41 deletions

View File

@@ -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.

View File

@@ -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';");
});
});

View File

@@ -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> {

View File

@@ -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>

View File

@@ -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);