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
@@ -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)
}
})