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:
parent
7baf1b7df5
commit
2ff34369e0
2 changed files with 65 additions and 1 deletions
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue