Skip to content
Draft
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
46 changes: 46 additions & 0 deletions src/Node/Block/CodeBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@
*/
class CodeBlock extends BlockNode
{
/**
* @param string $content The code content
* @param string|null $language The language identifier (e.g., 'php', 'js')
* @param bool $showLineNumbers Whether to display line numbers
* @param int $lineNumberStart Starting line number (default 1)
* @param array<int> $highlightLines Line numbers to highlight
*/
public function __construct(
protected string $content = '',
protected ?string $language = null,
protected bool $showLineNumbers = false,
protected int $lineNumberStart = 1,
protected array $highlightLines = [],
) {
}

Expand All @@ -35,6 +45,42 @@ public function getLanguage(): ?string
return $this->language;
}

public function showLineNumbers(): bool
{
return $this->showLineNumbers;
}

public function setShowLineNumbers(bool $show): void
{
$this->showLineNumbers = $show;
}

public function getLineNumberStart(): int
{
return $this->lineNumberStart;
}

public function setLineNumberStart(int $start): void
{
$this->lineNumberStart = $start;
}

/**
* @return array<int>
*/
public function getHighlightLines(): array
{
return $this->highlightLines;
}

/**
* @param array<int> $lines
*/
public function setHighlightLines(array $lines): void
{
$this->highlightLines = $lines;
}

public function getType(): string
{
return 'code_block';
Expand Down
109 changes: 109 additions & 0 deletions src/Parser/Block/FencedBlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,113 @@ public function removeIndent(string $line, int $indentLen): string

return $line;
}

/**
* Parse code block info string for language, line numbers, and highlight lines.
*
* Syntax examples:
* - `php` - just language
* - `php #` - language with line numbers
* - `php #=5` - language with line numbers starting at 5
* - `php {3,5-7}` - language with highlighted lines
* - `php # {3,5-7}` - language with line numbers and highlighted lines
* - `php #=5 {3,5-7}` - language with line numbers starting at 5 and highlighted lines
* - `#` - no language, just line numbers
* - `{3,5}` - no language, just highlighted lines
*
* @param string $info The info string after the opening fence
*
* @return array{language: string|null, showLineNumbers: bool, lineNumberStart: int, highlightLines: array<int>}
*/
public function parseCodeBlockInfo(string $info): array
{
$result = [
'language' => null,
'showLineNumbers' => false,
'lineNumberStart' => 1,
'highlightLines' => [],
];

$info = trim($info);
if ($info === '') {
return $result;
}

// Match the full pattern: [language] [#[=N]] [{lines}]
// Language: must start with word char or dot, can contain +, #, -, . (e.g., c++, c#, .net)
// Line numbers: # or #=N
// Highlight: {1,3-5,7}
$pattern = '/^(?<lang>[\w.][\w+#.-]*)?\s*(?<linenum>#(?:=(?<start>\d+))?)?(?:\s*\{(?<highlight>[0-9,\-\s]+)\})?$/';

if (!preg_match($pattern, $info, $matches)) {
// If pattern doesn't match, treat entire info as language (backwards compatible)
$result['language'] = $info;

return $result;
}

// Extract language
if (!empty($matches['lang'])) {
$result['language'] = $matches['lang'];
}

// Extract line numbers
if (!empty($matches['linenum'])) {
$result['showLineNumbers'] = true;
if (!empty($matches['start'])) {
$result['lineNumberStart'] = (int)$matches['start'];
}
}

// Extract highlight lines
if (!empty($matches['highlight'])) {
$result['highlightLines'] = $this->parseHighlightLines($matches['highlight']);
}

return $result;
}

