Skip to content
Open
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
4 changes: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController

Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting, a **Wrap lines** toggle, and a copy control. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. This is developer-oriented text formatting, not a full rich-text editor.

When a commit or reply has a fenced code block, its link preview image shows the first block, highlighted, in a dark editor window named after the block's language, with the first line of any text before it above. Later blocks and the text after the first block are left out of the image, but not the commit. Shorter code is drawn larger. Lines too wide for the window are cut with an ellipsis rather than wrapped, and code too long for it ends in an ellipsis line. Emoji in the code are left out of the image. Inline code and empty blocks keep the ordinary preview image.

In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal.

Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions.
Expand All @@ -212,7 +214,7 @@ Typing **#123** in a composer looks up that exact issue or pull request in the /

A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text.

Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).
Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [code preview images](app/Actions/CreateCommitOpenGraphImage.php), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).

### Draft previews

Expand Down
170 changes: 155 additions & 15 deletions app/Actions/CreateCommitOpenGraphImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use App\Models\Commit;
use App\Models\User;
use App\Services\CodeSpans;
use App\Services\HighlightedCode;
use App\Services\OpenGraphCanvas;
use App\Services\Urls;
use Illuminate\Support\Facades\Cache;
Expand All @@ -32,7 +34,26 @@
*/
private const array BODY_SIZES = [[56, 72], [48, 64], [40, 56], [34, 48], [32, 44]];

public function __construct(private Urls $urls)
private const int CODE_AVATAR_SIZE = 56;

private const int CODE_GAP = 16;

private const int CODE_MARGIN = 28;

private const int PROSE_SIZE = 26;

private const int PROSE_LINE_HEIGHT = 34;

private const int PROSE_LINES = 1;

/**
* Shorter code is drawn larger, as long as it fits.
*
* @var list<array{int, int}>
*/
private const array CODE_SIZES = [[28, 40], [24, 34], [22, 31], [20, 28]];

public function __construct(private Urls $urls, private CodeSpans $codeSpans, private HighlightedCode $highlightedCode)
{
//
}
Expand All @@ -54,34 +75,153 @@ private function render(User $user, Commit $commit): string
$lane = $canvas->lane($user);
$replyingTo = $commit->parent?->user?->handle;

$code = $this->code($commit->body);

if ($code !== null) {
$this->drawCode($canvas, $user, $lane, $replyingTo, ...$code);
} else {
$this->drawBody($canvas, $user, $lane, $replyingTo, $commit->body);
}

$canvas->footer();
$this->meta($canvas, $commit);

return $canvas->png();
}

private function drawBody(OpenGraphCanvas $canvas, User $user, int $lane, ?string $replyingTo, string $body): void
{
$headerHeight = self::AVATAR_SIZE + ($replyingTo === null ? 0 : self::REPLYING_TO_HEIGHT) + self::BODY_GAP;
[$size, $lineHeight, $body] = $this->fit($canvas, $this->urls->withShortenedUrls($commit->body), OpenGraphCanvas::FOOTER_RULE_Y - self::MIN_BOTTOM - self::MIN_TOP - $headerHeight);
[$size, $lineHeight, $lines] = $this->fit($canvas, $this->urls->withShortenedUrls($body), OpenGraphCanvas::FOOTER_RULE_Y - self::MIN_BOTTOM - self::MIN_TOP - $headerHeight);

$top = max(self::MIN_TOP, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $headerHeight - count($lines) * $lineHeight, 2));

$this->header($canvas, $user, $lane, $replyingTo, $top, self::AVATAR_SIZE);
$this->lines($canvas, $lines, $size, $lineHeight, $top + $headerHeight);
}

/**
* The code block in an editor window, under a smaller header and the first line of the prose before it.
*
* @param non-empty-list<list<array{text: string, classes: list<string>}>> $lines
*/
private function drawCode(OpenGraphCanvas $canvas, User $user, int $lane, ?string $replyingTo, string $prose, ?string $language, array $lines): void
{
$headerHeight = self::CODE_AVATAR_SIZE + ($replyingTo === null ? 0 : self::REPLYING_TO_HEIGHT) + self::CODE_GAP;

$prose = $prose === '' ? [] : $canvas->clamp($canvas->wrap($this->urls->withShortenedUrls($prose), 'Geist-Regular', self::PROSE_SIZE, self::BODY_WIDTH), self::PROSE_LINES, 'Geist-Regular', self::PROSE_SIZE, self::BODY_WIDTH);
$proseHeight = $prose === [] ? 0 : count($prose) * self::PROSE_LINE_HEIGHT + self::CODE_GAP;

$chrome = OpenGraphCanvas::WINDOW_TITLE_HEIGHT + OpenGraphCanvas::WINDOW_PADDING;
[$size, $lineHeight, $lines] = $this->fitCode($canvas, $lines, OpenGraphCanvas::FOOTER_RULE_Y - 2 * self::CODE_MARGIN - $headerHeight - $proseHeight - $chrome);
$windowHeight = $chrome + count($lines) * $lineHeight;

$top = max(self::CODE_MARGIN, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $headerHeight - $proseHeight - $windowHeight, 2));

