Show the pictures inside Reddit comments #5

Merged
thatguygriff merged 2 commits from reddit-inline-images into main 2026-08-27 19:30:00 +00:00
9 changed files with 293 additions and 26 deletions
Showing only changes of commit 0db18547c8 - Show all commits
+7 -1
View File
@@ -147,7 +147,13 @@ Things worth knowing before editing:
timeline thumbnails, and the still is in `preview.images`. A gallery's pictures are in
`media_metadata`, keyed and unordered; their order is only in `gallery_data`. Comment
bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put
back the constructs we chose to support, never `body_html`.
back the constructs we chose to support, never `body_html`. An image in a comment is
written as a token rather than an address — `![gif](giphy|Ve7wX45)`,
`![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and in every case the token is
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one
lookup rather than three special cases. A bare `preview.redd.it` address pasted into a
comment is in there too, keyed by the id inside the URL. Prefer `s.gif` over `s.mp4`
for an animated one: a GIF moves in an `<img>` and an MP4 needs a player.
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its
payloads are full of empty stub nodes, so the finder only accepts a node with actual
candidates in it. The page ships the linked post, the author's follow-ups, other
+7
View File
@@ -126,6 +126,13 @@ which is why it only appears once the script has run. What was behind a _load mo
not fetched — that is a second page and often a third — but it is counted and said out
loud rather than quietly dropped.
Pictures inside comments are shown as pictures. Reddit writes them as a token rather
than an address — a Giphy id, a subreddit emote, or an image uploaded to the comment —
and all three are looked up in the comment's own metadata to find the real file. An
image address someone simply pasted is shown too, which on Reddit is how most of them
arrive. All of it goes through the same `/m/` proxy as everything else, so reading a
comment thread never has your browser talking to Reddit.
Media never gets linked straight at a CDN. Instagram and TikTok reject requests without
a matching `Referer` (and sometimes cookies), and proxying keeps your browser from
talking to the platform at all. Every asset is registered under an opaque `/m/<id>` and
+19
View File
@@ -556,3 +556,22 @@ main { max-width: 680px; margin: 0 auto; }
max-height: 45dvh;
min-height: 0;
}
/* A picture someone put in a comment. Capped hard: it is a remark inside a
conversation, not the thing the page is about. */
.c__img {
display: block;
max-width: min(100%, 420px);
max-height: 40vh;
max-height: 40dvh;
width: auto;
height: auto;
margin: 8px 0;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--ink) 4%, transparent);
}
/* A lone image is the whole comment more often than not, so it should not
carry a paragraph's worth of space above it as well as its own. */
.c__body > p:first-child > .c__img:first-child { margin-top: 2px; }
+33 -1
View File
@@ -62,6 +62,7 @@ type Link = {
type CommentData = {
author?: string;
body?: string;
media_metadata?: Record<string, MediaMeta>;
created_utc?: number;
score?: number;
score_hidden?: boolean;
@@ -188,6 +189,37 @@ function bodyOf(link: Link): string | undefined {
return undefined;
}
/** The whole of `![...](...)`, with the target captured. */
const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g;
/**
* Point a comment's inline images at something fetchable.
*
* Reddit writes them as `![gif](giphy|Ve7wX45)`, `![img](emote|t5_2th52|4358)`
* or `![img](jo8gf0ca92zd1)` a token rather than an address. In every case
* the token is a key in that same comment's `media_metadata`, which is where
* the real URL is, so one lookup covers all three and none of them needs
* naming here.
*
* A target that is already an address is not a key, so it falls through
* untouched.
*/
export function resolveInlineImages(
body: string,
meta: Record<string, MediaMeta> | undefined,
): string {
if (!meta) return body;
return body.replace(INLINE_IMAGE, (whole, alt: string, token: string) => {
const entry = meta[token];
if (!entry || entry.status !== 'valid') return whole;
// An animated one has both; the GIF plays in an `<img>` on its own, which
// an MP4 does not.
const url = entry.s?.gif ?? entry.s?.u;
return url ? `![${alt}](${url})` : whole;
});
}
export function commentsFrom(listing: Listing<CommentData> | undefined): {
comments: Comment[];
more: number;
@@ -209,7 +241,7 @@ export function commentsFrom(listing: Listing<CommentData> | undefined): {
comments.push({
author: authorName(data.author),
...(data.body ? { text: data.body } : {}),
...(data.body ? { text: resolveInlineImages(data.body, data.media_metadata) } : {}),
...(isoFrom(data.created_utc) ? { postedAt: isoFrom(data.created_utc) } : {}),
// Reddit hides the score on a new comment so an early downvote cannot
// steer the rest. Showing a placeholder 1 would be a lie.
+52 -22
View File
@@ -13,6 +13,20 @@ import { escapeHtml, raw, type Raw } from './html.ts';
const REDDIT = 'https://www.reddit.com';
/**
* How an image in a comment becomes markup.
*
* Supplied by the caller rather than decided here, because the address has to
* go through the media proxy and this file knows nothing about that. Without
* one an image degrades to a link, which is what it was before.
*/
export type ImageRenderer = (url: string, alt: string) => string;
/** Worth showing as a picture rather than as a link to one. */
function looksLikeImage(url: string): boolean {
return /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(url);
}
/** Absolute http(s) only. `javascript:` and friends never become links. */
function safeHref(url: string): string | undefined {
try {
@@ -46,44 +60,59 @@ function trimUrlTail(url: string): string {
const INLINE = new RegExp(
[
'`([^`\\n]+)`', // 1 code
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 2 label, 3 href
'\\*\\*([^*\\n]+)\\*\\*', // 4 strong
'~~([^~\\n]+)~~', // 5 strike
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 6 em with asterisks
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 7 em with underscores
'(https?://[^\\s<>]+)', // 8 bare url
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 9 r/sub and u/name
// Before the link rule, or the `[` of an image matches as a link and
// leaves its `!` behind as text.
'!\\[([^\\]\\n]*)\\]\\(([^)\\s]+)\\)', // 2 alt, 3 src
'\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)', // 4 label, 5 href
'\\*\\*([^*\\n]+)\\*\\*', // 6 strong
'~~([^~\\n]+)~~', // 7 strike
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores
'(https?://[^\\s<>]+)', // 10 bare url
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 11 r/sub and u/name
].join('|'),
'g',
);
/** One line of body text: escaped, with the inline constructs put back. */
function inline(text: string): string {
function inline(text: string, image?: ImageRenderer): string {
let out = '';
let cursor = 0;
for (const match of text.matchAll(INLINE)) {
const [whole, code, label, href, strong, strike, emStar, emScore, url, subOrUser] = match;
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, subOrUser] =
match;
out += escapeHtml(text.slice(cursor, match.index));
cursor = match.index + whole.length;
if (code !== undefined) {
out += `<code>${escapeHtml(code)}</code>`;
} else if (src !== undefined) {
const safe = safeHref(src);
// Without a renderer to place it, an image is still a link to one.
out += safe ? (image ? image(safe, alt ?? '') : anchor(safe, alt || safe)) : escapeHtml(whole);
} else if (label !== undefined && href !== undefined) {
const safe = safeHref(href);
out += safe ? anchor(safe, label) : escapeHtml(whole);
} else if (strong !== undefined) {
out += `<strong>${inline(strong)}</strong>`;
out += `<strong>${inline(strong, image)}</strong>`;
} else if (strike !== undefined) {
out += `<del>${inline(strike)}</del>`;
out += `<del>${inline(strike, image)}</del>`;
} else if (emStar !== undefined || emScore !== undefined) {
out += `<em>${inline(emStar ?? emScore ?? '')}</em>`;
out += `<em>${inline(emStar ?? emScore ?? '', image)}</em>`;
} else if (url !== undefined) {
const trimmed = trimUrlTail(url);
const safe = safeHref(trimmed);
out += safe
? anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + escapeHtml(url.slice(trimmed.length))
: escapeHtml(whole);
const tail = escapeHtml(url.slice(trimmed.length));
if (!safe) {
out += escapeHtml(whole);
} else if (image && looksLikeImage(trimmed)) {
// People paste the address of a picture and mean the picture. On
// Reddit that is most of what an image in a comment even is.
out += image(safe, '') + tail;
} else {
out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail;
}
} else if (subOrUser !== undefined) {
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
out += anchor(`${REDDIT}${path}`, subOrUser);
@@ -101,7 +130,7 @@ const NUMBERED = /^\s{0,3}\d+[.)]\s+/;
* from the reply to it, and a comment that loses that separation reads as
* though the commenter said both halves.
*/
function blocks(lines: string[]): string {
function blocks(lines: string[], image?: ImageRenderer): string {
let out = '';
let at = 0;
@@ -139,7 +168,7 @@ function blocks(lines: string[]): string {
if (/^\s*>/.test(line)) {
const body = takeWhile((l) => /^\s*>/.test(l));
// Nested, so a quote of a quote keeps its shape.
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')))}</blockquote>`;
out += `<blockquote>${blocks(body.map((l) => l.replace(/^\s*>\s?/, '')), image)}</blockquote>`;
continue;
}
@@ -148,20 +177,21 @@ function blocks(lines: string[]): string {
const pattern = ordered ? NUMBERED : BULLET;
const items = takeWhile((l) => pattern.test(l));
const tag = ordered ? 'ol' : 'ul';
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''))}</li>`).join('')}</${tag}>`;
out += `<${tag}>${items.map((l) => `<li>${inline(l.replace(pattern, ''), image)}</li>`).join('')}</${tag}>`;
continue;
}
const paragraph = takeWhile(
(l) => l.trim() !== '' && !/^\s*>/.test(l) && !BULLET.test(l) && !NUMBERED.test(l) && !/^\s*```/.test(l),
);
out += `<p>${paragraph.map((l) => inline(l)).join('<br>')}</p>`;
out += `<p>${paragraph.map((l) => inline(l, image)).join('<br>')}</p>`;
}
return out;
}
/** Comment text, as safe markup. */
export function renderMarkdown(text: string): Raw {
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n')));
/** Comment text, as safe markup. `image` places the pictures; without it
* they stay links, which is what they were before. */
export function renderMarkdown(text: string, image?: ImageRenderer): Raw {
return raw(blocks(text.replace(/\r\n?/g, '\n').split('\n'), image));
}
+19 -1
View File
@@ -145,6 +145,22 @@ function shortWhen(postedAt: string | undefined): Raw {
})}</time>`;
}
/**
* A picture inside a comment.
*
* Through the proxy like everything else a comment full of `preview.redd.it`
* addresses would otherwise have the viewer's browser fetch every one of them
* straight from Reddit, which is the thing this whole app exists to avoid.
*
* No dimensions to reserve space with: the size is in the payload but not in
* the Markdown, so these are capped by the stylesheet and load at whatever
* shape they are.
*/
function renderCommentImage(url: string, alt: string): string {
return html`<img class="c__img" src="${proxyUrlFor({ url })}" alt="${alt}" loading="lazy" decoding="async">`
.value;
}
/** Everything hanging off a comment, however deep. Shown only while it is
* collapsed, so what a fold is hiding is never a mystery. */
function descendantsOf(comment: Comment): number {
@@ -179,7 +195,9 @@ function renderComment(comment: Comment, depth: number): Raw {
}</span>`
: ''}
</summary>
${comment.text ? html`<div class="c__body">${renderMarkdown(comment.text)}</div>` : ''}
${comment.text
? html`<div class="c__body">${renderMarkdown(comment.text, renderCommentImage)}</div>`
: ''}
${comment.replies.length || comment.moreReplies
? html`<div class="c__replies">
${comment.replies.map((reply) => renderComment(reply, depth + 1))}
+58
View File
@@ -1,9 +1,16 @@
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>'));
@@ -64,3 +71,54 @@ test('a single newline inside a paragraph is a line break, a blank line is a new
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('![a cat](https://i.redd.it/x.jpg)'),
'<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('![a cat](https://i.redd.it/x.jpg)');
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('![](https://i.redd.it/x.png)').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&amp;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('![x](javascript:alert(1))');
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('![" onerror=alert(1)](https://i.redd.it/x.jpg)');
assert.ok(!out.includes('onerror=alert(1)>'), out);
assert.ok(out.includes('&quot;'));
});
test('images inside a quote are still placed', () => {
assert.match(mdi('> ![](https://i.redd.it/x.gif)'), /<blockquote><p><img/);
});
+80 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { commentsFrom, mediaFromLink, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
import { commentsFrom, mediaFromLink, resolveInlineImages, toPost, treeFromDepths } from '../src/platforms/reddit.ts';
import { reddit } from '../src/platforms/reddit.ts';
import { originalUrlFor } from '../src/platforms/index.ts';
import { fixture } from './helpers.ts';
@@ -168,3 +168,82 @@ test('the page fallback rebuilds nesting from the depth on each comment', () =>
test('a comment the page gave no text for is dropped rather than shown empty', () => {
assert.deepEqual(treeFromDepths([{ depth: 0, author: 'a', score: 1, created: '', text: '' }]), []);
});
// Real shapes, captured from comments carrying each kind.
const GIPHY = {
'giphy|Ve7wX45gaOFmw8eeEM': {
status: 'valid',
e: 'AnimatedImage',
m: 'image/gif',
s: {
y: 200,
x: 304,
gif: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9',
mp4: 'https://external-preview.redd.it/CTp8.gif?width=304&height=200&format=mp4&s=a389',
},
},
};
const UPLOAD = {
jo8gf0ca92zd1: {
status: 'valid',
e: 'Image',
m: 'image/jpeg',
s: { y: 1270, x: 1274, u: 'https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226' },
},
};
test('a giphy comment points at the gif rather than at a token', () => {
// `![gif](giphy|ID)` is not an address, and renders as nothing at all until
// it is looked up in the comment's own media_metadata.
assert.equal(
resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY),
'![gif](https://external-preview.redd.it/CTp8.gif?width=304&height=200&s=b0e9)',
);
});
test('the animated form takes the gif, which plays on its own', () => {
const out = resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY);
assert.ok(out.includes('.gif?'), out);
assert.ok(!out.includes('format=mp4'), 'an mp4 would need a player to move');
});
test('an uploaded image resolves through the same lookup', () => {
// Giphy, emotes and uploads are all a token that is a key in the same map,
// so none of them needs naming.
assert.equal(
resolveInlineImages('![img](jo8gf0ca92zd1)', UPLOAD),
'![img](https://preview.redd.it/jo8gf0ca92zd1.jpeg?width=1274&s=c226)',
);
});
test('a target that is already an address is left alone', () => {
const already = '![a](https://i.redd.it/x.jpg)';
assert.equal(resolveInlineImages(already, UPLOAD), already);
assert.equal(resolveInlineImages(already, undefined), already);
});
test('a token with no entry, or a broken one, is not invented', () => {
assert.equal(resolveInlineImages('![gif](giphy|missing)', GIPHY), '![gif](giphy|missing)');
assert.equal(
resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }),
'![img](gone)',
);
});
test('inline images survive the walk into the comment tree', () => {
const { comments } = commentsFrom({
data: {
children: [{
kind: 't1',
data: {
author: 'a',
body: 'ha ![gif](giphy|Ve7wX45gaOFmw8eeEM)',
media_metadata: GIPHY,
replies: '',
},
}],
},
});
assert.match(comments[0]?.text ?? '', /external-preview\.redd\.it/);
});
+18
View File
@@ -237,3 +237,21 @@ test('markup in a quoted post is escaped like any other stranger\'s text', () =>
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: '![" onerror=alert(1) x="](https://preview.redd.it/x.jpeg?s=abc)\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="&quot; onerror=alert(1) x=&quot;"'), 'alt text must be escaped');
assert.ok(!/alt="" onerror/.test(page), 'the attribute must not be closable');
});