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([