Skip to content

Commit a0cf146

Browse files
Implement highlight and focus, start writing some tests
1 parent 0e9799c commit a0cf146

19 files changed

+596
-2
lines changed

meta/commonmark.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
use League\CommonMark\Environment\Environment;
4+
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
5+
use League\CommonMark\MarkdownConverter;
6+
use Phiki\Adapters\CommonMark\PhikiExtension;
7+
use Phiki\Theme\Theme;
8+
9+
require_once __DIR__ . '/../vendor/autoload.php';
10+
11+
$environment = new Environment();
12+
$environment->addExtension(new CommonMarkCoreExtension)->addExtension(new PhikiExtension(Theme::GithubLight));
13+
$converter = new MarkdownConverter($environment);
14+
15+
$markdown = <<<'MARKDOWN'
16+
```blade
17+
{{-- [code! highlight:start] --}}
18+
@if(true) {{-- [code! focus:start] --}}
19+
Hello, world!
20+
@endif {{-- [code! highlight:end] --}}
21+
22+
{{ $variable }} {{-- [code! focus:end] --}}
23+
```
24+
MARKDOWN;
25+
26+
echo $converter->convert($markdown);

src/Adapters/CommonMark/CodeBlockRenderer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use League\CommonMark\Node\Node;
88
use League\CommonMark\Renderer\ChildNodeRendererInterface;
99
use League\CommonMark\Renderer\NodeRendererInterface;
10+
use Phiki\Adapters\CommonMark\Transformers\AnnotationsTransformer;
1011
use Phiki\Adapters\CommonMark\Transformers\MetaTransformer;
1112
use Phiki\Grammar\Grammar;
1213
use Phiki\Phiki;
@@ -35,6 +36,7 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer)
3536
->withGutter($this->withGutter)
3637
->withMeta($meta)
3738
->transformer(new MetaTransformer)
39+
->transformer(new AnnotationsTransformer)
3840
->toString();
3941
}
4042

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Phiki\Adapters\CommonMark\Transformers\Annotations;
4+
5+
use Phiki\Phast\Element;
6+
7+
class Annotation
8+
{
9+
public function __construct(
10+
public AnnotationType $type,
11+
public int $start,
12+
public int $end,
13+
) {}
14+
15+
public function applyToLine(Element $line): void
16+
{
17+
$line->properties->get('class')->add(...$this->type->getLineClasses());
18+
}
19+
20+
public function applyToPre(Element $pre): void
21+
{
22+
$pre->properties->get('class')->add(...$this->type->getPreClasses());
23+
}
24+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace Phiki\Adapters\CommonMark\Transformers\Annotations;
4+
5+
class AnnotationRange
6+
{
7+
public function __construct(
8+
public AnnotationRangeKind $kind,
9+
public int $start,
10+
public int $end,
11+
) {}
12+
13+
public static function parse(string $range, int $index): ?self
14+
{
15+
$range = trim($range);
16+
17+
// Highlight the current line plus the next N lines.
18+
if (preg_match('/^\d+$/', $range) === 1) {
19+
return new AnnotationRange(AnnotationRangeKind::Fixed, $index, (int) $range + $index);
20+
}
21+
22+
// Highlight the current line plus the previous N lines.
23+
if (preg_match('/^-\d+$/', $range) === 1) {
24+
return new AnnotationRange(AnnotationRangeKind::Fixed, $index + (int) $range, $index);
25+
}
26+
27+
// Start highlighting from OFFSET for a total of AND lines.
28+
if (preg_match('/^(?<offset>\d+),(?<and>\d+)$/', $range, $matches) === 1) {
29+
return new AnnotationRange(AnnotationRangeKind::Fixed, $index + (int) $matches['offset'], $index + (int) $matches['offset'] + (int) $matches['and'] - 1);
30+
}
31+
32+
// Start highlighting from $index - OFFSET for a total of AND lines.
33+
if (preg_match('/^(?<offset>-\d+),(?<and>\d+)$/', $range, $matches) === 1) {
34+
return new AnnotationRange(AnnotationRangeKind::Fixed, $index + (int) $matches['offset'], $index + (int) $matches['offset'] + (int) $matches['and'] - 1);
35+
}
36+
37+
// Start highlighting in an open-ended manner from the current line.
38+
if (preg_match('/^start$/i', $range) === 1) {
39+
return new AnnotationRange(AnnotationRangeKind::OpenEnded, $index, $index);
40+
}
41+
42+
// Stop highlighting in an open-ended manner at the current line.
43+
if (preg_match('/^end$/i', $range) === 1) {
44+
return new AnnotationRange(AnnotationRangeKind::End, $index, $index);
45+
}
46+
47+
return null;
48+
}
49+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Phiki\Adapters\CommonMark\Transformers\Annotations;
4+
5+
enum AnnotationRangeKind
6+
{
7+
case Fixed;
8+
case OpenEnded;
9+
case End;
10+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Phiki\Adapters\CommonMark\Transformers\Annotations;
4+
5+
enum AnnotationType
6+
{
7+
case Highlight;
8+
case Focus;
9+
10+
/**
11+
* Get the keywords used to denote this annotation type.
12+
*
13+
* e.g. highlight can be denoted by `[code! highlight]`.
14+
*/
15+
public function keywords(): array
16+
{
17+
return match ($this) {
18+
self::Highlight => ['highlight', 'hl', '~~'],
19+
self::Focus => ['focus', 'f', '**'],
20+
};
21+
}
22+
23+
/**
24+
* Get the CSS classes to apply to lines with this annotation.
25+
*/
26+
public function getLineClasses(): array
27+
{
28+
return match ($this) {
29+
self::Highlight => ['highlight'],
30+
self::Focus => ['focus'],
31+
};
32+
}
33+
34+
/**
35+
* Get the CSS classes to apply to the pre element.
36+
*/
37+
public function getPreClasses(): array
38+
{
39+
return match ($this) {
40+
self::Focus => ['focus'],
41+
default => [],
42+
};
43+
}
44+
45+
/**
46+
* Get the type from the given keyword.
47+
*/
48+
public static function fromKeyword(string $keyword): self
49+
{
50+
return match ($keyword) {
51+
'highlight', 'hl', '~~' => self::Highlight,
52+
'focus', 'f', '**' => self::Focus,
53+
default => throw new \InvalidArgumentException("Unknown annotation keyword: {$keyword}"),
54+
};
55+
}
56+
}

src/Adapters/CommonMark/Transformers/AnnotationsTransformer.php

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,213 @@
22

33
namespace Phiki\Adapters\CommonMark\Transformers;
44

5+
use Phiki\Adapters\CommonMark\Transformers\Annotations\Annotation;
6+
use Phiki\Adapters\CommonMark\Transformers\Annotations\AnnotationRange;
7+
use Phiki\Adapters\CommonMark\Transformers\Annotations\AnnotationRangeKind;
8+
use Phiki\Adapters\CommonMark\Transformers\Annotations\AnnotationType;
59
use Phiki\Contracts\RequiresGrammarInterface;
10+
use Phiki\Grammar\Grammar;
11+
use Phiki\Phast\Element;
12+
use Phiki\Support\Arr;
613
use Phiki\Transformers\AbstractTransformer;
714
use Phiki\Transformers\Concerns\RequiresGrammar;
815

916
class AnnotationsTransformer extends AbstractTransformer implements RequiresGrammarInterface
1017
{
1118
use RequiresGrammar;
19+
20+
const ANNOTATION_REGEX = '/\[%s! (?<keyword>%s)(:(?<range>.+))?\]/';
21+
22+
const DANGLING_LINE_COMMENT_REGEX = '/(%s)\s*$/';
23+
24+
const COMMON_COMMENT_CHARACTERS = [
25+
'#', '//', ['/*', '*/'], ['/**', '*/'],
26+
];
27+
28+
const GRAMMAR_SPECIFIC_COMMENT_CHARACTERS = [
29+
Grammar::Antlers->value => ['{{#', '#}}'],
30+
Grammar::Blade->value => ['{{--', '--}}'],
31+
Grammar::Coq->value => ['(*', '*)'],
32+
Grammar::Asm->value => ';',
33+
Grammar::Html->value => ['<!--', '-->'],
34+
Grammar::Xml->value => ['<!--', '-->'],
35+
Grammar::Ini->value => [';'],
36+
];
37+
38+
/**
39+
* The collected list of annotations.
40+
*
41+
* @var array<int, array<Annotation>>
42+
*/
43+
protected array $annotations = [];
44+
45+
/**
46+
* Create a new instance.
47+
*
48+
* @param string $prefix The prefix used to denote annotations, e.g. `code` for `[code! highlight]`.
49+
*/
50+
public function __construct(protected string $prefix = 'code') {}
51+
52+
/**
53+
* Preprocess the code block content to discover annotations.
54+
*/
55+
public function preprocess(string $code): string
56+
{
57+
$lines = preg_split('/\R/', $code);
58+
$annotations = [];
59+
$unclosedAnnotationsStack = [];
60+
$processedAnnotationRegex = sprintf(self::ANNOTATION_REGEX, preg_quote($this->prefix, '/'), implode('|', array_map(fn (string $keyword) => preg_quote($keyword, '/'), array_merge(...array_map(fn (AnnotationType $type) => $type->keywords(), AnnotationType::cases())))));
61+
62+
for ($i = 0; $i < count($lines); $i++) {
63+
$line = $lines[$i];
64+
65+
if (preg_match($processedAnnotationRegex, $line, $matches, PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE) === 0) {
66+
continue;
67+
}
68+
69+
$type = AnnotationType::fromKeyword($matches['keyword'][0]);
70+
$annotation = null;
71+
$unclosed = false;
72+
73+
// If there is no specified range, then it only needs to apply to the current line.
74+
if ($matches['range'][0] === null) {
75+
$annotation = new Annotation($type, $i, $i);
76+
} else {
77+
$range = AnnotationRange::parse($matches['range'][0], $i);
78+
79+
// Invalid range provided, skip and move on.
80+
if (! $range) {
81+
continue;
82+
}
83+
84+
$unclosed = $range->kind === AnnotationRangeKind::OpenEnded;
85+
86+
// If the range is open ended, then we can add it to the stack to be closed later.
87+
if ($unclosed) {
88+
$unclosedAnnotationsStack[] = $annotation = new Annotation($type, $i, $i);
89+
} elseif ($range->kind === AnnotationRangeKind::End) {
90+
// If the range is ending something, then we need to find the most recent unclosed annotation of the same type and close it.
91+
for ($j = count($unclosedAnnotationsStack) - 1; $j >= 0; $j--) {
92+
if ($unclosedAnnotationsStack[$j]->type === $type) {
93+
$annotation = array_splice($unclosedAnnotationsStack, $j, 1)[0];
94+
$annotation->end = $i;
95+
break;
96+
}
97+
}
98+
} else {
99+
// Otherwise, we have a closed range so we can construct the annotation directly.
100+
$annotation = new Annotation($type, $range->start, $range->end);
101+
}
102+
}
103+
104+
// We should now try to remove the annotation from the line.
105+
// We'll first create a clone of the line to work with, removing any trailing whitespace
106+
// and replacing the annotation itself.
107+
$trimmed = rtrim(str_replace($matches[0][0], '', $line));
108+
109+
// We'll also create a variable to store the point as which we should cut off the line.
110+
$cutoffPoint = strlen($trimmed);
111+
112+
// Some grammars have their own comment characters, e.g. Blade, Antlers, Coq, etc.
113+
// We'll add those to the list of characters to check.
114+
$commentChars = array_merge(self::COMMON_COMMENT_CHARACTERS, isset(self::GRAMMAR_SPECIFIC_COMMENT_CHARACTERS[$this->grammar->name]) ? [self::GRAMMAR_SPECIFIC_COMMENT_CHARACTERS[$this->grammar->name]] : []);
115+
116+
// Then we can check for common comment characters at the end of the line.
117+
// We store a list of these in a constant:
118+
// - strings are characters for line comments
119+
// - arrays are beginning and ending comment pairs (block comments)
120+
[$l, $b] = Arr::partition($commentChars, fn(string|array $chars) => is_string($chars));
121+
122+
// We'll first check for line comments.
123+
$processedLineCommentRegex = sprintf(self::DANGLING_LINE_COMMENT_REGEX, implode('|', array_map(fn(string $char) => preg_quote($char, '/'), $l)));
124+
125+
// If we find a match, we can set the cutoff point and skip checking for block comments.
126+
if (preg_match($processedLineCommentRegex, $trimmed, $lineCommentMatches, PREG_OFFSET_CAPTURE) === 1) {
127+
$cutoffPoint = $lineCommentMatches[1][1];
128+
goto cutoff;
129+
}
130+
131+
$processedBlockCommentRegex = sprintf(
132+
'/%s$/',
133+
implode('|', array_map(fn(array $chars) => sprintf('(%s\s*%s)', preg_quote($chars[0], '/'), preg_quote($chars[1], '/')), $b)),
134+
);
135+
136+
// If we find a match, we can set the cutoff point.
137+
if (preg_match($processedBlockCommentRegex, $trimmed, $blockCommentMatches, PREG_OFFSET_CAPTURE) === 1) {
138+
$cutoffPoint = $blockCommentMatches[0][1];
139+
goto cutoff;
140+
}
141+
142+
// If we reach here, then we didn't find any comment characters, so we'll just cut off at the annotation itself.
143+
$cutoffPoint = $matches[0][1];
144+
145+
cutoff:
146+
// We can then trim the line down up to the cutoff point.
147+
$trimmed = substr($trimmed, 0, $cutoffPoint);
148+
149+
// If the line is now completely empty, we can remove the line entirely.
150+
if (trim($trimmed) === '') {
151+
// Doing an `unset` here will leave a gap in the array, so we need to make sure we reindex too,
152+
// since we want future index references to point to the correct lines still.
153+
unset($lines[$i]);
154+
$lines = array_values($lines);
155+
156+
// Reindexing will shift all future lines down by one, so we need to decrement $i to account for that.
157+
$i--;
158+
} else {
159+
// Otherwise we can just replace the line with the trimmed version.
160+
$lines[$i] = $trimmed;
161+
}
162+
163+
// If the annotation is unclosed, we don't want to add it to the annotations list yet.
164+
if ($unclosed) {
165+
continue;
166+
}
167+
168+
// We can finally add the annotation to the correct place.
169+
for ($k = $annotation->start; $k <= $annotation->end; $k++) {
170+
$annotations[$k][] = $annotation;
171+
}
172+
}
173+
174+
// Any annotations left in the unclosed stack are still unclosed and should be closed at the end of the document.
175+
foreach ($unclosedAnnotationsStack as $unclosedAnnotation) {
176+
$unclosedAnnotation->end = count($lines) - 1;
177+
for ($k = $unclosedAnnotation->start; $k <= $unclosedAnnotation->end; $k++) {
178+
$annotations[$k][] = $unclosedAnnotation;
179+
}
180+
}
181+
182+
$this->annotations = $annotations;
183+
184+
return implode("\n", $lines);
185+
}
186+
187+
public function pre(Element $pre): Element
188+
{
189+
if ($this->annotations === []) {
190+
return $pre;
191+
}
192+
193+
foreach ($this->annotations as $annotations) {
194+
foreach ($annotations as $annotation) {
195+
$annotation->applyToPre($pre);
196+
}
197+
}
198+
199+
return $pre;
200+
}
201+
202+
public function line(Element $span, array $tokens, int $index): Element
203+
{
204+
if ($this->annotations === [] || ! isset($this->annotations[$index])) {
205+
return $span;
206+
}
207+
208+
foreach ($this->annotations[$index] as $annotation) {
209+
$annotation->applyToLine($span);
210+
}
211+
212+
return $span;
213+
}
12214
}

0 commit comments

Comments
 (0)