Skip to content

Commit

Permalink
Merge pull request #86 from tempestphp/gutter
Browse files Browse the repository at this point in the history
Gutter
  • Loading branch information
brendt authored Apr 2, 2024
2 parents aadba2e + b83bbad commit cab67e7
Show file tree
Hide file tree
Showing 23 changed files with 565 additions and 98 deletions.
Binary file added .github/highlight-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ You can read about why I started this package [here](https://stitcher.io/blog/a-
- [Themes](#themes)
- [For the web](#for-the-web)
- [For the terminal](#for-the-terminal)
- [Gutter](#gutter)
- [Special highlighting tags](#special-highlighting-tags)
- [Emphasize strong and blur](#emphasize-strong-and-blur)
- [Additions and deletions](#additions-and-deletions)
Expand Down Expand Up @@ -60,6 +61,26 @@ echo $highlighter->parse($code, 'php');

![](./.github/terminal.png)

## Gutter

This package can render an optional gutter if needed.

```php
$highlighter = (new Highlighter())->withGutter(startAt: 10);
```

The gutter will show additions and deletions, and can start at any given line number:

![](./.github/highlight-4.png)

Finally, you can enable gutter rendering on the fly if you're using [commonmark code blocks](#commonmark-integration) by appending `{startAt}` to the language definition:

```md
```php{1}
echo 'hi'!
```
```

## Special highlighting tags

This package offers a collection of special tags that you can use within your code snippets. These tags won't be shown in the final output, but rather adjust the highlighter's default styling. All these tags work multi-line, and will still properly render its wrapped content.
Expand Down
30 changes: 30 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## 1.1.0

- Added Gutter support:

```php
$highlighter = (new \Tempest\Highlight\Highlighter())->withGutter();
```

**Note**: three new classes have been added for gutter support. If you copied over an existing theme, you'll need to add these:

```css
.hl-gutter {
display: inline-block;
font-size: 0.9em;
color: #555;
padding: 0 1ch;
}

.hl-gutter-addition {
background-color: #34A853;
color: #fff;
}

.hl-gutter-deletion {
background-color: #EA4334;
color: #fff;
}
```

**Note**: This package doesn't account for `pre` tag styling. You might need to make adjustments to how you style `pre` tags if you enable gutter support.
15 changes: 15 additions & 0 deletions src/After.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Highlight;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class After
{
public function __construct()
{
}
}
15 changes: 15 additions & 0 deletions src/Before.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Highlight;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Before
{
public function __construct()
{
}
}
20 changes: 15 additions & 5 deletions src/CommonMark/CodeBlockRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,32 @@
use League\CommonMark\Util\HtmlElement;
use Tempest\Highlight\Highlighter;

class CodeBlockRenderer implements NodeRendererInterface
final class CodeBlockRenderer implements NodeRendererInterface
{
public function __construct(
private Highlighter $highlighter = new Highlighter(),
) {
}

public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (! $node instanceof FencedCode) {
throw new InvalidArgumentException('Block must be instance of ' . FencedCode::class);
}

$highlight = new Highlighter();
$code = $node->getLiteral();
$language = $node->getInfoWords()[0] ?? 'txt';
preg_match('/^(?<language>[\w]+)(\{(?<startAt>[\d]+)\})?/', $node->getInfoWords()[0] ?? 'txt', $matches);

if ($startAt = ($matches['startAt']) ?? null) {
$this->highlighter->withGutter((int)$startAt);
}

return new HtmlElement(
'pre',
[],
$highlight->parse($code, $language)
$this->highlighter->parse(
content: $node->getLiteral(),
language: $matches['language'],
),
);
}
}
10 changes: 7 additions & 3 deletions src/CommonMark/InlineCodeBlockRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@
use League\CommonMark\Renderer\NodeRendererInterface;
use Tempest\Highlight\Highlighter;

class InlineCodeBlockRenderer implements NodeRendererInterface
final class InlineCodeBlockRenderer implements NodeRendererInterface
{
public function __construct(
private Highlighter $highlighter = new Highlighter(),
) {
}

public function render(Node $node, ChildNodeRendererInterface $childRenderer)
{
if (! $node instanceof Code) {
Expand All @@ -21,10 +26,9 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer)

preg_match('/^\{(?<match>[\w]+)}(?<code>.*)/', $node->getLiteral(), $match);

$highlighter = new Highlighter();
$language = $match['match'] ?? 'txt';
$code = $match['code'] ?? $node->getLiteral();

return '<code>' . $highlighter->parse($code, $language) . '</code>';
return '<code>' . $this->highlighter->parse($code, $language) . '</code>';
}
}
6 changes: 5 additions & 1 deletion src/Escape.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ public static function injection(string $input): string

public static function terminal(string $input): string
{
return str_replace(self::INJECTION_TOKEN, '', $input);
return preg_replace(
['/❷(.*?)❸/', '/❿/'],
'',
$input,
);
}

public static function html(string $input): string
Expand Down
144 changes: 107 additions & 37 deletions src/Highlighter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

namespace Tempest\Highlight;

use Generator;
use ReflectionClass;
use Tempest\Highlight\Languages\Base\BaseLanguage;
use Tempest\Highlight\Languages\Base\Injections\GutterInjection;
use Tempest\Highlight\Languages\Blade\BladeLanguage;
use Tempest\Highlight\Languages\Css\CssLanguage;
use Tempest\Highlight\Languages\DocComment\DocCommentLanguage;
Expand All @@ -25,8 +28,9 @@
final class Highlighter
{
private array $languages = [];
private ?GutterInjection $gutterInjection = null;
private ?Language $currentLanguage = null;
private bool $shouldEscape = true;
private bool $isNested = false;

public function __construct(
private readonly Theme $theme = new CssTheme(),
Expand All @@ -46,10 +50,18 @@ public function __construct(
->setLanguage('yaml', new YamlLanguage())
->setLanguage('yml', new YamlLanguage())
->setLanguage('twig', new TwigLanguage());
}

if ($this->theme instanceof TerminalTheme) {
$this->shouldEscape = false;
}
public function withGutter(int $startAt = 1): self
{
$this->gutterInjection = new GutterInjection($startAt);

return $this;
}

public function getGutterInjection(): ?GutterInjection
{
return $this->gutterInjection;
}

public function setLanguage(string $name, Language $language): self
Expand Down Expand Up @@ -85,11 +97,11 @@ public function setCurrentLanguage(Language $language): void
$this->currentLanguage = $language;
}

public function withoutEscaping(): self
public function nested(): self
{
$clone = clone $this;

$clone->shouldEscape = false;
$clone->isNested = true;

return $clone;
}
Expand All @@ -98,45 +110,103 @@ private function parseContent(string $content, Language $language): string
{
$tokens = [];

// Injections
foreach ($language->getInjections() as $injection) {
$parsedInjection = $injection->parse($content, $this->withoutEscaping());

// Injections are allowed to return one of two things:
// 1. A string of content, which will be used to replace the existing content
// 2. a `ParsedInjection` object, which contains both the new content AND a list of tokens to be parsed
//
// One benefit of returning ParsedInjections is that the list of returned tokens will be added
// to all other tokens detected by patterns, and thus follow all token rules.
// They are grouped and checked on whether tokens can be contained by other tokens.
// This offers more flexibility from the injection's point of view, and in same cases lead to more accurate highlighting.
//
// The other benefit is that injections returning ParsedInjection objects don't need to worry about Escape::injection anymore.
// This escape only exists to prevent outside patterns from matching already highlighted content that's injected.
// If an injection doesn't highlight content anymore, then there also isn't any danger for these kinds of collisions.
// And so, Escape::injection becomes obsolete.
//
// TODO: a future version might only allow ParsedTokens and no more standalone strings, but for now we'll keep it as is.
if (is_string($parsedInjection)) {
$content = $parsedInjection;
} else {
$content = $parsedInjection->content;
$tokens = [...$tokens, ...$parsedInjection->tokens];
}
// Before Injections
foreach ($this->getBeforeInjections($language) as $injection) {
$parsedInjection = $this->parseInjection($content, $injection);
$content = $parsedInjection->content;
$tokens = [...$tokens, ...$parsedInjection->tokens];
}

// Patterns
$tokens = [...$tokens, ...(new ParseTokens())($content, $language)];

$groupedTokens = (new GroupTokens())($tokens);
$content = (new RenderTokens($this->theme))($content, $groupedTokens);

$output = (new RenderTokens($this->theme))($content, $groupedTokens);
// After Injections
foreach ($this->getAfterInjections($language) as $injection) {
$parsedInjection = $this->parseInjection($content, $injection);
$content = $parsedInjection->content;
}

// Determine proper escaping
return match(true) {
$this->theme instanceof TerminalTheme => Escape::terminal($output),
$this->shouldEscape => Escape::html($output),
default => $output,
return match (true) {
$this->isNested => $content,
$this->theme instanceof TerminalTheme => Escape::terminal($content),
default => Escape::html($content),
};
}

/**
* @param Language $language
* @return \Tempest\Highlight\Injection[]
*/
private function getBeforeInjections(Language $language): Generator
{
foreach ($language->getInjections() as $injection) {
$after = (new ReflectionClass($injection))->getAttributes(After::class)[0] ?? null;

if ($after) {
continue;
}

// Only injections without the `After` attribute are allowed
yield $injection;
}
}

/**
* @param Language $language
* @return \Tempest\Highlight\Injection[]
*/
private function getAfterInjections(Language $language): Generator
{
if ($this->isNested) {
// After injections are only parsed at the very end
return;
}

foreach ($language->getInjections() as $injection) {
$after = (new ReflectionClass($injection))->getAttributes(After::class)[0] ?? null;

if (! $after) {
continue;
}

yield $injection;
}

// The gutter is always the latest injection
if ($this->gutterInjection) {
yield $this->gutterInjection;
}
}

private function parseInjection(string $content, Injection $injection): ParsedInjection
{
$parsedInjection = $injection->parse(
$content,
$this->nested(),
);

// Injections are allowed to return one of two things:
// 1. A string of content, which will be used to replace the existing content
// 2. a `ParsedInjection` object, which contains both the new content AND a list of tokens to be parsed
//
// One benefit of returning ParsedInjections is that the list of returned tokens will be added
// to all other tokens detected by patterns, and thus follow all token rules.
// They are grouped and checked on whether tokens can be contained by other tokens.
// This offers more flexibility from the injection's point of view, and in same cases lead to more accurate highlighting.
//
// The other benefit is that injections returning ParsedInjection objects don't need to worry about Escape::injection anymore.
// This escape only exists to prevent outside patterns from matching already highlighted content that's injected.
// If an injection doesn't highlight content anymore, then there also isn't any danger for these kinds of collisions.
// And so, Escape::injection becomes obsolete.
//
// TODO: a future version might only allow ParsedTokens and no more standalone strings, but for now we'll keep it as is.
if (is_string($parsedInjection)) {
return new ParsedInjection($parsedInjection);
}

return $parsedInjection;
}
}
2 changes: 1 addition & 1 deletion src/Languages/Base/BaseLanguage.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ public function getInjections(): array
new BlurInjection(),
new EmphasizeInjection(),
new StrongInjection(),
new CustomClassInjection(),
new AdditionInjection(),
new DeletionInjection(),
new CustomClassInjection(),
];
}

Expand Down
Loading

0 comments on commit cab67e7

Please sign in to comment.