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.
This commit is contained in:
Chris Troutner
2026-09-16 07:07:40 -07:00
parent 53d74398be
commit 3971e0736f
6 changed files with 264 additions and 22 deletions
@@ -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 = /<img\s([^>]*?)\/?>/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)
@@ -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)
}
@@ -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
)
)
}
}
+31 -1
View File
@@ -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"}]}
+43 -2
View File
@@ -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, /<a[^>]+href="https:\/\/memo\.fullstackcash\.net"/)
assert.doesNotMatch(html, /<a[^>]+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, /<a[^>]+href="https:\/\/i\.imgur\.com\/swCI56T\.jpeg"[^>]*>[\s\S]*<img/)
assert.match(html, /<a[^>]+target="_blank"/)
assert.match(html, /<img[^>]+src="https:\/\/i\.imgur\.com\/swCI56T\.jpeg"/)
assert.match(html, /<img[^>]+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, /<img[^>]+src="https:\/\/example\.com\/img\/photo\.webp\?w=500"/)
assert.match(html, /<img[^>]+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, /<a[^>]+href="https:\/\/example\.com\/photo\?format=jpg"/)
assert.doesNotMatch(html, /<img/)
})
test('falls back to a plain link when the image fails to load', () => {
const url = 'https://i.imgur.com/swCI56T.jpeg'
const html = render(`${url} basil leaves`, { initialFailedImages: [url] })
assert.doesNotMatch(html, /<img/)
assert.match(html, /<a[^>]+href="https:\/\/i\.imgur\.com\/swCI56T\.jpeg"/)
assert.match(html, />https:\/\/i\.imgur\.com\/swCI56T\.jpeg<\/a>/)
assert.match(html, /basil leaves/)
})
+42 -1
View File
@@ -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')
})