diff --git a/html/deno.json b/html/deno.json index 07fb93d2a146..cb1849947e5a 100644 --- a/html/deno.json +++ b/html/deno.json @@ -7,6 +7,7 @@ "./unstable-escape-css": "./unstable_escape_css.ts", "./unstable-escape-js": "./unstable_escape_js.ts", "./unstable-is-valid-custom-element-name": "./unstable_is_valid_custom_element_name.ts", - "./named-entity-list.json": "./named_entity_list.json" + "./named-entity-list.json": "./named_entity_list.json", + "./unstable-html": "./unstable_html.ts" } } diff --git a/html/unstable_html.ts b/html/unstable_html.ts new file mode 100644 index 000000000000..c01fd2616979 --- /dev/null +++ b/html/unstable_html.ts @@ -0,0 +1,71 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * A template literal tag function for creating HTML strings with interpolated + * values. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * This function processes template literals and concatenates them with + * interpolated values. Values are inserted as-is without any HTML escaping or + * sanitization. Undefined values are treated as empty strings. + * + * > [!WARNING] + * > **Security Warning**: This function does NOT escape HTML. When + * > interpolating user-provided data, you must manually escape it to prevent + * > XSS (Cross-Site Scripting) attacks. Only use this function with trusted + * > data or data that has been properly sanitized. Use + * > {@linkcode https://jsr.io/@std/html/doc/~/escape | escape()} for escaping. + * + * @param strings The template string array containing the static parts of the template + * @param values The values to be interpolated into the template + * @returns The resulting HTML string with interpolated values + * + * @example Usage with trusted content + * ```ts + * import { html } from "@std/html/unstable-html"; + * import { assertEquals } from "@std/assert/equals"; + * + * const name = "Alice"; + * const color = "blue"; + * const htmlContent = html` + *
+ *

Hello, ${name}!

+ *

Welcome to our site.

+ *
+ * `; + * + * assertEquals(htmlContent, ` + *
+ *

Hello, Alice!

+ *

Welcome to our site.

+ *
+ * `); + * ``` + * + * @example Usage with untrusted content that needs to be escaped + * ```ts + * import { html } from "@std/html/unstable-html"; + * import { assertEquals } from "@std/assert/equals"; + * import { escape } from "@std/html/entities"; + * + * // WARNING: This is vulnerable to XSS attacks! + * const userInput = ''; + * const unsafeHtml = html`
${userInput}
`; + * + * const safeHtml = html`
${escape(userInput)}
`; + * + * assertEquals(unsafeHtml, '
'); + * assertEquals(safeHtml, "
<script>alert("XSS")</script>
"); + * ``` + */ +export function html( + strings: TemplateStringsArray, + ...values: unknown[] +): string { + return strings.reduce( + (result, str, i) => result + str + (values[i] ?? ""), + "", + ); +} diff --git a/html/unstable_html_test.ts b/html/unstable_html_test.ts new file mode 100644 index 000000000000..cf99b0d523c7 --- /dev/null +++ b/html/unstable_html_test.ts @@ -0,0 +1,62 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals } from "@std/assert/equals"; +import { html } from "./unstable_html.ts"; + +Deno.test("html()", () => { + const a = "red"; + const b = "blue"; + const result = html` + ${b} + `; + assertEquals( + result, + ` + blue + `, + ); +}); + +Deno.test("html() treats undefined as empty string", () => { + assertEquals( + html` +
${undefined}
+ `, + ` +
+ `, + ); +}); + +Deno.test("html() with no interpolations", () => { + assertEquals( + html` +

hello

+ `, + ` +

hello

+ `, + ); +}); + +Deno.test("html() with empty string interpolation", () => { + assertEquals( + html` +
${""}
+ `, + ` +
+ `, + ); +}); + +Deno.test("html() does not escape HTML in interpolated values", () => { + const userInput = ''; + assertEquals( + html` +
${userInput}
+ `, + ` +
+ `, + ); +}); diff --git a/http/file_server.ts b/http/file_server.ts index 81a3de58f678..687dfa1340e0 100644 --- a/http/file_server.ts +++ b/http/file_server.ts @@ -55,6 +55,7 @@ import { getNetworkAddress } from "@std/net/unstable-get-network-address"; import { escape } from "@std/html/entities"; import { HEADER } from "./unstable_header.ts"; import { METHOD } from "./unstable_method.ts"; +import { html } from "@std/html/unstable-html"; interface EntryInfo { mode: string; @@ -438,18 +439,6 @@ function createBaseHeaders(): Headers { }); } -function html( - strings: TemplateStringsArray, - ...values: unknown[] -): string { - let out = ""; - for (let i = 0; i < strings.length; ++i) { - out += strings[i]; - if (i < values.length) out += values[i] ?? ""; - } - return out; -} - function dirViewerTemplate(dirname: string, entries: EntryInfo[]): string { const splitDirname = dirname.split("/").filter((path) => Boolean(path)); const headerPaths = ["home", ...splitDirname];