summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-04-21 22:00:09 -0400
committerOwen Jacobson <owen@grimoire.ca>2025-04-21 23:09:46 -0400
commitef87bb0719579d55a692992e1843f20e57f209d6 (patch)
tree9c6124461e4be0aec88af17ae83e77021f8c2cb9 /ui
parent1ef57107b1c355ef896327f0714344277df7ae18 (diff)
Add the following attributes to all markdown-generated links:
* `target="_blank"`: when Pilcrow is running in a browser, clicking a link should not replace Pilcrow with the target of the link. Pilcrow is "app-like" enough that opening links in a new tab _by default_, without user intervention, is likely more appropriate. * `rel="noreferrer"`, which (A) stops most UAs from setting a referrer header when following those links, and (B) also implies `noopener`, preventing the link target from using `window.opener` from reaching back into Pilcrow's DOM. I briefly experimented with DOMPurify's `RETURN_DOM_FRAGMENT` mode, which would have made the tests somewhat easier to write, but I wasn't able to find a good way to integrate the returned `DocumentFragment` objects with Svelte components, so HTML-as-strings it is. Sigh.
Diffstat (limited to 'ui')
-rw-r--r--ui/lib/markdown.js22
-rw-r--r--ui/lib/markdown.test.js55
2 files changed, 76 insertions, 1 deletions
diff --git a/ui/lib/markdown.js b/ui/lib/markdown.js
index 2e73309..c4f2803 100644
--- a/ui/lib/markdown.js
+++ b/ui/lib/markdown.js
@@ -1,6 +1,26 @@
import { marked } from 'marked';
import DOMPurify from 'dompurify';
+const extension = {
+ useNewRenderer: true,
+ renderer: {
+ link({ title, href, tokens }) {
+ const titleAttr = title ? ` title="${title}"` : ``;
+ const text = this.parser.parseInline(tokens);
+ return `<a
+ target="_blank"
+ rel="noreferrer"
+ ${titleAttr}
+ href="${href}">${text}</a>`;
+ }
+ }
+};
+
+marked.use(extension);
+
export function render(body) {
- return DOMPurify.sanitize(marked.parse(body, { breaks: true }));
+ const rendered = marked.parse(body, { breaks: true });
+ return DOMPurify.sanitize(rendered, {
+ ADD_ATTR: ['target']
+ });
}
diff --git a/ui/lib/markdown.test.js b/ui/lib/markdown.test.js
new file mode 100644
index 0000000..126eacd
--- /dev/null
+++ b/ui/lib/markdown.test.js
@@ -0,0 +1,55 @@
+import * as md from './markdown.js';
+import { expect, describe, it } from 'vitest';
+
+describe('render', async () => {
+ it('renders inline links', async () => {
+ const markdown = `[a link](https://example.com?foo=bar)`;
+ const html = md.render(markdown);
+ expect(html).toStrictEqual(
+ `<p><a href="https://example.com?foo=bar" rel="noreferrer" target="_blank">a link</a></p>
+`
+ );
+ });
+
+ it('renders inline links with titles', async () => {
+ const markdown = `[a link](https://example.com?foo=bar "what title")`;
+ const html = md.render(markdown);
+ expect(html).toStrictEqual(
+ `<p><a href="https://example.com?foo=bar" title="what title" rel="noreferrer" target="_blank">a link</a></p>
+`
+ );
+ });
+
+ it('renders footnote links', async () => {
+ const markdown = `
+[a link]
+
+[a link]: https://example.com?foo=bar`;
+ const html = md.render(markdown);
+ expect(html).toStrictEqual(
+ `<p><a href="https://example.com?foo=bar" rel="noreferrer" target="_blank">a link</a></p>
+`
+ );
+ });
+
+ it('renders footnote links with titles', async () => {
+ const markdown = `
+[a link]
+
+[a link]: https://example.com?foo=bar "what title"`;
+ const html = md.render(markdown);
+ expect(html).toStrictEqual(
+ `<p><a href="https://example.com?foo=bar" title="what title" rel="noreferrer" target="_blank">a link</a></p>
+`
+ );
+ });
+
+ it('renders links with embedded markup', async () => {
+ const markdown = `[a _link_](https://example.com?foo=bar)`;
+ const html = md.render(markdown);
+ expect(html).toStrictEqual(
+ `<p><a href="https://example.com?foo=bar" rel="noreferrer" target="_blank">a <em>link</em></a></p>
+`
+ );
+ });
+});