diff --git a/README.md b/README.md index aae5f7c1..66b0c5d4 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,24 @@ The server parser is a wrapper of [htmlparser2](https://github.com/fb55/htmlpars The client parser mimics the server parser by using the [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model/Introduction) API to parse the HTML string. -## Options (server only) +## Options + +### trustedTypePolicy (browser only) + +When running in the browser, you can pass a Trusted Types policy. The parser +uses `trustedTypePolicy.createHTML` right before assigning to `innerHTML`. + +```js +const trustedTypePolicy = window.trustedTypes?.createPolicy('my-policy', { + createHTML(input) { + return input; + }, +}); + +parse('
Hello
', { trustedTypePolicy }); +``` + +### Server parser options Because the server parser is a wrapper of [htmlparser2](https://github.com/fb55/htmlparser2), which implements [domhandler](https://github.com/fb55/domhandler), you can alter how the server parser parses your code with the options: diff --git a/__tests__/client/domparser.test.ts b/__tests__/client/domparser.test.ts new file mode 100644 index 00000000..ab3c7c23 --- /dev/null +++ b/__tests__/client/domparser.test.ts @@ -0,0 +1,67 @@ +import { getHTMLForInnerHTML } from '../../src/client/domparser'; + +describe('getHTMLForInnerHTML', () => { + it('returns the html string as-is when no trusted type policy is provided', () => { + const html = '
test
'; + const result = getHTMLForInnerHTML(html); + + expect(result).toBe(html); + }); + + it('returns the html string when trustedTypePolicy is undefined', () => { + const html = '

Hello World

'; + const result = getHTMLForInnerHTML(html, undefined); + + expect(result).toBe(html); + }); + + it('calls trustedTypePolicy.createHTML with the html string when policy is provided', () => { + const html = 'content'; + const trustedTypePolicy = { + createHTML: vi.fn((input: string) => input), + }; + + const result = getHTMLForInnerHTML(html, trustedTypePolicy); + + expect(trustedTypePolicy.createHTML).toHaveBeenCalledOnce(); + expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith(html); + expect(result).toBe(html); + }); + + it('returns the result of trustedTypePolicy.createHTML when policy is provided', () => { + const html = '
test
'; + const trustedHtml = 'TRUSTED_HTML_VALUE'; + const trustedTypePolicy = { + createHTML: vi.fn(() => trustedHtml), + }; + + const result = getHTMLForInnerHTML(html, trustedTypePolicy); + + expect(result).toBe(trustedHtml); + }); + + it('handles empty html string', () => { + const html = ''; + const result = getHTMLForInnerHTML(html); + + expect(result).toBe(''); + }); + + it('handles html string with special characters', () => { + const html = '
Test & "quotes" \'apostrophe\'
'; + const result = getHTMLForInnerHTML(html); + + expect(result).toBe(html); + }); + + it('calls trustedTypePolicy with complex html', () => { + const html = '

Nested content

'; + const trustedTypePolicy = { + createHTML: vi.fn((input: string) => input), + }; + + getHTMLForInnerHTML(html, trustedTypePolicy); + + expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith(html); + }); +}); diff --git a/__tests__/client/index.test.ts b/__tests__/client/index.test.ts index 11b4c618..4efe9484 100644 --- a/__tests__/client/index.test.ts +++ b/__tests__/client/index.test.ts @@ -17,6 +17,34 @@ describe('client parser', () => { testCaseSensitiveTags(htmlToDOM); if (isBrowser()) { + describe('trustedTypePolicy', () => { + it('uses policy before setting template innerHTML', () => { + const trustedTypePolicy = { + createHTML: vi.fn((input: string) => input), + }; + + htmlToDOM('
test
', { trustedTypePolicy }); + + expect(trustedTypePolicy.createHTML).toHaveBeenCalledOnce(); + expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith( + '
test
', + ); + }); + + it('uses policy before setting document innerHTML', () => { + const trustedTypePolicy = { + createHTML: vi.fn((input: string) => input), + }; + + htmlToDOM('
test
', { trustedTypePolicy }); + + expect(trustedTypePolicy.createHTML).toHaveBeenCalledOnce(); + expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith( + '
test
', + ); + }); + }); + describe('performance', () => { it('executes 1000 times in less than 50ms', () => { let times = 1000; diff --git a/__tests__/types/index.test.ts b/__tests__/types/index.test.ts index 3efd0007..7e6949da 100644 --- a/__tests__/types/index.test.ts +++ b/__tests__/types/index.test.ts @@ -12,5 +12,14 @@ parse('
text
', { decodeEntities: false }); // $ExpectType (Element | Text | Comment | ProcessingInstruction)[] parse('
text
', { lowerCaseTags: true }); +// $ExpectType (Element | Text | Comment | ProcessingInstruction)[] +parse('
text
', { + trustedTypePolicy: { + createHTML(input: string) { + return input; + }, + }, +}); + // $ExpectType (Element | Text | Comment | ProcessingInstruction)[] parse(''); diff --git a/src/client/domparser.ts b/src/client/domparser.ts index 8329fe35..29807ad7 100644 --- a/src/client/domparser.ts +++ b/src/client/domparser.ts @@ -1,3 +1,4 @@ +import type { TrustedTypePolicyLike } from '../types'; import { escapeSpecialCharacters, hasOpenTag } from './utilities'; // constants @@ -6,16 +7,32 @@ const HEAD = 'head'; const BODY = 'body'; const FIRST_TAG_REGEX = /<([a-zA-Z]+[0-9]?)/; // e.g.,

+export function getHTMLForInnerHTML( + html: string, + trustedTypePolicy?: TrustedTypePolicyLike, +) { + return trustedTypePolicy ? trustedTypePolicy.createHTML(html) : html; +} + // falls back to `parseFromString` if `createHTMLDocument` cannot be used /* eslint-disable @typescript-eslint/no-unused-vars */ /* v8 ignore start */ -let parseFromDocument = (html: string, tagName?: string): Document => { +let parseFromDocument = ( + html: string, + tagName?: string, + trustedTypePolicy?: TrustedTypePolicyLike, +): Document => { throw new Error( 'This browser does not support `document.implementation.createHTMLDocument`', ); }; -let parseFromString = (html: string, tagName?: string): Document => { +let parseFromString = ( + html: string, + tagName?: string, + trustedTypePolicy?: TrustedTypePolicyLike, +): Document => { + void trustedTypePolicy; throw new Error( 'This browser does not support `DOMParser.prototype.parseFromString`', ); @@ -39,7 +56,12 @@ if (typeof DOMParser === 'function') { * @param tagName - The element to render the HTML (with 'body' as fallback). * @returns - Document. */ - parseFromString = (html: string, tagName?: string): Document => { + parseFromString = ( + html: string, + tagName?: string, + trustedTypePolicy?: TrustedTypePolicyLike, + ): Document => { + void trustedTypePolicy; if (tagName) { html = `<${tagName}>${html}`; } @@ -66,18 +88,28 @@ if (typeof document === 'object' && document.implementation) { * @param tagName - The element to render the HTML (with 'body' as fallback). * @returns - Document */ - parseFromDocument = function (html: string, tagName?: string): Document { + parseFromDocument = function ( + html: string, + tagName?: string, + trustedTypePolicy?: TrustedTypePolicyLike, + ): Document { if (tagName) { const element = htmlDocument.documentElement.querySelector(tagName); if (element) { - element.innerHTML = html; + element.innerHTML = getHTMLForInnerHTML( + html, + trustedTypePolicy, + ) as string; } return htmlDocument; } - htmlDocument.documentElement.innerHTML = html; + htmlDocument.documentElement.innerHTML = getHTMLForInnerHTML( + html, + trustedTypePolicy, + ) as string; return htmlDocument; }; } @@ -90,7 +122,10 @@ if (typeof document === 'object' && document.implementation) { const template = typeof document === 'object' && document.createElement('template'); -let parseFromTemplate: (html: string) => NodeList; +let parseFromTemplate: ( + html: string, + trustedTypePolicy?: TrustedTypePolicyLike, +) => NodeList; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (template && template.content) { @@ -100,8 +135,11 @@ if (template && template.content) { * @param html - HTML string. * @returns - Nodes. */ - parseFromTemplate = (html: string): NodeList => { - template.innerHTML = html; + parseFromTemplate = ( + html: string, + trustedTypePolicy?: TrustedTypePolicyLike, + ): NodeList => { + template.innerHTML = getHTMLForInnerHTML(html, trustedTypePolicy) as string; return template.content.childNodes; }; } @@ -113,9 +151,13 @@ const createNodeList = () => document.createDocumentFragment().childNodes; * Parses HTML string to DOM nodes. * * @param html - HTML markup. + * @param trustedTypePolicy - Trusted Types policy. * @returns - DOM nodes. */ -export default function domparser(html: string): NodeList { +export default function domparser( + html: string, + trustedTypePolicy?: TrustedTypePolicyLike, +): NodeList { // Escape special characters before parsing html = escapeSpecialCharacters(html); @@ -143,7 +185,11 @@ export default function domparser(html: string): NodeList { case HEAD: case BODY: { - const elements = parseFromDocument(html).querySelectorAll(firstTagName); + const elements = parseFromDocument( + html, + undefined, + trustedTypePolicy, + ).querySelectorAll(firstTagName); // if there's a sibling element, then return both elements /* v8 ignore next */ @@ -159,10 +205,14 @@ export default function domparser(html: string): NodeList { default: { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (parseFromTemplate) { - return parseFromTemplate(html); + return parseFromTemplate(html, trustedTypePolicy); } - const element = parseFromDocument(html, BODY).querySelector(BODY); + const element = parseFromDocument( + html, + BODY, + trustedTypePolicy, + ).querySelector(BODY); return element?.childNodes ?? createNodeList(); } diff --git a/src/client/html-to-dom.ts b/src/client/html-to-dom.ts index 4eb5583d..36f8caab 100644 --- a/src/client/html-to-dom.ts +++ b/src/client/html-to-dom.ts @@ -1,3 +1,4 @@ +import type { HTMLDOMParserOptions } from '../types'; import domparser from './domparser'; import { formatDOM } from './utilities'; @@ -7,9 +8,13 @@ const DIRECTIVE_REGEX = /<(![a-zA-Z\s]+)>/; // e.g., * Parses HTML string to DOM nodes in browser. * * @param html - HTML markup. + * @param options - Parser options. * @returns - DOM elements. */ -export default function HTMLDOMParser(html: string) { +export default function HTMLDOMParser( + html: string, + options?: HTMLDOMParserOptions, +) { if (typeof html !== 'string') { throw new TypeError('First argument must be a string'); } @@ -22,5 +27,9 @@ export default function HTMLDOMParser(html: string) { const match = DIRECTIVE_REGEX.exec(html); const directive = match ? match[1] : undefined; - return formatDOM(domparser(html), null, directive); + return formatDOM( + domparser(html, options?.trustedTypePolicy), + null, + directive, + ); } diff --git a/src/server/html-to-dom.ts b/src/server/html-to-dom.ts index df491506..4147c30f 100644 --- a/src/server/html-to-dom.ts +++ b/src/server/html-to-dom.ts @@ -1,7 +1,7 @@ import { DomHandler } from 'domhandler'; -import type { ParserOptions } from 'htmlparser2'; import { Parser } from 'htmlparser2'; +import type { HTMLDOMParserOptions } from '../types'; import { unsetRootParent } from './utilities'; /** @@ -16,7 +16,10 @@ import { unsetRootParent } from './utilities'; * @param options - Parser options. * @returns - DOM nodes. */ -export default function HTMLDOMParser(html: string, options?: ParserOptions) { +export default function HTMLDOMParser( + html: string, + options?: HTMLDOMParserOptions, +) { if (typeof html !== 'string') { throw new TypeError('First argument must be a string.'); } diff --git a/src/types.ts b/src/types.ts index 4be12091..2b42ef9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,16 @@ +import type { DomHandlerOptions } from 'domhandler'; import type { Comment, Element, ProcessingInstruction, Text } from 'domhandler'; +import type { ParserOptions } from 'htmlparser2'; export type { Comment, Element, ProcessingInstruction, Text }; export type DOMNode = Comment | Element | ProcessingInstruction | Text; + +export interface TrustedTypePolicyLike { + createHTML(input: string): { toString(): string }; +} + +export type HTMLDOMParserOptions = ParserOptions & + DomHandlerOptions & { + trustedTypePolicy?: TrustedTypePolicyLike; + };