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.')
|
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
|
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.
|
// Decode a raw create-poll payload into poll_type, option_count, and question.
|
||||||
function decodeCreatePollPayload (raw) {
|
function decodeCreatePollPayload (raw) {
|
||||||
const buf = Buffer.from(raw)
|
const buf = Buffer.from(raw)
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ const React = require('react')
|
|||||||
const ReactDOMServer = require('react-dom/server')
|
const ReactDOMServer = require('react-dom/server')
|
||||||
const PostContent = require('../../src/components/post-feed/post-content')
|
const PostContent = require('../../src/components/post-feed/post-content')
|
||||||
|
|
||||||
function renderPostText (text) {
|
function renderPostText (text, options = {}) {
|
||||||
const element = React.createElement(PostContent, { text })
|
const element = React.createElement(PostContent, {
|
||||||
|
text,
|
||||||
|
initialFailedImages: options.initialFailedImages
|
||||||
|
})
|
||||||
return ReactDOMServer.renderToStaticMarkup(element)
|
return ReactDOMServer.renderToStaticMarkup(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,59 @@ const {
|
|||||||
parsePostText,
|
parsePostText,
|
||||||
YOUTUBE_EMBED_BASE_URL
|
YOUTUBE_EMBED_BASE_URL
|
||||||
} = require('../../services/youtube-embed')
|
} = 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 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)) {
|
for (const segment of parsePostText(text)) {
|
||||||
if (segment.type === 'youtube') {
|
if (segment.type === 'youtube') {
|
||||||
children.push(
|
children.push(
|
||||||
@@ -38,22 +86,35 @@ function PostContent ({ text = '' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const link of parsePostLinks(segment.text)) {
|
for (const link of parsePostLinks(segment.text)) {
|
||||||
if (link.type === 'link') {
|
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 {
|
|
||||||
children.push(React.createElement('span', null, link.text))
|
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
|
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
|
// 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"}]}
|
// {"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 ReactDOMServer = require('react-dom/server')
|
||||||
const PostContent = require('../../src/components/post-feed/post-content')
|
const PostContent = require('../../src/components/post-feed/post-content')
|
||||||
|
|
||||||
function render (text) {
|
function render (text, props = {}) {
|
||||||
return ReactDOMServer.renderToStaticMarkup(React.createElement(PostContent, { text }))
|
return ReactDOMServer.renderToStaticMarkup(React.createElement(PostContent, { text, ...props }))
|
||||||
}
|
}
|
||||||
|
|
||||||
test('renders an http URL as an anchor that opens in a new tab', () => {
|
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.match(html, /<a[^>]+href="https:\/\/memo\.fullstackcash\.net"/)
|
||||||
assert.doesNotMatch(html, /<a[^>]+href="https:\/\/youtu\.be\/dQw4w9WgXcQ"/)
|
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 test = require('node:test')
|
||||||
const assert = require('node:assert/strict')
|
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', () => {
|
test('parsePostLinks returns a single text segment for plain text', () => {
|
||||||
const text = 'just a normal memo'
|
const text = 'just a normal memo'
|
||||||
@@ -129,3 +133,40 @@ test('parsePostLinks round-trips: segments reconstruct the original text', () =>
|
|||||||
assert.equal(rebuilt, 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