Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions app/Devnull/Data/BodyLink.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

namespace App\Devnull\Data;

/**
* A URL in a commit's prose, shown as the web's LinkifiedText shows it: a commit
* on dev/null as its author and short hash, a GitHub repository, issue or pull
* request as GitHub writes it, and anything else shortened the way X does.
*
* The rules are the web's, from linkified-text.tsx and app/Services/Urls.php.
* A commit opens in the app, and everything else in the browser.
*/
final readonly class BodyLink
{
/**
* Top-level domains a bare domain like "nativephp.com" may end with. It's
* an allowlist because most file extensions are real TLDs too, so the ones
* that read like code or files (sh, py, rs, md, ...) are left out.
*/
private const array TopLevelDomains = [
'com', 'org', 'net', 'edu', 'gov', 'mil', 'info', 'biz', 'io', 'dev', 'app', 'co', 'ai', 'me', 'xyz', 'tech',
'gg', 'tv', 'fm', 'ly', 'to', 'is', 'blog', 'cloud', 'site', 'online', 'design', 'studio', 'tools', 'software',
'codes', 'wiki', 'news', 'website', 'club', 'ninja', 'rocks', 'academy', 'guide', 'social', 'digital', 'agency',
'fyi', 'lol', 'live',
'uk', 'de', 'fr', 'nl', 'es', 'it', 'pt', 'br', 'ca', 'au', 'jp', 'ch', 'se', 'no', 'dk', 'fi', 'be', 'at', 'ie',
'nz', 'us', 'eu', 'cz', 'sk', 'hu', 'ro', 'bg', 'gr', 'hr', 'si', 'ee', 'lv', 'lt', 'ru', 'ua', 'tr', 'il', 'za',
'mx', 'ar', 'kr', 'cn', 'tw', 'hk', 'sg', 'vn', 'th',
];

/**
* First path segments that are GitHub's own pages rather than an owner.
*/
private const array GitHubPages = [
'about', 'apps', 'codespaces', 'collections', 'customer-stories', 'enterprise', 'events', 'explore', 'features',
'issues', 'login', 'marketplace', 'new', 'notifications', 'orgs', 'organizations', 'pricing', 'pulls', 'search',
'security', 'settings', 'site', 'sponsors', 'topics', 'trending', 'users',
];

/**
* A commit on dev/null with nothing after its id.
*/
private const string CommitLink = '/^https?:\/\/(?:www\.)?dev-null\.sh\/@([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})\/commits\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i';

/**
* A GitHub repository, issue or pull request with nothing after its name or number.
*/
private const string GitHubLink = '/^https?:\/\/(?:www\.)?github\.com\/([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})\/([A-Za-z0-9._-]{1,100})(?:\/(issues|pull)\/([1-9][0-9]{0,9}))?$/i';

private const int MaxLength = 2048;

private const int MaxShortenedPathLength = 20;

/**
* @param string $href Where it goes: the URL, with https:// before a bare domain.
* @param string $shown What it shows.
* @param string $prefix What it shows muted before that: a commit's author or a repository's owner, and "/".
* @param string|null $commit The id of the dev/null commit it opens in the app.
*/
private function __construct(public string $href, public string $shown, public string $prefix = '', public ?string $commit = null) {}

/**
* Every link in some prose, in order, with where it starts and how long it
* is as written, in code points. Punctuation that ends the sentence rather
* than the URL stays prose.
*
* @return list<array{0: int, 1: int, 2: self}>
*/
public static function in(string $prose): array
{
preg_match_all(self::pattern(), $prose, $matches, PREG_OFFSET_CAPTURE);

$links = [];

foreach ($matches[0] as [$candidate, $byte]) {
$written = self::withoutTrailingPunctuation($candidate);
$href = preg_match('/^(https?)(:\/\/.*)$/is', $written, $scheme) === 1 ? strtolower($scheme[1]).$scheme[2] : 'https://'.$written;

if (str_ends_with($href, '://') || mb_strlen($href) > self::MaxLength) {
continue;
}

$links[] = [mb_strlen(substr($prose, 0, $byte)), mb_strlen($written), self::to($written, $href)];
}

return $links;
}

/**
* Whether it shows as a name and a short hash or number rather than as a URL.
*/
public function isNamed(): bool
{
return $this->prefix !== '';
}

private static function to(string $written, string $href): self
{
if (preg_match(self::CommitLink, $written, $commit) === 1) {
$id = strtolower($commit[2]);

return new self($href, substr(str_replace('-', '', $id), -7), $commit[1].'/', $id);
}

if (preg_match(self::GitHubLink, $href, $gitHub) === 1 && ! in_array(strtolower($gitHub[1]), self::GitHubPages, true)) {
return new self($href, $gitHub[2].(isset($gitHub[4]) ? '#'.$gitHub[4] : ''), $gitHub[1].'/');
}

return new self($href, self::shorten($written));
}

/**
* Like X: without its scheme or "www.", with the whole host, and at most 20
* characters after it, the last an ellipsis when it's cut. A cut never splits
* a percent-encoded character.
*/
private static function shorten(string $url): string
{
$url = (string) preg_replace('/^(?:https?:\/\/)?(?:www\.(?=[^\/?#]*\.))?/i', '', $url);
$host = (string) preg_replace('/[\/?#].*$/su', '', $url);
$path = mb_substr($url, mb_strlen($host));

if (mb_strlen($path) <= self::MaxShortenedPathLength) {
return $url;
}

$kept = (string) preg_replace('/%[0-9A-Fa-f]?$/', '', mb_substr($path, 0, self::MaxShortenedPathLength - 1));

return $host.$kept.'…';
}

/**
* A URL with a scheme, or a bare domain: ASCII labels ending in an allowed
* TLD, then maybe a path, query or fragment. A bare domain can't follow a
* letter, digit, "_", ".", "/", "@" or "-", its TLD is all lowercase or all
* uppercase, and it ends the word without a "(" or another label after it.
*/
private static function pattern(): string
{
$topLevelDomains = implode('|', [...self::TopLevelDomains, ...array_map(strtoupper(...), self::TopLevelDomains)]);

return '/\b[Hh][Tt][Tt][Pp][Ss]?:\/\/[^\s<>]+'
.'|(?<![\p{L}\p{N}_.\/@-])(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\.)+(?:'.$topLevelDomains.')'
.'(?![\p{L}\p{N}_(-]|\.[A-Za-z0-9])(?:[\/?#][^\s<>]*)?/u';
}

/**
* Strips punctuation that ends a sentence rather than the URL, keeping
* closing brackets that pair with one inside it.
*/
private static function withoutTrailingPunctuation(string $url): string
{
do {
$previous = $url;
$url = mb_rtrim($url, '.,;:!?\'"');

foreach (['(' => ')', '[' => ']'] as $open => $close) {
if (str_ends_with($url, $close) && mb_substr_count($url, $open) < mb_substr_count($url, $close)) {
$url = mb_substr($url, 0, -1);
}
}
} while ($url !== $previous);

return $url;
}
}
7 changes: 4 additions & 3 deletions app/Devnull/Data/BodyRun.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
namespace App\Devnull\Data;

