diff --git a/app/src/components/MarkdownPreview.js b/app/src/components/MarkdownPreview.js index c86733c3..b668c2ec 100644 --- a/app/src/components/MarkdownPreview.js +++ b/app/src/components/MarkdownPreview.js @@ -45,22 +45,59 @@ function rewriteURL(token, attr, fileURL) { } } +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) { - for (const child of token.children || []) { + 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); - const href = child.attrGet('href'); - // Following a link would close the preview; a fragment stays within the document. - if (href && !href.startsWith('#')) { - child.attrSet('target', '_blank'); - child.attrSet('rel', 'noopener noreferrer'); - } + openInNewTab(child); + } else if (child.type === 'link_close') { + insideLink = false; } - } + return [child]; + }); } }); diff --git a/app/src/components/MarkdownPreview.test.js b/app/src/components/MarkdownPreview.test.js index 4612679c..63f3d254 100644 --- a/app/src/components/MarkdownPreview.test.js +++ b/app/src/components/MarkdownPreview.test.js @@ -74,6 +74,47 @@ describe('renderMarkdown', () => { expect(div.querySelector('img').hasAttribute('src')).toBe(false); }); + test.each([ + ['https://tracker.example/pixel.gif'], + ['http://tracker.example/pixel.gif'], + // Protocol-relative: starts with a slash, but loads from another host. + ['//tracker.example/pixel.gif'], + ])('an image from another site (%s) is not loaded, it becomes a link', (src) => { + const div = renderToDOM(`![pixel](${src})`); + + expect(div.querySelector('img')).toBeNull(); + const a = div.querySelector('a'); + expect(a.getAttribute('href')).toBe(src); + expect(a.textContent).toBe('pixel'); + expect(a.getAttribute('target')).toBe('_blank'); + expect(a.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + test('an image from another site with no alt text is labeled by its URL', () => { + const a = renderToDOM('![](https://example.com/a.png)').querySelector('a'); + + expect(a.textContent).toBe('https://example.com/a.png'); + }); + + test('an image from another site inside a link becomes the link text, not a nested link', () => { + // The badge pattern common in READMEs. + const div = renderToDOM('[![build status](https://ci.example/badge.svg)](https://ci.example/project)'); + + expect(div.querySelector('img')).toBeNull(); + const links = div.querySelectorAll('a'); + expect(links).toHaveLength(1); + expect(links[0].getAttribute('href')).toBe('https://ci.example/project'); + expect(links[0].textContent).toBe('build status'); + }); + + test('media and embedded data images still load', () => { + const [media, data] = renderToDOM('![a](/media/x/a.png)\n\n![b](data:image/png;base64,AAAA)') + .querySelectorAll('img'); + + expect(media.getAttribute('src')).toBe('/media/x/a.png'); + expect(data.getAttribute('src')).toBe('data:image/png;base64,AAAA'); + }); + test('external links open in a new tab without an opener', () => { const a = renderToDOM('').querySelector('a');