@@ -563,7 +563,127 @@ const META_SEPARATOR = "\n\t\t<span aria-hidden>·</span>\n\t\t";
563563// unescaped in real prose.
564564const CODE_PLACEHOLDER = "CODE" ;
565565
566- /** Inline markdown within a single line/paragraph: code, bold, italic, links. */
566+ /**
567+ * Raw HTML a PR description is allowed to embed and have actually rendered,
568+ * instead of showing up as visible `<sup>` garbage. GitHub descriptions
569+ * routinely contain hand-written or bot-generated HTML (badges, `<details>`
570+ * spoilers, `<sup>`/`<br>`) alongside plain markdown — real CommonMark (and
571+ * GitHub's renderer) render both. Anything not on this list is left for
572+ * `escapeHtml` to neutralize as plain text, same as today.
573+ */
574+ const ALLOWED_HTML_TAGS = [
575+ "a" ,
576+ "b" ,
577+ "i" ,
578+ "em" ,
579+ "strong" ,
580+ "code" ,
581+ "pre" ,
582+ "br" ,
583+ "hr" ,
584+ "sup" ,
585+ "sub" ,
586+ "small" ,
587+ "mark" ,
588+ "kbd" ,
589+ "p" ,
590+ "div" ,
591+ "span" ,
592+ "details" ,
593+ "summary" ,
594+ "table" ,
595+ "thead" ,
596+ "tbody" ,
597+ "tr" ,
598+ "td" ,
599+ "th" ,
600+ "ul" ,
601+ "ol" ,
602+ "li" ,
603+ "blockquote" ,
604+ "img" ,
605+ "picture" ,
606+ "source" ,
607+ "figure" ,
608+ "figcaption" ,
609+ ] as const ;
610+ const VOID_HTML_TAGS = new Set ( [ "br" , "hr" , "img" , "source" ] ) ;
611+ /** Attributes kept per tag; everything else (style, on*, id, class, …) is dropped. */
612+ const ALLOWED_HTML_ATTRS : Partial <
613+ Record < ( typeof ALLOWED_HTML_TAGS ) [ number ] , string [ ] >
614+ > = {
615+ img : [ "src" , "alt" , "title" , "width" , "height" ] ,
616+ source : [ "srcset" , "src" , "media" , "type" ] ,
617+ details : [ "open" ] ,
618+ td : [ "align" , "colspan" , "rowspan" ] ,
619+ th : [ "align" , "colspan" , "rowspan" ] ,
620+ } ;
621+ const SAFE_URL_SCHEME = / ^ ( h t t p s ? : | m a i l t o : ) / i;
622+
623+ // Matches only a complete `<tag ...>`, `<tag ... />`, or `</tag>` for a
624+ // tag name in ALLOWED_HTML_TAGS — anything else (a stray `<`, a "5 < 10"
625+ // comparison, a disallowed tag like `<script>`) simply doesn't match and
626+ // falls through to normal text escaping.
627+ const INLINE_HTML_TAG_PATTERN = new RegExp (
628+ `<\\/?(?:${ ALLOWED_HTML_TAGS . join ( "|" ) } )(?:\\s[^<>]*)?\\/?>` ,
629+ "gi" ,
630+ ) ;
631+
632+ /** Rebuilds one matched tag with only its allowlisted, scheme-checked attributes. */
633+ function sanitizeTag ( tag : string ) : string {
634+ const isClosing = tag . startsWith ( "</" ) ;
635+ const name = / ^ < \/ ? ( [ a - z A - Z ] [ a - z A - Z 0 - 9 ] * ) / . exec ( tag ) ?. [ 1 ] ?. toLowerCase ( ) ;
636+ if ( ! name ) return "" ;
637+ if ( isClosing ) return VOID_HTML_TAGS . has ( name ) ? "" : `</${ name } >` ;
638+
639+ const allowedAttrs =
640+ ALLOWED_HTML_ATTRS [ name as ( typeof ALLOWED_HTML_TAGS ) [ number ] ] ?? [ ] ;
641+ const attrs : string [ ] = [ ] ;
642+ for ( const match of tag . matchAll ( / ( [ a - z A - Z - :] + ) \s * = \s * " ( [ ^ " ] * ) " / g) ) {
643+ const attrName = match [ 1 ] ?. toLowerCase ( ) ;
644+ const attrValue = match [ 2 ] ?? "" ;
645+ if ( ! attrName || ! allowedAttrs . includes ( attrName ) ) continue ;
646+ if (
647+ ( attrName === "src" || attrName === "href" ) &&
648+ ! SAFE_URL_SCHEME . test ( attrValue )
649+ ) {
650+ continue ;
651+ }
652+ attrs . push ( `${ attrName } ="${ escapeHtml ( attrValue ) } "` ) ;
653+ }
654+ // A raw <a href> gets the same forced target/rel as markdown links below —
655+ // the source's own target/rel (if any) is never trusted or kept.
656+ if ( name === "a" && / \s h r e f \s * = / . test ( tag ) ) {
657+ const hrefMatch = / h r e f \s * = \s * " ( [ ^ " ] * ) " / . exec ( tag ) ;
658+ const href = hrefMatch ?. [ 1 ] ;
659+ if ( href && SAFE_URL_SCHEME . test ( href ) ) {
660+ attrs . unshift ( `href="${ escapeHtml ( href ) } "` ) ;
661+ attrs . push ( 'target="_blank"' , 'rel="noopener noreferrer"' ) ;
662+ } else {
663+ return "" ; // an <a> with no safe href isn't worth keeping as a tag
664+ }
665+ }
666+ const attrsHtml = attrs . length > 0 ? ` ${ attrs . join ( " " ) } ` : "" ;
667+ return `<${ name } ${ attrsHtml } >` ;
668+ }
669+
670+ /**
671+ * Escapes plain text but passes allowlisted HTML tags through (sanitized) as
672+ * real markup — used for both inline text and raw HTML blocks below.
673+ */
674+ function sanitizeInlineHtml ( text : string ) : string {
675+ let result = "" ;
676+ let lastIndex = 0 ;
677+ for ( const match of text . matchAll ( INLINE_HTML_TAG_PATTERN ) ) {
678+ result += escapeHtml ( text . slice ( lastIndex , match . index ) ) ;
679+ result += sanitizeTag ( match [ 0 ] ) ;
680+ lastIndex = match . index + match [ 0 ] . length ;
681+ }
682+ result += escapeHtml ( text . slice ( lastIndex ) ) ;
683+ return result ;
684+ }
685+
686+ /** Inline markdown within a single line/paragraph: code, bold, italic, links, safe raw HTML. */
567687function renderInline ( text : string ) : string {
568688 const codeSpans : string [ ] = [ ] ;
569689 // Pull inline code out first so its literal contents never get treated as
@@ -574,7 +694,7 @@ function renderInline(text: string): string {
574694 return CODE_PLACEHOLDER ;
575695 } ) ;
576696
577- let html = escapeHtml ( withPlaceholders ) ;
697+ let html = sanitizeInlineHtml ( withPlaceholders ) ;
578698 // Links first — its own escaped brackets/parens would otherwise collide
579699 // with the bold/italic patterns below. A PR description is authored by
580700 // whoever opened the PR, not us — reject any scheme but http(s)/mailto so
@@ -598,13 +718,27 @@ function renderInline(text: string): string {
598718 return html . replaceAll ( CODE_PLACEHOLDER , ( ) => codeSpans [ spanIndex ++ ] ?? "" ) ;
599719}
600720
721+ /** `- [x] done` / `- [ ] todo` — GitHub's task-list checkbox syntax. */
722+ function renderListItem ( item : string ) : string {
723+ const task = / ^ \[ ( [ x X ] ) \] \s + ( .* ) $ / . exec ( item ) ;
724+ if ( ! task ) return `<li>${ renderInline ( item ) } </li>` ;
725+ const checked = task [ 1 ] !== " " ;
726+ return `<li class="task-list-item"><input type="checkbox" disabled${ checked ? " checked" : "" } > ${ renderInline ( task [ 2 ] ?? "" ) } </li>` ;
727+ }
728+
601729/**
602730 * Minimal markdown-to-HTML for a PR's own description — headers, fenced code
603- * blocks, blockquotes, lists, and paragraphs with inline formatting. Not a
604- * full CommonMark implementation (no nested lists, no tables); this package
605- * stays dependency-free by design, same reasoning as parseUnifiedDiff above.
731+ * blocks, blockquotes, lists, task checkboxes, and paragraphs with inline
732+ * formatting (including a safe subset of raw HTML — see ALLOWED_HTML_TAGS).
733+ * Not a full CommonMark implementation (no nested lists, no tables); this
734+ * package stays dependency-free by design, same reasoning as
735+ * parseUnifiedDiff above.
606736 */
607- function renderMarkdown ( markdown : string ) : string {
737+ function renderMarkdown ( rawMarkdown : string ) : string {
738+ // HTML comments (bot-generated descriptions lean on these to hide
739+ // metadata markers) render as nothing, same as every real markdown
740+ // renderer — not as visible "<!-- ... -->" text.
741+ const markdown = rawMarkdown . replace ( / < ! - - [ \s \S ] * ?- - > / g, "" ) ;
608742 const lines = markdown . replace ( / \r \n / g, "\n" ) . split ( "\n" ) ;
609743 const blocks : string [ ] = [ ] ;
610744 let paragraph : string [ ] = [ ] ;
@@ -618,10 +752,9 @@ function renderMarkdown(markdown: string): string {
618752 } ;
619753 const flushList = ( ) => {
620754 if ( ! list ) return ;
621- const items = list . items
622- . map ( ( item ) => `<li>${ renderInline ( item ) } </li>` )
623- . join ( "" ) ;
624- blocks . push ( `<${ list . tag } >${ items } </${ list . tag } >` ) ;
755+ blocks . push (
756+ `<${ list . tag } >${ list . items . map ( renderListItem ) . join ( "" ) } </${ list . tag } >` ,
757+ ) ;
625758 list = null ;
626759 } ;
627760 const flushQuote = ( ) => {
@@ -655,6 +788,25 @@ function renderMarkdown(markdown: string): string {
655788 continue ;
656789 }
657790
791+ // A line opening or closing one of our allowed HTML tags starts a raw
792+ // HTML block — real bot-generated PR descriptions embed multi-line
793+ // blocks like `<a href=…><picture><source …><img …></picture></a>` or
794+ // `<details><summary>…</summary>…</details>` for badges/collapsibles.
795+ // Consumed verbatim through the next blank line (CommonMark's own
796+ // blank-line-terminated HTML block rule), then sanitized as one unit
797+ // rather than run through paragraph/list parsing.
798+ if ( / ^ < \/ ? [ a - z A - Z ] / . test ( line . trim ( ) ) ) {
799+ flushAll ( ) ;
800+ const htmlLines : string [ ] = [ line ] ;
801+ i += 1 ;
802+ while ( i < lines . length && ! / ^ \s * $ / . test ( lines [ i ] ?? "" ) ) {
803+ htmlLines . push ( lines [ i ] ?? "" ) ;
804+ i += 1 ;
805+ }
806+ blocks . push ( sanitizeInlineHtml ( htmlLines . join ( "\n" ) ) ) ;
807+ continue ;
808+ }
809+
658810 const heading = / ^ ( # { 1 , 4 } ) \s + ( .* ) $ / . exec ( line ) ;
659811 if ( heading ?. [ 1 ] && heading [ 2 ] !== undefined ) {
660812 flushAll ( ) ;
@@ -1143,6 +1295,15 @@ details.failure p { margin: 0.375rem 0 0; font-size: 0.875rem; line-height: 1.25
11431295.markdown pre code { background: none; padding: 0; }
11441296.markdown a { color: var(--foreground); text-decoration: underline; text-underline-offset: 2px; }
11451297.markdown hr { border: none; border-top: 1px solid var(--border); margin: 1.25rem 0; }
1298+ .markdown img, .markdown picture { max-width: 100%; height: auto; }
1299+ .markdown .task-list-item { list-style: none; margin-left: -1.25rem; }
1300+ .markdown .task-list-item input { margin-right: 0.375rem; vertical-align: middle; }
1301+ .markdown details { margin: 0.75rem 0; border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); border-radius: 8px; padding: 0.5rem 0.75rem; }
1302+ .markdown summary { cursor: pointer; font-weight: 500; }
1303+ .markdown details[open] summary { margin-bottom: 0.5rem; }
1304+ .markdown table { border-collapse: collapse; margin: 0.75rem 0; font-size: 0.8125rem; }
1305+ .markdown th, .markdown td { border: 1px solid color-mix(in oklab, var(--border) 70%, transparent); padding: 0.375rem 0.625rem; text-align: left; }
1306+ .markdown th { background: color-mix(in oklab, var(--muted) 35%, transparent); font-weight: 600; }
11461307</style>
11471308</head>
11481309<body>
0 commit comments