CI / Typecheck, test, build (pull_request) Successful in 11s
A comment that was a picture rendered as either a link or, for a Giphy, the literal text ``. On r/aww that is most of the thread. Reddit writes an inline image as a token rather than an address, in three shapes: `` for a Giphy, `` for a subreddit emote, and `` for an image uploaded straight to the comment. The useful part is that all three tokens are keys in that same comment's own `media_metadata`, so this is one lookup and not three special cases. Nothing in the adapter has to know what Giphy is. The fourth shape is someone pasting the address of a picture, which on Reddit is how most images in comments actually arrive -- 117 of them against 21 Giphys in the sample I scanned. Those are shown as pictures too, decided by the file extension. A link that is not to an image stays a link. Animated ones take `s.gif` over `s.mp4` even though the MP4 is several times smaller: a GIF moves on its own in an `<img>`, and an MP4 would need a player element with autoplay, loop and muted set, for something the size of a postage stamp. Everything goes through the `/m/` proxy, like all other media. Without that a comment thread would have the reader's browser fetch dozens of files straight from Reddit, which is the one thing this whole app exists to avoid. Placing the image is the renderer's job, not the Markdown parser's, because the proxy is a render-time concern and markdown.ts knows nothing about it -- so it takes an optional `ImageRenderer` and, without one, an image stays a link exactly as before. The parser also learned `![...]` proper: the link rule was matching from the `[` and stranding the `!` as text. Verified on r/aww/comments/171dxph, which carries one Giphy and 77 pasted images: 35 render on the first page, all 35 load, all 35 through the proxy, no upstream address reaches the page, no token is left unresolved, and nothing overflows the column or scrolls the page sideways. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017nMQ2eDKnqALYhAibpTKTu
258 lines
9.8 KiB
TypeScript
258 lines
9.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { escapeHtml, html, raw } from '../src/render/html.ts';
|
|
import { linkify } from '../src/render/text.ts';
|
|
import { renderPost } from '../src/render/post.ts';
|
|
import { renderError } from '../src/render/error.ts';
|
|
import { oneSegment, type Media, type Post } from '../src/types.ts';
|
|
|
|
test('interpolations are escaped, Raw values are not', () => {
|
|
assert.equal(html`<p>${'<script>alert(1)</script>'}</p>`.value,
|
|
'<p><script>alert(1)</script></p>');
|
|
assert.equal(html`<p>${raw('<b>ok</b>')}</p>`.value, '<p><b>ok</b></p>');
|
|
assert.equal(html`${['a', '<b>']}`.value, 'a<b>');
|
|
assert.equal(html`${undefined}${null}${false}`.value, '');
|
|
});
|
|
|
|
test('attribute-breaking characters are escaped', () => {
|
|
assert.equal(escapeHtml(`" onload='x'`), '" onload='x'');
|
|
});
|
|
|
|
test('post text is escaped before links are made', () => {
|
|
const out = linkify('<img src=x onerror=alert(1)> https://example.com/a', 'x').value;
|
|
assert.ok(!out.includes('<img'), 'markup in post text must not survive');
|
|
assert.ok(out.includes('<a href="https://example.com/a"'));
|
|
});
|
|
|
|
test('mentions and hashtags point back at the right platform', () => {
|
|
assert.ok(linkify('hi @nasa', 'tiktok').value.includes('https://www.tiktok.com/@nasa'));
|
|
assert.ok(linkify('hi @nasa', 'bluesky').value.includes('https://bsky.app/profile/nasa'));
|
|
// Threads has no hashtag pages, so a tag stays plain text there.
|
|
assert.ok(!linkify('#moon', 'threads').value.includes('<a '));
|
|
assert.ok(linkify('#moon', 'instagram').value.includes('explore/tags/moon'));
|
|
});
|
|
|
|
test('newlines become line breaks', () => {
|
|
assert.equal(linkify('a\nb', 'x').value, 'a<br>b');
|
|
});
|
|
|
|
function post(overrides: Partial<Post> = {}): Post {
|
|
return {
|
|
platform: 'instagram',
|
|
platformLabel: 'Instagram',
|
|
originalUrl: 'https://www.instagram.com/p/ABC/',
|
|
author: { handle: '@nasa' },
|
|
textPosition: 'below',
|
|
segments: oneSegment({
|
|
text: 'caption',
|
|
media: [{ kind: 'image', url: 'https://cdn/1.jpg' }, { kind: 'image', url: 'https://cdn/2.jpg' }],
|
|
}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function withMedia(media: Media[], overrides: Partial<Post> = {}): Post {
|
|
return post({ segments: oneSegment({ text: 'caption', media }), ...overrides });
|
|
}
|
|
|
|
test('media is proxied, never linked straight at the CDN', () => {
|
|
const page = renderPost(post());
|
|
assert.ok(!page.includes('https://cdn/1.jpg'), 'upstream URLs must not reach the page');
|
|
assert.equal((page.match(/src="\/m\//g) ?? []).length, 2);
|
|
});
|
|
|
|
test('an HLS video is linked directly, because a proxy cannot rewrite a playlist', () => {
|
|
const page = renderPost(withMedia([
|
|
{ kind: 'video', url: 'https://video.bsky.app/x/playlist.m3u8', hls: true, direct: true },
|
|
]));
|
|
assert.ok(page.includes('https://video.bsky.app/x/playlist.m3u8'));
|
|
});
|
|
|
|
test('text sits below the media for Instagram and above it for X', () => {
|
|
const below = renderPost(post());
|
|
assert.ok(below.indexOf('class="media"') < below.indexOf('class="text"'));
|
|
|
|
const above = renderPost(post({ platform: 'x', platformLabel: 'X', textPosition: 'above' }));
|
|
assert.ok(above.indexOf('class="text"') < above.indexOf('class="media"'));
|
|
});
|
|
|
|
test('the layout toggle only appears when there is more than one item', () => {
|
|
assert.ok(renderPost(post()).includes('data-view="grid"'));
|
|
assert.ok(!renderPost(withMedia([{ kind: 'image', url: 'https://cdn/1.jpg' }]))
|
|
.includes('data-view="grid"'));
|
|
});
|
|
|
|
test('a video gets native controls and a source, not an iframe', () => {
|
|
const page = renderPost(withMedia([{ kind: 'video', url: 'https://cdn/v.mp4' }]));
|
|
assert.ok(page.includes('<video'));
|
|
assert.ok(page.includes('controls'));
|
|
assert.ok(page.includes('playsinline'));
|
|
assert.ok(!page.includes('<iframe'));
|
|
});
|
|
|
|
test('the copy button carries the clean original URL, and so does the page text', () => {
|
|
const page = renderPost(post());
|
|
assert.ok(page.includes('data-url="https://www.instagram.com/p/ABC/"'));
|
|
assert.ok(page.includes('<code>https://www.instagram.com/p/ABC/</code>'));
|
|
});
|
|
|
|
test('a failure still hands the link back', () => {
|
|
const page = renderError({
|
|
platform: 'tiktok',
|
|
platformLabel: 'TikTok',
|
|
originalUrl: 'https://www.tiktok.com/@a/video/1',
|
|
heading: 'Could not read that TikTok post',
|
|
detail: 'TikTok showed a verification puzzle instead of the post.',
|
|
});
|
|
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
|
|
assert.ok(page.includes('Open on TikTok'));
|
|
assert.ok(page.includes('verification puzzle'));
|
|
});
|
|
|
|
function redditPost(overrides: Partial<Post> = {}): Post {
|
|
return post({
|
|
platform: 'reddit',
|
|
platformLabel: 'Reddit',
|
|
originalUrl: 'https://www.reddit.com/r/aww/comments/abc/a/',
|
|
author: { handle: 'r/aww' },
|
|
textPosition: 'above',
|
|
segments: oneSegment({ title: 'A headline', media: [] }),
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
test('a post with no comments renders no comment section at all', () => {
|
|
assert.ok(!renderPost(redditPost()).includes('class="thread"'));
|
|
assert.ok(!renderPost(post()).includes('class="thread"'));
|
|
});
|
|
|
|
test('comments nest, so collapsing one takes its replies with it', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [
|
|
{
|
|
author: 'u/a',
|
|
text: 'top',
|
|
replies: [{ author: 'u/b', text: 'nested', replies: [] }],
|
|
},
|
|
],
|
|
}));
|
|
|
|
// The reply is inside the parent's <details>, not a sibling of it.
|
|
const parent = page.slice(page.indexOf('<details class="c"'));
|
|
const closing = parent.indexOf('</details>');
|
|
assert.ok(parent.slice(0, closing).includes('nested'), 'a reply must live inside its parent');
|
|
assert.ok(page.includes('<details class="c" open'), 'threads start expanded');
|
|
});
|
|
|
|
test('a comment says how much a fold would hide', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [
|
|
{
|
|
author: 'u/a',
|
|
text: 'top',
|
|
replies: [
|
|
{ author: 'u/b', text: 'one', replies: [{ author: 'u/c', text: 'two', replies: [] }] },
|
|
],
|
|
},
|
|
],
|
|
}));
|
|
assert.ok(page.includes('+2 replies'), 'the whole subtree is counted, not just direct replies');
|
|
});
|
|
|
|
test('comment text is escaped, and its markdown is rendered', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [{ author: 'u/a', text: '**bold** <img src=x onerror=alert(1)>', replies: [] }],
|
|
}));
|
|
assert.ok(!page.includes('<img src=x'), 'markup in a comment must not survive');
|
|
assert.ok(page.includes('<strong>bold</strong>'));
|
|
});
|
|
|
|
test('what the first page did not carry is counted rather than pretended away', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [{ author: 'u/a', text: 'x', replies: [], moreReplies: 3 }],
|
|
moreComments: 40,
|
|
commentCount: 443,
|
|
}));
|
|
assert.ok(page.includes('3 more replies, on Reddit'));
|
|
assert.ok(page.includes('40 more, behind'));
|
|
assert.ok(page.includes('1 of 443'));
|
|
});
|
|
|
|
function quoting(quoted: Post['segments'][number]['quoted']): Post {
|
|
return post({
|
|
platform: 'x',
|
|
platformLabel: 'X',
|
|
textPosition: 'above',
|
|
segments: oneSegment({ text: 'look at this', media: [], quoted }),
|
|
});
|
|
}
|
|
|
|
test('a quoted post is drawn with its own author, not folded under the quoter', () => {
|
|
const page = quoting({
|
|
author: { handle: '@other', displayName: 'Someone Else' },
|
|
text: 'the original words',
|
|
media: [],
|
|
url: 'https://x.com/other/status/9',
|
|
});
|
|
const html_ = renderPost(page);
|
|
|
|
assert.ok(html_.includes('class="quote"'));
|
|
assert.ok(html_.includes('@other'), 'the quoted handle must be on the page');
|
|
assert.ok(html_.includes('Someone Else'));
|
|
assert.ok(html_.includes('the original words'));
|
|
assert.ok(html_.includes('href="https://x.com/other/status/9"'));
|
|
});
|
|
|
|
test('a quoted post with no quote renders no block', () => {
|
|
assert.ok(!renderPost(quoting(undefined)).includes('class="quote"'));
|
|
});
|
|
|
|
test("the quoted post's media is proxied like any other", () => {
|
|
const html_ = renderPost(quoting({
|
|
author: { handle: '@other' },
|
|
media: [{ kind: 'image', url: 'https://pbs.twimg.com/q.jpg' }],
|
|
}));
|
|
assert.ok(!html_.includes('https://pbs.twimg.com/q.jpg'), 'upstream URLs must not reach the page');
|
|
assert.ok(html_.includes('src="/m/'));
|
|
});
|
|
|
|
test('a quoted post is separated from the words that quote it', () => {
|
|
const html_ = renderPost(quoting({
|
|
author: { handle: '@other' },
|
|
text: 'quoted words',
|
|
media: [],
|
|
}));
|
|
// The quoter's text closes before the block opens, so the two can never
|
|
// read as one paragraph by the same person.
|
|
assert.ok(html_.indexOf('look at this') < html_.indexOf('class="quote"'));
|
|
assert.ok(html_.indexOf('class="quote"') < html_.indexOf('quoted words'));
|
|
});
|
|
|
|
test('markup in a quoted post is escaped like any other stranger\'s text', () => {
|
|
const html_ = renderPost(quoting({
|
|
author: { handle: '<img src=x onerror=alert(1)>' },
|
|
text: '<script>alert(1)</script>',
|
|
media: [],
|
|
}));
|
|
assert.ok(!html_.includes('<script>alert(1)</script>'));
|
|
assert.ok(!html_.includes('<img src=x'));
|
|
});
|
|
|
|
test('a picture in a comment is proxied, and its alt text cannot break out', () => {
|
|
const page = renderPost(redditPost({
|
|
comments: [{
|
|
author: 'u/a',
|
|
text: '\n\nhttps://i.redd.it/y.png',
|
|
replies: [],
|
|
}],
|
|
}));
|
|
|
|
assert.ok(!page.includes('https://preview.redd.it/x.jpeg'), 'upstream URLs must not reach the page');
|
|
assert.ok(!page.includes('https://i.redd.it/y.png'), 'a pasted address is proxied too');
|
|
assert.equal((page.match(/<img class="c__img" src="\/m\//g) ?? []).length, 2);
|
|
// The payload survives as text inside the attribute, which is the point:
|
|
// its quotes are neutered, so it cannot close `alt="` and become markup.
|
|
assert.ok(page.includes('alt="" onerror=alert(1) x=""'), 'alt text must be escaped');
|
|
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
|
|
});
|