$this->header($canvas, $user, $lane, $replyingTo, $top, self::CODE_AVATAR_SIZE);
$this->lines($canvas, $prose, self::PROSE_SIZE, self::PROSE_LINE_HEIGHT, $top + $headerHeight);

$top = max(self::MIN_TOP, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $headerHeight - count($body) * $lineHeight, 2));
$windowTop = $top + $headerHeight + $proseHeight;

$canvas->window(OpenGraphCanvas::CONTENT_X, $windowTop, self::BODY_WIDTH, $windowHeight, $language);
$canvas->code($lines, $size, $lineHeight, OpenGraphCanvas::CONTENT_X + OpenGraphCanvas::WINDOW_PADDING, $windowTop + OpenGraphCanvas::WINDOW_TITLE_HEIGHT, self::BODY_WIDTH - 2 * OpenGraphCanvas::WINDOW_PADDING);
}

/**
* The avatar on the rail, with the name and handle beside it sized to the avatar.
*
* @param positive-int $avatarSize
*/
private function header(OpenGraphCanvas $canvas, User $user, int $lane, ?string $replyingTo, int $top, int $avatarSize): void
{
$nameSize = (int) round($avatarSize * 0.45);
$handleSize = (int) round($avatarSize * 0.325);

$canvas->rail($top + intdiv(self::AVATAR_SIZE, 2), $lane);
$canvas->avatar($user, OpenGraphCanvas::CONTENT_X + 10, $top, self::AVATAR_SIZE, $lane);
$canvas->rail($top + intdiv($avatarSize, 2), $lane);
$canvas->avatar($user, OpenGraphCanvas::CONTENT_X + 10, $top, $avatarSize, $lane);

$textX = OpenGraphCanvas::CONTENT_X + 10 + self::AVATAR_SIZE + 10 + 28;
$textX = OpenGraphCanvas::CONTENT_X + 10 + $avatarSize + 10 + 28;
$textWidth = OpenGraphCanvas::CONTENT_RIGHT - $textX;

$canvas->text($canvas->truncate($canvas->name($user), 'Geist-SemiBold', 36, $textWidth), 'Geist-SemiBold', 36, $textX, $top + 36, OpenGraphCanvas::INK);
$canvas->handle($user, 26, $textX, $top + 70, $textWidth, $lane);
$canvas->text($canvas->truncate($canvas->name($user), 'Geist-SemiBold', $nameSize, $textWidth), 'Geist-SemiBold', $nameSize, $textX, $top + $nameSize, OpenGraphCanvas::INK);
$canvas->handle($user, $handleSize, $textX, $top + (int) round($avatarSize * 0.875), $textWidth, $lane);

if ($replyingTo !== null) {
$canvas->text($canvas->truncate("↳ replying to @{$replyingTo}", 'GeistMono-Regular', 26, self::BODY_WIDTH), 'GeistMono-Regular', 26, OpenGraphCanvas::CONTENT_X, $top + self::AVATAR_SIZE + 38, OpenGraphCanvas::INK_MUTED);
$canvas->text($canvas->truncate("↳ replying to @{$replyingTo}", 'GeistMono-Regular', 26, self::BODY_WIDTH), 'GeistMono-Regular', 26, OpenGraphCanvas::CONTENT_X, $top + $avatarSize + 38, OpenGraphCanvas::INK_MUTED);
}
}

$bodyTop = $top + $headerHeight;
/**
* @param list<string> $lines
*/
private function lines(OpenGraphCanvas $canvas, array $lines, int $size, int $lineHeight, int $top): void
{
foreach ($lines as $index => $line) {
$canvas->text($line, 'Geist-Regular', $size, OpenGraphCanvas::CONTENT_X, $top + $index * $lineHeight + intdiv($lineHeight + $size, 2) - intdiv($size, 5), OpenGraphCanvas::INK);
}
}

foreach ($body as $index => $line) {
$canvas->text($line, 'Geist-Regular', $size, OpenGraphCanvas::CONTENT_X, $bodyTop + $index * $lineHeight + intdiv($lineHeight + $size, 2) - intdiv($size, 5), OpenGraphCanvas::INK);
/**
* The prose before the first code block, and that block's language and lines, or null when the body has no block
* or the first one is empty. Blank lines around the code are dropped, since the window has its own padding.
*
* @return array{string, string|null, non-empty-list<list<array{text: string, classes: list<string>}>>}|null
*/
private function code(string $body): ?array
{
$parts = $this->codeSpans->split($body);

foreach ($parts as $index => $part) {
if ($index % 2 === 0 || ! str_starts_with($part, '```')) {
continue;
}

[$block] = $this->codeSpans->highlight($part);
assert($block['type'] === 'block');

$lines = $this->highlightedCode->lines($block['html']);

while ($lines !== [] && $lines[0] === []) {
array_shift($lines);
}

while ($lines !== [] && array_last($lines) === []) {
array_pop($lines);
}

if ($lines === []) {
return null;
}

return [mb_trim(implode('', array_slice($parts, 0, $index))), $block['language'], $lines];
}

$canvas->footer();
$this->meta($canvas, $commit);
return null;
}

return $canvas->png();
/**
* Shorter code is drawn larger, as long as every line fits, then as long as their number does, cutting the
* longest. Code too long for the smallest size is cut after a muted ellipsis line.
*
* @param non-empty-list<list<array{text: string, classes: list<string>}>> $lines
* @return array{int, int, list<list<array{text: string, classes: list<string>}>>}
*/
private function fitCode(OpenGraphCanvas $canvas, array $lines, int $height): array
{
$longest = collect($lines)->max(fn (array $runs): int => mb_strlen(collect($runs)->implode('text')));
$width = self::BODY_WIDTH - 2 * OpenGraphCanvas::WINDOW_PADDING;

foreach ([true, false] as $isWidthFitted) {
foreach (self::CODE_SIZES as [$size, $lineHeight]) {
if (count($lines) * $lineHeight <= $height && (! $isWidthFitted || $longest <= $canvas->columns($size, $width))) {
return [$size, $lineHeight, $lines];
}
}
}

$count = max(1, intdiv($height, $lineHeight));

return [$size, $lineHeight, [...array_slice($lines, 0, $count - 1), [['text' => '…', 'classes' => ['hl-comment']]]]];
}

/**
Expand Down
84 changes: 84 additions & 0 deletions app/Services/HighlightedCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Services;

/**
* Reads the HTML that the highlighter makes of a code block back into lines of text, so that it can be drawn somewhere
* HTML can't go, like a link preview image.
*/
final readonly class HighlightedCode
{
private const int TAB_WIDTH = 4;

/**
* Splits the HTML into lines of runs of text, each with the classes of every span it sits in, outermost first.
* Spans nest (an hl-injection holds an hl-type), may run across lines (a block comment), and may have no class.
*
* @return list<list<array{text: string, classes: list<string>}>>
*/
public function lines(string $html): array
{
$parts = preg_split('/(<span(?: class="[^"]*")?>|<\/span>)/', $html, flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: [];

/** @var list<list<string>> $stack */
$stack = [];
$lines = [[]];
$column = 0;

foreach ($parts as $part) {
if ($part === '</span>') {
array_pop($stack);

continue;
}

if (str_starts_with($part, '<span')) {
preg_match('/class="([^"]*)"/', $part, $matches);

$stack[] = preg_split('/\s+/', $matches[1] ?? '', flags: PREG_SPLIT_NO_EMPTY) ?: [];

continue;
}

foreach (explode("\n", html_entity_decode($part, ENT_QUOTES | ENT_HTML5, 'UTF-8')) as $index => $text) {
if ($index > 0) {
$lines[] = [];
$column = 0;
}

if ($text === '') {
continue;
}

$text = $this->expandTabs($text, $column);
$column += mb_strlen($text);

$lines[array_key_last($lines)][] = ['text' => $text, 'classes' => array_merge(...$stack)];
}
}

return $lines;
}

/**
* Replaces each tab with the spaces up to the next tab stop, counting from where the text starts on its line.
*/
private function expandTabs(string $text, int $column): string
{
if (! str_contains($text, "\t")) {
return $text;
}

$expanded = '';

foreach (mb_str_split($text) as $character) {
$expanded .= $character === "\t"
? str_repeat(' ', self::TAB_WIDTH - ($column + mb_strlen($expanded)) % self::TAB_WIDTH)
: $character;
}

return $expanded;
}
}
Loading