From ec4ef538e622844cbc106940213b21810a565b36 Mon Sep 17 00:00:00 2001
From: Ryan Turnquist
Date: Mon, 6 Oct 2025 09:14:20 -0700
Subject: [PATCH] fix: escape grave character in template literals
---
.changeset/big-horses-sniff.md | 5 ++
.../__snapshots__/.name-cache.json | 5 ++
.../__snapshots__/csr-sanitized.expected.md | 12 +++++
.../__snapshots__/csr.expected.md | 17 +++++++
.../__snapshots__/dom.expected/tags/child.js | 6 +++
.../dom.expected/template.hydrate.js | 1 +
.../__snapshots__/dom.expected/template.js | 10 ++++
.../__snapshots__/html.expected/tags/child.js | 6 +++
.../__snapshots__/html.expected/template.js | 11 +++++
.../resume-sanitized.expected.md | 12 +++++
.../__snapshots__/resume.expected.md | 24 ++++++++++
.../__snapshots__/ssr-sanitized.expected.md | 12 +++++
.../__snapshots__/ssr.expected.md | 46 +++++++++++++++++++
.../escape-html-strings/tags/child.marko | 4 ++
.../escape-html-strings/template.marko | 5 ++
.../src/translator/core/html-comment.ts | 21 ++-------
.../src/translator/core/html-script.ts | 25 +++-------
.../src/translator/core/html-style.ts | 25 +++-------
.../translator/util/body-to-text-literal.ts | 35 ++++++++++++++
.../util/normalize-string-expression.ts | 2 +-
20 files changed, 229 insertions(+), 55 deletions(-)
create mode 100644 .changeset/big-horses-sniff.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/.name-cache.json
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr-sanitized.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/tags/child.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.hydrate.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/tags/child.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/template.js
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume-sanitized.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr-sanitized.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr.expected.md
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/tags/child.marko
create mode 100644 packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/template.marko
create mode 100644 packages/runtime-tags/src/translator/util/body-to-text-literal.ts
diff --git a/.changeset/big-horses-sniff.md b/.changeset/big-horses-sniff.md
new file mode 100644
index 0000000000..bd6af5c8a5
--- /dev/null
+++ b/.changeset/big-horses-sniff.md
@@ -0,0 +1,5 @@
+---
+"@marko/runtime-tags": patch
+---
+
+Escape grave (`) characters in template literals
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/.name-cache.json b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/.name-cache.json
new file mode 100644
index 0000000000..db413faf9a
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/.name-cache.json
@@ -0,0 +1,5 @@
+{
+ "vars": {
+ "props": {}
+ }
+}
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr-sanitized.expected.md b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr-sanitized.expected.md
new file mode 100644
index 0000000000..84ef69bd22
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr-sanitized.expected.md
@@ -0,0 +1,12 @@
+# Render
+```html
+
+ 1`
+
+ child`"'
+
+
+ ${value}
+
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr.expected.md b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr.expected.md
new file mode 100644
index 0000000000..25559b6e00
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/csr.expected.md
@@ -0,0 +1,17 @@
+# Render
+```html
+
+ 1`
+
+ child`"'
+
+
+ ${value}
+
+
+```
+
+# Mutations
+```
+INSERT div
+```
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/tags/child.js b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/tags/child.js
new file mode 100644
index 0000000000..64a9513ca1
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/tags/child.js
@@ -0,0 +1,6 @@
+export const $template = "child`\"'${value}";
+export const $walks = /* over(2) */"c";
+export const $setup = () => {};
+const value = "No!!";
+import * as _ from "@marko/runtime-tags/debug/dom";
+export default /* @__PURE__ */_._template("__tests__/tags/child.marko", $template, $walks, $setup);
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.hydrate.js b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.hydrate.js
new file mode 100644
index 0000000000..3eb9cd79d3
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.hydrate.js
@@ -0,0 +1 @@
+// size: 0
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.js b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.js
new file mode 100644
index 0000000000..00b85ed7be
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/dom.expected/template.js
@@ -0,0 +1,10 @@
+export const $template = `\` ${_child_template}
`;
+export const $walks = /* next(1), replace, over(2), beginChild, _child_walks, endChild, out(1) */`D%c/${_child_walks}&l`;
+import * as _ from "@marko/runtime-tags/debug/dom";
+import { $setup as _child, $template as _child_template, $walks as _child_walks } from "./tags/child.marko";
+const $count = /* @__PURE__ */_._let("count/2", ($scope, count) => _._text($scope["#text/0"], count));
+export function $setup($scope) {
+ _child($scope["#childScope/1"]);
+ $count($scope, 1);
+}
+export default /* @__PURE__ */_._template("__tests__/template.marko", $template, $walks, $setup);
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/tags/child.js b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/tags/child.js
new file mode 100644
index 0000000000..3246883e18
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/tags/child.js
@@ -0,0 +1,6 @@
+const value = "No!!";
+import * as _ from "@marko/runtime-tags/debug/html";
+export default _._template("__tests__/tags/child.marko", input => {
+ const $scope0_id = _._scope_id();
+ _._html("child`\"'${value}");
+});
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/template.js b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/template.js
new file mode 100644
index 0000000000..4cca5189e6
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/html.expected/template.js
@@ -0,0 +1,11 @@
+import * as _ from "@marko/runtime-tags/debug/html";
+import _child from "./tags/child.marko";
+export default _._template("__tests__/template.marko", input => {
+ const $scope0_id = _._scope_id();
+ let count = 1;
+ _._html(`${_._escape(count)}${_._el_resume($scope0_id, "#text/0")}\` `);
+ _child({});
+ _._html("
");
+ _._scope($scope0_id, {}, "__tests__/template.marko", 0);
+ _._resume_branch($scope0_id);
+});
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume-sanitized.expected.md b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume-sanitized.expected.md
new file mode 100644
index 0000000000..84ef69bd22
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume-sanitized.expected.md
@@ -0,0 +1,12 @@
+# Render
+```html
+
+ 1`
+
+ child`"'
+
+
+ ${value}
+
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume.expected.md b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume.expected.md
new file mode 100644
index 0000000000..fd9dc2207f
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/resume.expected.md
@@ -0,0 +1,24 @@
+# Render
+```html
+
+
+
+
+ 1
+
+ `
+
+ child`"'
+
+
+ ${value}
+
+
+
+
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr-sanitized.expected.md b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr-sanitized.expected.md
new file mode 100644
index 0000000000..0dc90cc6d8
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr-sanitized.expected.md
@@ -0,0 +1,12 @@
+# Render End
+```html
+
+ 1`
+
+ child`"'
+
+
+ ${value}
+
+
+```
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr.expected.md b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr.expected.md
new file mode 100644
index 0000000000..f6e0ffab56
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/__snapshots__/ssr.expected.md
@@ -0,0 +1,46 @@
+# Write
+```html
+ 1` child`"'${value}
+```
+
+# Render End
+```html
+
+
+
+ 1
+
+ `
+
+ child`"'
+
+
+ ${value}
+
+
+
+
+
+```
+
+# Mutations
+```
+INSERT html
+INSERT html/head
+INSERT html/body
+INSERT html/body/div
+INSERT html/body/div/#text0
+INSERT html/body/div/#comment
+INSERT html/body/div/#text1
+INSERT html/body/div/span0
+INSERT html/body/div/span0/#text
+INSERT html/body/div/span1
+INSERT html/body/div/span1/#text
+INSERT html/body/script
+INSERT html/body/script/#text
+```
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/tags/child.marko b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/tags/child.marko
new file mode 100644
index 0000000000..ee7a31f4de
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/tags/child.marko
@@ -0,0 +1,4 @@
+static const value = "No!!"
+child`"'
+\${value}
+
\ No newline at end of file
diff --git a/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/template.marko b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/template.marko
new file mode 100644
index 0000000000..526b6906b4
--- /dev/null
+++ b/packages/runtime-tags/src/__tests__/fixtures/escape-html-strings/template.marko
@@ -0,0 +1,5 @@
+
+
+ ${count}`
+
+
diff --git a/packages/runtime-tags/src/translator/core/html-comment.ts b/packages/runtime-tags/src/translator/core/html-comment.ts
index a9f7e69f11..9a1bc6c7b7 100644
--- a/packages/runtime-tags/src/translator/core/html-comment.ts
+++ b/packages/runtime-tags/src/translator/core/html-comment.ts
@@ -8,6 +8,7 @@ import {
} from "@marko/compiler/babel-utils";
import { WalkCode } from "../../common/types";
+import { bodyToTextLiteral } from "../util/body-to-text-literal";
import { generateUidIdentifier } from "../util/generate-uid";
import isInvokedFunction from "../util/is-invoked-function";
import { isOutputHTML } from "../util/marko-config";
@@ -180,23 +181,11 @@ export default {
}
}
} else {
- const templateQuasis: t.TemplateElement[] = [];
- const templateExpressions: t.Expression[] = [];
- let currentQuasi = "";
- for (const child of tag.node.body.body) {
- if (t.isMarkoText(child)) {
- currentQuasi += child.value;
- } else if (t.isMarkoPlaceholder(child)) {
- templateQuasis.push(t.templateElement({ raw: currentQuasi }));
- templateExpressions.push(child.value);
- currentQuasi = "";
- }
- }
+ const textLiteral = bodyToTextLiteral(tag.node.body);
- if (templateExpressions.length === 0) {
- write`${currentQuasi}`;
+ if (t.isStringLiteral(textLiteral)) {
+ write`${textLiteral}`;
} else {
- templateQuasis.push(t.templateElement({ raw: currentQuasi }));
addStatement(
"render",
getSection(tag),
@@ -209,7 +198,7 @@ export default {
getScopeAccessorLiteral(nodeBinding!),
true,
),
- t.templateLiteral(templateQuasis, templateExpressions),
+ textLiteral,
),
),
);
diff --git a/packages/runtime-tags/src/translator/core/html-script.ts b/packages/runtime-tags/src/translator/core/html-script.ts
index cc92e853ef..a17e66ee83 100644
--- a/packages/runtime-tags/src/translator/core/html-script.ts
+++ b/packages/runtime-tags/src/translator/core/html-script.ts
@@ -12,6 +12,7 @@ import {
import { getEventHandlerName, isEventHandler } from "../../common/helpers";
import { WalkCode } from "../../common/types";
+import { bodyToTextLiteral } from "../util/body-to-text-literal";
import evaluate from "../util/evaluate";
import { generateUidIdentifier } from "../util/generate-uid";
import isInvokedFunction from "../util/is-invoked-function";
@@ -405,29 +406,15 @@ export default {
}
}
} else {
- const templateQuasis: t.TemplateElement[] = [];
- const templateExpressions: t.Expression[] = [];
- let currentQuasi = "";
- let referencePlaceholder: t.MarkoPlaceholder | undefined;
- for (const child of tag.node.body.body) {
- if (t.isMarkoText(child)) {
- currentQuasi += child.value;
- } else if (t.isMarkoPlaceholder(child)) {
- referencePlaceholder ||= child;
- templateQuasis.push(t.templateElement({ raw: currentQuasi }));
- templateExpressions.push(child.value);
- currentQuasi = "";
- }
- }
+ const textLiteral = bodyToTextLiteral(tag.node.body);
- if (!referencePlaceholder) {
- write`${currentQuasi}`;
+ if (t.isStringLiteral(textLiteral)) {
+ write`${textLiteral.value}`;
} else {
- templateQuasis.push(t.templateElement({ raw: currentQuasi }));
addStatement(
"render",
getSection(tag),
- referencePlaceholder.value.extra?.referencedBindings,
+ textLiteral.extra?.referencedBindings,
t.expressionStatement(
callRuntime(
"_text_content",
@@ -436,7 +423,7 @@ export default {
getScopeAccessorLiteral(nodeBinding!),
true,
),
- t.templateLiteral(templateQuasis, templateExpressions),
+ textLiteral,
),
),
);
diff --git a/packages/runtime-tags/src/translator/core/html-style.ts b/packages/runtime-tags/src/translator/core/html-style.ts
index adf25c7980..45f1e2bc04 100644
--- a/packages/runtime-tags/src/translator/core/html-style.ts
+++ b/packages/runtime-tags/src/translator/core/html-style.ts
@@ -12,6 +12,7 @@ import {
import { getEventHandlerName, isEventHandler } from "../../common/helpers";
import { WalkCode } from "../../common/types";
+import { bodyToTextLiteral } from "../util/body-to-text-literal";
import evaluate from "../util/evaluate";
import { generateUidIdentifier } from "../util/generate-uid";
import isInvokedFunction from "../util/is-invoked-function";
@@ -405,29 +406,15 @@ export default {
}
}
} else {
- const templateQuasis: t.TemplateElement[] = [];
- const templateExpressions: t.Expression[] = [];
- let currentQuasi = "";
- let referencePlaceholder: t.MarkoPlaceholder | undefined;
- for (const child of tag.node.body.body) {
- if (t.isMarkoText(child)) {
- currentQuasi += child.value;
- } else if (t.isMarkoPlaceholder(child)) {
- referencePlaceholder ||= child;
- templateQuasis.push(t.templateElement({ raw: currentQuasi }));
- templateExpressions.push(child.value);
- currentQuasi = "";
- }
- }
+ const textLiteral = bodyToTextLiteral(tag.node.body);
- if (!referencePlaceholder) {
- write`${currentQuasi}`;
+ if (t.isStringLiteral(textLiteral)) {
+ write`${textLiteral}`;
} else {
- templateQuasis.push(t.templateElement({ raw: currentQuasi }));
addStatement(
"render",
getSection(tag),
- referencePlaceholder.value.extra?.referencedBindings,
+ textLiteral.extra?.referencedBindings,
t.expressionStatement(
callRuntime(
"_text_content",
@@ -436,7 +423,7 @@ export default {
getScopeAccessorLiteral(nodeBinding!),
true,
),
- t.templateLiteral(templateQuasis, templateExpressions),
+ textLiteral,
),
),
);
diff --git a/packages/runtime-tags/src/translator/util/body-to-text-literal.ts b/packages/runtime-tags/src/translator/util/body-to-text-literal.ts
new file mode 100644
index 0000000000..90c8ed133a
--- /dev/null
+++ b/packages/runtime-tags/src/translator/util/body-to-text-literal.ts
@@ -0,0 +1,35 @@
+import { types as t } from "@marko/compiler";
+
+export function bodyToTextLiteral(body: t.MarkoTagBody) {
+ const templateQuasis: t.TemplateElement[] = [];
+ const templateExpressions: t.Expression[] = [];
+ let currentQuasi = "";
+ let placeholderExtra: t.MarkoPlaceholder["extra"];
+ for (const child of body.body) {
+ if (t.isMarkoText(child)) {
+ currentQuasi += child.value;
+ } else if (t.isMarkoPlaceholder(child)) {
+ placeholderExtra ||= child.value.extra;
+ templateQuasis.push(templateElement(currentQuasi, false));
+ templateExpressions.push(child.value);
+ currentQuasi = "";
+ }
+ }
+ if (templateExpressions.length) {
+ templateQuasis.push(templateElement(currentQuasi, true));
+ const literal = t.templateLiteral(templateQuasis, templateExpressions);
+ literal.extra = placeholderExtra;
+ return literal;
+ }
+ return t.stringLiteral(currentQuasi);
+}
+
+function templateElement(value: string, tail: boolean) {
+ return t.templateElement(
+ {
+ raw: value.replace(/`/g, "\\`"),
+ cooked: value,
+ },
+ tail,
+ );
+}
diff --git a/packages/runtime-tags/src/translator/util/normalize-string-expression.ts b/packages/runtime-tags/src/translator/util/normalize-string-expression.ts
index 852809b733..c0df1e7ec2 100644
--- a/packages/runtime-tags/src/translator/util/normalize-string-expression.ts
+++ b/packages/runtime-tags/src/translator/util/normalize-string-expression.ts
@@ -44,7 +44,7 @@ export default function normalizeStringExpression(
strs.push(curStr);
return t.templateLiteral(
- strs.map((raw) => t.templateElement({ raw })),
+ strs.map((raw) => t.templateElement({ raw: raw.replace(/`/g, "\\`") })),
exprs,
);
} else if (curStr) {