diff --git a/docs/commonmark.mdx b/docs/commonmark.mdx index 8ea4ea86..b6aa6437 100644 --- a/docs/commonmark.mdx +++ b/docs/commonmark.mdx @@ -5,6 +5,8 @@ description: "Learn how to use Phiki with The PHP League's CommonMark library." If you're using `league/commonmark` to parse and render Markdown in your PHP project, you can easily integrate Phiki using our custom extension. +## Usage + ```php use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; @@ -57,3 +59,43 @@ $environment 'dark' => Theme::GithubDark, // [!code ++] ])); // [!code ++] ``` + +### Highlighting and focusing lines + +You can highlight and focus lines in your code blocks using special annotations in the code block's info string. + +```md +```php {2,4-8}{3} +``` + +The first set of braces represents the lines to highlight, while the second set represents the lines to focus on. + +In this example, line 2 will be highlighted, as well as lines 4 through 8. Line 3 will then be focused. + +If you only want to focus lines, you can use an empty set of braces for the highlighted lines: + +```md +```php {}{3} +``` + +#### Sample CSS + +Phiki does not style the highlighted or focused lines by default, so you will need to add your own CSS. + +You can use the following sample CSS to get started: + +```css +pre.phiki code .line.highlight { + background-color: hsl(197, 88%, 94%); +} + +.shiki.focus .line:not(.focus) { + transition: all 250ms; + filter: blur(2px); +} + +.shiki.focus:hover .line { + transition: all 250ms; + filter: blur(0); +} +``` diff --git a/src/Adapters/CommonMark/CodeBlockRenderer.php b/src/Adapters/CommonMark/CodeBlockRenderer.php index 642c6e8a..4ba19171 100644 --- a/src/Adapters/CommonMark/CodeBlockRenderer.php +++ b/src/Adapters/CommonMark/CodeBlockRenderer.php @@ -7,9 +7,11 @@ use League\CommonMark\Node\Node; use League\CommonMark\Renderer\ChildNodeRendererInterface; use League\CommonMark\Renderer\NodeRendererInterface; +use Phiki\Adapters\CommonMark\Transformers\MetaTransformer; use Phiki\Grammar\Grammar; use Phiki\Phiki; use Phiki\Theme\Theme; +use Phiki\Transformers\Meta; class CodeBlockRenderer implements NodeRendererInterface { @@ -27,8 +29,13 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer) $code = rtrim($node->getLiteral(), "\n"); $grammar = $this->detectGrammar($node); + $meta = new Meta(markdownInfo: $node->getInfoWords()[1] ?? null); - return $this->phiki->codeToHtml($code, $grammar, $this->theme)->withGutter($this->withGutter)->toString(); + return $this->phiki->codeToHtml($code, $grammar, $this->theme) + ->withGutter($this->withGutter) + ->withMeta($meta) + ->transformer(new MetaTransformer) + ->toString(); } protected function detectGrammar(FencedCode $node): Grammar|string diff --git a/src/Adapters/CommonMark/Transformers/MetaTransformer.php b/src/Adapters/CommonMark/Transformers/MetaTransformer.php new file mode 100644 index 00000000..f57f551c --- /dev/null +++ b/src/Adapters/CommonMark/Transformers/MetaTransformer.php @@ -0,0 +1,95 @@ +parse(); + + return $code; + } + + public function line(Element $span, array $tokens, int $index): Element + { + if (in_array($index + 1, $this->highlights, true)) { + $span->properties->get('class')->add('highlight'); + } + + if (in_array($index + 1, $this->focuses, true)) { + $span->properties->get('class')->add('focus'); + } + + return $span; + } + + protected function parse(): void + { + if (! $this->meta->markdownInfo) { + return; + } + + [$highlights, $focuses] = array_pad( + explode( + '}{', + rtrim(ltrim($this->meta->markdownInfo, '{'), '}'), + 2 + ), + 2, + null + ); + + if (! $highlights && ! $focuses) { + return; + } + + $highlights = array_map( + fn(array $part) => count($part) > 1 ? $part : $part[0], + array_map( + fn(string $part) => array_map(fn(string $number) => intval($number), explode('-', trim($part))), + explode(',', $highlights) + ) + ); + + foreach ($highlights as $part) { + if (is_array($part)) { + $this->highlights = array_merge($this->highlights, range($part[0], $part[1])); + } else { + $this->highlights[] = $part; + } + } + + $this->highlights = array_unique($this->highlights); + + if (! $focuses) { + return; + } + + $focuses = array_map( + fn(array $part) => count($part) > 1 ? $part : $part[0], + array_map( + fn(string $part) => array_map(fn(string $number) => intval($number), explode('-', trim($part))), + explode(',', $focuses) + ) + ); + + foreach ($focuses as $part) { + if (is_array($part)) { + $this->focuses = array_merge($this->focuses, range($part[0], $part[1])); + } else { + $this->focuses[] = $part; + } + } + + $this->focuses = array_unique($this->focuses); + } +} diff --git a/src/Contracts/TransformerInterface.php b/src/Contracts/TransformerInterface.php index d718ad53..d4823af5 100644 --- a/src/Contracts/TransformerInterface.php +++ b/src/Contracts/TransformerInterface.php @@ -6,6 +6,7 @@ use Phiki\Phast\Root; use Phiki\Token\HighlightedToken; use Phiki\Token\Token; +use Phiki\Transformers\Meta; interface TransformerInterface { @@ -59,4 +60,9 @@ public function token(Element $span, HighlightedToken $token, int $index, int $l * Modify the HTML output after the AST has been converted. */ public function postprocess(string $html): string; + + /** + * Supply the meta object to the transformer. + */ + public function withMeta(Meta $meta): void; } diff --git a/src/Output/Html/PendingHtmlOutput.php b/src/Output/Html/PendingHtmlOutput.php index 2132dc49..2f228ec2 100644 --- a/src/Output/Html/PendingHtmlOutput.php +++ b/src/Output/Html/PendingHtmlOutput.php @@ -15,6 +15,7 @@ use Phiki\Token\Token; use Phiki\Transformers\Decorations\DecorationTransformer; use Phiki\Transformers\Decorations\LineDecoration; +use Phiki\Transformers\Meta; use Psr\SimpleCache\CacheInterface; use Stringable; @@ -34,6 +35,8 @@ class PendingHtmlOutput implements Stringable protected int $startingLineNumber = 1; + protected Meta $meta; + /** * @param array $themes */ @@ -102,6 +105,13 @@ public function startingLine(int $lineNumber): self return $this; } + public function withMeta(Meta $meta): self + { + $this->meta = $meta; + + return $this; + } + public function toString(): string { return $this->__toString(); @@ -153,6 +163,14 @@ public function __toString(): string return $this->cache->get($cacheKey); } + if (! isset($this->meta)) { + $this->meta = new Meta(); + } + + foreach ($this->transformers as $transformer) { + $transformer->withMeta($this->meta); + } + [$code] = $this->callTransformerMethod('preprocess', $this->code); [$tokens] = $this->callTransformerMethod('tokens', call_user_func($this->generateTokensUsing, $code, $this->grammar)); [$highlightedTokens] = $this->callTransformerMethod('highlighted', call_user_func($this->highlightTokensUsing, $tokens, $this->themes)); diff --git a/src/Transformers/AbstractTransformer.php b/src/Transformers/AbstractTransformer.php index 188ad379..0cd23b6f 100644 --- a/src/Transformers/AbstractTransformer.php +++ b/src/Transformers/AbstractTransformer.php @@ -10,6 +10,11 @@ class AbstractTransformer implements TransformerInterface { + /** + * The meta information. + */ + protected Meta $meta; + /** * Modify the code before it is tokenized. */ @@ -87,4 +92,12 @@ public function postprocess(string $html): string { return $html; } + + /** + * Store the meta object. + */ + public function withMeta(Meta $meta): void + { + $this->meta = $meta; + } } diff --git a/src/Transformers/Meta.php b/src/Transformers/Meta.php new file mode 100644 index 00000000..02a11f82 --- /dev/null +++ b/src/Transformers/Meta.php @@ -0,0 +1,10 @@ +class A {} + diff --git a/tests/.pest/snapshots/Unit/CommonMark/PhikiExtensionTest/it_can_be_configured_using_environment_config_array.snap b/tests/.pest/snapshots/Unit/CommonMark/PhikiExtensionTest/it_can_be_configured_using_environment_config_array.snap deleted file mode 100644 index ad8b8e1d..00000000 --- a/tests/.pest/snapshots/Unit/CommonMark/PhikiExtensionTest/it_can_be_configured_using_environment_config_array.snap +++ /dev/null @@ -1,2 +0,0 @@ -
class A {}
-
diff --git a/tests/Adapters/CommonMark/PhikiExtensionTest.php b/tests/Adapters/CommonMark/PhikiExtensionTest.php index e9505480..b08aadfa 100644 --- a/tests/Adapters/CommonMark/PhikiExtensionTest.php +++ b/tests/Adapters/CommonMark/PhikiExtensionTest.php @@ -4,6 +4,7 @@ use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\MarkdownConverter; use Phiki\Adapters\CommonMark\PhikiExtension; +use Phiki\Tests\Fixtures\UselessTransformer; use Phiki\Theme\Theme; it('registers renderers', function () { @@ -47,3 +48,21 @@ class A {} expect($generated)->toMatchSnapshot(); }); + +it('understands the info string', function () { + $environment = new Environment; + + $environment + ->addExtension(new CommonMarkCoreExtension) + ->addExtension(new PhikiExtension('github-dark')); + + $markdown = new MarkdownConverter($environment); + + $generated = $markdown->convert(<<<'MD' + ```php {0-10} + class A {} + ``` + MD); + + expect($generated)->toMatchSnapshot(); +}); diff --git a/tests/Adapters/CommonMark/Transformers/MetaTransformerTest.php b/tests/Adapters/CommonMark/Transformers/MetaTransformerTest.php new file mode 100644 index 00000000..ec27df69 --- /dev/null +++ b/tests/Adapters/CommonMark/Transformers/MetaTransformerTest.php @@ -0,0 +1,166 @@ +toContain(''); +}); + +it('can highlight a range of lines', function () { + $output = markdown(<<<'MD' + ```php {1-2} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(2); +}); + +it('can highlight multiple lines', function () { + $output = markdown(<<<'MD' + ```php {1,3} + class A {} + function b() {} + $a = new A(); + ``` + MD); + + expect(substr_count($output, ''))->toBe(2); +}); + +it('can highlight multiple ranges of lines', function () { + $output = markdown(<<<'MD' + ```php {1-2,4} + class A {} + function b() {} + $a = new A(); + function c() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(3); +}); + +it('can focus a single line', function () { + $output = markdown(<<<'MD' + ```php {}{2} + class A {} + function b() {} + ``` + MD); + + expect($output)->toContain(''); +}); + +it('can focus a range of lines', function () { + $output = markdown(<<<'MD' + ```php {}{1-2} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(2); +}); + +it('can focus multiple lines', function () { + $output = markdown(<<<'MD' + ```php {}{1,3} + class A {} + function b() {} + $a = new A(); + ``` + MD); + + expect(substr_count($output, ''))->toBe(2); +}); + +it('can focus multiple ranges of lines', function () { + $output = markdown(<<<'MD' + ```php {}{1-2,4} + class A {} + function b() {} + $a = new A(); + function c() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(3); +}); + +it('can highlight and focus lines', function () { + $output = markdown(<<<'MD' + ```php {1,3}{2} + class A {} + function b() {} + $a = new A(); + ``` + MD); + + expect(substr_count($output, ''))->toBe(2); + expect(substr_count($output, ''))->toBe(1); +}); + +it('ignores invalid line numbers', function () { + $output = markdown(<<<'MD' + ```php {0,4,5}{0,4,5} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(0); + expect(substr_count($output, ''))->toBe(0); +}); + +it('ignores invalid ranges', function () { + $output = markdown(<<<'MD' + ```php {2-1,3-2}{2-1,3-2} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(0); + expect(substr_count($output, ''))->toBe(0); +}); + +it('can highlight and focus the same line', function () { + $output = markdown(<<<'MD' + ```php {2}{2} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(1); +}); + +it('ignores non-numeric input', function () { + $output = markdown(<<<'MD' + ```php {a,b}{c,d} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(0); + expect(substr_count($output, ''))->toBe(0); +}); + +it('ignores empty input', function () { + $output = markdown(<<<'MD' + ```php {}{} + class A {} + function b() {} + ``` + MD); + + expect(substr_count($output, ''))->toBe(0); + expect(substr_count($output, ''))->toBe(0); +}); diff --git a/tests/Fixtures/UselessTransformer.php b/tests/Fixtures/UselessTransformer.php index 05085c4f..b34a8a35 100644 --- a/tests/Fixtures/UselessTransformer.php +++ b/tests/Fixtures/UselessTransformer.php @@ -6,8 +6,10 @@ use Phiki\Phast\Element; use Phiki\Phast\Root; use Phiki\Token\HighlightedToken; +use Phiki\Transformers\AbstractTransformer; +use Phiki\Transformers\Meta; -class UselessTransformer implements TransformerInterface +class UselessTransformer extends AbstractTransformer { public $preprocessed = false; @@ -89,4 +91,9 @@ public function postprocess(string $html): string return $html; } + + public function meta(): Meta + { + return $this->meta; + } } diff --git a/tests/Pest.php b/tests/Pest.php index 601eab89..dee21290 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,9 @@ tokensToHighlightedTokens($tokens, $theme); } + +function markdown(string $input, Theme $theme = Theme::GithubLight): string +{ + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension)->addExtension(new PhikiExtension($theme)); + $converter = new MarkdownConverter($environment); + + return $converter->convert($input)->getContent(); +} diff --git a/tests/Unit/PendingHtmlOutputTest.php b/tests/Unit/PendingHtmlOutputTest.php index cdd44517..9d7eba81 100644 --- a/tests/Unit/PendingHtmlOutputTest.php +++ b/tests/Unit/PendingHtmlOutputTest.php @@ -5,6 +5,7 @@ use Phiki\Tests\Fixtures\FakeCache; use Phiki\Tests\Fixtures\UselessTransformer; use Phiki\Theme\Theme; +use Phiki\Transformers\Meta; it('calls transformer methods', function () { $transformer = new UselessTransformer; @@ -104,3 +105,26 @@ expect($pending2->toString())->toBe($pending->toString()); }); + +it('passes meta to transformers', function () { + $transformer = new class extends UselessTransformer { + public function meta(): Meta + { + return $this->meta; + } + }; + + $output = (new Phiki) + ->codeToHtml( + <<<'PHP' + echo "Hello, world!"; + PHP, + Grammar::Php, + Theme::GithubLight, + ) + ->transformer($transformer) + ->withMeta($meta = new Meta()) + ->toString(); + + expect($transformer->meta())->toBe($meta); +});