diff --git a/app/Services/CodeSpans.php b/app/Services/CodeSpans.php index 3c1a2f9a4..2d194a1cd 100644 --- a/app/Services/CodeSpans.php +++ b/app/Services/CodeSpans.php @@ -14,10 +14,11 @@ final readonly class CodeSpans { /** - * A fenced code block, from a line starting with ``` or more (and maybe a language) to a line that is only ``` or - * more, or inline code between two backticks on one line. Keep in sync with resources/js/lib/code.ts. + * A fenced code block, from a line starting with ``` or more (and maybe a language) to a line that is only as many + * backticks or more, so a block can hold a shorter fence, or inline code between two backticks on one line. Keep in + * sync with resources/js/lib/code.ts. */ - private const string PATTERN = '/((?`{3,})[^`\r\n]*\r?\n(?:[\s\S]*?\r?\n)?\k`*[ \t]*(?![^\r\n])|`[^`\r\n]+`/'; public function __construct(private Highlighter $highlighter) { @@ -35,7 +36,21 @@ public function split(string $text): array return [$text]; } - return preg_split(self::PATTERN, $text, flags: PREG_SPLIT_DELIM_CAPTURE) ?: [$text]; + // The prose and the code are found apart, since the fence the pattern captures isn't a part. + $prose = preg_split(self::PATTERN, $text) ?: [$text]; + preg_match_all(self::PATTERN, $text, $matches); + + $parts = []; + + foreach ($prose as $index => $part) { + $parts[] = $part; + + if (isset($matches[0][$index])) { + $parts[] = $matches[0][$index]; + } + } + + return $parts; } /** diff --git a/resources/js/components/wordmark-text.tsx b/resources/js/components/wordmark-text.tsx index 219a63c4c..3c582a335 100644 --- a/resources/js/components/wordmark-text.tsx +++ b/resources/js/components/wordmark-text.tsx @@ -1,8 +1,8 @@ import AppWordmark from '@/components/app-wordmark'; -import { codePattern } from '@/lib/code'; +import { splitCode } from '@/lib/code'; export function WordmarkText({ children }: { children: string }) { - return children.split(codePattern).flatMap((segment, segmentIndex) => + return splitCode(children).flatMap((segment, segmentIndex) => segmentIndex % 2 === 1 ? segment : segment diff --git a/resources/js/lib/code.ts b/resources/js/lib/code.ts index fece440b2..e44774669 100644 --- a/resources/js/lib/code.ts +++ b/resources/js/lib/code.ts @@ -1,14 +1,23 @@ import type { BodySegment } from '@/types'; // Keep in sync with app/Services/CodeSpans.php: a fenced code block, from a line starting with ``` or more (and maybe -// a language) to a line that is only ``` or more, or inline code between two backticks on one line. -const committedCode = String.raw`(? index % 3 !== 2); +} + const isBlock = (part: string | undefined): boolean => part?.startsWith('```') ?? false; @@ -39,8 +48,7 @@ export function mapProse( text: string, callback: (prose: string) => string, ): string { - return text - .split(codePattern) + return splitCode(text) .map((part, index) => (index % 2 === 0 ? callback(part) : part)) .join(''); } @@ -50,8 +58,7 @@ export function mapProse( * place in the text. */ export function withoutCode(text: string): string { - return text - .split(codePattern) + return splitCode(text) .map((part, index) => index % 2 === 0 ? part : ' '.repeat(part.length), ) @@ -65,8 +72,7 @@ export function mapTypedProse( text: string, callback: (prose: string) => string, ): string { - return text - .split(typedCodePattern) + return splitCode(text, typedCodePattern) .map((part, index) => (index % 2 === 0 ? callback(part) : part)) .join(''); } @@ -76,7 +82,7 @@ export function mapTypedProse( * only the server highlights code. */ export function bodySegments(text: string): BodySegment[] { - const parts = text.split(codePattern); + const parts = splitCode(text); const segments: BodySegment[] = []; parts.forEach((part, index) => { @@ -163,5 +169,7 @@ export function bodyBlocks( * The prose being typed, without the code. */ export function typedProse(text: string): string[] { - return text.split(typedCodePattern).filter((_, index) => index % 2 === 0); + return splitCode(text, typedCodePattern).filter( + (_, index) => index % 2 === 0, + ); } diff --git a/tests/Unit/CodeSpans.test.ts b/tests/Unit/CodeSpans.test.ts new file mode 100644 index 000000000..94ef4c4b4 --- /dev/null +++ b/tests/Unit/CodeSpans.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + bodySegments, + mapProse, + mapTypedProse, + splitCode, +} from '../../resources/js/lib/code.ts'; + +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', + html: '```\nfence inside fence\n```', + }, + { type: 'text', text: 'after' }, + ]); +}); + +test('closes a code block with as many backticks or more', () => { + assert.deepEqual(splitCode('````\ncode\n`````\nprose `x`'), [ + '', + '````\ncode\n`````', + '\nprose ', + '`x`', + '', + ]); +}); + +test('leaves the code of a nested block alone, committed or being typed', () => { + const upper = (prose: string) => prose.toUpperCase(); + + assert.equal(mapProse(`a\n${nested}\nb`, upper), `A\n${nested}\nB`); + assert.equal( + mapTypedProse('a\n````\n```\nstill typing', upper), + 'A\n````\n```\nstill typing', + ); +}); diff --git a/tests/Unit/Services/CodeSpansTest.php b/tests/Unit/Services/CodeSpansTest.php index be45656c4..6c1c1e376 100644 --- a/tests/Unit/Services/CodeSpansTest.php +++ b/tests/Unit/Services/CodeSpansTest.php @@ -142,6 +142,21 @@ ]); }); +it('keeps a shorter fence inside a code block', function (): void { + $codeSpans = resolve(CodeSpans::class); + $text = "before\n`````\n````markdown\n```\nfence inside fence\n```\n````\n`````\nafter `x`"; + + expect($codeSpans->split($text))->toBe([ + "before\n", + "`````\n````markdown\n```\nfence inside fence\n```\n````\n`````", + "\nafter ", + '`x`', + '', + ])->and($codeSpans->highlight("````markdown\n```\nfence inside fence\n```\n````")[0]['html']) + ->toContain('fence inside fence') + ->toContain('```'); +}); + it('never lets a code block pick its own CSS classes', function (): void { $codeSpans = resolve(CodeSpans::class);