Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-horses-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@marko/runtime-tags": patch
---

Escape grave (`) characters in template literals
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"vars": {
"props": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Render
```html
<div>
1`
<span>
child`"'
</span>
<span>
${value}
</span>
</div>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Render
```html
<div>
1`
<span>
child`"'
</span>
<span>
${value}
</span>
</div>
```

# Mutations
```
INSERT div
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const $template = "<span>child`\"'</span><span>${value}</span>";
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);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// size: 0
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const $template = `<div><!>\` ${_child_template}</div>`;
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);
Original file line number Diff line number Diff line change
@@ -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("<span>child`\"'</span><span>${value}</span>");
});
Original file line number Diff line number Diff line change
@@ -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(`<div>${_._escape(count)}${_._el_resume($scope0_id, "#text/0")}\` `);
_child({});
_._html("</div>");
_._scope($scope0_id, {}, "__tests__/template.marko", 0);
_._resume_branch($scope0_id);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Render
```html
<div>
1`
<span>
child`"'
</span>
<span>
${value}
</span>
</div>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Render
```html
<html>
<head />
<body>
<div>
1
<!--M_*1 #text/0-->
`
<span>
child`"'
</span>
<span>
${value}
</span>
</div>
<script>
WALKER_RUNTIME("M")("_");
M._.r = [_ =&gt; (_.a = [0,
{}])]
</script>
</body>
</html>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Render End
```html
<div>
1`
<span>
child`"'
</span>
<span>
${value}
</span>
</div>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Write
```html
<div>1<!--M_*1 #text/0-->` <span>child`"'</span><span>${value}</span></div><script>WALKER_RUNTIME("M")("_");M._.r=[_=>(_.a=[0,{}])]</script>
```

# Render End
```html
<html>
<head />
<body>
<div>
1
<!--M_*1 #text/0-->
`
<span>
child`"'
</span>
<span>
${value}
</span>
</div>
<script>
WALKER_RUNTIME("M")("_");
M._.r = [_ =&gt; (_.a = [0,
{}])]
</script>
</body>
</html>
```

# 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
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
static const value = "No!!"
<span>child`"'</span>
<span>\${value}</span>
<script>"`"</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<let/count=1>
<div>
${count}`
<child />
</div>
21 changes: 5 additions & 16 deletions packages/runtime-tags/src/translator/core/html-comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Access .value property for StringLiteral nodes.

When textLiteral is a StringLiteral, you should access its .value property to write the actual string content. This matches the correct pattern used in html-script.ts at line 412.

Apply this diff:

-          write`${textLiteral}`;
+          write`${textLiteral.value}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
write`${textLiteral}`;
write`${textLiteral.value}`;
🤖 Prompt for AI Agents
In packages/runtime-tags/src/translator/core/html-comment.ts around line 187,
the code writes textLiteral directly but when textLiteral is a StringLiteral you
must use its .value property; modify the write call to detect if textLiteral is
a StringLiteral (or has a .value) and pass textLiteral.value to write in that
case, otherwise keep writing textLiteral as before so the actual string content
is emitted (mirror the pattern used in html-script.ts).

} else {
templateQuasis.push(t.templateElement({ raw: currentQuasi }));
addStatement(
"render",
getSection(tag),
Expand All @@ -209,7 +198,7 @@ export default {
getScopeAccessorLiteral(nodeBinding!),
true,
),
t.templateLiteral(templateQuasis, templateExpressions),
textLiteral,
),
),
);
Expand Down
25 changes: 6 additions & 19 deletions packages/runtime-tags/src/translator/core/html-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -436,7 +423,7 @@ export default {
getScopeAccessorLiteral(nodeBinding!),
true,
),
t.templateLiteral(templateQuasis, templateExpressions),
textLiteral,
),
),
);
Expand Down
25 changes: 6 additions & 19 deletions packages/runtime-tags/src/translator/core/html-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Access .value property for StringLiteral nodes.

When textLiteral is a StringLiteral, you should access its .value property to write the actual string content. This matches the pattern used correctly in html-script.ts at line 412.

Apply this diff:

