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
5 changes: 3 additions & 2 deletions app/Devnull/Data/BodyRun.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
namespace App\Devnull\Data;

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

public function isMention(): bool
{
Expand Down
37 changes: 32 additions & 5 deletions app/Devnull/Data/CommitBody.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

/**
* Turns a commit's `body_segments` into blocks to draw, the way the web's
* CommitBody does: prose (with mentions linked) and inline code flow together
* in paragraphs, and fenced code blocks stand on their own.
* CommitBody does: prose (with 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
* exact slices of it: inline code loses its backticks, a block its fences, and
Expand All @@ -25,6 +26,12 @@ final class CommitBody

private const string FencedBlock = '/\G`{3,}[^`\r\n]*\r?\n(?:([\s\S]*?)\r?\n)?`{3,}[ \t]*(?![^\r\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.
*/
private const string Wordmark = '/(?<![\p{L}\p{M}\p{N}_.\/-])(\/dev\/null)(?![\p{L}\p{M}\p{N}_\/-])/u';

/**
* @param list<array<string, mixed>>|null $segments The `body_segments`, or null when the API sent none.
* @param list<Mention> $mentions
Expand Down Expand Up @@ -53,7 +60,7 @@ public static function blocks(string $body, ?array $segments, array $mentions =
$start = $text === '' ? false : mb_strpos($body, $text, $cursor);

if ($start === false) {
$runs[] = new BodyRun($text.($cut ? self::Ellipsis : ''));
array_push($runs, ...self::withWordmarks($text.($cut ? self::Ellipsis : '')));

break;
}
Expand Down Expand Up @@ -127,15 +134,35 @@ private static function withMentions(string $text, int $start, array $mentions):
}

if ($offset > $position) {
$runs[] = new BodyRun(mb_substr($text, $position, $offset - $position));
array_push($runs, ...self::withWordmarks(mb_substr($text, $position, $offset - $position)));
}

$runs[] = new BodyRun($handle, username: $mention->username);
$position = $offset + $mention->length;
}

if ($position < $length) {
$runs[] = new BodyRun(mb_substr($text, $position));
array_push($runs, ...self::withWordmarks(mb_substr($text, $position)));
}

return $runs;
}

/**
* Splits prose around each standalone `/dev/null`, which becomes a
* wordmark run. Like the web, the prose between two mentions is looked at
* on its own.
*
* @return list<BodyRun>
*/
private static function withWordmarks(string $prose): array
{
$runs = [];

foreach (preg_split(self::Wordmark, $prose, flags: PREG_SPLIT_DELIM_CAPTURE) ?: [$prose] as $index => $part) {
if ($part !== '') {
$runs[] = new BodyRun($part, isWordmark: $index % 2 === 1);
}
}

return $runs;
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 @@ -4,6 +4,13 @@
The runs inside a paragraph sit flush left with no blank lines between them on purpose: under whitespace-pre-wrap
any whitespace between those tags would print. Each line ends in a tag or a directive, whose newline PHP drops.

A standalone /dev/null is the wordmark, in semibold with tighter tracking like the web's. The web outlines its
middle slash in lane-0; EDGE text runs can't stroke a glyph, so that slash is solid lane-0 instead, in the regular
weight, which is nearer the outline's thin lines than a solid semibold slash or a faded one. iOS breaks lines
after a slash, so a word joiner (U+2060) after each one keeps the wordmark on one line. It never breaks before a
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 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.
Expand All @@ -25,6 +32,9 @@
<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->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>
@else
<native:text :text="$run->text" />
@endif
Expand Down
18 changes: 18 additions & 0 deletions tests/Feature/Screens/LogTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,24 @@
]);
});

it('draws a standalone /dev/null as the wordmark: semibold, tight, its middle slash regular in lane-0, held on one line', function () {
Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson(['body' => 'Piped to /dev/null, not /dev/nullable (/dev/null)'])]))]);

$screen = Native::visit('/log');
$wordmarks = findNodes($screen->tree(), fn (array $node): bool => ($node['props']['letter_spacing'] ?? null) === -0.025);
$slash = $wordmarks[0]['children'][1]['props'];

