From 3971e0736fded82f2a0cd65cadaedbb2ba1be767 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 07:07:40 -0700 Subject: [PATCH] Render image URLs inline in posts Recognize common image file extensions in post URLs (query string and fragment ignored), render them as inline images inside new-tab anchors with the filename as alt text, and fall back to a plain link when the image fails to load. Non-image URLs keep the existing plain-link behavior. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 66 +++++++++++++ psf-memo-client/acceptance/lib/render-post.js | 7 +- .../src/components/post-feed/post-content.js | 93 +++++++++++++++---- psf-memo-client/src/services/post-links.js | 32 ++++++- .../test/unit/post-content.test.js | 45 ++++++++- psf-memo-client/test/unit/post-links.test.js | 43 ++++++++- 6 files changed, 264 insertions(+), 22 deletions(-) diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 388ce18..4c7ec00 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -3214,6 +3214,60 @@ const handlers = [ throw new Error('Feed unexpectedly shows an embedded video player.') } } + }, + { + name: 'feed shows an image', + pattern: /^the feed shows an image with the URL (.+) and alt text (.+)$/, + run (m, example, world) { + const url = resolveParam(m[1], example) + const alt = resolveParam(m[2], example) + const rendered = getRenderedFeed(world) + const found = rendered.some((html) => + imagesIn(html).some((image) => + image.attrs.includes(`src="${url}"`) && + image.attrs.includes(`alt="${alt}"`) + ) + ) + if (!found) { + throw new Error(`Feed does not show an image with URL ${url} and alt text "${alt}".`) + } + } + }, + { + name: 'feed shows no image', + pattern: /^the feed shows no image$/, + run (m, example, world) { + const rendered = getRenderedFeed(world) + const found = rendered.some((html) => imagesIn(html).length > 0) + if (found) { + throw new Error('Feed unexpectedly shows an image.') + } + } + }, + { + name: 'feed does not show the URL as text', + pattern: /^the feed does not show the URL (.+) as text$/, + run (m, example, world) { + const url = resolveParam(m[1], example) + const rendered = getRenderedFeed(world) + const found = rendered.some((html) => + html.replace(/<[^>]+>/g, '').includes(url) + ) + if (found) { + throw new Error(`Feed unexpectedly shows the URL ${url} as text.`) + } + } + }, + { + name: 'image fails to load', + pattern: /^the image at (.+) fails to load$/, + run (m, example, world) { + const url = resolveParam(m[1], example) + world.failedImages = new Set([...(world.failedImages || []), url]) + world.renderedFeed = world.recentFeedPage.posts.map((post) => + renderPostText(post.text, { initialFailedImages: [...world.failedImages] }) + ) + } } ] @@ -3237,6 +3291,18 @@ function anchorsIn (html) { return anchors } +// Extract the image tags from a rendered HTML string. The acceptance adapter +// renders a small, controlled HTML subset, so a regex match is sufficient. +function imagesIn (html) { + const images = [] + const re = /]*?)\/?>/g + let match + while ((match = re.exec(html)) !== null) { + images.push({ attrs: match[1] }) + } + return images +} + // Decode a raw create-poll payload into poll_type, option_count, and question. function decodeCreatePollPayload (raw) { const buf = Buffer.from(raw) diff --git a/psf-memo-client/acceptance/lib/render-post.js b/psf-memo-client/acceptance/lib/render-post.js index 7949633..862decc 100644 --- a/psf-memo-client/acceptance/lib/render-post.js +++ b/psf-memo-client/acceptance/lib/render-post.js @@ -12,8 +12,11 @@ const React = require('react') const ReactDOMServer = require('react-dom/server') const PostContent = require('../../src/components/post-feed/post-content') -function renderPostText (text) { - const element = React.createElement(PostContent, { text }) +function renderPostText (text, options = {}) { + const element = React.createElement(PostContent, { + text, + initialFailedImages: options.initialFailedImages + }) return ReactDOMServer.renderToStaticMarkup(element) } diff --git a/psf-memo-client/src/components/post-feed/post-content.js b/psf-memo-client/src/components/post-feed/post-content.js index dcfcf04..1c61195 100644 --- a/psf-memo-client/src/components/post-feed/post-content.js +++ b/psf-memo-client/src/components/post-feed/post-content.js @@ -11,11 +11,59 @@ const { parsePostText, YOUTUBE_EMBED_BASE_URL } = require('../../services/youtube-embed') -const { parsePostLinks } = require('../../services/post-links') +const { + parsePostLinks, + isImageUrl, + imageAltText +} = require('../../services/post-links') -function PostContent ({ text = '' }) { +// A post image that falls back to a plain link if the image fails to load. +function PostImage ({ href, alt, failed, onError }) { + if (failed) { + return React.createElement( + 'a', + { + href, + target: '_blank', + rel: 'noopener noreferrer', + className: 'posts-feed-item-link' + }, + href + ) + } + + return React.createElement( + 'a', + { + href, + target: '_blank', + rel: 'noopener noreferrer', + className: 'posts-feed-item-image-link' + }, + React.createElement('img', { + src: href, + alt, + className: 'posts-feed-item-image', + onError + }) + ) +} + +function PostContent ({ text = '', initialFailedImages }) { + const [failedImages, setFailedImages] = React.useState( + () => new Set(initialFailedImages || []) + ) const children = [] + const failImage = (href) => { + setFailedImages((previous) => { + if (previous.has(href)) return previous + const next = new Set(previous) + next.add(href) + return next + }) + } + for (const segment of parsePostText(text)) { if (segment.type === 'youtube') { children.push( @@ -38,22 +86,35 @@ function PostContent ({ text = '' }) { } for (const link of parsePostLinks(segment.text)) { - if (link.type === 'link') { - children.push( - React.createElement( - 'a', - { - href: link.href, - target: '_blank', - rel: 'noopener noreferrer', - className: 'posts-feed-item-link' - }, - link.text - ) - ) - } else { + if (link.type !== 'link') { children.push(React.createElement('span', null, link.text)) + continue } + + if (isImageUrl(link.href)) { + children.push( + React.createElement(PostImage, { + href: link.href, + alt: imageAltText(link.href), + failed: failedImages.has(link.href), + onError: () => failImage(link.href) + }) + ) + continue + } + + children.push( + React.createElement( + 'a', + { + href: link.href, + target: '_blank', + rel: 'noopener noreferrer', + className: 'posts-feed-item-link' + }, + link.text + ) + ) } } diff --git a/psf-memo-client/src/services/post-links.js b/psf-memo-client/src/services/post-links.js index 4837298..a095d2e 100644 --- a/psf-memo-client/src/services/post-links.js +++ b/psf-memo-client/src/services/post-links.js @@ -72,7 +72,37 @@ function parsePostLinks (text) { return segments } -module.exports = { parsePostLinks } +// Common image file extensions recognized in a URL path. +const IMAGE_EXTENSION_RE = /\.(?:jpg|jpeg|png|gif|webp|bmp)$/i + +/** + * True when a URL's path ends in a recognized image file extension. Only the + * parsed pathname is examined, so query strings and fragments are ignored. + */ +function isImageUrl (url) { + if (typeof url !== 'string') return false + try { + return IMAGE_EXTENSION_RE.test(new URL(url).pathname) + } catch { + return false + } +} + +/** + * Derive accessible alt text for an image URL: its filename, or "post image" + * when the URL carries no filename. + */ +function imageAltText (url) { + try { + const segments = new URL(url).pathname.split('/') + const filename = segments[segments.length - 1] + return filename || 'post image' + } catch { + return 'post image' + } +} + +module.exports = { parsePostLinks, isImageUrl, imageAltText } // mutate4javascript-manifest-begin // {"version":1,"tested_at":"2026-09-16T02:28:07.979Z","module_hash":"9d4045719f423239c99405aadb91cd21944e7d2deaa670b15eb53aee5ee66639","functions":[{"id":"func/isBareDomainBoundary","name":"isBareDomainBoundary","line":21,"end_line":26,"hash":"6cbcecc22428ecc54a2598d4401a350b3d07a849b651e5c76198b9defb927aef"},{"id":"func/pushText","name":"pushText","line":29,"end_line":31,"hash":"abda2060349c814451b88fe350ca235069afdc769eaa3ffa90e9d214673c71b9"},{"id":"func/parsePostLinks","name":"parsePostLinks","line":40,"end_line":75,"hash":"7a51da000274bf77614f288f9db2ba8490069202919a00cda9c3c474d7bf48d8"}]} diff --git a/psf-memo-client/test/unit/post-content.test.js b/psf-memo-client/test/unit/post-content.test.js index c5f33e6..0b9ea7b 100644 --- a/psf-memo-client/test/unit/post-content.test.js +++ b/psf-memo-client/test/unit/post-content.test.js @@ -14,8 +14,8 @@ const React = require('react') const ReactDOMServer = require('react-dom/server') const PostContent = require('../../src/components/post-feed/post-content') -function render (text) { - return ReactDOMServer.renderToStaticMarkup(React.createElement(PostContent, { text })) +function render (text, props = {}) { + return ReactDOMServer.renderToStaticMarkup(React.createElement(PostContent, { text, ...props })) } test('renders an http URL as an anchor that opens in a new tab', () => { @@ -48,3 +48,44 @@ test('renders an embedded YouTube player and a separate link together', () => { assert.match(html, /]+href="https:\/\/memo\.fullstackcash\.net"/) assert.doesNotMatch(html, /]+href="https:\/\/youtu\.be\/dQw4w9WgXcQ"/) }) + +test('renders an image URL as an inline image inside a new-tab anchor', () => { + const html = render('https://i.imgur.com/swCI56T.jpeg Anong breed ng basil ito?') + assert.match(html, /]+href="https:\/\/i\.imgur\.com\/swCI56T\.jpeg"[^>]*>[\s\S]*]+target="_blank"/) + assert.match(html, /]+src="https:\/\/i\.imgur\.com\/swCI56T\.jpeg"/) + assert.match(html, /]+alt="swCI56T\.jpeg"/) +}) + +test('renders image alt text from the filename while keeping the full src URL', () => { + const html = render('https://example.com/img/photo.webp?w=500 a wide shot') + assert.match(html, /]+src="https:\/\/example\.com\/img\/photo\.webp\?w=500"/) + assert.match(html, /]+alt="photo\.webp"/) +}) + +test('does not render an image URL as visible text', () => { + const html = render('https://i.imgur.com/swCI56T.jpeg basil leaves') + const textOnly = html.replace(/<[^>]+>/g, '') + assert.doesNotMatch(textOnly, /i\.imgur\.com/) + assert.match(textOnly, /basil leaves/) +}) + +test('preserves surrounding text around an image', () => { + const html = render('https://cdn.example.com/pics/Sunset.PNG over the bay') + assert.match(html, /over the bay/) +}) + +test('renders a non-image URL as a plain link with no image element', () => { + const html = render('view https://example.com/photo?format=jpg here') + assert.match(html, /]+href="https:\/\/example\.com\/photo\?format=jpg"/) + assert.doesNotMatch(html, / { + const url = 'https://i.imgur.com/swCI56T.jpeg' + const html = render(`${url} basil leaves`, { initialFailedImages: [url] }) + assert.doesNotMatch(html, /]+href="https:\/\/i\.imgur\.com\/swCI56T\.jpeg"/) + assert.match(html, />https:\/\/i\.imgur\.com\/swCI56T\.jpeg<\/a>/) + assert.match(html, /basil leaves/) +}) diff --git a/psf-memo-client/test/unit/post-links.test.js b/psf-memo-client/test/unit/post-links.test.js index 5cf4e0b..78081e8 100644 --- a/psf-memo-client/test/unit/post-links.test.js +++ b/psf-memo-client/test/unit/post-links.test.js @@ -12,7 +12,11 @@ const test = require('node:test') const assert = require('node:assert/strict') -const { parsePostLinks } = require('../../src/services/post-links') +const { + parsePostLinks, + isImageUrl, + imageAltText +} = require('../../src/services/post-links') test('parsePostLinks returns a single text segment for plain text', () => { const text = 'just a normal memo' @@ -129,3 +133,40 @@ test('parsePostLinks round-trips: segments reconstruct the original text', () => assert.equal(rebuilt, text) } }) + +test('isImageUrl recognizes every supported image extension', () => { + for (const ext of ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']) { + assert.equal(isImageUrl(`https://example.com/photo.${ext}`), true, ext) + } +}) + +test('isImageUrl is case-insensitive', () => { + assert.equal(isImageUrl('https://example.com/photo.PNG'), true) + assert.equal(isImageUrl('https://example.com/photo.JPEG'), true) +}) + +test('isImageUrl ignores query strings and fragments', () => { + assert.equal(isImageUrl('https://example.com/photo.webp?w=500'), true) + assert.equal(isImageUrl('https://example.com/photo.png#section'), true) +}) + +test('isImageUrl rejects URLs that are not images', () => { + assert.equal(isImageUrl('https://example.com/page'), false) + assert.equal(isImageUrl('https://example.com/logo.svg'), false) + assert.equal(isImageUrl('https://example.com/photo?format=jpg'), false) + assert.equal(isImageUrl('not a url'), false) +}) + +test('imageAltText returns the URL filename', () => { + assert.equal(imageAltText('https://i.imgur.com/swCI56T.jpeg'), 'swCI56T.jpeg') + assert.equal(imageAltText('https://cdn.example.com/pics/Sunset.PNG'), 'Sunset.PNG') +}) + +test('imageAltText ignores query strings when deriving the filename', () => { + assert.equal(imageAltText('https://example.com/img/photo.webp?w=500'), 'photo.webp') +}) + +test('imageAltText falls back to "post image" when there is no filename', () => { + assert.equal(imageAltText('https://example.com/'), 'post image') + assert.equal(imageAltText('not a url'), 'post image') +})