@@ -129,20 +129,91 @@ function decodeEntities(value: string): string {
129129 } )
130130}
131131
132+ const BLOCK_BREAK_TAGS : ReadonlySet < string > = new Set ( [
133+ 'blockquote' ,
134+ 'div' ,
135+ 'h1' ,
136+ 'h2' ,
137+ 'h3' ,
138+ 'h4' ,
139+ 'h5' ,
140+ 'h6' ,
141+ 'li' ,
142+ 'ol' ,
143+ 'p' ,
144+ 'pre' ,
145+ 'table' ,
146+ 'tr' ,
147+ 'ul' ,
148+ ] )
149+
150+ const RAW_TEXT_TAGS : ReadonlySet < string > = new Set ( [ 'script' , 'style' ] )
151+
152+ const TAG_NAME_CHARACTER = / [ a - z 0 - 9 ] / iu
153+
154+ interface HtmlTag {
155+ name : string
156+ closing : boolean
157+ end : number
158+ }
159+
160+ function readTag ( value : string , start : number ) : HtmlTag | undefined {
161+ const end = value . indexOf ( '>' , start )
162+ if ( end < 0 ) return undefined
163+ const closing = value [ start + 1 ] === '/'
164+ const nameStart = start + ( closing ? 2 : 1 )
165+ let cursor = nameStart
166+ while ( cursor < end && TAG_NAME_CHARACTER . test ( value [ cursor ] ?? '' ) ) cursor += 1
167+ return { name : value . slice ( nameStart , cursor ) . toLowerCase ( ) , closing, end }
168+ }
169+
170+ function skipRawTextElement ( value : string , name : string , from : number ) : number {
171+ let index = from
172+ while ( index < value . length ) {
173+ const next = value . indexOf ( '<' , index )
174+ if ( next < 0 ) return value . length
175+ const tag = readTag ( value , next )
176+ if ( tag === undefined ) return value . length
177+ if ( tag . closing && tag . name === name ) return tag . end + 1
178+ index = tag . end + 1
179+ }
180+ return value . length
181+ }
182+
132183/**
133184 * The GitHub provider reads the releases Atom feed, whose `<content>` is the
134185 * body GitHub has already rendered to HTML. The renderer shows these notes as
135186 * Markdown, so the tags would print literally — `PyModel/pythinker-code@<tt>`
136187 * and the rest. Reduce the markup to text here, at the boundary that already
137188 * owns this field.
189+ *
190+ * This walks the input once and copies out only the text it passes, rather
191+ * than deleting tags from the string. Deletion is what lets `<scr<x>ipt>`
192+ * close back up into markup; a scan that never re-reads what it emitted cannot
193+ * produce a tag that was not already there. Markup the scan cannot terminate
194+ * ends the walk, so an unclosed `<` is dropped with the rest of the tail.
138195 */
139196function plainReleaseNotes ( value : string ) : string {
140- const text = value
141- . replaceAll ( / < ( s c r i p t | s t y l e ) \b [ ^ > ] * > [ \s \S ] * ?< \/ \1> / giu, '' )
142- . replaceAll ( / < l i \b [ ^ > ] * > / giu, '\n- ' )
143- . replaceAll ( / < b r \s * \/ ? > / giu, '\n' )
144- . replaceAll ( / < \/ ( p | d i v | l i | u l | o l | t r | h [ 1 - 6 ] ) > / giu, '\n' )
145- . replaceAll ( / < [ ^ > ] * > / gu, '' )
197+ let text = ''
198+ let index = 0
199+ while ( index < value . length ) {
200+ const next = value . indexOf ( '<' , index )
201+ if ( next < 0 ) {
202+ text += value . slice ( index )
203+ break
204+ }
205+ text += value . slice ( index , next )
206+ const tag = readTag ( value , next )
207+ if ( tag === undefined ) break
208+ if ( ! tag . closing && RAW_TEXT_TAGS . has ( tag . name ) ) {
209+ index = skipRawTextElement ( value , tag . name , tag . end + 1 )
210+ continue
211+ }
212+ if ( tag . name === 'li' && ! tag . closing ) text += '\n- '
213+ else if ( tag . name === 'br' ) text += '\n'
214+ else if ( tag . closing && BLOCK_BREAK_TAGS . has ( tag . name ) ) text += '\n'
215+ index = tag . end + 1
216+ }
146217 return decodeEntities ( text )
147218 . replaceAll ( / [ ^ \S \n ] + \n / gu, '\n' )
148219 . replaceAll ( / \n { 3 , } / gu, '\n\n' )
0 commit comments