/**
* Parse highlight line specification into array of line numbers.
*
* @param string $spec Comma-separated list of line numbers and ranges (e.g., "1,3-5,7")
*
* @return array<int>
*/
protected function parseHighlightLines(string $spec): array
{
$lines = [];
$parts = explode(',', $spec);

foreach ($parts as $part) {
$part = trim($part);
if ($part === '') {
continue;
}

if (str_contains($part, '-')) {
// Range: 3-7
[$start, $end] = explode('-', $part, 2);
$start = (int)trim($start);
$end = (int)trim($end);
if ($start > 0 && $end >= $start) {
for ($i = $start; $i <= $end; $i++) {
$lines[] = $i;
}
}
} else {
// Single line
$line = (int)$part;
if ($line > 0) {
$lines[] = $line;
}
}
}

// Remove duplicates and sort
$lines = array_unique($lines);
sort($lines);

return $lines;
}
}
13 changes: 10 additions & 3 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -880,9 +880,16 @@ protected function tryParseCodeBlock(Node $parent, array $lines, int $start): ?i
$this->addWarning('Unclosed code fence', $start, 1, true);
}

$language = $info !== '' ? $info : null;

$codeBlock = new CodeBlock(trim($content, "\n"), $language);
// Parse info string for language, line numbers, and highlight lines
$parsedInfo = $this->fencedBlockParser->parseCodeBlockInfo($info);

$codeBlock = new CodeBlock(
trim($content, "\n"),
$parsedInfo['language'],
$parsedInfo['showLineNumbers'],
$parsedInfo['lineNumberStart'],
$parsedInfo['highlightLines'],
);
$this->applyPendingAttributes($codeBlock);
$parent->appendChild($codeBlock);

Expand Down
99 changes: 94 additions & 5 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,9 @@ protected function getPlainText(Node $node): string
protected function renderCodeBlock(CodeBlock $node): string
{
$language = $node->getLanguage();
$showLineNumbers = $node->showLineNumbers();
$lineNumberStart = $node->getLineNumberStart();
$highlightLines = $node->getHighlightLines();
$attrs = $this->renderAttributes($node);

$code = $this->escape($node->getContent());
Expand All @@ -489,18 +492,104 @@ protected function renderCodeBlock(CodeBlock $node): string
$code = str_replace("\t", str_repeat(' ', $this->codeBlockTabWidth), $code);
}

// Add trailing newline inside code block (official djot behavior)
if ($code !== '' && !str_ends_with($code, "\n")) {
$code .= "\n";
// Build pre classes
$preClasses = [];
if ($showLineNumbers) {
$preClasses[] = 'line-numbers';
}
if ($highlightLines !== []) {
$preClasses[] = 'has-highlighted-lines';
}

// Build pre attributes
$preAttrs = $attrs;
if ($preClasses !== []) {
$preAttrs = $this->mergeClassAttribute($preAttrs, implode(' ', $preClasses));
}
if ($showLineNumbers && $lineNumberStart !== 1) {
$preAttrs .= ' data-start="' . $lineNumberStart . '"';
}

// If we have line numbers or highlighting, wrap each line
if ($showLineNumbers || $highlightLines !== []) {
$code = $this->renderCodeWithLineWrappers($code, $lineNumberStart, $highlightLines);
} else {
// Add trailing newline inside code block (official djot behavior)
if ($code !== '' && !str_ends_with($code, "\n")) {
$code .= "\n";
}
}

if ($language !== null) {
$langClass = 'class="language-' . $this->escape($language) . '"';

return '<pre' . $attrs . '><code ' . $langClass . '>' . $code . "</code></pre>\n";
return '<pre' . $preAttrs . '><code ' . $langClass . '>' . $code . "</code></pre>\n";
}

return '<pre' . $preAttrs . '><code>' . $code . "</code></pre>\n";
}

/**
* Wrap each line in a span with optional highlighting.
*
* @param string $code The escaped code content
* @param int $startLine The starting line number
* @param array<int> $highlightLines Lines to highlight
*
* @return string Code with line wrappers
*/
protected function renderCodeWithLineWrappers(string $code, int $startLine, array $highlightLines): string
{
$lines = explode("\n", $code);

// Remove last empty line if present (we'll add it back at the end)
$hadTrailingNewline = end($lines) === '';
if ($hadTrailingNewline) {
array_pop($lines);
}

$result = [];
$lineNum = $startLine;

foreach ($lines as $line) {
$classes = ['line'];
if (in_array($lineNum, $highlightLines, true)) {
$classes[] = 'highlighted';
}

$classAttr = implode(' ', $classes);
$result[] = '<span class="' . $classAttr . '" data-line="' . $lineNum . '">' . $line . '</span>';
$lineNum++;
}

return implode("\n", $result) . "\n";
}

