CI / Typecheck, test, build (pull_request) Successful in 26s
`preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&...` was rendering as plain text -- not a picture, and not even a link. The bare-address rule has always insisted on `https://`, so anything copied out of an address bar, where the browser hides the scheme, fell through to nothing at all. That predates the inline images from the last commit; it just did not matter until pictures started being worth placing. The rule is deliberately narrow: a host with a dot, a path, and an image extension. Widening it to every schemeless address would be the obvious move and is wrong, because a comment thread is full of dotted, slashed prose -- `src/render/post.ts` would become a link to a website in Tonga, and `node_modules/foo/bar.js` a website in Jersey. Requiring the extension costs nothing here, since the only thing worth guessing a scheme for is a picture. https is assumed. Every host that serves these redirects to it anyway. Checked both ways: the four address shapes that should become pictures do, and nine pieces of ordinary prose that must not -- file paths, a Windows drive letter, a relative path, an email address followed by a filename, a version number -- still do not. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
156 lines
6.3 KiB
TypeScript
156 lines
6.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { escapeHtml } from '../src/render/html.ts';
|
|
import { renderMarkdown } from '../src/render/markdown.ts';
|
|
|
|
const md = (text: string): string => String(renderMarkdown(text));
|
|
|
|
/** Stands in for the real one, which proxies. Escapes the way that one does:
|
|
* placing the image is the renderer's job, and so is making it safe. */
|
|
const img = (url: string, alt: string): string =>
|
|
`<img src="${escapeHtml(url)}" alt="${escapeHtml(alt)}">`;
|
|
const mdi = (text: string): string => String(renderMarkdown(text, img));
|
|
|
|
test('markup a commenter typed is text, not markup', () => {
|
|
const out = md('<script>alert(1)</script> & "quoted"');
|
|
assert.ok(!out.includes('<script>'));
|
|
assert.match(out, /<script>/);
|
|
assert.match(out, /&/);
|
|
});
|
|
|
|
test('only http and https become links', () => {
|
|
assert.match(md('[go](https://example.com/a)'), /<a href="https:\/\/example\.com\/a"/);
|
|
// The label is still shown; it is only the link that is refused.
|
|
const dangerous = md('[go](javascript:alert(1))');
|
|
assert.ok(!dangerous.includes('<a '));
|
|
assert.match(dangerous, /\[go\]/);
|
|
});
|
|
|
|
test('a bare URL is linked without swallowing the sentence it ends', () => {
|
|
const out = md('see https://example.com/x.');
|
|
assert.match(out, /href="https:\/\/example\.com\/x"/);
|
|
assert.ok(out.endsWith('.</p>'), `trailing full stop should stay outside the link: ${out}`);
|
|
});
|
|
|
|
test('an ampersand in a link target survives as one', () => {
|
|
const out = md('[x](https://example.com/?a=1&b=2)');
|
|
assert.match(out, /href="https:\/\/example\.com\/\?a=1&b=2"/);
|
|
});
|
|
|
|
test('a quote is a block of its own, so the reply is not read as part of it', () => {
|
|
const out = md('> they said this\n\nand I disagree');
|
|
assert.match(out, /<blockquote><p>they said this<\/p><\/blockquote><p>and I disagree<\/p>/);
|
|
});
|
|
|
|
test('quoting a quote keeps both levels', () => {
|
|
assert.match(md('> > deep\n> shallow'), /<blockquote><blockquote>/);
|
|
});
|
|
|
|
test('emphasis does not fire inside a word', () => {
|
|
// `snake_case_names` are ordinary in the subreddits this will be pointed at.
|
|
assert.equal(md('some_variable_name'), '<p>some_variable_name</p>');
|
|
assert.match(md('_yes_'), /<em>yes<\/em>/);
|
|
});
|
|
|
|
test('code is left exactly as typed', () => {
|
|
assert.match(md('`a < b && c`'), /<code>a < b && c<\/code>/);
|
|
assert.match(md('```\n<b>not bold</b>\n```'), /<pre><code><b>not bold<\/b><\/code><\/pre>/);
|
|
});
|
|
|
|
test('subreddit and user references link back to reddit', () => {
|
|
assert.match(md('over in r/aww'), /href="https:\/\/www\.reddit\.com\/r\/aww"/);
|
|
assert.match(md('ask u/someone'), /href="https:\/\/www\.reddit\.com\/u\/someone"/);
|
|
});
|
|
|
|
test('lists survive, both kinds', () => {
|
|
assert.match(md('- one\n- two'), /<ul><li>one<\/li><li>two<\/li><\/ul>/);
|
|
assert.match(md('1. one\n2. two'), /<ol><li>one<\/li><li>two<\/li><\/ol>/);
|
|
});
|
|
|
|
test('a single newline inside a paragraph is a line break, a blank line is a new one', () => {
|
|
assert.equal(md('one\ntwo'), '<p>one<br>two</p>');
|
|
assert.equal(md('one\n\ntwo'), '<p>one</p><p>two</p>');
|
|
});
|
|
|
|
test('an image is a picture when there is something to place it with', () => {
|
|
assert.equal(mdi(''),
|
|
'<p><img src="https://i.redd.it/x.jpg" alt="a cat"></p>');
|
|
});
|
|
|
|
test('an image degrades to a link when there is not', () => {
|
|
const out = md('');
|
|
assert.ok(out.includes('<a href="https://i.redd.it/x.jpg"'));
|
|
assert.ok(!out.includes('<img'));
|
|
});
|
|
|
|
test("an image's `!` is not left behind as text", () => {
|
|
// The link rule would otherwise match from the `[` and strand the bang.
|
|
assert.ok(!mdi('').includes('!'));
|
|
});
|
|
|
|
test('a pasted image address becomes the picture, not a link to it', () => {
|
|
// Which is how most images in a Reddit comment arrive.
|
|
const out = mdi('look\n\nhttps://preview.redd.it/abc.jpeg?width=1274&s=deadbeef');
|
|
assert.ok(out.includes('<img src="https://preview.redd.it/abc.jpeg?width=1274&s=deadbeef"'));
|
|
assert.ok(!out.includes('<a href'));
|
|
});
|
|
|
|
test('a link that is not an image is still a link', () => {
|
|
const out = mdi('see https://example.com/article');
|
|
assert.ok(out.includes('<a href="https://example.com/article"'));
|
|
assert.ok(!out.includes('<img'));
|
|
});
|
|
|
|
test('a sentence after a pasted image keeps its punctuation out of the address', () => {
|
|
const out = mdi('here https://i.redd.it/x.jpg.');
|
|
assert.ok(out.includes('src="https://i.redd.it/x.jpg"'), out);
|
|
assert.ok(out.endsWith('.</p>'), out);
|
|
});
|
|
|
|
test('only http and https become pictures', () => {
|
|
const out = mdi(')');
|
|
assert.ok(!out.includes('<img'));
|
|
assert.ok(out.includes('![x]'));
|
|
});
|
|
|
|
test("an image's alt text is escaped like anything else a stranger wrote", () => {
|
|
const out = mdi('');
|
|
assert.ok(!out.includes('onerror=alert(1)>'), out);
|
|
assert.ok(out.includes('"'));
|
|
});
|
|
|
|
test('images inside a quote are still placed', () => {
|
|
assert.match(mdi('> '), /<blockquote><p><img/);
|
|
});
|
|
|
|
test('an image address typed without a scheme is still the picture', () => {
|
|
// Which is how people type them: no https, straight from the address bar.
|
|
const out = mdi('preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e');
|
|
assert.ok(out.includes('<img src="https://preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e"'), out);
|
|
});
|
|
|
|
test('a schemeless image address mid-sentence keeps the sentence', () => {
|
|
const out = mdi('look at i.redd.it/x.png nice one');
|
|
assert.ok(out.startsWith('<p>look at <img'), out);
|
|
assert.ok(out.endsWith(' nice one</p>'), out);
|
|
});
|
|
|
|
test('prose full of dots and slashes is not mistaken for an address', () => {
|
|
// The reason this rule insists on a host, a path and an image extension.
|
|
for (const text of [
|
|
'the file is at src/render/post.ts',
|
|
'see node_modules/foo/bar.js',
|
|
'a path like ./images/cat.jpg',
|
|
'C:/Users/x/cat.png',
|
|
'email [email protected]/nope.jpg',
|
|
'version 1.2.3/4.png',
|
|
]) {
|
|
assert.ok(!mdi(text).includes('<img'), `treated as an image: ${text}`);
|
|
}
|
|
});
|
|
|
|
test('a schemeless address that is not an image is left alone', () => {
|
|
// Guessing a scheme is worth it for a picture and not for prose.
|
|
assert.equal(mdi('example.com/article'), '<p>example.com/article</p>');
|
|
});
|