diff --git a/packages/@glimmer-workspace/integration-tests/package.json b/packages/@glimmer-workspace/integration-tests/package.json index 27ac55e35b..22680f0ab7 100644 --- a/packages/@glimmer-workspace/integration-tests/package.json +++ b/packages/@glimmer-workspace/integration-tests/package.json @@ -29,6 +29,7 @@ "@simple-dom/document": "^1.4.0", "@simple-dom/serializer": "^1.4.0", "@simple-dom/void-map": "^1.4.0", + "@types/trusted-types": "^2.0.7", "js-reporters": "^2.1.0", "qunit": "^2.19.4", "simple-html-tokenizer": "^0.5.11" diff --git a/packages/@glimmer-workspace/integration-tests/test/trusted-html-test.ts b/packages/@glimmer-workspace/integration-tests/test/trusted-html-test.ts new file mode 100644 index 0000000000..7c28a4c2e5 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/trusted-html-test.ts @@ -0,0 +1,49 @@ +import type { TrustedTypePolicy, TrustedTypesWindow } from 'trusted-types/lib'; + +import { jitSuite, RenderTest, test } from '..'; + +let policy: TrustedTypePolicy | undefined; +if (typeof window !== 'undefined') { + let trustedTypes = (window as unknown as TrustedTypesWindow).trustedTypes; + if (trustedTypes?.createPolicy) { + policy = trustedTypes.createPolicy('test', { + createHTML: (s: string) => s, + createScript: (s: string) => s, + createScriptURL: (s: string) => s, + }); + } +} + +export class TrustedHTMLTests extends RenderTest { + static suiteName = 'TrustedHTML'; + + @test + 'renders TrustedHTML similar to SafeString'() { + if (!policy) return; + + let html = 'test\'""'; + this.registerHelper('trustedHTML', () => { + return policy?.createHTML(html); + }); + + this.render('
{{trustedHTML}}
'); + this.assertHTML('
test\'""'; + this.registerHelper('trustedHTML', () => { + return policy?.createHTML(html); + }); + + this.render('{{trustedHTML}}'); + this.assertHTML('test\'""'); + this.assertStableRerender(); + } +} + +jitSuite(TrustedHTMLTests); diff --git a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts index 7c7a98e79e..bc080cec42 100644 --- a/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts +++ b/packages/@glimmer/opcode-compiler/lib/opcode-builder/helpers/stdlib.ts @@ -74,6 +74,11 @@ export function StdAppend( op(Op.AppendSafeHTML); }); + when(ContentType.TrustedHTML, () => { + op(Op.AssertSame); + op(Op.AppendHTML); + }); + when(ContentType.Fragment, () => { op(Op.AssertSame); op(Op.AppendDocumentFragment); diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts index 03b1c9c855..522c46c593 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/content.ts @@ -11,7 +11,7 @@ import { isObject } from '@glimmer/util'; import { ContentType, CurriedType, Op } from '@glimmer/vm'; import { isCurriedType } from '../../curried-value'; -import { isEmpty, isFragment, isNode, isSafeString, shouldCoerce } from '../../dom/normalize'; +import { isEmpty, isFragment, isNode, isSafeString, isTrustedHTML, shouldCoerce } from '../../dom/normalize'; import { APPEND_OPCODES } from '../../opcodes'; import DynamicTextContent from '../../vm/content/text'; import { CheckReference } from './-debug-strip'; @@ -32,6 +32,8 @@ function toContentType(value: unknown) { return ContentType.Helper; } else if (isSafeString(value)) { return ContentType.SafeString; + } else if (isTrustedHTML(value)) { + return ContentType.TrustedHTML; } else if (isFragment(value)) { return ContentType.Fragment; } else if (isNode(value)) { @@ -87,7 +89,7 @@ APPEND_OPCODES.add(Op.AppendHTML, (vm) => { let reference = check(vm.stack.pop(), CheckReference); let rawValue = valueForRef(reference); - let value = isEmpty(rawValue) ? '' : String(rawValue); + let value = isEmpty(rawValue) ? '' : isTrustedHTML(rawValue) ? rawValue as string : String(rawValue); vm.elements().appendDynamicHTML(value); }); diff --git a/packages/@glimmer/runtime/lib/dom/normalize.ts b/packages/@glimmer/runtime/lib/dom/normalize.ts index 6de6ec7b4b..638a87ea33 100644 --- a/packages/@glimmer/runtime/lib/dom/normalize.ts +++ b/packages/@glimmer/runtime/lib/dom/normalize.ts @@ -1,4 +1,5 @@ import type { Dict, SimpleDocumentFragment, SimpleNode } from '@glimmer/interfaces'; +import type { TrustedTypesWindow } from 'trusted-types/lib'; export interface SafeString { toHTML(): string; @@ -43,6 +44,18 @@ export function isEmpty(value: unknown): boolean { return value === null || value === undefined || typeof (value as Dict).toString !== 'function'; } +let isHTML: ((value: unknown) => boolean) | undefined; +if (typeof window !== 'undefined') { + let trustedTypes = (window as unknown as TrustedTypesWindow).trustedTypes; + if (trustedTypes?.isHTML) { + isHTML = trustedTypes?.isHTML.bind(trustedTypes); + } +} + +export function isTrustedHTML(value: unknown): boolean { + return isHTML ? isHTML(value) : false; +} + export function isSafeString(value: unknown): value is SafeString { return typeof value === 'object' && value !== null && typeof (value as any).toHTML === 'function'; } diff --git a/packages/@glimmer/runtime/package.json b/packages/@glimmer/runtime/package.json index 08147b44cb..e07e0703ce 100644 --- a/packages/@glimmer/runtime/package.json +++ b/packages/@glimmer/runtime/package.json @@ -43,7 +43,8 @@ "@glimmer/util": "workspace:^", "@glimmer/validator": "workspace:^", "@glimmer/vm": "workspace:^", - "@glimmer/wire-format": "workspace:^" + "@glimmer/wire-format": "workspace:^", + "@types/trusted-types": "^2.0.7" }, "devDependencies": { "@glimmer-workspace/build-support": "workspace:^", diff --git a/packages/@glimmer/vm/lib/content.ts b/packages/@glimmer/vm/lib/content.ts index ddbe0290ce..ac8d805d42 100644 --- a/packages/@glimmer/vm/lib/content.ts +++ b/packages/@glimmer/vm/lib/content.ts @@ -7,4 +7,5 @@ export const ContentType = { Fragment: 5, Node: 6, Other: 8, + TrustedHTML: 9, } as const;