/**
* Merge a class into an existing attributes string.
*
* @param string $attrs Existing attributes string (may be empty or start with space)
* @param string $class Class(es) to add
*
* @return string Updated attributes string
*/
protected function mergeClassAttribute(string $attrs, string $class): string
{
if ($attrs === '') {
return ' class="' . $class . '"';
}

// Check if there's already a class attribute
if (preg_match('/\sclass="([^"]*)"/', $attrs, $matches)) {
// Append to existing class
$existingClass = $matches[1];
$newClass = $existingClass . ' ' . $class;

return (string)preg_replace('/\sclass="[^"]*"/', ' class="' . $newClass . '"', $attrs);
}

return '<pre' . $attrs . '><code>' . $code . "</code></pre>\n";
// No existing class, prepend new class attribute
return ' class="' . $class . '"' . $attrs;
}

protected function renderBlockQuote(BlockQuote $node): string
Expand Down
77 changes: 77 additions & 0 deletions tests/TestCase/Parser/BlockParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -532,4 +532,81 @@ public function testRawBlockTrimsLeadingAndTrailingBlankLines(): void
$this->assertCount(1, $children);
$this->assertSame('<b>bold</b>', $children[0]->getContent());
}

public function testCodeBlockWithLineNumbers(): void
{
$doc = $this->parser->parse("```php #\necho 'hello';\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertSame('php', $codeBlock->getLanguage());
$this->assertTrue($codeBlock->showLineNumbers());
$this->assertSame(1, $codeBlock->getLineNumberStart());
}

public function testCodeBlockWithLineNumbersStartOffset(): void
{
$doc = $this->parser->parse("```php #=5\necho 'hello';\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertSame('php', $codeBlock->getLanguage());
$this->assertTrue($codeBlock->showLineNumbers());
$this->assertSame(5, $codeBlock->getLineNumberStart());
}

public function testCodeBlockWithHighlightLines(): void
{
$doc = $this->parser->parse("```php {2,4-6}\nline1\nline2\nline3\nline4\nline5\nline6\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertSame('php', $codeBlock->getLanguage());
$this->assertFalse($codeBlock->showLineNumbers());
$this->assertSame([2, 4, 5, 6], $codeBlock->getHighlightLines());
}

public function testCodeBlockWithLineNumbersAndHighlight(): void
{
$doc = $this->parser->parse("```php # {2,4}\nline1\nline2\nline3\nline4\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertSame('php', $codeBlock->getLanguage());
$this->assertTrue($codeBlock->showLineNumbers());
$this->assertSame([2, 4], $codeBlock->getHighlightLines());
}

public function testCodeBlockWithLineNumbersOffsetAndHighlight(): void
{
$doc = $this->parser->parse("```php #=10 {2,4}\nline1\nline2\nline3\nline4\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertSame('php', $codeBlock->getLanguage());
$this->assertTrue($codeBlock->showLineNumbers());
$this->assertSame(10, $codeBlock->getLineNumberStart());
$this->assertSame([2, 4], $codeBlock->getHighlightLines());
}

public function testCodeBlockLineNumbersOnlyNoLanguage(): void
{
$doc = $this->parser->parse("```#\ncode\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertNull($codeBlock->getLanguage());
$this->assertTrue($codeBlock->showLineNumbers());
}

public function testCodeBlockHighlightOnlyNoLanguage(): void
{
$doc = $this->parser->parse("```{1,3}\nline1\nline2\nline3\n```");

$codeBlock = $doc->getChildren()[0];
$this->assertInstanceOf(CodeBlock::class, $codeBlock);
$this->assertNull($codeBlock->getLanguage());
$this->assertFalse($codeBlock->showLineNumbers());
$this->assertSame([1, 3], $codeBlock->getHighlightLines());
}
}
Loading