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'}`, 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``; + 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'}`, 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), + '
' + ); + }); + }); });