mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* 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>
170 lines
6.3 KiB
JavaScript
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>
|
|
</>
|
|
}
|