diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2bfc962a5..1a231a5dc 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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. diff --git a/app/Services/CodeSpans.php b/app/Services/CodeSpans.php index 3c1a2f9a4..fbdd80787 100644 --- a/app/Services/CodeSpans.php +++ b/app/Services/CodeSpans.php @@ -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 + * @return list */ public function highlight(string $text): array { @@ -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)), ]; } } diff --git a/resources/js/components/code-block.tsx b/resources/js/components/code-block.tsx index cf04563d3..8c1d0a151 100644 --- a/resources/js/components/code-block.tsx +++ b/resources/js/components/code-block.tsx @@ -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(null); @@ -52,8 +54,11 @@ export function CodeBlock({
{(language || isCopyable) && (
- - {language} + + {filename ?? language} {isCopyable && (
diff --git a/resources/js/components/commit-body.tsx b/resources/js/components/commit-body.tsx index fac23d0a0..2ab08b37c 100644 --- a/resources/js/components/commit-body.tsx +++ b/resources/js/components/commit-body.tsx @@ -92,6 +92,7 @@ export function BodySegments({ key={index} html={segment.html} language={segment.language} + filename={segment.filename} isCopyable={!isStatic} /> ); diff --git a/resources/js/lib/code.ts b/resources/js/lib/code.ts index fece440b2..e57d4b68b 100644 --- a/resources/js/lib/code.ts +++ b/resources/js/lib/code.ts @@ -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')), }; } diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 8af6a4e68..944316ca3 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -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; diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 5809dd842..ddc569000 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -734,7 +734,7 @@ ['type' => 'text', 'text' => 'Run '], ['type' => 'code', 'code' => 'make'], ['type' => 'text', 'text' => ' :fire'], - ['type' => 'block', 'language' => 'php', 'html' => '$name = \'<b>\';'], + ['type' => 'block', 'language' => 'php', 'filename' => null, 'html' => '$name = \'<b>\';'], ]) ->where('replies.data.0.body_segments.1', ['type' => 'code', 'code' => 'make test']) ->missing('replies.data.0.body_segments.2')); diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 218a02af3..dd2ac3663 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -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( + , + ); + + assert.match( + markup, + /]*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', diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx index 0ec93e3e2..82a764ec7 100644 --- a/tests/Unit/LinkifiedText.test.tsx +++ b/tests/Unit/LinkifiedText.test.tsx @@ -133,6 +133,7 @@ test('keeps captured mention identities in regular bodies alongside highlighted { type: 'block', language: 'php', + filename: null, html: '// @ORIGINAL', }, ]} diff --git a/tests/Unit/NotificationInbox.test.tsx b/tests/Unit/NotificationInbox.test.tsx index 16619e4d9..9fdb35966 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -220,6 +220,7 @@ void test('renders highlighted notification blocks without a nested copy button' { type: 'block', language: 'php', + filename: null, html: 'return ":fire:";', }, ], diff --git a/tests/Unit/NotificationIndex.test.tsx b/tests/Unit/NotificationIndex.test.tsx index b9be50c0f..43e45ed72 100644 --- a/tests/Unit/NotificationIndex.test.tsx +++ b/tests/Unit/NotificationIndex.test.tsx @@ -45,6 +45,7 @@ const notification = { { type: 'block', language: 'php', + filename: null, html: 'return round($score, 6);', }, ], diff --git a/tests/Unit/QuotedCommit.test.tsx b/tests/Unit/QuotedCommit.test.tsx index 45f086bf7..3b7cee2e7 100644 --- a/tests/Unit/QuotedCommit.test.tsx +++ b/tests/Unit/QuotedCommit.test.tsx @@ -20,6 +20,7 @@ const commit = { { type: 'block', language: 'php', + filename: null, html: 'return round($score, 6);', }, ], diff --git a/tests/Unit/Services/CodeSpansTest.php b/tests/Unit/Services/CodeSpansTest.php index be45656c4..62912c9d8 100644 --- a/tests/Unit/Services/CodeSpansTest.php +++ b/tests/Unit/Services/CodeSpansTest.php @@ -66,6 +66,7 @@ [ 'type' => 'block', 'language' => 'php', + 'filename' => null, 'html' => '$name = \'devnull\';', ], ]); @@ -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([ @@ -92,7 +93,7 @@ $segments = $codeSpans->highlight("```{$language}\n\n```"); expect($segments)->toBe([ - ['type' => 'block', 'language' => $language, 'html' => '<script>alert(1)</script>'], + ['type' => 'block', 'language' => $language, 'filename' => null, 'html' => '<script>alert(1)</script>'], ]); })->with([ 'unknown language' => 'brainfuck', @@ -111,15 +112,26 @@ $codeSpans = resolve(CodeSpans::class); expect($codeSpans->highlight("```PHP\n\$name;\n```"))->toBe([ - ['type' => 'block', 'language' => 'PHP', 'html' => '$name;'], + ['type' => 'block', 'language' => 'PHP', 'filename' => null, 'html' => '$name;'], ]); }); -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' => '$name;'], + expect($codeSpans->highlight("{$fence}\n\$name;\n```"))->toBe([ + ['type' => 'block', 'language' => 'php', 'filename' => 'app/Models/User.php', 'html' => '$name;'], + ]); +})->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;'], ]); }); @@ -127,7 +139,7 @@ $codeSpans = resolve(CodeSpans::class); expect($codeSpans->highlight("```\n```"))->toBe([ - ['type' => 'block', 'language' => null, 'html' => ''], + ['type' => 'block', 'language' => null, 'filename' => null, 'html' => ''], ]); }); @@ -136,9 +148,9 @@ expect($codeSpans->highlight("feature:\n````php\n\$login;\n````"))->toBe([ ['type' => 'text', 'text' => 'feature:'], - ['type' => 'block', 'language' => 'php', 'html' => '$login;'], + ['type' => 'block', 'language' => 'php', 'filename' => null, 'html' => '$login;'], ])->and($codeSpans->highlight("```\n// test\n````"))->toBe([ - ['type' => 'block', 'language' => null, 'html' => '// test'], + ['type' => 'block', 'language' => null, 'filename' => null, 'html' => '// test'], ]); });