Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions tests/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { renderSupplierText } from "../web/src/markdown.ts";

function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}

Deno.test("plain supplier text with markdown links becomes clickable, and safe", () => {
// #295: Harderwijk's agenda text, as iBabs delivers it.
const html = renderSupplierText(
"De vergadering is te volgen via [www.harderwijk.nl/vergaderingen](https://www.harderwijk.nl/vergaderingen).&#xD;\n&#xD;\nProces: <script>alert(1)</script> is geen markup.",
);
assert(
html.includes(
'<a href="https://www.harderwijk.nl/vergaderingen">www.harderwijk.nl/vergaderingen</a>',
),
`the markdown link is rendered, got ${html}`,
);
assert(!html.includes("&#xD;") && !html.includes("\r"), "carriage-return entities are dropped");
assert(!html.includes("<script>"), "angle brackets in supplier text never become tags");
assert(html.includes("&lt;script&gt;"), "they are shown as text instead");
});

Deno.test("supplier HTML is passed through as before", () => {
const html = "<p>Vergadering in de <b>raadzaal</b>.</p>";
assert(renderSupplierText(html) === html, "HTML from Notubiz keeps its markup");
assert(
renderSupplierText("") === "" && renderSupplierText(undefined) === "",
"empty stays empty",
);
});
4 changes: 2 additions & 2 deletions web/src/MeetingAgendaTree.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { renderDocumentMarkdown } from "./markdown.ts";
import { renderDocumentMarkdown, renderSupplierText } from "./markdown.ts";
import { createEventDispatcher } from "svelte";
import type { EntityContentResponse, MeetingAgendaItem, MeetingMotion } from "../../src/types.ts";
import ReaderLoading from "./ReaderLoading.svelte";
Expand Down Expand Up @@ -129,7 +129,7 @@
</header>

{#if item.description}
<div class="meeting-agenda__description">{@html item.description}</div>
<div class="meeting-agenda__description">{@html renderSupplierText(item.description)}</div>
{/if}

{#if item.documents?.length}
Expand Down
36 changes: 36 additions & 0 deletions web/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,39 @@ export function renderDocumentMarkdown(markdown?: string): string {
export function renderOwnMarkdown(markdown: string): string {
return parse(markdown);
}

/* The tags a supplier's HTML actually uses. Anything else in angle brackets
* is text and gets escaped: a stray `<script>` in plain text is not markup. */
const HTML_TAG =
/<\/?(?:p|br|a|ul|ol|li|strong|b|em|i|u|div|span|h[1-6]|table|thead|tbody|tr|td|th|blockquote|hr)\b[^>]*>/i;

/** Decode the numeric character references a supplier leaves in plain text
* (`&#xD;` for a carriage return is iBabs's favourite) so they neither show
* up literally after escaping nor survive as stray `\r`. */
function decodeNumericEntities(text: string): string {
return text
.replaceAll(/&#x([0-9a-f]+);/gi, (_match, hex: string) =>
String.fromCodePoint(parseInt(hex, 16)),
)
.replaceAll(/&#(\d+);/g, (_match, dec: string) => String.fromCodePoint(parseInt(dec, 10)))
.replaceAll("\r", "");
}

/** Text a supplier wrote about a meeting or an agenda item, inserted with
* {@html}.
*
* Notubiz hands over HTML and that is shown as it is, as before. iBabs hands
* over plain text that griffies write with markdown in it: a link such as
* `[www.harderwijk.nl/vergaderingen](https://www.harderwijk.nl/vergaderingen)`
* used to appear with its brackets, unclickable (#295). Text without markup
* is now escaped and rendered as markdown, so links become links and line
* breaks survive, while nothing in it can become markup of its own. */
export function renderSupplierText(text?: string): string {
if (!text?.trim()) {
return "";
}
if (HTML_TAG.test(text)) {
return text;
}
return parse(escapeMarkdownSource(decodeNumericEntities(text)));
}
Loading