From 4669fe0b6aa771e1c8730f07835e20507adec19e Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sun, 30 Aug 2026 23:36:53 -0300 Subject: [PATCH 1/2] Resolve a giphy token with no metadata to look it up in `![gif](giphy|zUW23b6FmzB5e)` is a token, not an address, and the only thing that turned it into one was a lookup in the comment's own `media_metadata`. Reddit ships plenty of comments carrying such a token and no `media_metadata` at all, and with nothing to look it up in the token itself was what the comment showed. Giphy is the one of the three token kinds whose id means something off Reddit, so that one can be resolved without the lookup. A variant name after the id is dropped: Giphy does not serve every variant of every gif, but the full one is always there. The other two still resolve only through the metadata. An emote id and an upload id name nothing outside Reddit, so with no entry for them there is still nothing to point them at. Closes #10 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PLkmgp1fWbA4XbarxKdRKt --- CLAUDE.md | 14 +++++++---- src/platforms/reddit.ts | 42 +++++++++++++++++++++++---------- test/reddit.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c51bf5..e2b024e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,11 +149,15 @@ Things worth knowing before editing: bodies are Markdown, rendered by `src/render/markdown.ts` — escape first, then put 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 `` and an MP4 needs a player. An address + `![img](emote|t5_2th52|4358)`, `![img](jo8gf0ca92zd1)` — and the token is usually a key + in that same comment's own `media_metadata`, so `resolveInlineImages` is one lookup + rather than three special cases. Usually: plenty of comments carry a Giphy token and + no `media_metadata` at all, and Giphy is the one of the three whose id means something + off Reddit, so that token alone falls back to `i.giphy.com/media//giphy.gif`. A + variant name after the id is dropped — Giphy does not serve every variant of every + gif. 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 `` and an MP4 needs a player. An address typed without a scheme counts as well, but only when it ends in an image extension — the rule wants a host, a path *and* that extension, because comments are full of dotted, slashed prose that must not turn into links. diff --git a/src/platforms/reddit.ts b/src/platforms/reddit.ts index fc307b1..2942597 100644 --- a/src/platforms/reddit.ts +++ b/src/platforms/reddit.ts @@ -192,14 +192,30 @@ function bodyOf(link: Link): string | undefined { /** The whole of `![...](...)`, with the target captured. */ const INLINE_IMAGE = /!\[([^\]\n]*)\]\(([^)\s]+)\)/g; +/** + * A Giphy token, which unlike the others says what it points at. + * + * Reddit writes a variant name after the id on some of them + * (`giphy|abc123|downsized`); only the id is kept, because Giphy does not + * serve every variant for every gif but always serves the full one. + */ +const GIPHY_TOKEN = /^giphy\|([A-Za-z0-9]+)(?:\|[a-z_]+)?$/; + /** * 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. + * or `![img](jo8gf0ca92zd1)` — a token rather than an address. Usually 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. + * + * Usually, not always: Reddit ships plenty of comments carrying a Giphy token + * and no `media_metadata` at all, and with nothing to look the token up in + * those showed the token itself where the gif should have been. Giphy is the + * one kind that can be resolved without the lookup, the id in it being Giphy's + * own, so it falls back to Giphy's address for that id. The other two cannot: + * their ids mean nothing off Reddit. * * A target that is already an address is not a key, so it falls through * untouched. @@ -208,15 +224,17 @@ export function resolveInlineImages( body: string, meta: Record | 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 `` on its own, which - // an MP4 does not. - const url = entry.s?.gif ?? entry.s?.u; - return url ? `![${alt}](${url})` : whole; + const entry = meta?.[token]; + if (entry?.status === 'valid') { + // An animated one has both; the GIF plays in an `` on its own, + // which an MP4 does not. + const url = entry.s?.gif ?? entry.s?.u; + if (url) return `![${alt}](${url})`; + } + + const giphy = GIPHY_TOKEN.exec(token)?.[1]; + return giphy ? `![${alt}](https://i.giphy.com/media/${giphy}/giphy.gif)` : whole; }); } diff --git a/test/reddit.test.ts b/test/reddit.test.ts index 9032a7f..fc96efd 100644 --- a/test/reddit.test.ts +++ b/test/reddit.test.ts @@ -224,13 +224,63 @@ test('a target that is already an address is left alone', () => { }); test('a token with no entry, or a broken one, is not invented', () => { - assert.equal(resolveInlineImages('![gif](giphy|missing)', GIPHY), '![gif](giphy|missing)'); + // An upload id and an emote id mean nothing off Reddit, so with no entry to + // look them up in there is nothing to point them at. + assert.equal(resolveInlineImages('![img](missing)', GIPHY), '![img](missing)'); assert.equal( resolveInlineImages('![img](gone)', { gone: { status: 'failed', e: 'Image' } }), '![img](gone)', ); }); +test('a giphy token resolves even when the comment carried no metadata', () => { + // Reddit ships plenty of these with no `media_metadata` at all. The id in + // the token is Giphy's own, so it does not need Reddit to be readable. + assert.equal( + resolveInlineImages('![gif](giphy|zUW23b6FmzB5e)', undefined), + '![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)', + ); + assert.equal( + resolveInlineImages('![gif](giphy|missing)', GIPHY), + '![gif](https://i.giphy.com/media/missing/giphy.gif)', + ); +}); + +test('the metadata still wins where there is any, being what Reddit will serve', () => { + assert.match(resolveInlineImages('![gif](giphy|Ve7wX45gaOFmw8eeEM)', GIPHY), /redd\.it/); +}); + +test('a variant name after the id is dropped, not all of them being served', () => { + assert.equal( + resolveInlineImages('![gif](giphy|zUW23b6FmzB5e|downsized)', undefined), + '![gif](https://i.giphy.com/media/zUW23b6FmzB5e/giphy.gif)', + ); +}); + +test('only a giphy token is guessed at, and only a well-formed one', () => { + for (const token of ['emote|t5_2th52|4358', 'giphy|', 'giphy|../evil', 'giphy|a|b|c']) { + assert.equal(resolveInlineImages(`![x](${token})`, undefined), `![x](${token})`); + } +}); + +test('a real comment carrying a giphy token gets the gif', () => { + // Captured from the post itself: the comment has the token and no + // `media_metadata`, which is the shape that used to show the token instead. + const post = toPost(fixture('reddit/video.json'), URL_); + const all: string[] = []; + const walk = (list: typeof post.comments) => { + for (const comment of list ?? []) { + if (comment.text) all.push(comment.text); + walk(comment.replies); + } + }; + walk(post.comments); + + const gif = all.find((text) => text.includes('giphy')); + assert.ok(gif, 'the fixture should still carry a giphy comment'); + assert.match(gif, /!\[gif\]\(https:\/\/i\.giphy\.com\/media\/QfzMP70zmNQiDf5sGP\/giphy\.gif\)/); +}); + test('inline images survive the walk into the comment tree', () => { const { comments } = commentsFrom({ data: { -- 2.54.0 From 5b4378a838b9ddcf06f92055ff27790ad22f4c54 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sun, 30 Aug 2026 23:37:59 -0300 Subject: [PATCH 2/2] Give a video its shape before it has any data A Reddit video sat in the wrong box until you pressed play, for two reasons that looked like one. The renderer put only `aspect-ratio` on the `