/**
* A run of a paragraph: plain prose, a mention, inline code, or a standalone
* `/dev/null`, which draws as the wordmark.
* A run of a paragraph: plain prose, a mention, inline code, a link, or a
* standalone `/dev/null`, which draws as the wordmark. A link's text is the
* URL as written.
*/
final readonly class BodyRun
{
public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false) {}
public function __construct(public string $text, public ?string $username = null, public bool $isCode = false, public bool $isWordmark = false, public ?BodyLink $link = null) {}

public function isMention(): bool
{
Expand Down
21 changes: 21 additions & 0 deletions app/Devnull/Data/Commit.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,27 @@ public function plainText(): string
));
}

/**
* The links in the body, in the order it draws them. A long commit's
* preview is cut at a space, so its links are the first of these.
*
* @return list<BodyLink>
*/
public function links(): array
{
$links = [];

foreach ($this->content as $block) {
foreach ($block->runs as $run) {
if ($run->link !== null) {
$links[] = $run->link;
}
}
}

return $links;
}

public function isDeleted(): bool
{
return $this->deletedAt !== null;
Expand Down
40 changes: 36 additions & 4 deletions app/Devnull/Data/CommitBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

/**
* Turns a commit's `body_segments` into blocks to draw, the way the web's
* CommitBody does: prose (with mentions linked, and `/dev/null` as the
* wordmark) and inline code flow together in paragraphs, and fenced code
* CommitBody does: prose (with URLs and mentions linked, and `/dev/null` as
* the wordmark) and inline code flow together in paragraphs, and fenced code
* blocks stand on their own.
*
* Mention offsets count code points into the whole body, but segments aren't
Expand Down Expand Up @@ -60,12 +60,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::withWordmarks($text.($cut ? self::Ellipsis : '')));
array_push($runs, ...self::withLinks($text.($cut ? self::Ellipsis : ''), null, []));

break;
}

array_push($runs, ...self::withMentions($text, $start, $mentions));
array_push($runs, ...self::withLinks($text, $start, $mentions));
$cursor = $start + mb_strlen($text);

if ($cut) {
Expand Down Expand Up @@ -106,6 +106,38 @@ public static function blocks(string $body, ?array $segments, array $mentions =
return $blocks;
}

/**
* Splits prose that starts at $start in the body around the links inside
* it, then the prose between them around its mentions, as the web does.
* Prose that wasn't found in the body has no start, so no mentions.
*
* @param list<Mention> $mentions
* @return list<BodyRun>
*/
private static function withLinks(string $text, ?int $start, array $mentions): array
{
$runs = [];
$position = 0;
$prose = fn (string $part, int $at): array => $start === null
? self::withWordmarks($part)
: self::withMentions($part, $start + $at, $mentions);

foreach (BodyLink::in($text) as [$offset, $length, $link]) {
if ($offset > $position) {
array_push($runs, ...$prose(mb_substr($text, $position, $offset - $position), $position));
}

$runs[] = new BodyRun(mb_substr($text, $offset, $length), link: $link);
$position = $offset + $length;
}

if ($position < mb_strlen($text)) {
array_push($runs, ...$prose(mb_substr($text, $position), $position));
}

return $runs;
}

/**
* Splits prose that starts at $start in the body around the mentions inside it.
*
Expand Down
17 changes: 17 additions & 0 deletions app/NativeComponents/Concerns/ShowsCommits.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,23 @@ public function openImage(string $id, int $index = 0): void
$this->openInBrowser(($this->shownCommit($id)?->images[$index] ?? null)?->url);
}

/**
* Opens a link in a commit's body, by its place among the body's links: a
* commit on dev/null here in the app, anything else in the browser.
*/
public function openLink(string $id, int $index): void
{
$link = $this->shownCommit($id)?->links()[$index] ?? null;

if ($link?->commit !== null) {
$this->openCommit($link->commit);

return;
}

$this->openInBrowser($link?->href);
}

/**
* Share link: the web's copy-link button. The share sheet has Copy, and AirDrop and Messages besides.
*/
Expand Down
10 changes: 10 additions & 0 deletions resources/views/native/partials/commit-body.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@
slash, even after a space, so a zero-width space (U+200B) after a space lets the line break there as a browser
would; without it, a few wordmarks in a row can't wrap at all. VoiceOver skips both and still reads "/dev/null".

A link is Commit Blue and opens on a tap, numbered by its place among the body's links. Like the web, a URL shows
shortened, and a dev/null commit or a GitHub repository, issue or pull request shows as a mono name with the owner
muted, held on one line by a word joiner after its slash. The web's dev/null and GitHub marks before those are icons,
which text runs can't hold, so they're left out.

A body too long to show whole in a list shows its start, as devnull cut it, and "Show more" in Commit Blue, which
opens the rest where it is. The commit a page is about ($focused) is always whole. $muted sets it in muted ink, for
the commits around the one a page is about.
--}}
@php($size ??= 'text-[15px]')
@php($ink = ($muted ?? false) ? 'text-theme-ink-muted' : 'text-theme-ink')
@php($folded = ! ($focused ?? false) && $commit->isFolded() && ! in_array($commit->id, $unfolded ?? [], true))
@php($linkIndex = 0)
@foreach ($folded ? $commit->previewContent : $commit->content as $block)
@if ($block->isCode())
<native:column class="w-full mt-1 mb-1 rounded-md border border-theme-hairline bg-theme-wash">
Expand All @@ -32,6 +38,10 @@
<native:text :text="$run->text" font="semibold" class="font-semibold text-theme-primary" @press="openProfile('{{ $run->username }}')" />
@elseif ($run->isCode)
<native:text :text="$run->text" font="mono" class="text-[13px] bg-theme-wash" />
@elseif ($run->link?->isNamed())
<native:text font="mono" class="text-[13px] text-theme-primary" @press="openLink('{{ $commit->id }}', {{ $linkIndex++ }})"><native:text :text="$run->link->prefix" class="text-theme-ink-muted" />&#x2060;<native:text :text="$run->link->shown" /></native:text>
@elseif ($run->link)
<native:text :text="$run->link->shown" class="text-theme-primary" @press="openLink('{{ $commit->id }}', {{ $linkIndex++ }})" />
@elseif ($run->isWordmark)
@php($afterSpace = $loop->index > 0 && ctype_space(substr($block->runs[$loop->index - 1]->text, -1)))
<native:text font="semibold" class="font-semibold tracking-tight">@if ($afterSpace)&#x200B;@endif/&#x2060;dev<native:text text="/" font="default" class="font-normal text-theme-lane-0" />&#x2060;null</native:text>
Expand Down
33 changes: 33 additions & 0 deletions tests/Feature/Screens/LogTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,39 @@
->and(strtolower($slash['dark_color']))->toBe(strtolower(config('native-ui.theme.dark.lane-0')));
});

