From bab07f2d725a60b53064ea34697cd8f2b5790d6e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 10 Jun 2026 10:43:02 +0100 Subject: [PATCH] fix: linkify urls in mastodon --- .../social/runtime/server/_social/linkify.ts | 54 +++++++++++++++ .../runtime/server/_social/mastodon.get.ts | 4 +- test/unit/linkify.spec.ts | 67 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 modules/social/runtime/server/_social/linkify.ts create mode 100644 test/unit/linkify.spec.ts diff --git a/modules/social/runtime/server/_social/linkify.ts b/modules/social/runtime/server/_social/linkify.ts new file mode 100644 index 00000000..0571b297 --- /dev/null +++ b/modules/social/runtime/server/_social/linkify.ts @@ -0,0 +1,54 @@ +const URL_PATTERN + = /\b((?:https?:\/\/|www\.)[^\s<]+|(?:[a-z0-9-]+\.)+[a-z]{2,24}\/[^\s<]*)/gi + +const TRAILING_PUNCTUATION = /[)\].,;:!?'"]+$/ + +function balanceTrailing (url: string): { url: string, trailing: string } { + let trailing = '' + let current = url + while (true) { + const match = current.match(TRAILING_PUNCTUATION) + if (!match) break + const candidate = current.slice(0, -match[0].length) + const opens = (candidate.match(/\(/g) || []).length + const closes = (candidate.match(/\)/g) || []).length + if (match[0].includes(')') && opens > closes) break + trailing = match[0] + trailing + current = candidate + } + return { url: current, trailing } +} + +function toHref (raw: string): string { + if (/^https?:\/\//i.test(raw)) return raw + return `https://${raw}` +} + +/** + * Wrap bare URLs and domain-like tokens in Mastodon-style HTML with `` tags, + * leaving any text already inside an `` element untouched. + * + * Mastodon only auto-links text that starts with a protocol; references like + * `roe.dev/ama` stay as plain text in the rendered `content` field. + */ +export function linkifyBareUrls (html: string): string { + let result = '' + let cursor = 0 + const anchor = /]*>[\s\S]*?<\/a>/gi + let match: RegExpExecArray | null + while ((match = anchor.exec(html))) { + result += linkifyText(html.slice(cursor, match.index)) + result += match[0] + cursor = match.index + match[0].length + } + result += linkifyText(html.slice(cursor)) + return result +} + +function linkifyText (segment: string): string { + if (!segment) return segment + return segment.replace(URL_PATTERN, raw => { + const { url, trailing } = balanceTrailing(raw) + return `${url}${trailing}` + }) +} diff --git a/modules/social/runtime/server/_social/mastodon.get.ts b/modules/social/runtime/server/_social/mastodon.get.ts index e512a8bc..415b2cc8 100644 --- a/modules/social/runtime/server/_social/mastodon.get.ts +++ b/modules/social/runtime/server/_social/mastodon.get.ts @@ -1,6 +1,8 @@ import { createRestAPIClient } from 'masto' import { parseURL, withProtocol } from 'ufo' +import { linkifyBareUrls } from './linkify' + export default defineEventHandler(async event => { const acct = useRuntimeConfig(event).social.networks.mastodon.identifier @@ -49,7 +51,7 @@ export default defineEventHandler(async event => { height: m.meta?.original?.height, alt: m.description, })), - html: p.content, + html: linkifyBareUrls(p.content), })), ) }) diff --git a/test/unit/linkify.spec.ts b/test/unit/linkify.spec.ts new file mode 100644 index 00000000..b6c3bbe1 --- /dev/null +++ b/test/unit/linkify.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' + +import { linkifyBareUrls } from '../../modules/social/runtime/server/_social/linkify' + +describe('linkifyBareUrls', () => { + it('wraps bare domain references with a link', () => { + expect(linkifyBareUrls('

roe.dev/ama

')).toBe( + '

roe.dev/ama

', + ) + }) + + it('leaves existing anchors untouched', () => { + const input = '

see roe.dev/ama for more

' + expect(linkifyBareUrls(input)).toBe(input) + }) + + it('does not double-wrap content inside an anchor that also references another domain', () => { + const input = '

roe.dev/ama roe.dev/ama

' + expect(linkifyBareUrls(input)).toBe( + '

roe.dev/ama roe.dev/ama

', + ) + }) + + it('linkifies plain protocol URLs', () => { + expect(linkifyBareUrls('visit https://example.com/foo!')).toBe( + 'visit https://example.com/foo!', + ) + }) + + it('keeps trailing punctuation outside the link', () => { + expect(linkifyBareUrls('see roe.dev/ama.')).toBe( + 'see roe.dev/ama.', + ) + expect(linkifyBareUrls('(see roe.dev/ama)')).toBe( + '(see roe.dev/ama)', + ) + }) + + it('keeps balanced parentheses inside the link', () => { + expect(linkifyBareUrls('see https://en.wikipedia.org/wiki/Foo_(bar) ok')).toBe( + 'see https://en.wikipedia.org/wiki/Foo_(bar) ok', + ) + }) + + it('leaves dotted prose like node.js or foo.bar alone (no path)', () => { + expect(linkifyBareUrls('

node.js and foo.bar are fine

')).toBe( + '

node.js and foo.bar are fine

', + ) + }) + + it('still linkifies www.-prefixed hosts even without a path', () => { + expect(linkifyBareUrls('go to www.example.com today')).toBe( + 'go to www.example.com today', + ) + }) + + it('does not break on empty input', () => { + expect(linkifyBareUrls('')).toBe('') + }) + + it('handles the real-world mastodon post that triggered this', () => { + const input = '

I've always been interested in tech

roe.dev/ama

' + expect(linkifyBareUrls(input)).toBe( + '

I've always been interested in tech

roe.dev/ama

', + ) + }) +}) -- 2.51.2