1+ import { marked , type Tokens } from "marked" ;
2+
13import type { OteEvent , OteEventStatus , OteFeed , OteOrganizer } from "./types.js" ;
24
35export type {
@@ -19,6 +21,36 @@ export type {
1921const CRLF = "\r\n" ;
2022const encoder = new TextEncoder ( ) ;
2123
24+ /** Escapes a value for literal display inside an HTML fragment (X-ALT-DESC). */
25+ function escapeHtml ( value : string ) : string {
26+ return value
27+ . replace ( / & / g, "&" )
28+ . replace ( / < / g, "<" )
29+ . replace ( / > / g, ">" )
30+ . replace ( / " / g, """ ) ;
31+ }
32+
33+ // `description` is plain text or Markdown (OTE spec). DESCRIPTION (RFC 5545
34+ // TEXT) can't hold markup, so this is rendered separately into an HTML
35+ // fragment for X-ALT-DESC;FMTTYPE=text/html — the de facto (non-standard but
36+ // widely implemented, e.g. Outlook) extension for rich-text VEVENT
37+ // descriptions. Raw inline/block HTML in the source is escaped rather than
38+ // passed through live, so it can't smuggle real markup into a client that
39+ // renders this fragment.
40+ const descriptionRenderer = new marked . Renderer ( ) ;
41+ descriptionRenderer . html = ( { text } : Tokens . HTML | Tokens . Tag ) => escapeHtml ( text ) ;
42+
43+ /** Renders an OTE `description` (plain text or Markdown) to an HTML fragment. */
44+ function descriptionToHtml ( markdown : string ) : string {
45+ return marked . parse ( markdown , {
46+ renderer : descriptionRenderer ,
47+ // A plain-text description is the common case, and a lone newline in one
48+ // reads as an intended line break, not two words meant to run together.
49+ breaks : true ,
50+ async : false ,
51+ } ) ;
52+ }
53+
2254/** Escapes a value for an iCalendar TEXT property (RFC 5545 §3.3.11). */
2355function escapeText ( value : string ) : string {
2456 return value
@@ -147,13 +179,23 @@ function vevent(event: OteEvent, dtstamp: string): string[] {
147179 // Both event.url and location.onlineUrl map to iCal URL. The canonical page
148180 // wins; when both exist the attend link is preserved in DESCRIPTION.
149181 const url = event . url ?? event . location ?. onlineUrl ;
182+ // Built in lockstep with descriptionParts: Outlook ignores DESCRIPTION
183+ // entirely once X-ALT-DESC is present, so the HTML version must carry
184+ // every fact the plain-text one does, not just the rendered description.
150185 const descriptionParts : string [ ] = [ ] ;
151- if ( event . description ) descriptionParts . push ( event . description ) ;
186+ const htmlParts : string [ ] = [ ] ;
187+ if ( event . description ) {
188+ descriptionParts . push ( event . description ) ;
189+ htmlParts . push ( descriptionToHtml ( event . description ) ) ;
190+ }
152191 if ( event . url && event . location ?. onlineUrl ) {
153192 descriptionParts . push ( `Online: ${ event . location . onlineUrl } ` ) ;
193+ const onlineUrl = escapeHtml ( event . location . onlineUrl ) ;
194+ htmlParts . push ( `<p>Online: <a href="${ onlineUrl } ">${ onlineUrl } </a></p>` ) ;
154195 }
155196 if ( event . status === "moved-online" ) {
156197 descriptionParts . push ( "This event has moved online." ) ;
198+ htmlParts . push ( "<p>This event has moved online.</p>" ) ;
157199 }
158200 // offers/cfp/eligibility have no iCalendar structure to hold them (accepted
159201 // total loss, per the spec's own mapping tables) — degraded to readable
@@ -162,10 +204,17 @@ function vevent(event: OteEvent, dtstamp: string): string[] {
162204 if ( event . cfp ) {
163205 const window = event . cfp . closesAt ? ` (closes ${ event . cfp . closesAt } )` : "" ;
164206 descriptionParts . push ( `Call for proposals: ${ event . cfp . url } ${ window } ` ) ;
207+ const cfpUrl = escapeHtml ( event . cfp . url ) ;
208+ htmlParts . push (
209+ `<p>Call for proposals: <a href="${ cfpUrl } ">${ cfpUrl } </a>${ escapeHtml ( window ) } </p>` ,
210+ ) ;
165211 }
166212 if ( event . eligibility ) {
167213 const note = event . eligibility . note ? ` — ${ event . eligibility . note } ` : "" ;
168214 descriptionParts . push ( `Eligibility: ${ event . eligibility . type } ${ note } ` ) ;
215+ htmlParts . push (
216+ `<p>Eligibility: ${ escapeHtml ( event . eligibility . type ) } ${ escapeHtml ( note ) } </p>` ,
217+ ) ;
169218 }
170219 if ( event . offers && event . offers . length > 0 ) {
171220 const offerLines = event . offers . map ( ( o ) => {
@@ -174,6 +223,7 @@ function vevent(event: OteEvent, dtstamp: string): string[] {
174223 return [ o . name , price , o . url ] . filter ( ( part ) : part is string => Boolean ( part ) ) . join ( " — " ) ;
175224 } ) ;
176225 descriptionParts . push ( `Tickets:\n${ offerLines . join ( "\n" ) } ` ) ;
226+ htmlParts . push ( `<p>Tickets:<br/>${ offerLines . map ( escapeHtml ) . join ( "<br/>" ) } </p>` ) ;
177227 }
178228 if ( descriptionParts . length > 0 ) {
179229 lines . push (
@@ -183,6 +233,14 @@ function vevent(event: OteEvent, dtstamp: string): string[] {
183233 ) ,
184234 ) ;
185235 }
236+ if ( htmlParts . length > 0 ) {
237+ lines . push (
238+ ...prop (
239+ withLanguage ( "X-ALT-DESC;FMTTYPE=text/html" , event . textLanguage ) ,
240+ escapeText ( htmlParts . join ( "\n" ) ) ,
241+ ) ,
242+ ) ;
243+ }
186244
187245 if ( event . location ?. venue ) {
188246 lines . push ( ...prop ( "LOCATION" , escapeText ( event . location . venue ) ) ) ;
0 commit comments