Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div>Hello</div>', { 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:

Expand Down
67 changes: 67 additions & 0 deletions __tests__/client/domparser.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<div>test</div>';
const result = getHTMLForInnerHTML(html);

expect(result).toBe(html);
});

it('returns the html string when trustedTypePolicy is undefined', () => {
const html = '<p>Hello World</p>';
const result = getHTMLForInnerHTML(html, undefined);

expect(result).toBe(html);
});

it('calls trustedTypePolicy.createHTML with the html string when policy is provided', () => {
const html = '<span>content</span>';
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 = '<div>test</div>';
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 = '<div>Test & "quotes" \'apostrophe\'</div>';
const result = getHTMLForInnerHTML(html);

expect(result).toBe(html);
});

it('calls trustedTypePolicy with complex html', () => {
const html = '<div><p>Nested <span>content</span></p></div>';
const trustedTypePolicy = {
createHTML: vi.fn((input: string) => input),
};

getHTMLForInnerHTML(html, trustedTypePolicy);

expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith(html);
});
});
28 changes: 28 additions & 0 deletions __tests__/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div>test</div>', { trustedTypePolicy });

expect(trustedTypePolicy.createHTML).toHaveBeenCalledOnce();
expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith(
'<div>test</div>',
);
});

it('uses policy before setting document innerHTML', () => {
const trustedTypePolicy = {
createHTML: vi.fn((input: string) => input),
};

htmlToDOM('<body><div>test</div></body>', { trustedTypePolicy });

expect(trustedTypePolicy.createHTML).toHaveBeenCalledOnce();
expect(trustedTypePolicy.createHTML).toHaveBeenCalledWith(
'<body><div>test</div></body>',
);
});
});

describe('performance', () => {
it('executes 1000 times in less than 50ms', () => {
let times = 1000;
Expand Down
9 changes: 9 additions & 0 deletions __tests__/types/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,14 @@ parse('<div>text</div>', { decodeEntities: false });
// $ExpectType (Element | Text | Comment | ProcessingInstruction)[]
parse('<div>text</div>', { lowerCaseTags: true });

// $ExpectType (Element | Text | Comment | ProcessingInstruction)[]
parse('<div>text</div>', {
trustedTypePolicy: {
createHTML(input: string) {
return input;
},
},
});

// $ExpectType (Element | Text | Comment | ProcessingInstruction)[]
parse('');
76 changes: 63 additions & 13 deletions src/client/domparser.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { TrustedTypePolicyLike } from '../types';
import { escapeSpecialCharacters, hasOpenTag } from './utilities';
Comment thread
remarkablemark marked this conversation as resolved.

// constants
Expand All @@ -6,16 +7,32 @@ const HEAD = 'head';
const BODY = 'body';
const FIRST_TAG_REGEX = /<([a-zA-Z]+[0-9]?)/; // e.g., <h1>

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`',
);
Expand All @@ -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}</${tagName}>`;
}
Expand All @@ -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;
};
}
Expand All @@ -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) {
Expand All @@ -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;
};
}
Expand All @@ -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);

Expand Down Expand Up @@ -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 */
Expand All @@ -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();
}
Expand Down
13 changes: 11 additions & 2 deletions src/client/html-to-dom.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HTMLDOMParserOptions } from '../types';
import domparser from './domparser';
import { formatDOM } from './utilities';

Expand All @@ -7,9 +8,13 @@ const DIRECTIVE_REGEX = /<(![a-zA-Z\s]+)>/; // e.g., <!doctype html>
* 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');
}
Expand All @@ -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,
);
}
7 changes: 5 additions & 2 deletions src/server/html-to-dom.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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.');
}
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading