Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions app/Services/CodeSpans.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '/((?<![^\n])`{3,}[^`\r\n]*\r?\n(?:[\s\S]*?\r?\n)?`{3,}[ \t]*(?![^\r\n])|`[^`\r\n]+`)/';
private const string PATTERN = '/(?<![^\n])(?<fence>`{3,})[^`\r\n]*\r?\n(?:[\s\S]*?\r?\n)?\k<fence>`*[ \t]*(?![^\r\n])|`[^`\r\n]+`/';

public function __construct(private Highlighter $highlighter)
{
Expand All @@ -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;
}

/**
Expand Down
4 changes: 2 additions & 2 deletions resources/js/components/wordmark-text.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 19 additions & 11 deletions resources/js/lib/code.ts
Original file line number Diff line number Diff line change
@@ -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`(?<![^\n])\`{3,}[^\`\r\n]*\r?\n(?:[\s\S]*?\r?\n)?\`{3,}[ \t]*(?![^\r\n])|\`[^\`\r\n]+\``;
export const codePattern = new RegExp(`(${committedCode})`);
// 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.
const committedCode = String.raw`(?<![^\n])(\`{3,})[^\`\r\n]*\r?\n(?:[\s\S]*?\r?\n)?\2\`*[ \t]*(?![^\r\n])|\`[^\`\r\n]+\``;
const codePattern = new RegExp(`(${committedCode})`);
// While typing, a block or inline code that isn't closed yet also runs to the end of the text.
const typedCodePattern = new RegExp(
`(${committedCode}|${String.raw`(?<![^\n])\`\`\`[\s\S]*$|\`[^\`\r\n]*$`})`,
);

/**
* Splits the text into prose and code, alternating: even indexes are prose (maybe empty), odd ones are code. The
* fence the pattern captures to close a block isn't a part.
*/
export function splitCode(text: string, pattern = codePattern): string[] {
return text.split(pattern).filter((_, index) => index % 3 !== 2);
}

const isBlock = (part: string | undefined): boolean =>
part?.startsWith('```') ?? false;

Expand Down Expand Up @@ -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('');
}
Expand All @@ -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),
)
Expand All @@ -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('');
}
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
);
}
42 changes: 42 additions & 0 deletions tests/Unit/CodeSpans.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
15 changes: 15 additions & 0 deletions tests/Unit/Services/CodeSpansTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down