Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gutter #86

Merged
merged 6 commits into from
Apr 2, 2024
Merged
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
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 @@ -45,10 +49,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 @@ -84,11 +96,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 @@ -97,45 +109,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
Loading