Skip to content

Commit 456e87b

Browse files
committed
Add ReleaseNotesContent component
Create new layout component for displaying release notes.
1 parent 183d8a3 commit 456e87b

1 file changed

Lines changed: 177 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import React, { useMemo } from 'react';
2+
3+
type ReleaseNotesContentProps = {
4+
releaseNotes: string;
5+
className?: string;
6+
};
7+
8+
type ParsedReleaseNotes =
9+
| { type: 'html'; html: string }
10+
| { type: 'text'; text: string };
11+
12+
const ALLOWED_TAGS = new Set([
13+
'a',
14+
'blockquote',
15+
'br',
16+
'code',
17+
'em',
18+
'h1',
19+
'h2',
20+
'h3',
21+
'h4',
22+
'h5',
23+
'h6',
24+
'hr',
25+
'li',
26+
'ol',
27+
'p',
28+
'pre',
29+
'strong',
30+
'ul',
31+
]);
32+
33+
const BLOCKED_TAGS = new Set([
34+
'button',
35+
'embed',
36+
'form',
37+
'iframe',
38+
'img',
39+
'input',
40+
'link',
41+
'meta',
42+
'object',
43+
'script',
44+
'style',
45+
'svg',
46+
'template',
47+
]);
48+
49+
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
50+
const SANITIZE_BASE_URL = 'https://github.com';
51+
52+
function isLikelyHtml(input: string): boolean {
53+
return /<\/?[a-z][\s\S]*>/i.test(input);
54+
}
55+
56+
function sanitizeAnchor(element: HTMLElement): void {
57+
const href = element.getAttribute('href');
58+
for (const { name } of Array.from(element.attributes)) {
59+
if (name !== 'href' && name !== 'title') {
60+
element.removeAttribute(name);
61+
}
62+
}
63+
64+
if (!href) {
65+
element.removeAttribute('href');
66+
return;
67+
}
68+
69+
const trimmedHref = href.trim();
70+
if (!trimmedHref) {
71+
element.removeAttribute('href');
72+
return;
73+
}
74+
75+
try {
76+
const parsed = new URL(trimmedHref, SANITIZE_BASE_URL);
77+
if (!SAFE_LINK_PROTOCOLS.has(parsed.protocol)) {
78+
element.removeAttribute('href');
79+
return;
80+
}
81+
element.setAttribute('href', parsed.toString());
82+
element.setAttribute('target', '_blank');
83+
element.setAttribute('rel', 'noopener noreferrer');
84+
} catch {
85+
element.removeAttribute('href');
86+
}
87+
}
88+
89+
function sanitizeNode(node: Node): void {
90+
if (node.nodeType === Node.COMMENT_NODE) {
91+
node.parentNode?.removeChild(node);
92+
return;
93+
}
94+
95+
if (node.nodeType !== Node.ELEMENT_NODE) {
96+
return;
97+
}
98+
99+
const element = node as HTMLElement;
100+
const tagName = element.tagName.toLowerCase();
101+
102+
if (BLOCKED_TAGS.has(tagName)) {
103+
element.remove();
104+
return;
105+
}
106+
107+
if (!ALLOWED_TAGS.has(tagName)) {
108+
const parent = element.parentNode;
109+
if (!parent) return;
110+
111+
while (element.firstChild) {
112+
const child = element.firstChild;
113+
parent.insertBefore(child, element);
114+
sanitizeNode(child);
115+
}
116+
parent.removeChild(element);
117+
return;
118+
}
119+
120+
if (tagName === 'a') {
121+
sanitizeAnchor(element);
122+
} else {
123+
for (const { name } of Array.from(element.attributes)) {
124+
element.removeAttribute(name);
125+
}
126+
}
127+
128+
for (const child of Array.from(element.childNodes)) {
129+
sanitizeNode(child);
130+
}
131+
}
132+
133+
function sanitizeReleaseNotesHtml(input: string): string {
134+
if (typeof DOMParser === 'undefined') return '';
135+
136+
const document = new DOMParser().parseFromString(input, 'text/html');
137+
for (const child of Array.from(document.body.childNodes)) {
138+
sanitizeNode(child);
139+
}
140+
return document.body.innerHTML.trim();
141+
}
142+
143+
function parseReleaseNotes(input: string): ParsedReleaseNotes {
144+
const trimmed = input.trim();
145+
if (!trimmed) {
146+
return { type: 'text', text: '' };
147+
}
148+
149+
if (!isLikelyHtml(trimmed)) {
150+
return { type: 'text', text: trimmed };
151+
}
152+
153+
const sanitizedHtml = sanitizeReleaseNotesHtml(trimmed);
154+
if (!sanitizedHtml) {
155+
return { type: 'text', text: trimmed };
156+
}
157+
158+
return { type: 'html', html: sanitizedHtml };
159+
}
160+
161+
export const ReleaseNotesContent: React.FC<ReleaseNotesContentProps> = ({
162+
releaseNotes,
163+
className,
164+
}) => {
165+
const parsed = useMemo(() => parseReleaseNotes(releaseNotes), [releaseNotes]);
166+
167+
if (parsed.type === 'html') {
168+
return (
169+
<div
170+
className={className}
171+
dangerouslySetInnerHTML={{ __html: parsed.html }}
172+
/>
173+
);
174+
}
175+
176+
return <div className={`${className || ''} release-notes-plain`.trim()}>{parsed.text}</div>;
177+
};

0 commit comments

Comments
 (0)