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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"nette/neon": "^3.4",
"nikic/php-parser": "^5.7",
"phiki/phiki": "^2.1",
"php-collective/djot": "^0.1.15",
"php-collective/djot": "^0.1.21",
"php": ">=8.2",
"symfony/process": "^7.3"
},
Expand Down
67 changes: 56 additions & 11 deletions src/Render/Djot/CodeGroupExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@

/**
* Djot extension that renders `::: code-group` blocks as tabbed code panes.
*
* Supports language hints with optional `[label]` suffix:
* - `php` -> language: php, label: php
* - `php [Installation]` -> language: php, label: Installation
* - `[Custom Label]` -> language: null, label: Custom Label
* - `c++`, `c#`, `text/html` -> works with special characters
*/
final class CodeGroupExtension implements ExtensionInterface
{
Expand Down Expand Up @@ -52,7 +58,7 @@ public function register(DjotConverter $converter): void
return;
}

$event->setHtml($this->renderCodeGroup($codeBlocks));
$event->setHtml($this->renderCodeGroup($node, $codeBlocks));
});
}

Expand Down Expand Up @@ -96,14 +102,17 @@ protected function extractCodeBlocks(Div $node): array
/**
* Render grouped code blocks as a DaisyUI tabs layout.
*
* @param \Djot\Node\Block\Div $wrapper Original div node for attribute preservation.
* @param list<\Djot\Node\Block\CodeBlock> $blocks Code blocks.
*/
protected function renderCodeGroup(array $blocks): string
protected function renderCodeGroup(Div $wrapper, array $blocks): string
{
$this->groupIndex++;
$groupName = 'glaze-code-group-' . $this->groupIndex;

$html = '<div class="glaze-code-group" role="tablist">';
$attrs = $this->buildWrapperAttributes($wrapper);
$html = '<div' . $attrs . ' role="tablist">';

foreach ($blocks as $index => $block) {
$metadata = $this->parseLanguageMetadata($block->getLanguage(), $index + 1);
$label = $this->escapeAttribute($metadata['label']);
Expand All @@ -123,9 +132,42 @@ protected function renderCodeGroup(array $blocks): string
return $html . '</div>';
}

/**
* Build wrapper div attributes, preserving custom attributes from the original div.
*
* @param \Djot\Node\Block\Div $wrapper Original div node.
*/
protected function buildWrapperAttributes(Div $wrapper): string
{
$classes = ['glaze-code-group'];

// Add any additional classes from the original div (except 'code-group')
$existingClasses = (string)$wrapper->getAttribute('class');
foreach (preg_split('/\s+/', $existingClasses) ?: [] as $class) {
$class = trim($class);
if ($class !== '' && $class !== 'code-group' && !in_array($class, $classes, true)) {
$classes[] = $class;
}
}

$attrs = ' class="' . $this->escapeAttribute(implode(' ', $classes)) . '"';

// Copy other attributes (except class)
foreach ($wrapper->getAttributes() as $name => $value) {
if ($name === 'class') {
continue;
}
$attrs .= ' ' . $this->escapeAttribute($name) . '="' . $this->escapeAttribute((string)$value) . '"';
}

return $attrs;
}

/**
* Parse a Djot code fence language hint with optional `[label]` suffix.
*
* Supports any non-whitespace characters in language names (c++, c#, text/html, etc.)
*
* @param string|null $language Raw language hint from Djot code fence.
* @param int $position One-based position in the group.
* @return array{language: string|null, label: string}
Expand All @@ -137,16 +179,19 @@ protected function parseLanguageMetadata(?string $language, int $position): arra
return ['language' => null, 'label' => 'Code ' . $position];
}

$matches = [];
preg_match('/^(?<lang>[A-Za-z0-9_-]+)?(?:\s*\[(?<label>[^\]]+)\])?.*$/', $raw, $matches);

$resolvedLanguage = trim($matches['lang'] ?? '');
if ($resolvedLanguage === '') {
$resolvedLanguage = null;
// Match: optional language (any non-whitespace, non-[ chars), optional [label]
if (preg_match('/^(?:(?<lang>[^\s\[]+)\s*)?(?:\[(?<label>[^\]]+)])?$/', $raw, $matches) !== 1) {
return ['language' => $raw, 'label' => $raw];
}

$resolvedLabel = trim($matches['label'] ?? '');
if ($resolvedLabel === '') {
$matchedLanguage = $matches['lang'] ?? null;
$matchedLabel = $matches['label'] ?? null;

$resolvedLanguage = $matchedLanguage !== '' ? $matchedLanguage : null;
$resolvedLabel = $matchedLabel !== null ? trim($matchedLabel) : null;

// Fallback label to language name or position
if ($resolvedLabel === null) {
$resolvedLabel = $resolvedLanguage ?? 'Code ' . $position;
}

Expand Down
50 changes: 50 additions & 0 deletions tests/Unit/Render/Djot/CodeGroupExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,54 @@ public function testCodeGroupCanRenderWithoutPhiki(): void
$this->assertStringContainsString('aria-label="php"', $html);
$this->assertStringContainsString('aria-label="Code 2"', $html);
}

/**
* Ensure special characters in language names are supported (c++, c#, text/html).
*/
public function testSpecialCharactersInLanguageNames(): void
{
$converter = new DjotConverter();
$converter->addExtension(new CodeGroupExtension());

$html = $converter->convert(
"::: code-group\n\n```c++ [C++]\nint main() {}\n```\n\n```c# [C#]\nclass Program {}\n```\n\n:::\n",
);

$this->assertStringContainsString('aria-label="C++"', $html);
$this->assertStringContainsString('aria-label="C#"', $html);
$this->assertStringContainsString('class="language-c++"', $html);
$this->assertStringContainsString('class="language-c#"', $html);
}

/**
* Ensure custom attributes from the div are preserved.
*/
public function testAttributePreservation(): void
{
$converter = new DjotConverter();
$converter->addExtension(new CodeGroupExtension());

$html = $converter->convert(
"{#my-code .custom-theme data-section=\"intro\"}\n::: code-group\n\n```php\ntest\n```\n\n:::\n",
);

$this->assertStringContainsString('id="my-code"', $html);
$this->assertStringContainsString('class="glaze-code-group custom-theme"', $html);
$this->assertStringContainsString('data-section="intro"', $html);
}

/**
* Ensure multiple custom classes are preserved alongside glaze-code-group.
*/
public function testMultipleClassesPreserved(): void
{
$converter = new DjotConverter();
$converter->addExtension(new CodeGroupExtension());

$html = $converter->convert(
"{.extra-class .another-class}\n::: code-group\n\n```php\ntest\n```\n\n:::\n",
);

$this->assertStringContainsString('class="glaze-code-group extra-class another-class"', $html);
}
}
Loading