diff --git a/app/Devnull/Data/BodyRun.php b/app/Devnull/Data/BodyRun.php index 59f93131..02295ad5 100644 --- a/app/Devnull/Data/BodyRun.php +++ b/app/Devnull/Data/BodyRun.php @@ -5,13 +5,14 @@ /** * A run of a paragraph: plain prose, a mention, inline code, a link, or a * standalone `/dev/null`, which draws as the wordmark, or a hex color, which - * draws with a swatch of it. A link's text is the URL as written, and a - * color's is the color as written, `#` included. A mention of a GitHub - * organization has its handle in $username too, and $isOrganization set. + * draws with a swatch of it, or a hashtag, which searches commits for it. A + * link's text is the URL as written, and a color's or a hashtag's is as + * written, `#` included. A mention of a GitHub organization has its handle in + * $username too, and $isOrganization set. */ final readonly class BodyRun { - public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null, public bool $isOrganization = false) {} + public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null, public ?string $color = null, public bool $isOrganization = false, public bool $isHashtag = false) {} public function isMention(): bool { diff --git a/app/Devnull/Data/CommitBody.php b/app/Devnull/Data/CommitBody.php index ac7ea774..0adf5784 100644 --- a/app/Devnull/Data/CommitBody.php +++ b/app/Devnull/Data/CommitBody.php @@ -4,12 +4,12 @@ /** * Turns a commit's `body_segments` into blocks to draw, the way the web's - * CommitBody does: prose (with URLs and mentions linked, hex colors beside a - * swatch, and `/dev/null` as the wordmark) and inline code flow together in - * paragraphs, lines that start with `>` group into quotes, and fenced code - * blocks stand on their own. + * CommitBody does: prose (with URLs, mentions and hashtags linked, hex colors + * beside a swatch, and `/dev/null` as the wordmark) and inline code flow + * together in paragraphs, lines that start with `>` group into quotes, and + * fenced code blocks stand on their own. * - * Mention and color offsets count code points into the whole body, but segments aren't + * Mention, color and hashtag offsets count code points into the whole body, but segments aren't * exact slices of it: inline code loses its backticks, a block its fences, and * prose the line break next to a block. So each segment is found in the body, * moving forward, and mentions are placed by where their segment starts. @@ -41,6 +41,14 @@ final class CommitBody */ private const string Color = '/(?:(?))[#\x{FF03}]([0-9a-f]{8}|[0-9a-f]{6}|(?=[0-9]*[a-f]|0)[0-9a-f]{3,4})(?=\p{P}*(?:[\s\x{00AD}\x{2060}\x{200A}-\x{200D}\x{20E2}]|\z))/iu'; + /** + * A hashtag: a `#` and a letter, then letters, marks, digits and + * underscores, not straight after a word character, `&`, `/`, `#`, `>` or + * `-`, so `a#b`, `{` and a URL's fragment aren't one. The web's rule, + * from linkified-text.tsx. + */ + private const string Hashtag = '/(?-])#\p{L}[\p{L}\p{M}\p{N}_]*/u'; + /** * A `/dev/null` that stands on its own, not part of a longer path, a word * or a name. The web's rule, from withWordmarks() in linkified-text.tsx. @@ -116,6 +124,7 @@ public static function blocks(string $body, ?array $segments, array $mentions = $runs = []; $cursor = 0; $colors = self::colors($body); + $hashtags = self::hashtags($body); foreach ($segments as $segment) { switch ($segment['type'] ?? null) { @@ -132,12 +141,12 @@ public static function blocks(string $body, ?array $segments, array $mentions = $start = $text === '' ? false : mb_strpos($body, $text, $cursor); if ($start === false) { - array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [])); + array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, [], [], [])); break; } - array_push($runs, ...self::withLinks($text, $start, $mentions, $colors)); + array_push($runs, ...self::withLinks($text, $start, $mentions, $colors, $hashtags)); $cursor = $start + mb_strlen($text); if ($cut) { @@ -193,7 +202,7 @@ private static function paragraphs(array $runs): array $quoted = false; foreach ($runs as $run) { - $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null && ! $run->isColor(); + $prose = ! $run->isMention() && ! $run->isCode && ! $run->isWordmark && $run->link === null && ! $run->isColor() && ! $run->isHashtag; $lines = $prose ? array_map(fn (string $text): BodyRun => new BodyRun($text), preg_split('/(?<=\n)/', $run->text, flags: PREG_SPLIT_NO_EMPTY) ?: []) @@ -266,15 +275,16 @@ private static function withoutTrailingBreak(array $runs): array * * @param list $mentions * @param list $colors + * @param list $hashtags * @return list */ - private static function withLinks(string $text, ?int $start, array $mentions, array $colors): array + private static function withLinks(string $text, ?int $start, array $mentions, array $colors, array $hashtags): array { $runs = []; $position = 0; $prose = fn (string $part, int $at): array => $start === null ? self::withWordmarks($part) - : self::withMentions($part, $start + $at, $mentions, $colors); + : self::withMentions($part, $start + $at, $mentions, $colors, $hashtags); foreach (BodyLink::in($text) as [$offset, $length, $link]) { if ($offset > $position) { @@ -297,9 +307,10 @@ private static function withLinks(string $text, ?int $start, array $mentions, ar * * @param list $mentions * @param list $colors + * @param list $hashtags * @return list */ - private static function withMentions(string $text, int $start, array $mentions, array $colors): array + private static function withMentions(string $text, int $start, array $mentions, array $colors, array $hashtags): array { $runs = []; $position = 0; @@ -321,7 +332,7 @@ private static function withMentions(string $text, int $start, array $mentions, } if ($offset > $position) { - array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors)); + array_push($runs, ...self::withColors(mb_substr($text, $position, $offset - $position), $start + $position, $colors, $hashtags)); } $runs[] = new BodyRun($handle, username: $mention->username, isOrganization: $mention->isOrganization); @@ -329,7 +340,7 @@ private static function withMentions(string $text, int $start, array $mentions, } if ($position < $length) { - array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors)); + array_push($runs, ...self::withColors(mb_substr($text, $position), $start + $position, $colors, $hashtags)); } return $runs; @@ -340,9 +351,10 @@ private static function withMentions(string $text, int $start, array $mentions, * in the whole body that fall inside it, as the web's withColors() does. * * @param list $colors + * @param list $hashtags * @return list */ - private static function withColors(string $text, int $start, array $colors): array + private static function withColors(string $text, int $start, array $colors, array $hashtags): array { $runs = []; $position = 0; @@ -355,11 +367,42 @@ private static function withColors(string $text, int $start, array $colors): arr continue; } - array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + array_push($runs, ...self::withHashtags(mb_substr($text, $position, $offset - $position), $start + $position, $hashtags)); $runs[] = new BodyRun(mb_substr($text, $offset, $colorLength), color: $value); $position = $offset + $colorLength; } + array_push($runs, ...self::withHashtags(mb_substr($text, $position), $start + $position, $hashtags)); + + return $runs; + } + + /** + * Splits prose that starts at $start in the body around the hashtags + * found in the whole body that fall inside it, as the web's withHashtags() + * does. One a color already took, like `#fff`, never falls inside. + * + * @param list $hashtags + * @return list + */ + private static function withHashtags(string $text, int $start, array $hashtags): array + { + $runs = []; + $position = 0; + $length = mb_strlen($text); + + foreach ($hashtags as [$offset, $hashtagLength]) { + $offset -= $start; + + if ($offset < $position || $offset + $hashtagLength > $length) { + continue; + } + + array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position))); + $runs[] = new BodyRun(mb_substr($text, $offset, $hashtagLength), isHashtag: true); + $position = $offset + $hashtagLength; + } + array_push($runs, ...self::withWordmarks(mb_substr($text, $position))); return $runs; @@ -375,11 +418,7 @@ private static function withColors(string $text, int $start, array $colors): arr */ private static function colors(string $body): array { - $prose = ''; - - foreach (self::parts($body) as $index => $part) { - $prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part)); - } + $prose = self::prose($body); preg_match_all(self::Color, "\n".$prose, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); @@ -390,6 +429,40 @@ private static function colors(string $body): array ], $matches); } + /** + * The hashtags in the body's prose, in order, as where each starts and how + * long it is in code points, `#` included. Code is blanked out first, so + * nothing in it counts. + * + * @return list + */ + private static function hashtags(string $body): array + { + $prose = self::prose($body); + + preg_match_all(self::Hashtag, $prose, $matches, PREG_OFFSET_CAPTURE); + + return array_map(fn (array $match): array => [ + mb_strlen(substr($prose, 0, $match[1])), + mb_strlen($match[0]), + ], $matches[0]); + } + + /** + * The body with its code blanked out to spaces of the same length, so + * what is found in the prose keeps its place in the body. + */ + private static function prose(string $body): string + { + $prose = ''; + + foreach (self::parts($body) as $index => $part) { + $prose .= $index % 2 === 0 ? $part : str_repeat(' ', mb_strlen($part)); + } + + return $prose; + } + /** * Splits prose around each standalone `/dev/null`, which becomes a * wordmark run. Like the web, the prose between two mentions is looked at diff --git a/app/NativeComponents/Concerns/OpensHashtags.php b/app/NativeComponents/Concerns/OpensHashtags.php new file mode 100644 index 00000000..b7b5b1be --- /dev/null +++ b/app/NativeComponents/Concerns/OpensHashtags.php @@ -0,0 +1,17 @@ +openInTab('/search', ['query' => $hashtag]); + } +} diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index da0f7fea..05407927 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -43,6 +43,7 @@ trait ShowsCommits { use OpensCommitSheet; + use OpensHashtags; use OpensStargazers; use VotesOnPolls; use WritesInBackground; diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index 2302f411..2f4d0ba9 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -24,6 +24,7 @@ use App\Icons\Lucide; use App\Models\PendingWrite; use App\NativeComponents\Concerns\AttachesPhotos; +use App\NativeComponents\Concerns\OpensHashtags; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\PlaysVoiceNotes; use App\NativeComponents\Concerns\RecordsVoiceNotes; @@ -103,6 +104,7 @@ class Conversation extends NativeComponent { use AttachesPhotos; + use OpensHashtags; use OpensInTab; use PlaysVoiceNotes; use RecordsVoiceNotes; diff --git a/app/NativeComponents/ScheduledCommits.php b/app/NativeComponents/ScheduledCommits.php index 180bc470..2b0be955 100644 --- a/app/NativeComponents/ScheduledCommits.php +++ b/app/NativeComponents/ScheduledCommits.php @@ -9,6 +9,7 @@ use App\Devnull\SiteLink; use App\Devnull\Timezone; use App\Devnull\Toasts; +use App\NativeComponents\Concerns\OpensHashtags; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\TalksToDevnull; @@ -36,6 +37,7 @@ */ class ScheduledCommits extends NativeComponent { + use OpensHashtags; use OpensInTab; use RefreshesInBackground; use TalksToDevnull; diff --git a/app/NativeComponents/Search.php b/app/NativeComponents/Search.php index 725e52f5..c82ff53d 100644 --- a/app/NativeComponents/Search.php +++ b/app/NativeComponents/Search.php @@ -31,6 +31,10 @@ * devnull won't search commits for fewer than three characters, so a shorter * term only finds developers. * + * A hashtag tapped in a commit or a message opens it with the hashtag as the + * term ($data['query']), searched straight away, as the web's hashtag links + * do, and the field isn't focused, so the keyboard leaves the results in view. + * * Neither answer is kept (see ResponseCache): a search is about the moment. */ class Search extends NativeComponent @@ -66,6 +70,11 @@ class Search extends NativeComponent public string $query = ''; + /** + * Whether the field takes focus when the screen opens: only for an empty one, as on the web. + */ + public bool $autofocus = true; + /** @var list */ public array $people = []; @@ -87,6 +96,16 @@ class Search extends NativeComponent */ private ?RowCache $rowCache = null; + public function mount(): void + { + $query = $this->data('query'); + + if (is_string($query) && trim($query) !== '') { + $this->autofocus = false; + $this->pick($query); + } + } + public function navTitle(): string { return 'Search'; @@ -106,6 +125,14 @@ public function pick(string $term): void $this->search(); } + /** + * A hashtag in one of the results searches for it here, in place of what's in the field. + */ + public function openHashtag(string $hashtag): void + { + $this->pick($hashtag); + } + public function open(string $username): void { $person = $this->person($username); diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 349ac9e1..2e447d8c 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -13,6 +13,9 @@ A hex color has a square of that color before it, as the web's swatch, held to it by a narrow no-break space so they wrap together. + A hashtag is medium weight in Commit Blue and searches commits for it on a tap, as the web's. In the composer's + preview ($static), as in the web's static preview, it stays plain text. + A quote, lines that start with ">", sits behind an amber rule on the left like the web's blockquote, its runs drawn as a paragraph's. @@ -52,6 +55,8 @@ @elseif ($run->isColor())   +@elseif ($run->isHashtag && ! ($static ?? false)) + @elseif ($run->isWordmark) @php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1))) @if ($afterSpace)​@endif/⁠dev⁠null diff --git a/resources/views/native/partials/composer-preview.blade.php b/resources/views/native/partials/composer-preview.blade.php index f56ce5de..7959f818 100644 --- a/resources/views/native/partials/composer-preview.blade.php +++ b/resources/views/native/partials/composer-preview.blade.php @@ -8,7 +8,7 @@ @if (trim($sheetBody) === '' && $sheetImages === [] && $sheetPoll === null) Nothing to preview yet. @else - @include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true]) + @include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true, 'static' => true]) @include('native.partials.commit-media', ['commit' => $draftCommit, 'withEmbeds' => false, 'scoreAction' => false]) @if ($draftCommit->poll) diff --git a/resources/views/native/partials/message-body.blade.php b/resources/views/native/partials/message-body.blade.php index 9edf3c3a..a69fc811 100644 --- a/resources/views/native/partials/message-body.blade.php +++ b/resources/views/native/partials/message-body.blade.php @@ -1,11 +1,12 @@ {{-- A message's body, drawn from its segments as a commit's is (see partials/commit-body): prose with tappable - mentions, links and inline code, hex colors beside their swatch, quotes behind a rule on the left, and fenced - code as mono text in a box. $ink is the bubble's text colour, so a body on your lane reads in that lane's label - colour, and $quoteRule the quote's rule: amber, or the label colour on your lane, as the web's pale rule in your - own bubbles. + mentions, links and hashtags, inline code, hex colors beside their swatch, quotes behind a rule on the left, and + fenced code as mono text in a box. $ink is the bubble's text colour, so a body on your lane reads in that lane's + label colour, and $quoteRule the quote's rule: amber, or the label colour on your lane, as the web's pale rule in + your own bubbles. A link is in $linkInk, Commit Blue off your lane and the lane's label colour on it, with a named link's owner muted in $linkPrefixInk, and opens by its place among the body's links (see Conversation::openMessageLink). + A hashtag is medium weight in $mentionInk, as a mention is, and searches commits for it on a tap. $maxLines caps the body to the first block at that many lines, for the pinned sheet's preview, so its links work before Show more. --}} @@ -33,6 +34,8 @@ @elseif ($run->isColor())   +@elseif ($run->isHashtag) + @else @endif diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php index 1172e448..29f914c8 100644 --- a/resources/views/native/search.blade.php +++ b/resources/views/native/search.blade.php @@ -10,7 +10,7 @@ @include('native.partials.live-stream-sheet') - @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => true, 'a11yLabel' => 'Search developers or commits']) + @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => $autofocus, 'a11yLabel' => 'Search developers or commits']) diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index f9ce6fc6..fc0da284 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -581,6 +581,33 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): $screen->press("openProfile('ada')")->assertNavigatedTo('/log/users/ada'); }); +it('links a hashtag to a search for it in the tab, leaving a hex color and code as they are', function () { + $body = 'Shipped #laravel in #fff and `#code`'; + + fakeThread([ + 'body' => $body, + 'body_segments' => [ + ['type' => 'text', 'text' => 'Shipped #laravel in #fff and '], + ['type' => 'code', 'code' => '#code'], + ], + ]); + + $screen = Native::visit('/log/commits/c2'); + + expect(paragraphRuns($screen, 'Shipped'))->toBe([ + ['Shipped '], + ['#laravel', 'hashtag'], + [' in '], + ["■\u{202F}#fff", 'wordmark'], + [' and '], + ['#code', 'code'], + ]); + + $screen->press("openHashtag('#laravel')")->assertNavigatedTo('/log/search'); + + expect($screen->instance()->getNavigationIntent()->data)->toBe(['query' => '#laravel']); +}); + it('draws a hex color after a square of that color, in light and dark mode alike', function () { $body = 'Brand orange is #ff8a4c, not #123'; diff --git a/tests/Feature/Screens/ComposerPreviewTest.php b/tests/Feature/Screens/ComposerPreviewTest.php index 90e41ac0..8f66b849 100644 --- a/tests/Feature/Screens/ComposerPreviewTest.php +++ b/tests/Feature/Screens/ComposerPreviewTest.php @@ -66,6 +66,17 @@ function previewComposerOnLog(?array $preview = null): TestableComponent ->and(treeShows(nodeByRef($screen, 'sheet-draft-preview'), 'Shipped 🚀 with '))->toBeTrue(); }); +it('leaves a hashtag in the preview as plain text, as the web does', function () { + $screen = finishRefreshes(previewComposerOnLog([['data' => [ + 'body' => 'Shipped #laravel', + 'body_segments' => [['type' => 'text', 'text' => 'Shipped #laravel']], + 'preview_segments' => null, + 'mentions' => [], + ]]])->input('sheet-body', 'Shipped #laravel')->tap('sheet-preview')); + + expect(paragraphRuns($screen, 'Shipped #laravel'))->toBe([['Shipped '], ['#laravel']]); +}); + it('shows the photos, the poll and the commit being quoted in the preview', function () { $screen = previewComposerOnLog() ->call('quoteCommit', 'c1') diff --git a/tests/Feature/Screens/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index 14d6bc75..ed33e1a3 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -723,6 +723,22 @@ function pulledAvatars(TestableComponent $screen, int $id): array finishRefreshes(Native::visit('/remotes/conversations/ada'))->press("openProfile('grace')")->assertNavigatedTo('/remotes/users/grace'); }); +it('links a hashtag in a message to a search for it in the tab', function () { + serveRemoteThread(threadJson([ + remoteMessageJson(['id' => 1, 'body' => 'loving #nativephp lately']), + ], ['read_through_id' => 1])); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect(paragraphRuns($screen, 'loving'))->toBe([ + ['loving '], + ['#nativephp', 'hashtag'], + [' lately'], + ]); + + $screen->press("openHashtag('#nativephp')")->assertNavigatedTo('/remotes/search'); +}); + it('opens no link preview that is not a web page', function () { serveRemoteThread(threadJson([ remoteMessageJson(['id' => 1, 'body' => 'Tap me', 'link_preview' => ['url' => 'javascript:alert(1)', 'title' => 'Tap me']]), diff --git a/tests/Feature/Screens/SearchTest.php b/tests/Feature/Screens/SearchTest.php index 5c24047c..c9ad12a8 100644 --- a/tests/Feature/Screens/SearchTest.php +++ b/tests/Feature/Screens/SearchTest.php @@ -114,6 +114,26 @@ function searchedFor(string $kind): array ->and(searchedFor('commits'))->toBe(['@ada']); }); +it('searches commits for a tapped hashtag straight away, without taking focus', function () { + serveSearch(commits: [commitJson(['id' => 'c1', 'body' => 'Shipped it #ab with #laravel'])]); + + $screen = Native::visit('/log/search', ['query' => '#ab']) + ->assertSet('query', '#ab') + ->assertSet('autofocus', false) + ->assertSee('1 match') + ->assertSee('Shipped it'); + + // The # counts towards the three characters devnull searches commits for, as on the web. + expect(searchedFor('commits'))->toBe(['#ab']); + + // A hashtag in the results searches for it here. + $screen->press("openHashtag('#laravel')") + ->assertNoNavigation() + ->assertSet('query', '#laravel'); + + expect(searchedFor('commits'))->toBe(['#ab', '#laravel']); +}); + it('only finds developers for a term too short to search commits by', function () { serveSearch(); diff --git a/tests/Pest.php b/tests/Pest.php index 65830772..41e32e2e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -667,7 +667,11 @@ function paragraphRuns(TestableComponent $screen, string $text): array $text = $run['props']['text'] ?? str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children'] ?? []))); return match (true) { - isset($run['on_press']) => [$text, str_starts_with($text, '@') ? 'mention' : 'link'], + isset($run['on_press']) => [$text, match (true) { + str_starts_with($text, '@') => 'mention', + str_starts_with($text, '#') => 'hashtag', + default => 'link', + }], isset($run['style']['bg_color']) => [$text, 'code'], isset($run['children']) => [$text, 'wordmark'], default => [$text], diff --git a/tests/Unit/Devnull/CommitBodyTest.php b/tests/Unit/Devnull/CommitBodyTest.php index 8be74974..7872bbb3 100644 --- a/tests/Unit/Devnull/CommitBodyTest.php +++ b/tests/Unit/Devnull/CommitBodyTest.php @@ -374,6 +374,63 @@ function colorsOf(string $body): array expect(colorsOf('See https://example.com/#fff for #abc'))->toBe([['#abc', 'abc']]); }); +/** + * The hashtags in a body that is all prose, as written. + * + * @return list + */ +function hashtagsOf(string $body): array +{ + $hashtags = []; + + foreach (CommitBody::blocks($body, null) as $block) { + foreach ($block->runs as $run) { + if ($run->isHashtag) { + $hashtags[] = $run->text; + } + } + } + + return $hashtags; +} + +it('links hashtags, as the web does', function () { + expect(hashtagsOf('Shipped it #buildinpublic, then #laravel_12.'))->toBe(['#buildinpublic', '#laravel_12']) + ->and(runsOf(CommitBody::blocks('Shipped #laravel today', null)))->toBe([ + ['Shipped '], + ['#laravel'], + [' today'], + ]); +}); + +it('links hashtags in any script, counting code points so what comes before them does not shift them', function () { + expect(hashtagsOf('🚀 Bom dia #café e #日本語 #Ünïcödé'))->toBe(['#café', '#日本語', '#Ünïcödé']); +}); + +it('leaves a hex color as a color rather than a hashtag', function () { + expect(hashtagsOf('White is #fff and orange #ff8a4c, but #fffoo is a tag'))->toBe(['#fffoo']) + ->and(colorsOf('White is #fff'))->toBe([['#fff', 'fff']]); +}); + +it('only takes a hashtag that starts with a letter and does not follow a word, &, /, #, > or -', function () { + expect(hashtagsOf('a#b { #123 ##two x-#dash >#quote /#path #1st (#ok)'))->toBe(['#ok']); +}); + +it('leaves hashtags in URLs, mentions and code alone', function () { + $body = 'See https://example.com/a#section with @ada#x and `#code` then #real'; + + $blocks = CommitBody::blocks($body, [ + ['type' => 'text', 'text' => 'See https://example.com/a#section with @ada#x and '], + ['type' => 'code', 'code' => '#code'], + ['type' => 'text', 'text' => ' then #real'], + ], [new Mention('ada', 39, 4)]); + + $hashtags = array_values(array_map(fn (BodyRun $run): string => $run->text, array_filter($blocks[0]->runs, fn (BodyRun $run): bool => $run->isHashtag))); + + expect($hashtags)->toBe(['#real']) + ->and(hashtagsOf("```\n#block\n```\nand #after"))->toBe(['#after']); +}); + /** * Each block as its kind and its text, runs joined. *