diff --git a/packages/lit-html/src/static.ts b/packages/lit-html/src/static.ts
index 3ef8775c4f..baf77affbf 100644
--- a/packages/lit-html/src/static.ts
+++ b/packages/lit-html/src/static.ts
@@ -9,8 +9,11 @@ import {html as coreHtml, svg as coreSvg, TemplateResult} from './lit-html.js';
* Wraps a string so that it behaves like part of the static template
* strings instead of a dynamic value.
*
- * This is a very unsafe operation and may break templates if changes
- * the structure of a template. Do not pass user input to this function
+ * Users must take care to ensure that adding the static string to the template
+ * results in well-formed HTML, or else templates may break unexpectedly.
+ *
+ * Note that this function is unsafe to use on untrusted content, as it will be
+ * directly parsed into HTML. Do not pass user input to this function
* without sanitizing it.
*
* Static values can be changed, but they will cause a complete re-render
@@ -20,6 +23,41 @@ export const unsafeStatic = (value: string) => ({
_$litStatic$: value,
});
+const textFromStatic = (value: StaticValue) => {
+ if (value._$litStatic$ !== undefined) {
+ return value._$litStatic$;
+ } else {
+ throw new Error(
+ `Value passed to 'literal' function must be a 'literal' result: ${value}. Use 'unsafeStatic' to pass non-literal values, but
+ take care to ensure page security.`
+ );
+ }
+};
+
+/**
+ * Tags a string literal so that it behaves like part of the static template
+ * strings instead of a dynamic value.
+ *
+ * The only values that may be used in template expressions are other tagged
+ * `literal` results or `unsafeStatic` values (note that untrusted content
+ * should never be passed to `unsafeStatic`).
+ *
+ * Users must take care to ensure that adding the static string to the template
+ * results in well-formed HTML, or else templates may break unexpectedly.
+ *
+ * Static values can be changed, but they will cause a complete re-render since
+ * they effectively create a new template.
+ */
+export const literal = (
+ strings: TemplateStringsArray,
+ ...values: unknown[]
+) => ({
+ _$litStatic$: values.reduce(
+ (acc, v, idx) => acc + textFromStatic(v as StaticValue) + strings[idx + 1],
+ strings[0]
+ ),
+});
+
type StaticValue = ReturnType;
const stringsCache = new Map();
diff --git a/packages/lit-html/src/test/static_test.ts b/packages/lit-html/src/test/static_test.ts
index 313c58bdb4..5693e2aa60 100644
--- a/packages/lit-html/src/test/static_test.ts
+++ b/packages/lit-html/src/test/static_test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: BSD-3-Clause
*/
import {render} from '../lit-html.js';
-import {html, unsafeStatic} from '../static.js';
+import {html, literal, unsafeStatic} from '../static.js';
import {assert} from '@esm-bundle/chai';
import {stripExpressionComments} from './test-utils/strip-markers.js';
@@ -16,13 +16,13 @@ suite('static', () => {
});
test('static text binding', () => {
- render(html`${unsafeStatic('Hello
')}`, container);
+ render(html`${literal`Hello
`}`, container);
// If this were a dynamic binding, the tags would be escaped
assert.equal(stripExpressionComments(container.innerHTML), 'Hello
');
});
test('static attribute binding', () => {
- render(html``, container);
+ render(html``, container);
assert.equal(
stripExpressionComments(container.innerHTML),
''
@@ -32,19 +32,19 @@ suite('static', () => {
});
test('static tag binding', () => {
- const tagName = unsafeStatic('div');
+ const tagName = literal`div`;
render(html`<${tagName}>${'A'}${tagName}>`, container);
assert.equal(stripExpressionComments(container.innerHTML), 'A
');
});
test('static attribute name binding', () => {
- render(html``, container);
+ render(html``, container);
assert.equal(
stripExpressionComments(container.innerHTML),
''
);
- render(html``, container);
+ render(html``, container);
assert.equal(
stripExpressionComments(container.innerHTML),
''
@@ -52,10 +52,7 @@ suite('static', () => {
});
test('static attribute name binding', () => {
- render(
- html``,
- container
- );
+ render(html``, container);
assert.equal(
stripExpressionComments(container.innerHTML),
''
@@ -63,14 +60,14 @@ suite('static', () => {
});
test('dynamic binding after static text binding', () => {
- render(html`${unsafeStatic('Hello
')}${'World
'}`, container);
+ render(html`${literal`Hello
`}${'World
'}`, container);
assert.equal(
stripExpressionComments(container.innerHTML),
'Hello
<p>World</p>'
);
// Make sure `null` is handled
- render(html`${unsafeStatic('Hello
')}${null}`, container);
+ render(html`${literal`Hello
`}${null}`, container);
assert.equal(stripExpressionComments(container.innerHTML), 'Hello
');
});
@@ -126,4 +123,45 @@ suite('static', () => {
// previously used value does not restore static DOM
assert.notStrictEqual(div3, div);
});
+
+ test('interpolating statics into statics', () => {
+ const start = literal`<${literal`sp${literal`an`}`}>`;
+ const end = literal`${unsafeStatic('span')}>`;
+ render(html`a${start}b${end}c
`, container);
+ assert.equal(
+ stripExpressionComments(container.innerHTML),
+ 'abc
'
+ );
+ });
+
+ test('interpolating non-statics into statics throws', () => {
+ assert.throws(() => {
+ literal`a${literal`bar`}b${'shouldthrow'}`;
+ });
+ });
+
+ suite('unsafe', () => {
+ test('static tag binding', () => {
+ const tagName = unsafeStatic('div');
+ render(html`<${tagName}>${'A'}${tagName}>`, container);
+ assert.equal(
+ stripExpressionComments(container.innerHTML),
+ 'A
'
+ );
+ });
+
+ test('static attribute name binding', () => {
+ render(html``, container);
+ assert.equal(
+ stripExpressionComments(container.innerHTML),
+ ''
+ );
+
+ render(html``, container);
+ assert.equal(
+ stripExpressionComments(container.innerHTML),
+ ''
+ );
+ });
+ });
});