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
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController

### Code, emoji, and mentions

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.
Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting, a **Wrap lines** toggle, and a copy control. A filename after the language, as in ```` ```php app/Models/User.php ```` or ```` ```php title="app/Models/User.php" ````, labels the block in place of the language. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. This is developer-oriented text formatting, not a full rich-text editor.

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.

Expand Down
12 changes: 7 additions & 5 deletions app/Services/CodeSpans.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public function withoutCode(string $text): string
* Splits the text into prose, inline code and highlighted code blocks. The highlighter escapes the code, so a
* block's HTML is safe to render as is. The line breaks around a block are dropped, since it is a block already.
*
* @return list<array{type: 'text', text: string}|array{type: 'code', code: string}|array{type: 'block', language: string|null, html: string}>
* @return list<array{type: 'text', text: string}|array{type: 'code', code: string}|array{type: 'block', language: string|null, filename: string|null, html: string}>
*/
public function highlight(string $text): array
{
Expand Down Expand Up @@ -124,17 +124,19 @@ private function isBlock(?string $part): bool
}

/**
* @return array{type: 'block', language: string|null, html: string}
* @return array{type: 'block', language: string|null, filename: string|null, html: string}
*/
private function block(string $part): array
{
$lines = explode("\n", str_replace("\r\n", "\n", $part));
$language = strtok(mb_ltrim($lines[0], '`'), " \t") ?: null;
[$language, $filename] = array_pad(preg_split('/[ \t]+/', mb_trim(mb_ltrim($lines[0], '`'), " \t"), 2) ?: [], 2, '');
$filename = (string) preg_replace('/^title="(.+)"$/', '$1', $filename);

return [
'type' => 'block',
'language' => $language,
'html' => $this->highlighter->parse(implode("\n", array_slice($lines, 1, -1)), $language === null ? null : mb_strtolower($language)),
'language' => $language === '' ? null : $language,
'filename' => $filename === '' ? null : $filename,
'html' => $this->highlighter->parse(implode("\n", array_slice($lines, 1, -1)), $language === '' ? null : mb_strtolower($language)),
];
}
}
9 changes: 7 additions & 2 deletions resources/js/components/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { cn } from '@/lib/utils';
export function CodeBlock({
html,
language,
filename,
isCopyable = true,
}: {
html: string;
language: string | null;
filename: string | null;
isCopyable?: boolean;
}) {
const codeRef = useRef<HTMLPreElement>(null);
Expand Down Expand Up @@ -52,8 +54,11 @@ export function CodeBlock({
<div className="my-2 rounded-md border bg-muted/60">
{(language || isCopyable) && (
<div className="flex h-8 items-center justify-between gap-2 border-b pr-1 pl-3">
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground select-none">
{language}
<span
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground select-none"
title={filename ?? undefined}
>
{filename ?? language}
</span>
{isCopyable && (
<div className="flex shrink-0 items-center gap-0.5">
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/commit-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function BodySegments({
key={index}
html={segment.html}
language={segment.language}
filename={segment.filename}
isCopyable={!isStatic}
/>
);
Expand Down
5 changes: 4 additions & 1 deletion resources/js/lib/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ function escapeHtml(text: string): string {

function block(part: string): BodySegment {
const lines = part.replaceAll('\r\n', '\n').split('\n');
const language = lines[0].replace(/^`+/, '').trim().split(/[ \t]/)[0];
const [, language = '', rest = ''] =
/^`+[ \t]*([^ \t]*)[ \t]*([\s\S]*?)[ \t]*$/.exec(lines[0]) ?? [];
const filename = rest.replace(/^title="([\s\S]+)"$/, '$1');

return {
type: 'block',
language: language === '' ? null : language,
filename: filename === '' ? null : filename,
html: escapeHtml(lines.slice(1, -1).join('\n')),
};
}
Expand Down
7 changes: 6 additions & 1 deletion resources/js/types/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ export type CommitImage = {
export type BodySegment =
| { type: 'text'; text: string }
| { type: 'code'; code: string }
| { type: 'block'; language: string | null; html: string };
| {
type: 'block';
language: string | null;
filename: string | null;
html: string;
};

export type CommitTranslation = {
language: string;
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/Controllers/CommitControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,7 @@
['type' => 'text', 'text' => 'Run '],
['type' => 'code', 'code' => 'make'],
['type' => 'text', 'text' => ' :fire'],
['type' => 'block', 'language' => 'php', 'html' => '<span class="hl-variable">$name</span> = <span class="hl-value">\'&lt;b&gt;\'</span>;'],
['type' => 'block', 'language' => 'php', 'filename' => null, 'html' => '<span class="hl-variable">$name</span> = <span class="hl-value">\'&lt;b&gt;\'</span>;'],
])
->where('replies.data.0.body_segments.1', ['type' => 'code', 'code' => 'make test'])
->missing('replies.data.0.body_segments.2'));
Expand Down
34 changes: 34 additions & 0 deletions tests/Unit/ComposerPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,40 @@ for (const [body, expected] of [
});
}

for (const fence of [
'```php app/Models/User.php \t',
'``` php\ttitle="app/Models/User.php"',
]) {
test(`labels the code block of "${fence}" with its filename`, () => {
const markup = renderPage(
<CommitBody
segments={bodySegments(`${fence}\n$name;\n\`\`\``)}
mentions={null}
/>,
);

assert.match(
markup,
/<span[^>]*title="app\/Models\/User\.php"[^>]*>app\/Models\/User\.php<\/span>/,
);
assert.ok(!markup.includes('>php<'));
});
}

for (const [fence, language, filename] of [
['```php app/Models/User.php \t', 'php', 'app/Models/User.php'],
['``` php\ttitle="app/Models/User.php"', 'php', 'app/Models/User.php'],
['```php\u00a0app.php', 'php\u00a0app.php', null],
['```php', 'php', null],
['```', null, null],
] as const) {
test(`reads the language and filename of "${fence}" like the server`, () => {
assert.deepEqual(bodySegments(`${fence}\n$name;\n\`\`\``), [
{ type: 'block', language, filename, html: '$name;' },
]);
});
}

test('links the mentions the server found in the preview', () => {
const preview = previewFor({
body: 'Pairing with @alice and @nobody-here',
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/LinkifiedText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ test('keeps captured mention identities in regular bodies alongside highlighted
{
type: 'block',
language: 'php',
filename: null,
html: '<span class="hl-comment">// @ORIGINAL</span>',
},
]}
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/NotificationInbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ void test('renders highlighted notification blocks without a nested copy button'
{
type: 'block',
language: 'php',
filename: null,
html: '<span class="hl-keyword">return</span> &quot;:fire:&quot;;',
},
],
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/NotificationIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const notification = {
{
type: 'block',
language: 'php',
filename: null,
html: '<span class="hl-keyword">return</span> round($score, 6);',
},
],
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/QuotedCommit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const commit = {
{
type: 'block',
language: 'php',
filename: null,
html: '<span class="hl-keyword">return</span> round($score, 6);',
},
],
Expand Down
30 changes: 21 additions & 9 deletions tests/Unit/Services/CodeSpansTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
[
'type' => 'block',
'language' => 'php',
'filename' => null,
'html' => '<span class="hl-variable">$name</span> = <span class="hl-value">\'devnull\'</span>;',
],
]);
Expand All @@ -78,7 +79,7 @@

expect($segments)->toBe([
['type' => 'text', 'text' => 'before'],
['type' => 'block', 'language' => null, 'html' => "x\ny"],
['type' => 'block', 'language' => null, 'filename' => null, 'html' => "x\ny"],
['type' => 'text', 'text' => "after{$lineBreak}{$lineBreak}end"],
]);
})->with([
Expand All @@ -92,7 +93,7 @@
$segments = $codeSpans->highlight("```{$language}\n<script>alert(1)</script>\n```");

expect($segments)->toBe([
['type' => 'block', 'language' => $language, 'html' => '&lt;script&gt;alert(1)&lt;/script&gt;'],
['type' => 'block', 'language' => $language, 'filename' => null, 'html' => '&lt;script&gt;alert(1)&lt;/script&gt;'],
]);
})->with([
'unknown language' => 'brainfuck',
Expand All @@ -111,23 +112,34 @@
$codeSpans = resolve(CodeSpans::class);

expect($codeSpans->highlight("```PHP\n\$name;\n```"))->toBe([
['type' => 'block', 'language' => 'PHP', 'html' => '<span class="hl-variable">$name</span>;'],
['type' => 'block', 'language' => 'PHP', 'filename' => null, 'html' => '<span class="hl-variable">$name</span>;'],
]);
});

it('takes the language from the first word of the fence', function (): void {
it('takes the language from the first word of the fence and the filename from the rest', function (string $fence): void {
$codeSpans = resolve(CodeSpans::class);

expect($codeSpans->highlight("```php title=\"app.php\"\n\$name;\n```"))->toBe([
['type' => 'block', 'language' => 'php', 'html' => '<span class="hl-variable">$name</span>;'],
expect($codeSpans->highlight("{$fence}\n\$name;\n```"))->toBe([
['type' => 'block', 'language' => 'php', 'filename' => 'app/Models/User.php', 'html' => '<span class="hl-variable">$name</span>;'],
]);
})->with([
'path' => "```php app/Models/User.php \t",
'title' => "``` php\ttitle=\"app/Models/User.php\"",
]);

it('only separates the filename from the language with spaces or tabs', function (): void {
$codeSpans = resolve(CodeSpans::class);

expect($codeSpans->highlight("```php\u{a0}app.php\n\$name;\n```"))->toBe([
['type' => 'block', 'language' => "php\u{a0}app.php", 'filename' => null, 'html' => '$name;'],
]);
});

it('keeps an empty code block', function (): void {
$codeSpans = resolve(CodeSpans::class);

expect($codeSpans->highlight("```\n```"))->toBe([
['type' => 'block', 'language' => null, 'html' => ''],
['type' => 'block', 'language' => null, 'filename' => null, 'html' => ''],
]);
});

Expand All @@ -136,9 +148,9 @@

expect($codeSpans->highlight("feature:\n````php\n\$login;\n````"))->toBe([
['type' => 'text', 'text' => 'feature:'],
['type' => 'block', 'language' => 'php', 'html' => '<span class="hl-variable">$login</span>;'],
['type' => 'block', 'language' => 'php', 'filename' => null, 'html' => '<span class="hl-variable">$login</span>;'],
])->and($codeSpans->highlight("```\n// test\n````"))->toBe([
['type' => 'block', 'language' => null, 'html' => '// test'],
['type' => 'block', 'language' => null, 'filename' => null, 'html' => '// test'],
]);
});

Expand Down