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
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,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 title after the language, as in ```` ```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
19 changes: 14 additions & 5 deletions app/Services/CodeSpans.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,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 @@ -139,17 +139,26 @@ 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, $meta] = array_pad(preg_split('/[ \t]+/', mb_trim(mb_ltrim($lines[0], '`'), " \t"), 2) ?: [], 2, '');
preg_match_all('/\{[^}]*\}|([A-Za-z][\w-]*)="([^"]*)"|[^ \t]+/', $meta, $tokens, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL);
$attributes = [];

foreach ($tokens as [, $key, $value]) {
if ($key !== null && $value !== '') {
$attributes[$key] ??= $value;
}
}

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' => $attributes['title'] ?? $attributes['filename'] ?? null,
'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 @@ -9,11 +9,13 @@ export function CodeBlock({
html,
fullHtml,
language,
filename,
isCopyable = true,
}: {
html: string;
fullHtml?: string;
language: string | null;
filename: string | null;
isCopyable?: boolean;
}) {
const codeRef = useRef<HTMLPreElement>(null);
Expand Down Expand Up @@ -63,8 +65,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 @@ -100,6 +100,7 @@ export function BodySegments({
html={segment.html}
fullHtml={segment.fullHtml}
language={segment.language}
filename={segment.filename}
isCopyable={!isStatic}
/>
);
Expand Down
13 changes: 12 additions & 1 deletion resources/js/lib/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@ 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 = '', meta = ''] =
/^`+[ \t]*([^ \t]*)([\s\S]*)$/.exec(lines[0]) ?? [];
const attributes = new Map<string, string>();

for (const [, key, value] of meta.matchAll(
/\{[^}]*\}|([A-Za-z][\w-]*)="([^"]*)"|[^ \t]+/g,
)) {
if (key !== undefined && value !== '' && !attributes.has(key)) {
attributes.set(key, value);
}
}

return {
type: 'block',
language: language === '' ? null : language,
filename: attributes.get('title') ?? attributes.get('filename') ?? null,
html: escapeHtml(lines.slice(1, -1).join('\n')),
};
}
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type BodySegment =
| {
type: 'block';
language: string | null;
filename: string | null;
html: string;
// The whole block, when a preview shows only its first lines, so copying it still copies all of it.
fullHtml?: 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
1 change: 1 addition & 0 deletions tests/Unit/CodeSpans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,28 @@

const nested = '````markdown\n```\nfence inside fence\n```\n````';

test('keeps a shorter fence inside a code block', () => {
assert.deepEqual(bodySegments(`before\n${nested}\nafter`), [
{ type: 'text', text: 'before' },
{
type: 'block',
language: 'markdown',
filename: null,
html: '```\nfence inside fence\n```',
},
{ type: 'text', text: 'after' },
]);
});

Check warning on line 23 in tests/Unit/CodeSpans.test.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(no-floating-promises)

tests/Unit/CodeSpans.test.ts:12:1: Promises must be awaited, add void operator to ignore.

test('closes a code block with as many backticks or more', () => {
assert.deepEqual(splitCode('````\ncode\n`````\nprose `x`'), [
'',
'````\ncode\n`````',
'\nprose ',
'`x`',
'',
]);
});

Check warning on line 33 in tests/Unit/CodeSpans.test.ts

View workflow job for this annotation

GitHub Actions / lint

typescript(no-floating-promises)

tests/Unit/CodeSpans.test.ts:25:1: Promises must be awaited, add void operator to ignore.

test('leaves the code of a nested block alone, committed or being typed', () => {
const upper = (prose: string) => prose.toUpperCase();
Expand Down
50 changes: 50 additions & 0 deletions tests/Unit/ComposerPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,56 @@ for (const [body, expected] of [
});
}

for (const fence of [
'``` php\ttitle="app/Models/User.php" \t',
'```php filename="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\ttitle="app/Models/User.php" \t', 'php', 'app/Models/User.php'],
['```php filename="app/Models/User.php"', 'php', 'app/Models/User.php'],
['```php filename="a.php" title="b.php"', 'php', 'b.php'],
['```php title="" filename="a.php"', 'php', 'a.php'],
['```php title="a.php" title="b.php"', 'php', 'a.php'],
['```php title="My Model.php"', 'php', 'My Model.php'],
[
'```php {1, 3} showLineNumbers title="app.php" tab="Pest 4"',
'php',
'app.php',
],
['```php app/Models/User.php', 'php', null],
['```php {1,3}', 'php', null],
['```php tab=Pest', 'php', null],
['```php title=""', 'php', null],
['```php title="app.php', 'php', null],
['```php caption="see title="app.php"', 'php', null],
['```php {1 title="app.php"}', 'php', null],
['```php\u00a0title="app.php"', 'php\u00a0title="app.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 @@ -171,6 +171,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 @@ -240,6 +240,7 @@ function categories(html: string, pressed: boolean): Set<string> {
{
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
51 changes: 42 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,55 @@
$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 its title', function (string $fence, string $filename): 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' => $filename, 'html' => '<span class="hl-variable">$name</span>;'],
]);
})->with([
'title' => ["``` php\ttitle=\"app/Models/User.php\" \t", 'app/Models/User.php'],
'filename' => ['```php filename="app/Models/User.php"', 'app/Models/User.php'],
'title and filename' => ['```php filename="a.php" title="b.php"', 'b.php'],
'empty title and filename' => ['```php title="" filename="a.php"', 'a.php'],
'two titles' => ['```php title="a.php" title="b.php"', 'a.php'],
'spaces in the title' => ['```php title="My Model.php"', 'My Model.php'],
'other attributes' => ['```php {1, 3} showLineNumbers title="app.php" tab="Pest 4"', 'app.php'],
]);

it('keeps only the language when the fence has no title', function (string $fence): void {
$codeSpans = resolve(CodeSpans::class);

expect($codeSpans->highlight("{$fence}\n\$name;\n```"))->toBe([
['type' => 'block', 'language' => 'php', 'filename' => null, 'html' => '<span class="hl-variable">$name</span>;'],
]);
})->with([
'path' => '```php app/Models/User.php',
'line range' => '```php {1,3}',
'unquoted attribute' => '```php tab=Pest',
'empty title' => '```php title=""',
'unclosed title' => '```php title="app.php',
'title inside another attribute' => '```php caption="see title="app.php"',
'title inside a line range' => '```php {1 title="app.php"}',
]);

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

expect($codeSpans->highlight("```php\u{a0}title=\"app.php\"\n\$name;\n```"))->toBe([
['type' => 'block', 'language' => "php\u{a0}title=\"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 +169,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
Loading