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:
Chris Troutner
2026-09-15 19:08:49 -07:00
parent 968970b364
commit 6c7952f469
5 changed files with 343 additions and 19 deletions
@@ -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)
}
})