mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}]}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user