wrolpi/app/src/components/MarkdownPreview.js
lrnselfreliance 04779b8557
Preview markdown files formatted, with a raw toggle (#780)
* Preview markdown files formatted, with a raw toggle

Markdown files are typed text/markdown (libmagic reports them as text/plain, or
as a programming language when they hold code) and keep the text indexer, so
they stay deep-searchable.  Existing files switch type on their next refresh.

The file preview renders markdown with markdown-it into the page, so it follows
the theme tokens and the night media filter.  A Formatted/Raw toggle shows the
source instead, and the choice is remembered.  Relative links and images
resolve against the file's directory; URLs that climb out of /media/ are
dropped.  Raw HTML in a file is escaped rather than parsed, and the output is
also sanitized with DOMPurify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Do not load images from other sites in the markdown preview

An image from another site (http:, https:, or protocol-relative //) loaded as
soon as the preview opened, telling that site who was reading the file and
when.  Such an image now becomes a link labeled with its alt text, so the
reader chooses whether to follow it.  Inside a link (a README badge) it becomes
the outer link's text, since links cannot nest.  Media and embedded data:
images still load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:26:42 -06:00

170 lines
6.3 KiB
JavaScript

import React from "react";
import MarkdownIt from "markdown-it";
import DOMPurify from "dompurify";
import {useLocalStorage} from "./Common";
import {Button, ButtonGroup, Loading, Message} from "./ui";
/*
* Raw HTML in the file is escaped, not parsed: markdown files arrive with downloads, and the preview
* renders on the API's origin. `validateLink` (on by default) refuses javascript:, vbscript:, file:,
* and non-image data: URLs. DOMPurify runs anyway, so neither guard is the only one.
*/
const markdown = new MarkdownIt({html: false, linkify: true});
// A URL with a scheme (https:, mailto:, ...) points somewhere other than the media directory.
const URL_WITH_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
/**
* Resolve a URL written in a markdown file against the file's own media URL, the way a browser
* would resolve it against a document in that directory.
*
* Returns the URL unchanged if it is not relative, or null if it climbs out of the media directory;
* only media is served relative to a file.
*/
function resolveRelativeURL(url, fileURL) {
if (!url || URL_WITH_SCHEME.test(url) || url.startsWith('/') || url.startsWith('#')) {
return url;
}
const resolved = new URL(url, new URL(fileURL, window.location.origin));
if (!resolved.pathname.startsWith('/media/')) {
return null;
}
return resolved.pathname + resolved.search + resolved.hash;
}
function rewriteURL(token, attr, fileURL) {
const url = token.attrGet(attr);
if (url === null) {
return;
}
const resolved = resolveRelativeURL(url, fileURL);
if (resolved === null) {
token.attrs = token.attrs.filter(([name]) => name !== attr);
} else {
token.attrSet(attr, resolved);
}
}
function openInNewTab(token) {
const href = token.attrGet('href');
// Following a link would close the preview; a fragment stays within the document.
if (href && !href.startsWith('#')) {
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
}
}
/** An image the preview may load: one served by this WROLPi, or embedded in the file. */
function isLocalImage(src) {
return !src || src.startsWith('data:') || (src.startsWith('/') && !src.startsWith('//'));
}
/**
* Replace an image from another site with a link to it. Loading it would tell that site who is
* reading the file and when (a tracking pixel), so the reader chooses whether to follow it.
*
* Inside a link (a README badge) the image becomes the outer link's text; links cannot nest.
*/
function replaceRemoteImage(state, image, insideLink) {
const src = image.attrGet('src');
const text = new state.Token('text', '', 0);
text.content = image.content || src;
if (insideLink) {
return [text];
}
const linkOpen = new state.Token('link_open', 'a', 1);
linkOpen.attrSet('href', src);
openInNewTab(linkOpen);
return [linkOpen, text, new state.Token('link_close', 'a', -1)];
}
markdown.core.ruler.push('wrolpi_media_urls', (state) => {
const {fileURL} = state.env;
for (const token of state.tokens) {
if (!token.children) {
continue;
}
let insideLink = false;
token.children = token.children.flatMap(child => {
if (child.type === 'image') {
rewriteURL(child, 'src', fileURL);
return isLocalImage(child.attrGet('src')) ? [child] : replaceRemoteImage(state, child, insideLink);
} else if (child.type === 'link_open') {
insideLink = true;
rewriteURL(child, 'href', fileURL);
openInNewTab(child);
} else if (child.type === 'link_close') {
insideLink = false;
}
return [child];
});
}
});
/** Render markdown to sanitized HTML. Relative URLs resolve against `fileURL`, the file's /media/ URL. */
export function renderMarkdown(text, fileURL) {
const html = markdown.render(text, {fileURL});
return DOMPurify.sanitize(html, {ADD_ATTR: ['target']});
}
export function isMarkdownFile(mimetype, lowerPath) {
// By suffix too, so a markdown file whose stored type is still text/plain (or a language libmagic
// guessed) is not shown raw; the 2026_10_05_1200 migration retags those rows, a refresh does not.
return mimetype.startsWith('text/markdown')
|| (mimetype.startsWith('text/') && (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')));
}
/** Preview a markdown file, formatted or as its raw source. */
export function MarkdownPreview({url}) {
const [formatted, setFormatted] = useLocalStorage('markdownPreviewFormatted', true);
const [text, setText] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
const controller = new AbortController();
setText(null);
setError(null);
fetch(url, {signal: controller.signal})
.then(response => {
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.text();
})
.then(setText)
.catch(e => {
if (e.name !== 'AbortError') {
console.error(e);
setError(e.message);
}
});
return () => controller.abort();
}, [url]);
const html = React.useMemo(() => text === null ? null : renderMarkdown(text, url), [text, url]);
let body;
if (error) {
body = <Message kind='error' title='Could not load file'>{error}</Message>;
} else if (text === null) {
body = <Loading/>;
} else if (formatted) {
body = <div className='markdown-body' dangerouslySetInnerHTML={{__html: html}}/>;
} else {
body = <pre className='markdown-raw' data-testid='markdown-raw'>{text}</pre>;
}
return <>
<ButtonGroup>
<Button size='small' variant={formatted ? 'filled' : 'default'} onClick={() => setFormatted(true)}>
Formatted
</Button>
<Button size='small' variant={formatted ? 'default' : 'filled'} onClick={() => setFormatted(false)}>
Raw
</Button>
</ButtonGroup>
<div className='markdown-preview'>
{body}
</div>
</>
}