Skip to content

Commit f199a5c

Browse files
hhkaosclaude
andcommitted
feat(export-rss,export-ics): render Markdown descriptions to HTML
OTE's `description` field allows Markdown, but both exporters treated it as plain text. RSS now renders it to HTML in the item body; ICS adds X-ALT-DESC;FMTTYPE=text/html alongside the unchanged plain-text DESCRIPTION, since Outlook drops DESCRIPTION once X-ALT-DESC is present. Raw HTML inside the Markdown source is escaped rather than passed through live. Also fixes export-rss's reverse-parser (used by apps/preview), which only read <p> elements and would silently drop Markdown lists/headings from the description. Closes #50 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 07f282a commit f199a5c

17 files changed

Lines changed: 243 additions & 17 deletions

File tree

packages/export-ics/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to `@opentechevents/export-ics` are documented here.
44

5+
## 0.3.1
6+
7+
- Added `X-ALT-DESC;FMTTYPE=text/html``description` (plain text or
8+
Markdown, per the OTE spec) is now rendered to HTML for this de facto
9+
rich-text extension (Outlook 2007+, Thunderbird/Lightning), alongside the
10+
unchanged plain-text `DESCRIPTION`. Built from the same parts as
11+
`DESCRIPTION` (online link, moved-online note, cfp/eligibility/offers),
12+
since Outlook ignores `DESCRIPTION` entirely once `X-ALT-DESC` is present.
13+
Raw inline/block HTML found in the Markdown source is escaped rather than
14+
passed through live.
15+
516
## 0.3.0
617

718
- Initial package release for OTE spec v0.3.

packages/export-ics/README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ usage or I/O error.
2828
| --- | --- |
2929
| `id` | `UID` |
3030
| `name` | `SUMMARY` (`;LANGUAGE=<textLanguage>` when set) |
31-
| `description` | `DESCRIPTION` (`;LANGUAGE=<textLanguage>` when set) |
31+
| `description` | `DESCRIPTION` (`;LANGUAGE=<textLanguage>` when set, literal Markdown source) + `X-ALT-DESC;FMTTYPE=text/html` (rendered HTML, see below) |
3232
| `startDate` / `endDate` + `timezone` | `DTSTART` / `DTEND` (see below) |
3333
| `url` (else `location.onlineUrl`) | `URL` |
3434
| `location.venue` | `LOCATION` |
@@ -68,3 +68,17 @@ Decisions worth knowing:
6868
- **Dropped, not approximated**: `attendanceMode`, `languages`, `license`,
6969
`source` have no iCal equivalent and are omitted. Absent fields stay absent
7070
(e.g. no `STATUS` is invented when `status` is missing).
71+
- **`description` is plain text or Markdown (OTE spec)**, but RFC 5545
72+
`DESCRIPTION` is TEXT-only — it can't hold markup. `X-ALT-DESC;FMTTYPE=text/html`
73+
is the de facto (non-standard, but widely implemented — Outlook 2007+,
74+
Thunderbird/Lightning) extension for a rich-text alternative, so it carries
75+
the Markdown rendered to HTML. It's built from the same parts as
76+
`DESCRIPTION` (the `Online:`/moved-online note, `cfp`/`eligibility`/`offers`
77+
text), not just the description alone: Outlook ignores `DESCRIPTION`
78+
entirely once `X-ALT-DESC` is present, so nothing may exist only in one of
79+
the two. Raw inline/block HTML found inside the Markdown source is escaped
80+
rather than passed through live, so it can't smuggle real markup into a
81+
client that renders this fragment. Apple Calendar's support for
82+
`X-ALT-DESC` is inconsistent; Google Calendar ignores it and always shows
83+
plain-text `DESCRIPTION`, which is why that property always stays
84+
populated too.

packages/export-ics/fixtures/feed.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@
102102
"timezone": "Europe/Madrid",
103103
"status": "moved-online",
104104
"location": { "onlineUrl": "https://meet.example/pydata-madrid" }
105+
},
106+
{
107+
"id": "https://mdtest.example/2026-11",
108+
"name": "Markdown description test",
109+
"description": "**Bold** intro with a [link](https://example.org/info).\n\n<script>alert(1)</script> should not run.",
110+
"startDate": "2026-11-03T18:00",
111+
"timezone": "Europe/Madrid"
105112
}
106113
]
107114
}

packages/export-ics/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opentechevents/export-ics",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Converts an OTE Feed into an iCalendar (.ics) document",
55
"license": "MIT",
66
"homepage": "https://github.com/OpenTechEvents/ote-tools/tree/main/packages/export-ics",
@@ -29,7 +29,8 @@
2929
"typecheck": "tsc -p tsconfig.json --noEmit"
3030
},
3131
"dependencies": {
32-
"@opentechevents/validate": "workspace:*"
32+
"@opentechevents/validate": "workspace:*",
33+
"marked": "^18.0.9"
3334
},
3435
"devDependencies": {
3536
"@types/node": "^22.0.0",

packages/export-ics/src/index.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { marked, type Tokens } from "marked";
2+
13
import type { OteEvent, OteEventStatus, OteFeed, OteOrganizer } from "./types.js";
24

35
export type {
@@ -19,6 +21,36 @@ export type {
1921
const CRLF = "\r\n";
2022
const 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, "&amp;")
28+
.replace(/</g, "&lt;")
29+
.replace(/>/g, "&gt;")
30+
.replace(/"/g, "&quot;");
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). */
2355
function 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)));

