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
3 changed files with 48 additions and 4 deletions
Showing only changes of commit 031101c382 - Show all commits
+4 -1
View File
@@ -153,7 +153,10 @@ Things worth knowing before editing:
a key in that same comment's own `media_metadata`, so `resolveInlineImages` is one 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 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` 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. for an animated one: a GIF moves in an `<img>` 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.
- **Threads** — same media schema as Instagram (`src/platforms/meta-media.ts`). Its - **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 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 candidates in it. The page ships the linked post, the author's follow-ups, other
+13 -3
View File
@@ -69,7 +69,12 @@ const INLINE = new RegExp(
'(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks '(?<![\\w*])\\*([^*\\n]+)\\*(?![\\w*])', // 8 em with asterisks
'(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores '(?<![\\w_])_([^_\\n]+)_(?![\\w_])', // 9 em with underscores
'(https?://[^\\s<>]+)', // 10 bare url '(https?://[^\\s<>]+)', // 10 bare url
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 11 r/sub and u/name // 11 the same thing with the scheme left off, which is how people type
// them. Narrow on purpose: a host, a path, and an image extension. Prose
// is full of dotted words, and `src/render/post.ts` must not become a
// link to a website in Tonga.
'(?<![\\w@/.])((?:[a-z0-9-]+\\.)+[a-z]{2,}/[^\\s<>]*\\.(?:jpe?g|png|gif|webp|avif)(?:\\?[^\\s<>]*)?)',
'(?<![\\w/])(/?[ru]/[A-Za-z0-9_][A-Za-z0-9_-]{1,30})', // 12 r/sub and u/name
].join('|'), ].join('|'),
'g', 'g',
); );
@@ -80,8 +85,8 @@ function inline(text: string, image?: ImageRenderer): string {
let cursor = 0; let cursor = 0;
for (const match of text.matchAll(INLINE)) { for (const match of text.matchAll(INLINE)) {
const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, subOrUser] = const [whole, code, alt, src, label, href, strong, strike, emStar, emScore, url, schemeless,
match; subOrUser] = match;
out += escapeHtml(text.slice(cursor, match.index)); out += escapeHtml(text.slice(cursor, match.index));
cursor = match.index + whole.length; cursor = match.index + whole.length;
@@ -113,6 +118,11 @@ function inline(text: string, image?: ImageRenderer): string {
} else { } else {
out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail; out += anchor(safe, trimmed.replace(/^https?:\/\/(www\.)?/, '')) + tail;
} }
} else if (schemeless !== undefined) {
// Assumed https: every host that serves these redirects to it anyway,
// and a picture is the one thing worth guessing a scheme for.
const safe = safeHref(`https://${schemeless}`);
out += safe ? (image ? image(safe, '') : anchor(safe, schemeless)) : escapeHtml(whole);
} else if (subOrUser !== undefined) { } else if (subOrUser !== undefined) {
const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`; const path = subOrUser.startsWith('/') ? subOrUser : `/${subOrUser}`;
out += anchor(`${REDDIT}${path}`, subOrUser); out += anchor(`${REDDIT}${path}`, subOrUser);
+31
View File
@@ -122,3 +122,34 @@ test("an image's alt text is escaped like anything else a stranger wrote", () =>
test('images inside a quote are still placed', () => { test('images inside a quote are still placed', () => {
assert.match(mdi('> ![](https://i.redd.it/x.gif)'), /<blockquote><p><img/); assert.match(mdi('> ![](https://i.redd.it/x.gif)'), /<blockquote><p><img/);
}); });
test('an image address typed without a scheme is still the picture', () => {
// Which is how people type them: no https, straight from the address bar.
const out = mdi('preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&s=b27e');
assert.ok(out.includes('<img src="https://preview.redd.it/lz4drsqh0clh1.jpeg?width=1290&amp;s=b27e"'), out);
});
test('a schemeless image address mid-sentence keeps the sentence', () => {
const out = mdi('look at i.redd.it/x.png nice one');
assert.ok(out.startsWith('<p>look at <img'), out);
assert.ok(out.endsWith(' nice one</p>'), out);
});
test('prose full of dots and slashes is not mistaken for an address', () => {
// The reason this rule insists on a host, a path and an image extension.
for (const text of [
'the file is at src/render/post.ts',
'see node_modules/foo/bar.js',
'a path like ./images/cat.jpg',
'C:/Users/x/cat.png',
'email [email protected]/nope.jpg',
'version 1.2.3/4.png',
]) {
assert.ok(!mdi(text).includes('<img'), `treated as an image: ${text}`);
}
});
test('a schemeless address that is not an image is left alone', () => {
// Guessing a scheme is worth it for a picture and not for prose.
assert.equal(mdi('example.com/article'), '<p>example.com/article</p>');
});