-          write`${textLiteral}`;
+          write`${textLiteral.value}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
write`${textLiteral}`;
write`${textLiteral.value}`;
🤖 Prompt for AI Agents
In packages/runtime-tags/src/translator/core/html-style.ts around line 412, the
code writes textLiteral directly which fails for StringLiteral AST nodes; update
the write call to check if textLiteral is a StringLiteral and, if so, use its
.value property (otherwise use the node as before), mirroring the pattern used
in html-script.ts at line 412 so the actual string content is emitted.

} else {
templateQuasis.push(t.templateElement({ raw: currentQuasi }));
addStatement(
"render",
getSection(tag),
referencePlaceholder.value.extra?.referencedBindings,
textLiteral.extra?.referencedBindings,
t.expressionStatement(
callRuntime(
"_text_content",
Expand All @@ -436,7 +423,7 @@ export default {
getScopeAccessorLiteral(nodeBinding!),
true,
),
t.templateLiteral(templateQuasis, templateExpressions),
textLiteral,
),
),
);
Expand Down
35 changes: 35 additions & 0 deletions packages/runtime-tags/src/translator/util/body-to-text-literal.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
Comment on lines +27 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incomplete escaping for template literal quasis (risk of invalid/broken output).

When building TemplateElement.raw, only backticks are escaped. You must also escape backslashes and the sequence "${" in quasis; otherwise text like "${x}" will be parsed as an expression, and backslashes may alter escapes.

Apply:

 function templateElement(value: string, tail: boolean) {
   return t.templateElement(
     {
-      raw: value.replace(/`/g, "\\`"),
+      raw: value
+        .replace(/\\/g, "\\\\")  // escape backslash first
+        .replace(/`/g, "\\`")    // escape backtick
+        .replace(/\$\{/g, "\\${"), // escape "${"
       cooked: value,
     },
     tail,
   );
 }

Also consider reusing a shared escape helper (eg, from normalize-string-expression) to avoid divergence.

🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 30-30: Incomplete string escaping or encoding
This does not escape backslash characters in the input.

🤖 Prompt for AI Agents
In packages/runtime-tags/src/translator/util/body-to-text-literal.ts around
lines 27 to 35, the TemplateElement.raw currently only escapes backticks which
can yield invalid quasis when backslashes or the sequence "${" are present;
update the escaping to also replace backslashes with "\\\\" and replace "${"
with "\\${" (in that order to avoid double-escaping) before passing to raw, and
if a shared escape helper (eg. normalize-string-expression) exists, import and
reuse it instead of duplicating logic to keep escaping consistent.

Original file line number Diff line number Diff line change
Expand Up @@ -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, "\\`") })),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incomplete escaping: backslashes must be escaped before backticks.

The current implementation only escapes backticks but not backslashes. In JavaScript template literals, both characters require escaping in the raw value. This can lead to syntax errors or incorrect string values when the input contains backslashes.

For example:

  • Input foo\bar should produce raw foo\\bar, but currently produces foo\bar (invalid)
  • Input foo\`` should produce raw foo\`, but currently produces foo\`` (incorrect)

Apply this diff to escape backslashes before backticks:

-      strs.map((raw) => t.templateElement({ raw: raw.replace(/`/g, "\\`") })),
+      strs.map((raw) => t.templateElement({ raw: raw.replace(/\\/g, "\\\\").replace(/`/g, "\\`") })),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
strs.map((raw) => t.templateElement({ raw: raw.replace(/`/g, "\\`") })),
strs.map((raw) =>
t.templateElement({
raw: raw.replace(/\\/g, "\\\\").replace(/`/g, "\\`"),
})
),
🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 47-47: Incomplete string escaping or encoding
This does not escape backslash characters in the input.

🤖 Prompt for AI Agents
In packages/runtime-tags/src/translator/util/normalize-string-expression.ts
around line 47, the code only escapes backticks which leaves backslashes
unescaped and can produce invalid template raw values; update the transformation
to first escape backslashes and then escape backticks (e.g., apply
raw.replace(/\\/g, "\\\\").replace(/`/g, "\\`")) so backslashes are doubled
before backticks are escaped.

exprs,
);
} else if (curStr) {
Expand Down