Show the pictures inside Reddit comments
CI / Typecheck, test, build (pull_request) Successful in 11s
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
This commit is contained in:
+33
-1
@@ -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 ``, ``
|
||||
* or `` — 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 ? `` : 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
@@ -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
@@ -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))}
|
||||
|
||||
Reference in New Issue
Block a user