interweave
Version:
React library to safely render HTML, filter attributes, autowrap text, autolink, and much more.
355 lines (337 loc) • 6.61 kB
text/typescript
/* eslint-disable no-bitwise, no-magic-numbers, sort-keys */
import { ConfigMap, FilterMap, NodeConfig } from './types';
// https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories
export const TYPE_FLOW = 1;
export const TYPE_SECTION = 1 << 1;
export const TYPE_HEADING = 1 << 2;
export const TYPE_PHRASING = 1 << 3;
export const TYPE_EMBEDDED = 1 << 4;
export const TYPE_INTERACTIVE = 1 << 5;
export const TYPE_PALPABLE = 1 << 6;
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element
const tagConfigs: Record<string, Partial<NodeConfig>> = {
a: {
content: TYPE_FLOW | TYPE_PHRASING,
self: false,
type: TYPE_FLOW | TYPE_PHRASING | TYPE_INTERACTIVE | TYPE_PALPABLE,
},
address: {
invalid: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'address',
'article',
'aside',
'section',
'div',
'header',
'footer',
],
self: false,
},
audio: {
children: ['track', 'source'],
},
br: {
type: TYPE_FLOW | TYPE_PHRASING,
void: true,
},
body: {
content:
TYPE_FLOW |
TYPE_SECTION |
TYPE_HEADING |
TYPE_PHRASING |
TYPE_EMBEDDED |
TYPE_INTERACTIVE |
TYPE_PALPABLE,
},
button: {
content: TYPE_PHRASING,
type: TYPE_FLOW | TYPE_PHRASING | TYPE_INTERACTIVE | TYPE_PALPABLE,
},
caption: {
content: TYPE_FLOW,
parent: ['table'],
},
col: {
parent: ['colgroup'],
void: true,
},
colgroup: {
children: ['col'],
parent: ['table'],
},
details: {
children: ['summary'],
type: TYPE_FLOW | TYPE_INTERACTIVE | TYPE_PALPABLE,
},
dd: {
content: TYPE_FLOW,
parent: ['dl'],
},
dl: {
children: ['dt', 'dd'],
type: TYPE_FLOW,
},
dt: {
content: TYPE_FLOW,
invalid: ['footer', 'header'],
parent: ['dl'],
},
figcaption: {
content: TYPE_FLOW,
parent: ['figure'],
},
footer: {
invalid: ['footer', 'header'],
},
header: {
invalid: ['footer', 'header'],
},
hr: {
type: TYPE_FLOW,
void: true,
},
img: {
void: true,
},
li: {
content: TYPE_FLOW,
parent: ['ul', 'ol', 'menu'],
},
main: {
self: false,
},
ol: {
children: ['li'],
type: TYPE_FLOW,
},
picture: {
children: ['source', 'img'],
type: TYPE_FLOW | TYPE_PHRASING | TYPE_EMBEDDED,
},
rb: {
parent: ['ruby', 'rtc'],
},
rp: {
parent: ['ruby', 'rtc'],
},
rt: {
content: TYPE_PHRASING,
parent: ['ruby', 'rtc'],
},
rtc: {
content: TYPE_PHRASING,
parent: ['ruby'],
},
ruby: {
children: ['rb', 'rp', 'rt', 'rtc'],
},
source: {
parent: ['audio', 'video', 'picture'],
void: true,
},
summary: {
content: TYPE_PHRASING,
parent: ['details'],
},
table: {
children: ['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr'],
type: TYPE_FLOW,
},
tbody: {
parent: ['table'],
children: ['tr'],
},
td: {
content: TYPE_FLOW,
parent: ['tr'],
},
tfoot: {
parent: ['table'],
children: ['tr'],
},
th: {
content: TYPE_FLOW,
parent: ['tr'],
},
thead: {
parent: ['table'],
children: ['tr'],
},
tr: {
parent: ['table', 'tbody', 'thead', 'tfoot'],
children: ['th', 'td'],
},
track: {
parent: ['audio', 'video'],
void: true,
},
ul: {
children: ['li'],
type: TYPE_FLOW,
},
video: {
children: ['track', 'source'],
},
wbr: {
type: TYPE_FLOW | TYPE_PHRASING,
void: true,
},
};
function createConfigBuilder(config: Partial<NodeConfig>): (tagName: string) => void {
return (tagName: string) => {
tagConfigs[tagName] = {
...config,
...tagConfigs[tagName],
};
};
}
['address', 'main', 'div', 'figure', 'p', 'pre'].forEach(
createConfigBuilder({
content: TYPE_FLOW,
type: TYPE_FLOW | TYPE_PALPABLE,
}),
);
[
'abbr',
'b',
'bdi',
'bdo',
'cite',
'code',
'data',
'dfn',
'em',
'i',
'kbd',
'mark',
'q',
'ruby',
'samp',
'strong',
'sub',
'sup',
'time',
'u',
'var',
].forEach(
createConfigBuilder({
content: TYPE_PHRASING,
type: TYPE_FLOW | TYPE_PHRASING | TYPE_PALPABLE,
}),
);
['p', 'pre'].forEach(
createConfigBuilder({
content: TYPE_PHRASING,
type: TYPE_FLOW | TYPE_PALPABLE,
}),
);
['s', 'small', 'span', 'del', 'ins'].forEach(
createConfigBuilder({
content: TYPE_PHRASING,
type: TYPE_FLOW | TYPE_PHRASING,
}),
);
['article', 'aside', 'footer', 'header', 'nav', 'section', 'blockquote'].forEach(
createConfigBuilder({
content: TYPE_FLOW,
type: TYPE_FLOW | TYPE_SECTION | TYPE_PALPABLE,
}),
);
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach(
createConfigBuilder({
content: TYPE_PHRASING,
type: TYPE_FLOW | TYPE_HEADING | TYPE_PALPABLE,
}),
);
['audio', 'canvas', 'iframe', 'img', 'video'].forEach(
createConfigBuilder({
type: TYPE_FLOW | TYPE_PHRASING | TYPE_EMBEDDED | TYPE_PALPABLE,
}),
);
// Disable this map from being modified
export const TAGS: ConfigMap = Object.freeze(tagConfigs);
// Tags that should never be allowed, even if the allow list is disabled
export const BANNED_TAG_LIST = [
'applet',
'base',
'body',
'command',
'embed',
'frame',
'frameset',
'head',
'html',
'link',
'meta',
'noscript',
'object',
'script',
'style',
'title',
];
export const ALLOWED_TAG_LIST = Object.keys(TAGS).filter(
(tag) => tag !== 'canvas' && tag !== 'iframe',
);
// Filters apply to HTML attributes
export const FILTER_ALLOW = 1;
export const FILTER_DENY = 2;
export const FILTER_CAST_NUMBER = 3;
export const FILTER_CAST_BOOL = 4;
export const FILTER_NO_CAST = 5;
// Attributes not listed here will be denied
// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
export const ATTRIBUTES: FilterMap = Object.freeze({
alt: FILTER_ALLOW,
cite: FILTER_ALLOW,
class: FILTER_ALLOW,
colspan: FILTER_CAST_NUMBER,
controls: FILTER_CAST_BOOL,
datetime: FILTER_ALLOW,
default: FILTER_CAST_BOOL,
disabled: FILTER_CAST_BOOL,
dir: FILTER_ALLOW,
height: FILTER_ALLOW,
href: FILTER_ALLOW,
id: FILTER_ALLOW,
kind: FILTER_ALLOW,
label: FILTER_ALLOW,
lang: FILTER_ALLOW,
loading: FILTER_ALLOW,
loop: FILTER_CAST_BOOL,
media: FILTER_ALLOW,
muted: FILTER_CAST_BOOL,
poster: FILTER_ALLOW,
rel: FILTER_ALLOW,
role: FILTER_ALLOW,
rowspan: FILTER_CAST_NUMBER,
scope: FILTER_ALLOW,
sizes: FILTER_ALLOW,
span: FILTER_CAST_NUMBER,
start: FILTER_CAST_NUMBER,
style: FILTER_NO_CAST,
src: FILTER_ALLOW,
srclang: FILTER_ALLOW,
srcset: FILTER_ALLOW,
tabindex: FILTER_ALLOW,
target: FILTER_ALLOW,
title: FILTER_ALLOW,
type: FILTER_ALLOW,
width: FILTER_ALLOW,
});
// Attributes to camel case for React props
export const ATTRIBUTES_TO_PROPS: Record<string, string> = Object.freeze({
class: 'className',
colspan: 'colSpan',
datetime: 'dateTime',
rowspan: 'rowSpan',
srclang: 'srcLang',
srcset: 'srcSet',
tabindex: 'tabIndex',
});