packages/export-ics/test/cli.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe("ote-export-ics CLI", () => {
3434
const out = join(mkdtempSync(join(tmpdir(), "ote-export-ics-")), "feed.ics");
3535
expect(runCli([fixture, out], io)).toBe(0);
3636
expect(readFileSync(out, "utf8")).toContain("BEGIN:VCALENDAR");
37-
expect(io.outLines[0]).toContain("6 events");
37+
expect(io.outLines[0]).toContain("7 events");
3838
});
3939

4040
it("invalid feed → exit 1 with validation errors", () => {

packages/export-ics/test/export-ics.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,33 @@ describe("feedToIcs", () => {
159159
expect(vevent).toContain("This event has moved online.");
160160
});
161161

162+
it("renders event.description as Markdown into X-ALT-DESC;FMTTYPE=text/html, alongside plain-text DESCRIPTION", () => {
163+
const vevent = veventFor("https://mdtest.example/2026-11");
164+
expect(vevent).toContain("X-ALT-DESC;FMTTYPE=text/html:");
165+
expect(vevent).toContain("<strong>Bold</strong>");
166+
expect(vevent).toContain('<a href="https://example.org/info">link</a>');
167+
// DESCRIPTION stays the literal Markdown source — the universal
168+
// plain-text fallback for clients that don't read X-ALT-DESC.
169+
expect(vevent).toContain("DESCRIPTION:**Bold** intro");
170+
});
171+
172+
it("escapes raw HTML found inside a Markdown description instead of passing it through live", () => {
173+
const vevent = veventFor("https://mdtest.example/2026-11");
174+
const altDesc = vevent
175+
.split("\r\n")
176+
.find((line) => line.startsWith("X-ALT-DESC;FMTTYPE=text/html:"));
177+
expect(altDesc).not.toContain("<script>");
178+
// escapeHtml runs first ("&lt;script&gt;"), then escapeText backslash-
179+
// escapes the semicolons in those entities for the TEXT wire format.
180+
expect(altDesc).toContain("&lt\\;script&gt\\;");
181+
});
182+
183+
it("omits X-ALT-DESC when there is nothing to put in it", () => {
184+
expect(veventFor("https://minimal.example/meetup/2026-09")).not.toContain(
185+
"X-ALT-DESC",
186+
);
187+
});
188+
162189
it("folds every content line at 75 octets", () => {
163190
const encoder = new TextEncoder();
164191
const lines = ics.split("\r\n");

packages/export-rss/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to `@opentechevents/export-rss` are documented here.
44

5+
## 0.3.1
6+
7+
- `description` (plain text or Markdown, per the OTE spec) is now rendered to
8+
HTML before being embedded in the item body, instead of being escaped as
9+
literal text with newlines turned into `<br/>`. Raw inline/block HTML found
10+
in the Markdown source is escaped rather than passed through live.
11+
- `parse.ts`'s reverse-parser (used by `apps/preview`) now reads every
12+
top-level block in the item body, not just `<p>` elements, so Markdown
13+
lists/headings/blockquotes in the description survive the round trip.
14+
515
## 0.3.0
616

717
- Initial package release for OTE spec v0.3.

packages/export-rss/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ Decisions worth knowing:
5656
(`parse.ts`, used by the preview app) — which only recognizes those five
5757
labels — reads them as part of the free-text description instead of
5858
silently dropping an unrecognized label.
59+
- **`description` is plain text or Markdown (OTE spec)** and is rendered to
60+
HTML before being embedded (entity-encoded, per the point above) — so a
61+
feed reader that treats `<description>` as markup shows real formatting
62+
(bold, links, lists) instead of literal Markdown syntax. Raw inline/block
63+
HTML found inside the Markdown source is escaped rather than passed
64+
through live, so it can't smuggle real markup into whatever renders the
65+
item body. A lone newline is treated as a line break (`breaks: true`),
66+
since a short plain-text description is the common case and a single `\n`
67+
in one reads as an intended break, not two words meant to run together.
5968
- **`feed.license` is optional (D029).** When every event declares its own
6069
license instead of a shared feed-level one, there's no single value for
6170
channel `copyright` to state — RSS's channel model has no per-item

packages/export-rss/fixtures/feed.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@
102102
"timezone": "Europe/Madrid",
103103
"status": "moved-online",
104104
"location": { "onlineUrl": "https://meet.example/pydata-madrid" }
105+
},
106+
{
107+
"id": "https://mdtest.example/2026-11",
108+
"name": "Markdown description test",
109+
"description": "**Bold** intro with a [link](https://example.org/info).\n\n<script>alert(1)</script> should not run.",
110+
"startDate": "2026-11-03T18:00",
111+
"timezone": "Europe/Madrid"
105112
}
106113
]
107114
}

0 commit comments

Comments
 (0)