it('links URLs in Commit Blue, opening a dev/null commit here and anything else in the browser', function () {
Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([
'id' => 'c1',
'body' => 'Read https://www.example.com/a-long-path-to-the-release-notes, then https://dev-null.sh/@Hacksore/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8 and github.com/laravel/framework/pull/42',
])]))]);

$screen = Native::visit('/log');
$primary = strtolower(config('native-ui.theme.light.primary'));
$link = findNode($screen->tree(), fn (array $node): bool => ($node['props']['text'] ?? null) === 'example.com/a-long-path-to-the…');
$repository = findNode($screen->tree(), fn (array $node): bool => ($node['children'][0]['props']['text'] ?? null) === 'laravel/');

expect(paragraphRuns($screen, 'Read'))->toBe([
['Read '],
['example.com/a-long-path-to-the…', 'link'],
[', then '],
['Hacksore/5149ca8', 'link'],
[' and '],
['laravel/framework#42', 'link'],
])
->and(strtolower($link['props']['color']))->toBe($primary)
->and($repository['props']['font_name'])->toBe('mono')
->and(strtolower($repository['props']['color']))->toBe($primary)
->and(strtolower($repository['children'][0]['props']['color']))->toBe(strtolower(config('native-ui.theme.light.ink-muted')));

$screen->press("openLink('c1', 0)")
->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://www.example.com/a-long-path-to-the-release-notes')
->press("openLink('c1', 2)")
->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/laravel/framework/pull/42')
->assertNoNavigation()
->press("openLink('c1', 1)")
->assertNavigatedTo('/log/commits/01a14a9d-7971-73ea-8f35-135fe5149ca8');
});

it('shows a link preview as a compact row and opens it in the browser', function () {
Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([
'id' => 'c1',
Expand Down
Loading
Loading