expect(paragraphRuns($screen, 'Piped to'))->toBe([['Piped to '], ['/dev/null', 'wordmark'], [', not /dev/nullable ('], ['/dev/null', 'wordmark'], [')']])
->and(array_map(fn (array $wordmark): array => array_map(fn (array $run): string => $run['props']['text'], $wordmark['children']), $wordmarks))->toBe([
["\u{200B}/\u{2060}dev", '/', "\u{2060}null"],
["/\u{2060}dev", '/', "\u{2060}null"],
])
->and($wordmarks[0]['props'])->toMatchArray(['font_name' => 'semibold', 'font_weight' => 5])
->and($slash)->toMatchArray(['font_name' => 'default', 'font_weight' => 3])
->and(strtolower($slash['color']))->toBe(strtolower(config('native-ui.theme.light.lane-0')))
->and(strtolower($slash['dark_color']))->toBe(strtolower(config('native-ui.theme.dark.lane-0')));
});

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
5 changes: 4 additions & 1 deletion tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,9 @@ function pageJson(array $items, ?string $nextCursor = null, string $path = 'http

/**
* The runs of the first paragraph whose text contains $text, as [text, style]:
* 'mention' for a pressable run, 'code' for one on a wash, or nothing for prose.
* 'mention' for a pressable run, 'code' for one on a wash, 'wordmark' for one
* drawn from runs of its own (read without its invisible line-break hints), or
* nothing for prose.
*
* @return list<array{0: string, 1?: string}>
*/
Expand Down Expand Up @@ -646,6 +648,7 @@ function paragraphRuns(TestableComponent $screen, string $text): array
return array_map(fn (array $run): array => match (true) {
isset($run['on_press']) => [$run['props']['text'], 'mention'],
isset($run['style']['bg_color']) => [$run['props']['text'], 'code'],
isset($run['children']) => [str_replace(["\u{200B}", "\u{2060}"], '', implode('', array_map(fn (array $part): string => $part['props']['text'], $run['children']))), 'wordmark'],
default => [$run['props']['text']],
}, $paragraph['children']);
}
Expand Down
53 changes: 53 additions & 0 deletions tests/Unit/Devnull/CommitBodyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function runsOf(array $blocks): array
return array_map(fn (BodyRun $run): array => match (true) {
$run->isMention() => [$run->text, '@'.$run->username],
$run->isCode => [$run->text, 'code'],
$run->isWordmark => [$run->text, 'wordmark'],
default => [$run->text],
}, $blocks[0]->runs);
}
Expand Down Expand Up @@ -118,3 +119,55 @@ function runsOf(array $blocks): array
expect($commit->mentions)->toHaveCount(2)
->and(runsOf($commit->content))->toBe([['😀 '], ['@ada', '@ada'], [' '], ['x', 'code'], [' '], ['@bob', '@bob']]);
});

it('draws a standalone /dev/null as the wordmark, keeping its text', function () {
$body = 'piped to /dev/null, then /dev/null. (/dev/null)';

$blocks = CommitBody::blocks($body, null);

expect(runsOf($blocks))->toBe([
['piped to '],
['/dev/null', 'wordmark'],
[', then '],
['/dev/null', 'wordmark'],
['. ('],
['/dev/null', 'wordmark'],
[')'],
])->and(implode('', array_map(fn (BodyRun $run): string => $run->text, $blocks[0]->runs)))->toBe($body);
});

it('leaves /dev/null inside longer paths, words, URLs and usernames as text', function () {
$body = '/dev/nullable /dev/null/log a/dev/null /dev/null_1 /DEV/NULL https://example.com/dev/null @devnull';

expect(runsOf(CommitBody::blocks($body, null)))->toBe([[$body]]);
});

it('draws the wordmark beside mentions but not in inline code or code blocks', function () {
$body = "@ada sent it to /dev/null, not `/dev/null`:\n```sh\necho hi > /dev/null\n```";

$blocks = CommitBody::blocks($body, [
['type' => 'text', 'text' => '@ada sent it to /dev/null, not '],
['type' => 'code', 'code' => '/dev/null'],
['type' => 'text', 'text' => ':'],
['type' => 'block', 'language' => 'sh', 'html' => 'echo hi &gt; /dev/null'],
], [new Mention('ada', 0, 4)]);

expect(runsOf($blocks))->toBe([
['@ada', '@ada'],
[' sent it to '],
['/dev/null', 'wordmark'],
[', not '],
['/dev/null', 'code'],
[':'],
])->and($blocks[1]->code)->toBe('echo hi > /dev/null');
});

it('draws the wordmark at the end of a preview, before the ellipsis it was cut with', function () {
$body = 'Logs go to /dev/null and nowhere else, which is the whole point of it.';

expect(runsOf(CommitBody::blocks($body, [['type' => 'text', 'text' => 'Logs go to /dev/null…']])))->toBe([
['Logs go to '],
['/dev/null', 'wordmark'],
['…'],
]);
});
Loading