Skip to content

Commit ebfc40b

Browse files
committed
feat(html): add escapeJs and escapeCss functions
1 parent c3331d5 commit ebfc40b

File tree

5 files changed

+211
-0
lines changed

5 files changed

+211
-0
lines changed

html/deno.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"exports": {
55
".": "./mod.ts",
66
"./entities": "./entities.ts",
7+
"./unstable-escape-css": "./unstable_escape_css.ts",
8+
"./unstable-escape-js": "./unstable_escape_js.ts",
79
"./unstable-is-valid-custom-element-name": "./unstable_is_valid_custom_element_name.ts",
810
"./named-entity-list.json": "./named_entity_list.json"
911
}

html/unstable_escape_css.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
// This module is browser compatible.
3+
4+
/**
5+
* Escapes a string for direct interpolation into an external
6+
* CSS style sheet, within a `<style>` element, or in a selector.
7+
*
8+
* Uses identical logic to
9+
* [`CSS.escape`](https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape_static)
10+
* in browsers.
11+
*
12+
* @param str The string to escape.
13+
* @returns The escaped string.
14+
*
15+
* @example Usage
16+
* ```ts
17+
* import { escapeCss } from "@std/html/unstable-escape-css";
18+
* import { assertEquals } from "@std/assert";
19+
*
20+
* // Invalid in a CSS selector, even though it's a valid HTML ID
21+
* const elementId = "123";
22+
* // Unsafe for interpolation
23+
* const contentInput = `<!-- '" --></style>`;
24+
*
25+
* const selector = `#${escapeCss(elementId)}`;
26+
* const content = `"${escapeCss(contentInput)}"`;
27+
*
28+
* // Usable as a CSS selector
29+
* assertEquals(selector, String.raw`#\31 23`);
30+
* // Safe for interpolation
31+
* assertEquals(content, String.raw`"\<\!--\ \'\"\ --\>\<\/style\>"`);
32+
*
33+
* // Usage
34+
* `<style>
35+
* ${selector}::after {
36+
* content: ${content};
37+
* }
38+
* </style>`;
39+
* ```
40+
*/
41+
export function escapeCss(str: string): string {
42+
const matcher =
43+
// deno-lint-ignore no-control-regex
44+
/(\0)|([\x01-\x1f\x7f]|(?<=^-?)\d)|(^-$|[ -,.\/:-@\[-^`\{-~])/g;
45+
46+
return str.replaceAll(matcher, (_, g1, g2, g3) => {
47+
return g1 != null
48+
// null char
49+
? "�"
50+
: g2 != null
51+
// control char or digit at start
52+
? escapeAsCodePoint(g2)
53+
// solo dash or special char
54+
: escapeChar(g3);
55+
});
56+
}
57+
58+
function escapeAsCodePoint(char: string) {
59+
return `\\${char.codePointAt(0)!.toString(16)} `;
60+
}
61+
function escapeChar(char: string) {
62+
return "\\" + char;
63+
}

html/unstable_escape_css_test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
// This module is browser compatible.
3+
import { escapeCss } from "./unstable_escape_css.ts";
4+
import { assertEquals } from "@std/assert";
5+
6+
export const testCases = [
7+
{ input: "", expected: "" },
8+
{ input: "a", expected: "a" },
9+
{ input: "A", expected: "A" },
10+
{ input: "0", expected: String.raw`\30 ` },
11+
{ input: "123", expected: String.raw`\31 23` },
12+
{ input: "-123", expected: String.raw`-\31 23` },
13+
{ input: "a-123", expected: "a-123" },
14+
{ input: "a23", expected: "a23" },
15+
{ input: "-", expected: String.raw`\-` },
16+
{ input: "a-", expected: "a-" },
17+
{ input: "_", expected: "_" },
18+
{ input: " ", expected: String.raw`\ ` },
19+
{ input: "\n", expected: String.raw`\a ` },
20+
{ input: "\r", expected: String.raw`\d ` },
21+
{ input: "\t", expected: String.raw`\9 ` },
22+
{ input: "\f", expected: String.raw`\c ` },
23+
{ input: "\v", expected: String.raw`\b ` },
24+
{ input: "\0", expected: "\ufffd" },
25+
{ input: "\ufffd", expected: "\ufffd" },
26+
{ input: "\x01", expected: String.raw`\1 ` },
27+
{ input: "\x1f", expected: String.raw`\1f ` },
28+
{ input: "\x7f", expected: String.raw`\7f ` },
29+
{ input: "文字", expected: "文字" },
30+
{ input: "💩", expected: "💩" },
31+
{ input: "\uffff", expected: "\uffff" },
32+
{ input: "\u{10ffff}", expected: "\u{10ffff}" },
33+
{
34+
input: "<style>\r\n<!-- -->\r\n</style>",
35+
expected: String.raw`\<style\>\d \a \<\!--\ --\>\d \a \<\/style\>`,
36+
},
37+
{
38+
input:
39+
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
40+
expected: String
41+
.raw`\ \!\"\#\$\%\&\'\(\)\*\+\,-\.\/0123456789\:\;\<\=\>\?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ\[\\\]\^_\`abcdefghijklmnopqrstuvwxyz\{\|\}\~`,
42+
},
43+
];
44+
45+
Deno.test("escapeCss() gives same results as CSS.escape in browser", async (t) => {
46+
for (const { input, expected } of testCases) {
47+
await t.step(JSON.stringify(input), () => {
48+
const result = escapeCss(input);
49+
assertEquals(result, expected);
50+
});
51+
}
52+
});

html/unstable_escape_js.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
// This module is browser compatible.
3+
4+
/** Options for {@linkcode escapeJs} */
5+
export type EscapeJsOptions = {
6+
/**
7+
* The number of spaces or tab to use for indentation in the output.
8+
* If not specified, no extra whitespace is added.
9+
*/
10+
space?: number | "\t";
11+
};
12+
13+
/**
14+
* Escapes a JavaScript object or other data for safe interpolation inside a `<script>` tag.
15+
*
16+
* The data must be JSON-serializable (plain object, array, string, number (excluding `NaN`/infinities), boolean, or null).
17+
*
18+
* The output remains fully JSON-compatible, but it additionally escapes characters and sequences that are problematic
19+
* in JavaScript contexts, including within `<script>` tags.
20+
*
21+
* @param data The data to escape.
22+
* @param options Options for escaping.
23+
* @returns The escaped string.
24+
*
25+
* @example Usage
26+
* ```ts
27+
* import { escapeJs } from "@std/html/unstable-escape-js";
28+
* import { assertEquals } from "@std/assert";
29+
* // Example data to escape
30+
* const input = {
31+
* foo: "</script>",
32+
* bar: "<SCRIPT>",
33+
* baz: "<!-- ",
34+
* quux: "\u2028\u2029<>",
35+
* };
36+
* assertEquals(
37+
* escapeJs(input),
38+
* String.raw`{"foo":"\u003c/script>","bar":"\u003cSCRIPT>","baz":"\u003c!-- ","quux":"\u2028\u2029<>"}`,
39+
* );
40+
* ```
41+
*/
42+
export function escapeJs(data: unknown, options: EscapeJsOptions = {}): string {
43+
const { space } = options;
44+
return JSON.stringify(data, null, space)
45+
.replaceAll(
46+
/[\u2028\u2029]|<(?=!--|\/?script)/gi,
47+
(m) => String.raw`\u${m.charCodeAt(0).toString(16).padStart(4, "0")}`,
48+
);
49+
}

html/unstable_escape_js_test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2018-2025 the Deno authors. MIT license.
2+
// This module is browser compatible.
3+
4+
import { assertEquals } from "@std/assert";
5+
import { escapeJs } from "./unstable_escape_js.ts";
6+
import { dedent } from "@std/text/unstable-dedent";
7+
8+
Deno.test("escapeJs() escapes strings for <script> context", async (t) => {
9+
const testCases = [
10+
{ input: "</script>", expected: String.raw`"\u003c/script>"` },
11+
{ input: "<SCRIPT>", expected: String.raw`"\u003cSCRIPT>"` },
12+
{ input: "<!-- ", expected: String.raw`"\u003c!-- "` },
13+
{ input: "\u2028\u2029<>", expected: String.raw`"\u2028\u2029<>"` },
14+
];
15+
16+
for (const { input, expected } of testCases) {
17+
await t.step(JSON.stringify(input), () => {
18+
const result = escapeJs(input);
19+
assertEquals(result, expected);
20+
});
21+
}
22+
});
23+
24+
Deno.test("escapeJs() escapes object for <script> context", async (t) => {
25+
const input = {
26+
foo: "</script>",
27+
bar: "<SCRIPT>",
28+
baz: "<!-- ",
29+
quux: "\u2028\u2029<>",
30+
};
31+
32+
const expected = dedent`
33+
{
34+
"foo": "\\u003c/script>",
35+
"bar": "\\u003cSCRIPT>",
36+
"baz": "\\u003c!-- ",
37+
"quux": "\\u2028\\u2029<>"
38+
}
39+
`;
40+
41+
await t.step(JSON.stringify(input), () => {
42+
const result = escapeJs(input, { space: 2 });
43+
assertEquals(result, expected);
44+
});
45+
});

0 commit comments

Comments
 (0)