11import { marked } from "marked" ;
2+ import sanitizeHtml from "sanitize-html" ;
23import { codeToHtml } from "shiki" ;
34
45/**
@@ -15,6 +16,116 @@ const renderer = {
1516
1617marked . use ( { renderer } ) ;
1718
19+ const ALLOWED_TAGS = [
20+ "a" ,
21+ "blockquote" ,
22+ "br" ,
23+ "code" ,
24+ "dd" ,
25+ "del" ,
26+ "details" ,
27+ "div" ,
28+ "dl" ,
29+ "dt" ,
30+ "em" ,
31+ "h1" ,
32+ "h2" ,
33+ "h3" ,
34+ "h4" ,
35+ "h5" ,
36+ "h6" ,
37+ "hr" ,
38+ "img" ,
39+ "kbd" ,
40+ "li" ,
41+ "ol" ,
42+ "p" ,
43+ "pre" ,
44+ "s" ,
45+ "span" ,
46+ "strong" ,
47+ "sub" ,
48+ "summary" ,
49+ "sup" ,
50+ "table" ,
51+ "tbody" ,
52+ "td" ,
53+ "th" ,
54+ "thead" ,
55+ "tr" ,
56+ "ul" ,
57+ ] ;
58+
59+ const ALLOWED_ATTRIBUTES : sanitizeHtml . IOptions [ "allowedAttributes" ] = {
60+ a : [ "href" , "name" , "rel" , "target" , "title" ] ,
61+ code : [ "class" , "style" ] ,
62+ div : [ "class" ] ,
63+ img : [ "alt" , "height" , "src" , "title" , "width" ] ,
64+ pre : [ "class" , "style" , "tabindex" ] ,
65+ span : [ "class" , "style" ] ,
66+ td : [ "align" , "colspan" , "rowspan" ] ,
67+ th : [ "align" , "colspan" , "rowspan" ] ,
68+ } ;
69+
70+ const ALLOWED_STYLES : sanitizeHtml . IOptions [ "allowedStyles" ] = {
71+ "*" : {
72+ "background-color" : [
73+ / ^ # [ 0 - 9 a - f ] { 3 , 8 } $ / i,
74+ / ^ r g b ( a ) ? \( [ \d \s , % . ] + \) $ / i,
75+ / ^ h s l ( a ) ? \( [ \d \s , % . ] + \) $ / i,
76+ ] ,
77+ color : [
78+ / ^ # [ 0 - 9 a - f ] { 3 , 8 } $ / i,
79+ / ^ r g b ( a ) ? \( [ \d \s , % . ] + \) $ / i,
80+ / ^ h s l ( a ) ? \( [ \d \s , % . ] + \) $ / i,
81+ ] ,
82+ "font-style" : [ / ^ i t a l i c $ / , / ^ n o r m a l $ / ] ,
83+ "font-weight" : [
84+ / ^ (?: [ 1 - 9 ] \d { 0 , 2 } | 1 0 0 0 ) $ / ,
85+ / ^ b o l d $ / ,
86+ / ^ b o l d e r $ / ,
87+ / ^ l i g h t e r $ / ,
88+ / ^ n o r m a l $ / ,
89+ ] ,
90+ "text-decoration" : [ / ^ u n d e r l i n e $ / , / ^ n o n e $ / ] ,
91+ } ,
92+ } ;
93+
94+ function ensureNoopenerNoreferrer ( rel : string | undefined ) : string {
95+ const tokens = new Set ( ( rel ?? "" ) . split ( / \s + / ) . filter ( Boolean ) ) ;
96+ tokens . add ( "noopener" ) ;
97+ tokens . add ( "noreferrer" ) ;
98+
99+ return Array . from ( tokens ) . join ( " " ) ;
100+ }
101+
102+ const SANITIZE_OPTIONS : sanitizeHtml . IOptions = {
103+ allowedTags : ALLOWED_TAGS ,
104+ allowedAttributes : ALLOWED_ATTRIBUTES ,
105+ allowedSchemes : [ "http" , "https" , "mailto" , "tel" ] ,
106+ allowedSchemesByTag : {
107+ img : [ "http" , "https" ] ,
108+ } ,
109+ allowedStyles : ALLOWED_STYLES ,
110+ allowProtocolRelative : false ,
111+ disallowedTagsMode : "discard" ,
112+ transformTags : {
113+ a : ( tagName , attribs ) => {
114+ if ( attribs . target ?. toLowerCase ( ) !== "_blank" ) {
115+ return { tagName, attribs } ;
116+ }
117+
118+ return {
119+ tagName,
120+ attribs : {
121+ ...attribs ,
122+ rel : ensureNoopenerNoreferrer ( attribs . rel ) ,
123+ } ,
124+ } ;
125+ } ,
126+ } ,
127+ } ;
128+
18129/**
19130 * Convert markdown to HTML with syntax highlighting
20131 */
@@ -61,5 +172,5 @@ export async function markdownToHtml(markdown: string): Promise<string> {
61172 }
62173 }
63174
64- return result ;
175+ return sanitizeHtml ( result , SANITIZE_OPTIONS ) ;
65176}
0 commit comments