diff --git a/composer.json b/composer.json index 5b40664..7daf003 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "require": { "php": "^8.2", "league/commonmark": "^2.5.3", - "ext-mbstring": "*" + "ext-mbstring": "*", + "psr/simple-cache": "^3.0" }, "autoload": { "psr-4": { diff --git a/docs/caching.mdx b/docs/caching.mdx new file mode 100644 index 0000000..2f2cff8 --- /dev/null +++ b/docs/caching.mdx @@ -0,0 +1,40 @@ +--- +title: Caching +--- + +Syntax highlighting can be a resource-intensive operation, especially for large code snippets or when processing many snippets in a short period. + +To improve performance, Phiki includes a built-in caching mechanism built around the `psr/simple-cache` interface. + +## Enabling caching + +Enabling caching is as simple as providing a cache implementation to the `Phiki::cache()` method. + +```php +class SimpleCache implements \Psr\SimpleCache\CacheInterface +{ + // ... +} + +$phiki = (new Phiki) + ->cache(new SimpleCache); +``` + +This will enable caching for all subsequent calls to `codeToHtml()`. The cache will store the generated HTML for each unique combination of code, grammar, theme(s), gutter setting and `Transformer` class. + +## Cache invalidation + +Phiki does not include any built-in cache invalidation mechanism apart from a change in the cache key. + +If you need to invalidate the cache for any reason, you must do so using the methods provided by your chosen cache implementation. + +## Caching individual snippets + +If you don't want to cache all syntax highlighted code, you can also cache individual `codeToHtml()` calls by passing a cache implementation to the `PendingHtmlOutput::cache()` method. + +```php +$html = (new Phiki) + ->codeToHtml("cache(new SimpleCache) + ->toString(); +``` diff --git a/docs/docs.json b/docs/docs.json index 4605da2..f83d979 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -22,7 +22,8 @@ "pages": [ "installation", "highlighting-code", - "multi-themes" + "multi-themes", + "caching" ] }, { diff --git a/docs/laravel.mdx b/docs/laravel.mdx index a06da9b..606c52d 100644 --- a/docs/laravel.mdx +++ b/docs/laravel.mdx @@ -50,6 +50,37 @@ class AppServiceProvider extends ServiceProvider } ``` +### Caching + +Phiki automatically enables caching when used in a Laravel application. It uses your application's default cache store (`CACHE_STORE`) to cache highlighted code blocks. + +If you wish to customize the cache store used by Phiki, you can do so in the `boot` method of a service provider. + +```php +use Phiki\Adapters\Laravel\Facades\Phiki; +use Illuminate\Support\Facades\Cache; + +class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + Phiki::cache(Cache::store('redis')); + } +} +``` + +#### Cache invalidation + +Phiki's cache key is based on the content of the code block, the grammar, the themes chosen, the gutter setting, and any transformers used. + +If any of these change, Phiki will automatically generate a new cache key and re-highlight the code block. + +If you need to manually clear Phiki's cache, you can do so by calling the `php artisan cache:clear` command, which will clear the entire cache for your application. + +```sh +php artisan cache:clear +``` + ## `Str::markdown()` If you're using the `Str::markdown()` helper method, you can use Phiki's [CommonMark](/commonmark) extension to highlight code blocks inside of your Markdown. @@ -57,10 +88,11 @@ If you're using the `Str::markdown()` helper method, you can use Phiki's [Common ```php use Illuminate\Support\Str; use Phiki\Adapters\CommonMark\PhikiExtension; +use Phiki\Phiki; use Phiki\Theme\Theme; Str::markdown($markdown, extensions: [ - new PhikiExtension(Theme::GithubLight), + new PhikiExtension(Theme::GithubLight, resolve(Phiki::class)), ]); ``` @@ -72,9 +104,10 @@ As per the documentation on Phiki's [gutter](/highlighting-code), you can enable use Illuminate\Support\Str; use Phiki\Adapters\CommonMark\PhikiExtension; use Phiki\Theme\Theme; +use Phiki\Phiki; Str::markdown($markdown, extensions: [ - new PhikiExtension(Theme::GithubLight, withGutter: true), + new PhikiExtension(Theme::GithubLight, resolve(Phiki::class), withGutter: true), ]); ``` @@ -85,13 +118,14 @@ As per the documentation on [Multiple themes](/multiple-themes), you can also pa ```php use Illuminate\Support\Str; use Phiki\Adapters\CommonMark\PhikiExtension; +use Phiki\Phiki; use Phiki\Theme\Theme; Str::markdown($markdown, extensions: [ new PhikiExtension([ 'light' => Theme::GithubLight, 'dark' => Theme::GithubDark, - ]), + ], resolve(Phiki::class)), ]); ``` diff --git a/src/Adapters/Laravel/Facades/Phiki.php b/src/Adapters/Laravel/Facades/Phiki.php index 4a86606..0a82812 100644 --- a/src/Adapters/Laravel/Facades/Phiki.php +++ b/src/Adapters/Laravel/Facades/Phiki.php @@ -9,9 +9,11 @@ * @method static array> tokensToHighlightedTokens(array> $tokens, string|array|\Phiki\Theme\Theme $theme) * @method static array> codeToHighlightedTokens(string $code, string|\Phiki\Grammar\Grammar $grammar, string|array|\Phiki\Theme\Theme $theme) * @method static \Phiki\Output\Html\PendingHtmlOutput codeToHtml(string $code, string|\Phiki\Grammar\Grammar $grammar, string|array|\Phiki\Theme\Theme $theme) + * @method static \Phiki\Environment environment() * @method static \Phiki\Phiki extend(\Phiki\Contracts\ExtensionInterface $extension) * @method static \Phiki\Phiki grammar(string $name, string|\Phiki\Grammar\ParsedGrammar $pathOrGrammar) * @method static \Phiki\Phiki theme(string $name, string|\Phiki\Theme\ParsedTheme $pathOrTheme) + * @method static \Phiki\Phiki cache(\Psr\SimpleCache\CacheInterface $cache) * * @see \Phiki\Phiki */ diff --git a/src/Adapters/Laravel/PhikiServiceProvider.php b/src/Adapters/Laravel/PhikiServiceProvider.php index 5e8435d..9815a74 100644 --- a/src/Adapters/Laravel/PhikiServiceProvider.php +++ b/src/Adapters/Laravel/PhikiServiceProvider.php @@ -3,6 +3,7 @@ namespace Phiki\Adapters\Laravel; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\ServiceProvider; use Phiki\Phiki; @@ -13,7 +14,7 @@ class PhikiServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->singleton(Phiki::class, static fn () => new Phiki); + $this->app->singleton(Phiki::class, static fn () => (new Phiki)->cache(Cache::store())); } /** diff --git a/src/Environment.php b/src/Environment.php index a29bd49..f377326 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -9,6 +9,7 @@ use Phiki\Theme\ParsedTheme; use Phiki\Theme\Theme; use Phiki\Theme\ThemeRepository; +use Psr\SimpleCache\CacheInterface; class Environment { @@ -16,6 +17,8 @@ class Environment public readonly ThemeRepository $themes; + public ?CacheInterface $cache = null; + public function __construct() { $this->grammars = new GrammarRepository; @@ -42,4 +45,11 @@ public function theme(string $slug, string|ParsedTheme $theme): static return $this; } + + public function cache(CacheInterface $cache): static + { + $this->cache = $cache; + + return $this; + } } diff --git a/src/Output/Html/PendingHtmlOutput.php b/src/Output/Html/PendingHtmlOutput.php index 624b8f2..1575349 100644 --- a/src/Output/Html/PendingHtmlOutput.php +++ b/src/Output/Html/PendingHtmlOutput.php @@ -13,6 +13,7 @@ use Phiki\Theme\ParsedTheme; use Phiki\Token\HighlightedToken; use Phiki\Token\Token; +use Psr\SimpleCache\CacheInterface; use Stringable; class PendingHtmlOutput implements Stringable @@ -23,6 +24,8 @@ class PendingHtmlOutput implements Stringable protected ?Closure $highlightTokensUsing = null; + protected ?CacheInterface $cache = null; + protected array $transformers = []; protected int $startingLineNumber = 1; @@ -56,6 +59,13 @@ public function highlightTokensUsing(Closure $callback): self return $this; } + public function cache(?CacheInterface $cache): self + { + $this->cache = $cache; + + return $this; + } + public function withGutter(bool $withGutter = true): self { $this->withGutter = $withGutter; @@ -82,6 +92,19 @@ public function toString(): string return $this->__toString(); } + public function cacheKey(): string + { + return 'phiki_html_' . md5(serialize([ + $this->code, + $this->grammar->scopeName, + array_keys($this->themes), + ...array_map(fn (ParsedTheme $theme) => $theme->name, $this->themes), + $this->withGutter, + $this->startingLineNumber, + ...array_map(fn (TransformerInterface $transformer) => get_class($transformer), $this->transformers), + ])); + } + protected function callTransformerMethod(string $method, mixed ...$args): mixed { if ($this->transformers === []) { @@ -109,6 +132,12 @@ private function getDefaultThemeId(): string public function __toString(): string { + $cacheKey = $this->cacheKey(); + + if (isset($this->cache) && $this->cache->has($cacheKey)) { + return $this->cache->get($cacheKey); + } + [$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)); @@ -197,6 +226,10 @@ public function __toString(): string [$root] = $this->callTransformerMethod('root', new Root([$pre])); [$html] = $this->callTransformerMethod('postprocess', $root->__toString()); + if (isset($this->cache)) { + $this->cache->set($cacheKey, $html); + } + return $html; } } diff --git a/src/Phiki.php b/src/Phiki.php index 4969392..71b7278 100644 --- a/src/Phiki.php +++ b/src/Phiki.php @@ -12,6 +12,7 @@ use Phiki\TextMate\Tokenizer; use Phiki\Theme\ParsedTheme; use Phiki\Theme\Theme; +use Psr\SimpleCache\CacheInterface; class Phiki { @@ -55,6 +56,7 @@ public function codeToHighlightedTokens(string $code, string|Grammar $grammar, s public function codeToHtml(string $code, string|Grammar $grammar, string|array|Theme $theme): PendingHtmlOutput { return (new PendingHtmlOutput($code, $this->environment->grammars->resolve($grammar), $this->wrapThemes($theme))) + ->cache($this->environment->cache) ->generateTokensUsing(fn (string $code, ParsedGrammar $grammar) => $this->codeToTokens($code, $grammar)) ->highlightTokensUsing(fn (array $tokens, array $themes) => $this->tokensToHighlightedTokens($tokens, $themes)); } @@ -88,4 +90,11 @@ public function theme(string $name, string|ParsedTheme $pathOrTheme): static return $this; } + + public function cache(CacheInterface $cache): static + { + $this->environment->cache($cache); + + return $this; + } } diff --git a/tests/Fixtures/FakeCache.php b/tests/Fixtures/FakeCache.php new file mode 100644 index 0000000..2717a69 --- /dev/null +++ b/tests/Fixtures/FakeCache.php @@ -0,0 +1,63 @@ +store[$key] ?? $default; + } + + public function set($key, $value, $ttl = null): bool + { + $this->store[$key] = $value; + return true; + } + + public function delete($key): bool + { + unset($this->store[$key]); + return true; + } + + public function clear(): bool + { + $this->store = []; + return true; + } + + public function getMultiple($keys, $default = null): array + { + $results = []; + foreach ($keys as $key) { + $results[$key] = $this->get($key, $default); + } + return $results; + } + + public function setMultiple($values, $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + return true; + } + + public function deleteMultiple($keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } + + public function has($key): bool + { + return array_key_exists($key, $this->store); + } +} diff --git a/tests/Unit/PendingHtmlOutputTest.php b/tests/Unit/PendingHtmlOutputTest.php index 54afbdd..5a36604 100644 --- a/tests/Unit/PendingHtmlOutputTest.php +++ b/tests/Unit/PendingHtmlOutputTest.php @@ -2,6 +2,7 @@ use Phiki\Grammar\Grammar; use Phiki\Phiki; +use Phiki\Tests\Fixtures\FakeCache; use Phiki\Tests\Fixtures\UselessTransformer; use Phiki\Theme\Theme; @@ -57,3 +58,49 @@ expect($html)->toContain('> 1codeToHtml( + <<<'PHP' + echo "Hello, world!"; + PHP, + Grammar::Php, + Theme::GithubLight, + ) + ->cache($cache); + + $pending->toString(); + + expect($cache->has($pending->cacheKey()))->toBeTrue(); +}); + +it('can read from cache', function () { + $cache = new FakeCache; + + $pending = (new Phiki) + ->codeToHtml( + <<<'PHP' + echo "Hello, world!"; + PHP, + Grammar::Php, + Theme::GithubLight, + ) + ->cache($cache); + + $pending->toString(); + + $pending2 = (new Phiki) + ->codeToHtml( + <<<'PHP' + echo "Hello, world!"; + PHP, + Grammar::Php, + Theme::GithubLight, + ) + ->cache($cache); + + expect($pending2->toString())->toBe($pending->toString()); +}); diff --git a/tests/Unit/PhikiTest.php b/tests/Unit/PhikiTest.php index 6d4b5ad..47775f3 100644 --- a/tests/Unit/PhikiTest.php +++ b/tests/Unit/PhikiTest.php @@ -4,56 +4,54 @@ use Phiki\Phiki; use Phiki\Theme\Theme; -describe('Phiki', function () { - it('can be constructed', function () { - expect(new Phiki)->toBeInstanceOf(Phiki::class); - }); - - it('can generate html from code', function () { - expect((new Phiki)->codeToHtml(<<<'PHP' - function add(int|float $a, int|float $b): int|float { - return $a + $b; - } - PHP, 'php', 'github-dark'))->toString()->toBeString(); - }); - - it('adds a language data property and class if grammar has a name', function () { - $html = (new Phiki)->codeToHtml(<<<'PHP' - function add(int|float $a, int|float $b): int|float { - return $a + $b; - } - PHP, 'php', 'github-dark')->toString(); - - expect($html)->toContain('data-language="php"'); - expect($html)->toContain('language-php'); - }); - - it('does not add a language data property and class if grammar has no name', function () { - $html = (new Phiki)->codeToHtml(<<<'PHP' - function add(int|float $a, int|float $b): int|float { - return $a + $b; - } - PHP, Grammar::Txt, 'github-dark')->toString(); - - expect($html)->not->toContain('data-language'); - expect($html)->not->toContain('language-'); - }); - - it('can generate code with multiple themes', function () { - $code = (new Phiki)->codeToHtml(<<<'PHP' - echo "Hello, world"; - PHP, Grammar::Php, ['light' => Theme::GithubLight, 'dark' => Theme::GithubDark])->toString(); - - expect($code)->toContain('github-light')->toContain('github-dark'); - expect($code)->toContain('--phiki-dark-color'); - expect($code)->toContain('--phiki-dark-background-color'); - }); - - it('accepts a grammar enum member', function () { - expect((new Phiki)->codeToTokens('echo $a;', Grammar::Php))->toBeArray(); - }); - - it('accepts a theme enum member', function () { - expect((new Phiki)->codeToHtml('echo $a;', Grammar::Php, Theme::GithubDark))->__toString()->toBeString(); - }); +it('can be constructed', function () { + expect(new Phiki)->toBeInstanceOf(Phiki::class); +}); + +it('can generate html from code', function () { + expect((new Phiki)->codeToHtml(<<<'PHP' + function add(int|float $a, int|float $b): int|float { + return $a + $b; + } + PHP, 'php', 'github-dark'))->toString()->toBeString(); +}); + +it('adds a language data property and class if grammar has a name', function () { + $html = (new Phiki)->codeToHtml(<<<'PHP' + function add(int|float $a, int|float $b): int|float { + return $a + $b; + } + PHP, 'php', 'github-dark')->toString(); + + expect($html)->toContain('data-language="php"'); + expect($html)->toContain('language-php'); +}); + +it('does not add a language data property and class if grammar has no name', function () { + $html = (new Phiki)->codeToHtml(<<<'PHP' + function add(int|float $a, int|float $b): int|float { + return $a + $b; + } + PHP, Grammar::Txt, 'github-dark')->toString(); + + expect($html)->not->toContain('data-language'); + expect($html)->not->toContain('language-'); +}); + +it('can generate code with multiple themes', function () { + $code = (new Phiki)->codeToHtml(<<<'PHP' + echo "Hello, world"; + PHP, Grammar::Php, ['light' => Theme::GithubLight, 'dark' => Theme::GithubDark])->toString(); + + expect($code)->toContain('github-light')->toContain('github-dark'); + expect($code)->toContain('--phiki-dark-color'); + expect($code)->toContain('--phiki-dark-background-color'); +}); + +it('accepts a grammar enum member', function () { + expect((new Phiki)->codeToTokens('echo $a;', Grammar::Php))->toBeArray(); +}); + +it('accepts a theme enum member', function () { + expect((new Phiki)->codeToHtml('echo $a;', Grammar::Php, Theme::GithubDark))->__toString()->toBeString(); });