From 69c1fb4e7c134ec5a2ebfabb7bf89860446147a4 Mon Sep 17 00:00:00 2001 From: Razvan Iacob Date: Mon, 26 Oct 2026 20:48:18 -0400 Subject: [PATCH] feat: label code blocks with their filename Co-Authored-By: Claude --- KNOWLEDGE_BASE.md | 2 +- app/Services/CodeSpans.php | 19 +++++-- resources/js/components/code-block.tsx | 9 +++- resources/js/components/commit-body.tsx | 1 + resources/js/lib/code.ts | 13 ++++- resources/js/types/commit.ts | 1 + .../Controllers/CommitControllerTest.php | 2 +- tests/Unit/CodeSpans.test.ts | 1 + tests/Unit/ComposerPreview.test.tsx | 50 ++++++++++++++++++ tests/Unit/LinkifiedText.test.tsx | 1 + tests/Unit/NotificationInbox.test.tsx | 1 + tests/Unit/NotificationIndex.test.tsx | 1 + tests/Unit/QuotedCommit.test.tsx | 1 + tests/Unit/Services/CodeSpansTest.php | 51 +++++++++++++++---- 14 files changed, 134 insertions(+), 19 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 8584aabf2..4efb043f3 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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. diff --git a/app/Services/CodeSpans.php b/app/Services/CodeSpans.php index 2d194a1cd..8de72ac7d 100644 --- a/app/Services/CodeSpans.php +++ b/app/Services/CodeSpans.php @@ -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 + * @return list */ public function highlight(string $text): array { @@ -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)), ]; } } diff --git a/resources/js/components/code-block.tsx b/resources/js/components/code-block.tsx index 96e2b0486..e355c78f2 100644 --- a/resources/js/components/code-block.tsx +++ b/resources/js/components/code-block.tsx @@ -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(null); @@ -63,8 +65,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 f35f0da0e..d90cbae2d 100644 --- a/resources/js/components/commit-body.tsx +++ b/resources/js/components/commit-body.tsx @@ -100,6 +100,7 @@ export function BodySegments({ html={segment.html} fullHtml={segment.fullHtml} language={segment.language} + filename={segment.filename} isCopyable={!isStatic} /> ); diff --git a/resources/js/lib/code.ts b/resources/js/lib/code.ts index e44774669..1484baf3e 100644 --- a/resources/js/lib/code.ts +++ b/resources/js/lib/code.ts @@ -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(); + + 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')), }; } diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 82f791fb8..355cb4a21 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -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; 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/CodeSpans.test.ts b/tests/Unit/CodeSpans.test.ts index 94ef4c4b4..cb7d02729 100644 --- a/tests/Unit/CodeSpans.test.ts +++ b/tests/Unit/CodeSpans.test.ts @@ -15,6 +15,7 @@ test('keeps a shorter fence inside a code block', () => { { type: 'block', language: 'markdown', + filename: null, html: '```\nfence inside fence\n```', }, { type: 'text', text: 'after' }, diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 218a02af3..ed5bb0511 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -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( + , + ); + + 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\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', diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx index a7508c76c..c8cd7bd73 100644 --- a/tests/Unit/LinkifiedText.test.tsx +++ b/tests/Unit/LinkifiedText.test.tsx @@ -171,6 +171,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 635fdfb77..b20028dd1 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -240,6 +240,7 @@ function categories(html: string, pressed: boolean): Set { { type: 'block', language: 'php', + filename: null, html: 'return ":fire:";', }, ], diff --git a/tests/Unit/NotificationIndex.test.tsx b/tests/Unit/NotificationIndex.test.tsx index 4d8a6d625..be2fdc8bd 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 3d91a714b..502865fda 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 6c1c1e376..a7fc30d81 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,47 @@ $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 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' => '$name;'], + expect($codeSpans->highlight("{$fence}\n\$name;\n```"))->toBe([ + ['type' => 'block', 'language' => 'php', 'filename' => $filename, 'html' => '$name;'], + ]); +})->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' => '$name;'], + ]); +})->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;'], ]); }); @@ -127,7 +160,7 @@ $codeSpans = resolve(CodeSpans::class); expect($codeSpans->highlight("```\n```"))->toBe([ - ['type' => 'block', 'language' => null, 'html' => ''], + ['type' => 'block', 'language' => null, 'filename' => null, 'html' => ''], ]); }); @@ -136,9 +169,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'], ]); });