Stop URL facets swallowing trailing punctuation

The URL regex captured trailing sentence punctuation and an unmatched closing
paren ("see https://x.com).") into the link facet, so Bluesky rendered the
punctuation as part of a malformed link. Trim it like the official atproto
tokenizer. Add tests for the trim and for byte-offset correctness after
multibyte characters (emoji/accents).
This commit is contained in:
Paulo Castellano 2026-06-13 20:02:54 -03:00
parent 7baf1b7df5
commit 2ff34369e0
2 changed files with 65 additions and 1 deletions

View file

@ -180,7 +180,7 @@ private function parseFacets(string $text): array
);
foreach ($urlMatches[0] as $match) {
$url = $match[0];
$url = $this->trimTrailingUrlPunctuation($match[0]);
$start = $this->getUtf8ByteOffset($text, (int) $match[1]);
$end = $start + strlen($url);
@ -299,6 +299,24 @@ private function getUtf8ByteOffset(string $text, int $charOffset): int
return strlen(substr($text, 0, $charOffset));
}
/**
* Trailing sentence punctuation and an unmatched closing paren are almost
* never part of a URL (e.g. "see https://x.com)."). Mirrors the official
* atproto link tokenizer so the link facet doesn't over-extend past the URL.
*/
private function trimTrailingUrlPunctuation(string $url): string
{
if (preg_match('/[.,;:!?]$/', $url)) {
$url = substr($url, 0, -1);
}
if (str_ends_with($url, ')') && ! str_contains($url, '(')) {
$url = substr($url, 0, -1);
}
return $url;
}
private function buildPostUrl(string $handle, string $postId): string
{
return "https://bsky.app/profile/{$handle}/post/{$postId}";

View file

@ -101,6 +101,52 @@
});
});
test('bluesky publisher strips trailing punctuation from URL facets', function () {
$this->post->update(['content' => 'see https://example.com).']);
Http::fake([
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
$link = collect($request['record']['facets'] ?? [])
->first(fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#link');
return $link
&& $link['features'][0]['uri'] === 'https://example.com'
&& $link['index']['byteEnd'] === $link['index']['byteStart'] + strlen('https://example.com');
});
});
test('bluesky publisher computes byte offsets after multibyte characters', function () {
$this->post->update(['content' => 'Olá 🎉 #café']);
Http::fake([
config('trypost.platforms.bluesky.default_service').'/xrpc/com.atproto.repo.createRecord' => Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
$text = $request['record']['text'];
$tag = collect($request['record']['facets'] ?? [])
->first(fn ($facet) => $facet['features'][0]['$type'] === 'app.bsky.richtext.facet#tag');
// byteStart must be the UTF-8 byte position of '#café', not its character index.
return $tag
&& $tag['index']['byteStart'] === strpos($text, '#café')
&& $tag['index']['byteEnd'] === strpos($text, '#café') + strlen('#café');
});
});
test('bluesky publisher resolves mentions to DIDs as facets', function () {
$this->post->update(['content' => 'Shout out to @friend.bsky.social']);