From 90d02728c0e4c0c88556a7590ad71e5822eeb77c Mon Sep 17 00:00:00 2001 From: Razvan Iacob Date: Thu, 29 Oct 2026 15:00:33 -0400 Subject: [PATCH] fix: keep line ranges and titles out of the code block language --- app/Services/CodeSpans.php | 12 ++++--- resources/js/components/code-block.tsx | 2 +- resources/js/lib/code.ts | 18 +++++++---- tests/Unit/ComposerPreview.test.tsx | 43 ++++++++++++++++++-------- tests/Unit/Services/CodeSpansTest.php | 24 +++++++++++++- 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/app/Services/CodeSpans.php b/app/Services/CodeSpans.php index 8de72ac7d..31a5fc736 100644 --- a/app/Services/CodeSpans.php +++ b/app/Services/CodeSpans.php @@ -144,11 +144,13 @@ private function isBlock(?string $part): bool private function block(string $part): array { $lines = explode("\n", str_replace("\r\n", "\n", $part)); - [$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); + preg_match_all('/\{[^}]*\}|([A-Za-z][\w-]*)=(?:"([^"]*)"|\'([^\']*)\')|([^ \t{]+)/', mb_ltrim($lines[0], '`'), $tokens, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL); + $language = $tokens[0][4] ?? null; $attributes = []; - foreach ($tokens as [, $key, $value]) { + foreach ($tokens as [, $key, $double, $single]) { + $value = $double ?? $single; + if ($key !== null && $value !== '') { $attributes[$key] ??= $value; } @@ -156,9 +158,9 @@ private function block(string $part): array return [ 'type' => 'block', - 'language' => $language === '' ? null : $language, + 'language' => $language, 'filename' => $attributes['title'] ?? $attributes['filename'] ?? null, - 'html' => $this->highlighter->parse(implode("\n", array_slice($lines, 1, -1)), $language === '' ? null : mb_strtolower($language)), + 'html' => $this->highlighter->parse(implode("\n", array_slice($lines, 1, -1)), $language === null ? null : mb_strtolower($language)), ]; } } diff --git a/resources/js/components/code-block.tsx b/resources/js/components/code-block.tsx index 210345235..eeb516b1a 100644 --- a/resources/js/components/code-block.tsx +++ b/resources/js/components/code-block.tsx @@ -70,7 +70,7 @@ export function CodeBlock({ 'before:mask-b-from-[calc(100%-3rem)] before:mask-b-to-100%', )} > - {(language || isCopyable) && ( + {(filename || language || isCopyable) && (
(); - for (const [, key, value] of meta.matchAll( - /\{[^}]*\}|([A-Za-z][\w-]*)="([^"]*)"|[^ \t]+/g, - )) { + for (const [, key, double, single] of tokens) { + const value = double ?? single; + if (key !== undefined && value !== '' && !attributes.has(key)) { attributes.set(key, value); } @@ -46,7 +52,7 @@ function block(part: string): BodySegment { return { type: 'block', - language: language === '' ? null : language, + language, filename: attributes.get('title') ?? attributes.get('filename') ?? null, html: escapeHtml(lines.slice(1, -1).join('\n')), }; diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index 36128ef8b..77f7e11e8 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -316,21 +316,25 @@ for (const [body, expected] of [ for (const fence of [ '``` php\ttitle="app/Models/User.php" \t', '```php filename="app/Models/User.php"', + '``` title="app/Models/User.php"', ]) { - test(`labels the code block of "${fence}" with its filename`, () => { - const markup = renderPage( - , - ); + for (const isStatic of [false, true]) { + test(`labels the code block of "${fence}" with its filename in ${isStatic ? 'excerpts' : 'commits'}`, () => { + const markup = renderPage( + , + ); - assert.match( - markup, - /]*title="app\/Models\/User\.php"[^>]*>app\/Models\/User\.php<\/span>/, - ); - assert.ok(!markup.includes('>php<')); - }); + assert.match( + markup, + /]*title="app\/Models\/User\.php"[^>]*>app\/Models\/User\.php<\/span>/, + ); + assert.ok(!markup.includes('>php<')); + }); + } } for (const [fence, language, filename] of [ @@ -352,6 +356,19 @@ for (const [fence, language, filename] of [ ['```php title="app.php', 'php', null], ['```php caption="see title="app.php"', 'php', null], ['```php {1 title="app.php"}', 'php', null], + ['```php{1,3} title="app.php"', 'php', 'app.php'], + ["```php title='app.php'", 'php', 'app.php'], + ["```php filename='app.php'", 'php', 'app.php'], + ['```php title="it\'s.php"', 'php', "it's.php"], + ['```php title=\'"a".php\'', 'php', '"a".php'], + ["```php title=''", 'php', null], + ["```php title='app.php", 'php', null], + ["``` title='app.php'", null, 'app.php'], + ['```php{1,3}', 'php', null], + ['``` title="app.php"', null, 'app.php'], + ['```title="app.php"', null, 'app.php'], + ['```{1,3}', null, null], + ['``` {1,3} title="app.php"', null, 'app.php'], ['```php\u00a0title="app.php"', 'php\u00a0title="app.php"', null], ['```php', 'php', null], ['```', null, null], diff --git a/tests/Unit/Services/CodeSpansTest.php b/tests/Unit/Services/CodeSpansTest.php index a7fc30d81..edc60e49c 100644 --- a/tests/Unit/Services/CodeSpansTest.php +++ b/tests/Unit/Services/CodeSpansTest.php @@ -130,6 +130,11 @@ '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'], + 'line range right after the language' => ['```php{1,3} title="app.php"', 'app.php'], + 'single quotes' => ["```php title='app.php'", 'app.php'], + 'single-quoted filename' => ["```php filename='app.php'", 'app.php'], + 'apostrophe in double quotes' => ['```php title="it\'s.php"', "it's.php"], + 'double quotes in single quotes' => ["```php title='\"a\".php'", '"a".php'], ]); it('keeps only the language when the fence has no title', function (string $fence): void { @@ -146,9 +151,26 @@ '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"}', + 'line range right after the language' => '```php{1,3}', + 'empty single-quoted title' => "```php title=''", + 'unclosed single-quoted title' => "```php title='app.php", ]); -it('only separates the language from the rest of the fence with spaces or tabs', function (): void { +it('takes no language from a fence that starts with a title or a line range', function (string $fence, ?string $filename): void { + $codeSpans = resolve(CodeSpans::class); + + expect($codeSpans->highlight("{$fence}\n\$name;\n```"))->toBe([ + ['type' => 'block', 'language' => null, 'filename' => $filename, 'html' => '$name;'], + ]); +})->with([ + 'title' => ['``` title="app.php"', 'app.php'], + 'title right after the backticks' => ['```title="app.php"', 'app.php'], + 'single-quoted title' => ["``` title='app.php'", 'app.php'], + 'line range' => ['```{1,3}', null], + 'line range and title' => ['``` {1,3} title="app.php"', 'app.php'], +]); + +it('does not separate the language from the rest of the fence with a non-breaking space', function (): void { $codeSpans = resolve(CodeSpans::class); expect($codeSpans->highlight("```php\u{a0}title=\"app.php\"\n\$name;\n```"))->toBe([