mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Format post URLs and bare domains as links
Render http(s) URLs and bare domains in post text as anchors that open in a new tab. Explicit URLs keep their scheme; bare domains get an https href while their visible text stays as written. Embeddable YouTube links keep their embedded-player behavior. By coder.
This commit is contained in:
@@ -3138,6 +3138,48 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feed shows a link that opens in a new tab',
|
||||
pattern: /^the feed shows a link to (.+) that opens in a new tab$/,
|
||||
run (m, example, world) {
|
||||
const href = resolveParam(m[1], example)
|
||||
const rendered = getRenderedFeed(world)
|
||||
const found = rendered.some((html) =>
|
||||
anchorsIn(html).some((anchor) =>
|
||||
anchor.attrs.includes(`href="${href}"`) &&
|
||||
anchor.attrs.includes('target="_blank"')
|
||||
)
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Feed does not show a link to ${href} that opens in a new tab.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feed shows a link with the text',
|
||||
pattern: /^the feed shows a link with the text (.+)$/,
|
||||
run (m, example, world) {
|
||||
const label = resolveParam(m[1], example)
|
||||
const rendered = getRenderedFeed(world)
|
||||
const found = rendered.some((html) =>
|
||||
anchorsIn(html).some((anchor) => anchor.text === label)
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Feed does not show a link with the text "${label}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feed shows no link',
|
||||
pattern: /^the feed shows no link$/,
|
||||
run (m, example, world) {
|
||||
const rendered = getRenderedFeed(world)
|
||||
const found = rendered.some((html) => anchorsIn(html).length > 0)
|
||||
if (found) {
|
||||
throw new Error('Feed unexpectedly shows a link.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'feed does not show raw URL',
|
||||
pattern: /^the feed does not show the raw URL (.+)$/,
|
||||
@@ -3183,6 +3225,18 @@ function getRenderedFeed (world) {
|
||||
return world.renderedFeed
|
||||
}
|
||||
|
||||
// Extract the anchors from a rendered HTML string. The acceptance adapter
|
||||
// renders a small, controlled HTML subset, so a regex match is sufficient.
|
||||
function anchorsIn (html) {
|
||||
const anchors = []
|
||||
const re = /<a\s([^>]*)>([\s\S]*?)<\/a>/g
|
||||
let match
|
||||
while ((match = re.exec(html)) !== null) {
|
||||
anchors.push({ attrs: match[1], text: match[2] })
|
||||
}
|
||||
return anchors
|
||||
}
|
||||
|
||||
// Decode a raw create-poll payload into poll_type, option_count, and question.
|
||||
function decodeCreatePollPayload (raw) {
|
||||
const buf = Buffer.from(raw)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Render Memo post text, embedding YouTube videos inline when present.
|
||||
Render Memo post text, embedding YouTube videos inline and linking URLs.
|
||||
|
||||
Written in plain React.createElement style so the same module can be used
|
||||
both by the JSX components in the browser build and by the acceptance
|
||||
@@ -11,29 +11,54 @@ const {
|
||||
parsePostText,
|
||||
YOUTUBE_EMBED_BASE_URL
|
||||
} = require('../../services/youtube-embed')
|
||||
const { parsePostLinks } = require('../../services/post-links')
|
||||
|
||||
function PostContent ({ text = '' }) {
|
||||
const segments = parsePostText(text)
|
||||
const children = segments.map((segment, index) => {
|
||||
let key = 0
|
||||
const children = []
|
||||
|
||||
for (const segment of parsePostText(text)) {
|
||||
if (segment.type === 'youtube') {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
key: index,
|
||||
className: 'posts-feed-item-youtube'
|
||||
},
|
||||
React.createElement('iframe', {
|
||||
src: `${YOUTUBE_EMBED_BASE_URL}/${segment.videoId}`,
|
||||
title: `YouTube video ${segment.videoId}`,
|
||||
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
allowFullScreen: true,
|
||||
frameBorder: '0'
|
||||
})
|
||||
children.push(
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
key: key++,
|
||||
className: 'posts-feed-item-youtube'
|
||||
},
|
||||
React.createElement('iframe', {
|
||||
src: `${YOUTUBE_EMBED_BASE_URL}/${segment.videoId}`,
|
||||
title: `YouTube video ${segment.videoId}`,
|
||||
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
allowFullScreen: true,
|
||||
frameBorder: '0'
|
||||
})
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
return React.createElement('span', { key: index }, segment.text)
|
||||
})
|
||||
|
||||
for (const link of parsePostLinks(segment.text)) {
|
||||
if (link.type === 'link') {
|
||||
children.push(
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
key: key++,
|
||||
href: link.href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: 'posts-feed-item-link'
|
||||
},
|
||||
link.text
|
||||
)
|
||||
)
|
||||
} else {
|
||||
children.push(React.createElement('span', { key: key++ }, link.text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return React.createElement(React.Fragment, null, ...children)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Pure helpers for turning URLs and bare domains in Memo post text into link
|
||||
segments.
|
||||
|
||||
These functions have no React or network dependencies, so they can be unit
|
||||
tested directly and reused by the UI and acceptance adapters.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
// Strip trailing punctuation that is never part of a link.
|
||||
const TRAILING_PUNCTUATION_RE = /[.,;:!?)\]]+$/
|
||||
|
||||
// An explicit URL with a scheme, or a bare domain with at least one dot and a
|
||||
// two-or-more-letter TLD, optionally followed by a path. The scheme form is
|
||||
// listed first so an http(s) URL is never re-matched as a bare domain.
|
||||
const LINK_RE = /(https?:\/\/[^\s]+)|((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?:\/[^\s]*)?)/gi
|
||||
|
||||
// A bare-domain match is only a link when it is not part of a larger token
|
||||
// such as an email address.
|
||||
function isBareDomainBoundary (input, start, end) {
|
||||
if (start > 0 && /[A-Za-z0-9_.@-]/.test(input[start - 1])) return false
|
||||
if (end < input.length && input[end] === '@') return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Push a non-empty text segment onto the segment list.
|
||||
function pushText (segments, text) {
|
||||
if (text) segments.push({ type: 'text', text })
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a post's text into segments. Each segment is either a plain text
|
||||
* fragment ({ type: 'text', text }) or a link
|
||||
* ({ type: 'link', href, text }). Explicit http(s) URLs keep their scheme;
|
||||
* bare domains are linked with an https scheme while their visible text is
|
||||
* left as written.
|
||||
*/
|
||||
function parsePostLinks (text) {
|
||||
const input = String(text ?? '')
|
||||
const segments = []
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = LINK_RE.exec(input)) !== null) {
|
||||
const raw = match[0]
|
||||
const isHttp = Boolean(match[1])
|
||||
const start = match.index
|
||||
const end = start + raw.length
|
||||
|
||||
if (!isHttp && !isBareDomainBoundary(input, start, end)) continue
|
||||
|
||||
const url = raw.replace(TRAILING_PUNCTUATION_RE, '')
|
||||
|
||||
pushText(segments, input.slice(lastIndex, start))
|
||||
|
||||
if (isHttp) {
|
||||
segments.push({ type: 'link', href: url, text: url })
|
||||
} else {
|
||||
segments.push({ type: 'link', href: `https://${url}`, text: url })
|
||||
}
|
||||
|
||||
// Leave any trailing punctuation behind so it joins the following text.
|
||||
lastIndex = start + url.length
|
||||
}
|
||||
|
||||
pushText(segments, input.slice(lastIndex))
|
||||
|
||||
if (segments.length === 0) {
|
||||
segments.push({ type: 'text', text: input })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
module.exports = { parsePostLinks }
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Unit tests for the post content renderer.
|
||||
|
||||
The renderer turns post text into static HTML: http(s) URLs and bare domains
|
||||
become anchors that open in a new tab, while embeddable YouTube links keep
|
||||
their embedded-player behavior.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
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 }))
|
||||
}
|
||||
|
||||
test('renders an http URL as an anchor that opens in a new tab', () => {
|
||||
const html = render('visit https://memo.fullstackcash.net for details')
|
||||
assert.match(html, /<a[^>]+href="https:\/\/memo\.fullstackcash\.net"/)
|
||||
assert.match(html, /<a[^>]+target="_blank"/)
|
||||
assert.match(html, /<a[^>]+rel="noopener noreferrer"/)
|
||||
})
|
||||
|
||||
test('renders an http URL with its original scheme', () => {
|
||||
const html = render('link http://example.com/path here')
|
||||
assert.match(html, /<a[^>]+href="http:\/\/example\.com\/path"/)
|
||||
})
|
||||
|
||||
test('renders a bare domain as an https anchor with unchanged visible text', () => {
|
||||
const html = render('go to www.example.com now')
|
||||
assert.match(html, /<a[^>]+href="https:\/\/www\.example\.com"/)
|
||||
assert.match(html, />www\.example\.com<\/a>/)
|
||||
})
|
||||
|
||||
test('renders no anchor for plain text', () => {
|
||||
const html = render('just a normal memo')
|
||||
assert.doesNotMatch(html, /<a[\s>]/)
|
||||
})
|
||||
|
||||
test('renders an embedded YouTube player and a separate link together', () => {
|
||||
const html = render('watch https://youtu.be/dQw4w9WgXcQ then read https://memo.fullstackcash.net')
|
||||
assert.match(html, /<iframe[^>]+src="https:\/\/www\.youtube\.com\/embed\/dQw4w9WgXcQ"/)
|
||||
assert.match(html, /<a[^>]+href="https:\/\/memo\.fullstackcash\.net"/)
|
||||
assert.doesNotMatch(html, /<a[^>]+href="https:\/\/youtu\.be\/dQw4w9WgXcQ"/)
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
Unit tests for the post link parser.
|
||||
|
||||
The parser turns a post's text into text and link segments. A link segment
|
||||
is either an http(s) URL (scheme preserved) or a bare domain (linked with an
|
||||
https scheme while its visible text is left as written). Trailing sentence
|
||||
punctuation never becomes part of the link. Plain text and email addresses
|
||||
stay plain text.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { parsePostLinks } = require('../../src/services/post-links')
|
||||
|
||||
test('parsePostLinks returns a single text segment for plain text', () => {
|
||||
const text = 'just a normal memo'
|
||||
assert.deepEqual(parsePostLinks(text), [{ type: 'text', text }])
|
||||
})
|
||||
|
||||
test('parsePostLinks returns an empty text segment for an empty string', () => {
|
||||
assert.deepEqual(parsePostLinks(''), [{ type: 'text', text: '' }])
|
||||
})
|
||||
|
||||
test('parsePostLinks links an https URL and preserves its scheme', () => {
|
||||
assert.deepEqual(parsePostLinks('visit https://memo.fullstackcash.net for details'), [
|
||||
{ type: 'text', text: 'visit ' },
|
||||
{ type: 'link', href: 'https://memo.fullstackcash.net', text: 'https://memo.fullstackcash.net' },
|
||||
{ type: 'text', text: ' for details' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks links an http URL and preserves its scheme', () => {
|
||||
assert.deepEqual(parsePostLinks('link http://example.com/path here'), [
|
||||
{ type: 'text', text: 'link ' },
|
||||
{ type: 'link', href: 'http://example.com/path', text: 'http://example.com/path' },
|
||||
{ type: 'text', text: ' here' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks keeps trailing punctuation out of the link', () => {
|
||||
assert.deepEqual(parsePostLinks('read https://memo.fullstackcash.net, then reply'), [
|
||||
{ type: 'text', text: 'read ' },
|
||||
{ type: 'link', href: 'https://memo.fullstackcash.net', text: 'https://memo.fullstackcash.net' },
|
||||
{ type: 'text', text: ', then reply' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks links a bare domain with an https href and unchanged visible text', () => {
|
||||
assert.deepEqual(parsePostLinks('visit memo.fullstackcash.net for details'), [
|
||||
{ type: 'text', text: 'visit ' },
|
||||
{ type: 'link', href: 'https://memo.fullstackcash.net', text: 'memo.fullstackcash.net' },
|
||||
{ type: 'text', text: ' for details' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks links a bare domain with a path', () => {
|
||||
assert.deepEqual(parsePostLinks('see memo.fullstackcash.net/feed now'), [
|
||||
{ type: 'text', text: 'see ' },
|
||||
{ type: 'link', href: 'https://memo.fullstackcash.net/feed', text: 'memo.fullstackcash.net/feed' },
|
||||
{ type: 'text', text: ' now' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks links a bare www domain', () => {
|
||||
assert.deepEqual(parsePostLinks('go to www.example.com now'), [
|
||||
{ type: 'text', text: 'go to ' },
|
||||
{ type: 'link', href: 'https://www.example.com', text: 'www.example.com' },
|
||||
{ type: 'text', text: ' now' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks keeps trailing punctuation out of a bare-domain link', () => {
|
||||
assert.deepEqual(parsePostLinks('visit memo.fullstackcash.net, it is live'), [
|
||||
{ type: 'text', text: 'visit ' },
|
||||
{ type: 'link', href: 'https://memo.fullstackcash.net', text: 'memo.fullstackcash.net' },
|
||||
{ type: 'text', text: ', it is live' }
|
||||
])
|
||||
})
|
||||
|
||||
test('parsePostLinks handles multiple links in one post', () => {
|
||||
assert.deepEqual(
|
||||
parsePostLinks('watch https://youtu.be/dQw4w9WgXcQ then read https://memo.fullstackcash.net'),
|
||||
[
|
||||
{ type: 'text', text: 'watch ' },
|
||||
{ type: 'link', href: 'https://youtu.be/dQw4w9WgXcQ', text: 'https://youtu.be/dQw4w9WgXcQ' },
|
||||
{ type: 'text', text: ' then read ' },
|
||||
{ type: 'link', href: 'https://memo.fullstackcash.net', text: 'https://memo.fullstackcash.net' }
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('parsePostLinks does not link an email address', () => {
|
||||
const text = 'write to me at chris@example.com please'
|
||||
assert.deepEqual(parsePostLinks(text), [{ type: 'text', text }])
|
||||
})
|
||||
|
||||
test('parsePostLinks does not link a dotted user name before an @ sign', () => {
|
||||
const text = 'write to first.last@example.com please'
|
||||
assert.deepEqual(parsePostLinks(text), [{ type: 'text', text }])
|
||||
})
|
||||
|
||||
test('parsePostLinks round-trips: segments reconstruct the original text', () => {
|
||||
const samples = [
|
||||
'just a normal memo',
|
||||
'visit memo.fullstackcash.net for details',
|
||||
'read https://memo.fullstackcash.net, then reply',
|
||||
'go to www.example.com now',
|
||||
'watch https://youtu.be/dQw4w9WgXcQ then read https://memo.fullstackcash.net',
|
||||
'write to chris@example.com please',
|
||||
''
|
||||
]
|
||||
for (const text of samples) {
|
||||
const rebuilt = parsePostLinks(text)
|
||||
.map((segment) => segment.text)
|
||||
.join('')
|
||||
assert.equal(rebuilt, text)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user