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: {