├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── config └── torchlight.php ├── phpunit.xml.dist ├── src ├── Blade │ ├── BladeManager.php │ ├── CodeComponent.php │ └── EngineDecorator.php ├── Block.php ├── Client.php ├── Commands │ └── Install.php ├── Contracts │ └── PostProcessor.php ├── Exceptions │ ├── ConfigurationException.php │ ├── RequestException.php │ └── TorchlightException.php ├── Manager.php ├── Middleware │ └── RenderTorchlight.php ├── PostProcessors │ └── SimpleSwapProcessor.php ├── Torchlight.php └── TorchlightServiceProvider.php └── tests ├── BaseTestCase.php ├── BlockTest.php ├── ClientTest.php ├── ClientTimeoutTest.php ├── CustomizationTest.php ├── DualThemeTest.php ├── FindIdsTest.php ├── LivewireTest.php ├── MiddlewareAndComponentTest.php ├── PostProcessorTest.php ├── RealClientTest.php └── Support ├── an-inline-component-with-post-processors.blade.php ├── an-inline-component-with-swaps.blade.php ├── an-inline-component.blade.php ├── contents-via-file-2.blade.php ├── contents-via-file.blade.php ├── dedent_works_properly.blade.php ├── file-must-be-passed-through-contents.blade.php ├── simple-js-hello-world.blade.php ├── simple-php-hello-world-new-theme.blade.php ├── simple-php-hello-world-with-attributes.blade.php ├── simple-php-hello-world-with-classes.blade.php ├── simple-php-hello-world-with-style.blade.php ├── simple-php-hello-world.blade.php ├── two-codes-in-one-tag.blade.php └── two-simple-php-hello-world.blade.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | - reopened 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | php: ['8.0', '8.1', '8.2', '8.3'] 21 | laravel: ['8.*', '9.*', '10.*', '11.*', '12.*'] 22 | dependency-version: [prefer-lowest, prefer-stable] 23 | include: 24 | - laravel: 8.* 25 | testbench: 6.* 26 | - laravel: 9.* 27 | testbench: 7.* 28 | - laravel: 10.* 29 | testbench: 8.* 30 | - laravel: 11.* 31 | testbench: 9.* 32 | - laravel: 12.* 33 | testbench: 10.* 34 | exclude: 35 | - laravel: 8.* 36 | php: 8.1 37 | dependency-version: prefer-lowest 38 | - laravel: 8.* 39 | php: 8.2 40 | dependency-version: prefer-lowest 41 | - laravel: 8.* 42 | php: 8.3 43 | dependency-version: prefer-lowest 44 | - laravel: 9.* 45 | php: 8.2 46 | dependency-version: prefer-lowest 47 | - laravel: 9.* 48 | php: 8.3 49 | dependency-version: prefer-lowest 50 | - laravel: 10.* 51 | php: 8.0 52 | - laravel: 11.* 53 | php: 8.0 54 | - laravel: 11.* 55 | php: 8.1 56 | - laravel: 12.* 57 | php: '8.0' 58 | - laravel: 12.* 59 | php: '8.1' 60 | 61 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 62 | 63 | steps: 64 | - name: Checkout code 65 | uses: actions/checkout@v4 66 | 67 | - name: Cache dependencies 68 | uses: actions/cache@v4 69 | with: 70 | path: ~/.composer/cache/files 71 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 72 | 73 | - name: Setup PHP 74 | uses: shivammathur/setup-php@v2 75 | with: 76 | php-version: ${{ matrix.php }} 77 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 78 | coverage: none 79 | 80 | - name: Install dependencies 81 | run: | 82 | composer config minimum-stability dev 83 | composer self-update 84 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 85 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 86 | 87 | - name: Execute tests 88 | run: vendor/bin/phpunit 89 | 90 | - name: Install Livewire V2 (Below Laravel 11.0) 91 | if: "! startsWith(matrix.laravel, '11.') && ! startsWith(matrix.laravel, '12.')" 92 | run: | 93 | composer require "livewire/livewire:^2.3.10" -W --${{ matrix.dependency-version }} --no-interaction 94 | 95 | - name: Test with Livewire V2 96 | if: "! startsWith(matrix.laravel, '11.') && ! startsWith(matrix.laravel, '12.')" 97 | run: vendor/bin/phpunit 98 | 99 | - name: Install Livewire V3 (Above Laravel 9.0) 100 | if: "! startsWith(matrix.laravel, '8.') && ! startsWith(matrix.laravel, '9.')" 101 | run: | 102 | composer require "livewire/livewire:^3" -W --${{ matrix.dependency-version }} --no-interaction 103 | 104 | - name: Test with Livewire V3 105 | if: "! startsWith(matrix.laravel, '8.') && ! startsWith(matrix.laravel, '9.')" 106 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | .env 3 | .phpunit.result.cache 4 | composer.lock -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - concat_without_spaces 5 | - not_operator_with_successor_space 6 | - cast_spaces 7 | - trailing_comma_in_multiline_array 8 | - heredoc_to_nowdoc 9 | - phpdoc_summary 10 | 11 | risky: false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## 0.5.11 - 2022-02-13 6 | 7 | ### Added 8 | - Support for Laravel 9 [#29](https://github.com/torchlight-api/torchlight-laravel/pull/29) 9 | - Better support for PHP 8.1 [#30](https://github.com/torchlight-api/torchlight-laravel/pull/30) 10 | 11 | ## 0.5.10 - 2022-02-01 12 | 13 | ### Added 14 | - Added the ability to define multiple themes for e.g. dark mode. 15 | - Cache time is now configurable. 16 | 17 | ## 0.5.9 - 2022-01-19 18 | 19 | ### Fixed 20 | 21 | - Fix cosmetic trailing space issue 22 | 23 | ## 0.5.8 - 2022-01-19 24 | 25 | ### Added 26 | 27 | - Attributes from the API will now be passed on to the code component. (The API now returns 'data-lang' as an attribute.) 28 | 29 | ## 0.5.7 - 2021-11-02 30 | 31 | ### Added 32 | 33 | - `Block` is now Macroable 34 | 35 | ## 0.5.6 - 2021-11-01 36 | 37 | ### Added 38 | 39 | - Added the ability to run post-processors _per block_ rather than globally. ([#20](https://github.com/torchlight-api/torchlight-laravel/pull/20)) 40 | 41 | ## 0.5.5 - 2021-09-06 42 | 43 | ### Changed 44 | - Changed the signature of the file processor. 45 | 46 | ## 0.5.4 - 2021-09-06 47 | 48 | ### Added 49 | - Added the ability to configure the directories where Torchlight looks for snippets. 50 | 51 | ## 0.5.3 - 2021-08-14 52 | 53 | ### Changed 54 | - Post-processors don't run if Laravel is compiling views. 55 | 56 | ### Added 57 | - You can set `tab_width` to `false` to output literal tabs into the rendered HTML. 58 | 59 | ### Fixed 60 | - Livewire middleware won't be registered for V1 of Livewire, since it's not possible. 61 | 62 | ## 0.5.2 - 2021-08-02 63 | 64 | ### Fixed 65 | - Replace tabs with spaces in code before it's sent to the API. 66 | 67 | ## 0.5.1 - 2021-08-01 68 | 69 | ### Added 70 | - Added support for Laravel Livewire ([#10](https://github.com/torchlight-api/torchlight-laravel/pull/10)) 71 | - Added post-processors to allow your app to hook into the rendered response before it's sent to the browser. 72 | 73 | ## 0.5.0 - 2021-07-31 74 | 75 | ### Changed 76 | - Changed the signature for the Manager class. Remove the requirement for the container to be passed in. 77 | 78 | ## 0.4.6 - 2021-07-28 79 | 80 | ### Added 81 | - Added the ability to send `options` from the config file to the API. 82 | 83 | ## 0.4.5 - 2021-07-18 84 | 85 | ### Changed 86 | - The default response (used if a request fails) now includes the `
64 | //70 | 71 | // Then Laravel will strip the leading whitespace off of the first 72 | // line, of content making it impossible for us to know how 73 | // much to dedent the rest of the code. 74 | 75 | // We're hijacking this `withAttributes` method because it is called 76 | // _after_ the buffer is opened but before the content. So we echo 77 | // out some nonsense which will prevent Laravel from trimming 78 | // the whitespace. We'll replace it later. We only do this 79 | // if it's not a file-based-contents component. 80 | if (is_null($this->contents)) { 81 | echo $this->trimFixDelimiter; 82 | } 83 | 84 | return parent::withAttributes($attributes); 85 | } 86 | 87 | public function capture($contents) 88 | { 89 | $contents = $contents ?: $this->contents; 90 | $contents = Torchlight::processFileContents($contents) ?: $contents; 91 | 92 | if (Str::startsWith($contents, $this->trimFixDelimiter)) { 93 | $contents = Str::replaceFirst($this->trimFixDelimiter, '', $contents); 94 | } 95 | 96 | BladeManager::registerBlock($this->block->code($contents)); 97 | } 98 | 99 | /** 100 | * Get the view / contents that represent the component. 101 | * 102 | * @return string 103 | */ 104 | public function render() 105 | { 106 | // Put all of the attributes on the code element, merging in our placeholder 107 | // classes and style string. Echo out the slot, but capture it using output 108 | // buffering. We then pass it through as the contents to highlight, leaving 109 | // the placeholder so we can replace it later with fully highlighted code. 110 | // We have to add the ##PRE## and ##POST## tags to cover a framework bug. 111 | // @see BladeManager::renderContent. 112 | return <<<'EOT' 113 | ##PRE_TL_COMPONENT##65 | // public function { 66 | // // test 67 | // } 68 | // 69 | //
placeholder('attrs') }}{{
114 | $attributes->except('style')->merge([
115 | 'class' => $block->placeholder('classes'),
116 | 'style' => $attributes->get('style') . $block->placeholder('styles')
117 | ])
118 | }}>{{ $slot }}{{ $block->placeholder() }}
##POST_TL_COMPONENT##
119 | EOT;
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/Blade/EngineDecorator.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Blade;
7 |
8 | use Illuminate\Contracts\View\Engine;
9 | use Torchlight\Torchlight;
10 |
11 | class EngineDecorator implements Engine
12 | {
13 | public $decorated;
14 |
15 | public function __construct($resolved)
16 | {
17 | $this->decorated = $resolved;
18 | }
19 |
20 | public function __get($name)
21 | {
22 | return $this->decorated->{$name};
23 | }
24 |
25 | public function __set($name, $value)
26 | {
27 | $this->decorated->{$name} = $value;
28 | }
29 |
30 | public function __call($name, $arguments)
31 | {
32 | return call_user_func_array([$this->decorated, $name], $arguments);
33 | }
34 |
35 | public function get($path, array $data = [])
36 | {
37 | Torchlight::currentlyCompilingViews(true);
38 |
39 | $result = $this->decorated->get($path, $data);
40 |
41 | Torchlight::currentlyCompilingViews(false);
42 |
43 | return $result;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/Block.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight;
7 |
8 | use Illuminate\Support\Arr;
9 | use Illuminate\Support\Str;
10 | use Illuminate\Support\Traits\Macroable;
11 |
12 | class Block
13 | {
14 | use Macroable;
15 |
16 | /**
17 | * @var null|callable
18 | */
19 | public static $generateIdsUsing;
20 |
21 | /**
22 | * The language of the code that is being highlighted.
23 | *
24 | * @var string
25 | */
26 | public $language;
27 |
28 | /**
29 | * The theme of the code.
30 | *
31 | * @var string
32 | */
33 | public $theme;
34 |
35 | /**
36 | * The code itself.
37 | *
38 | * @var string
39 | */
40 | public $code;
41 |
42 | /**
43 | * The post processors.
44 | *
45 | * @var array
46 | */
47 | public $postProcessors = [];
48 |
49 | /**
50 | * The highlighted code, wrapped in pre+code tags.
51 | *
52 | * @var string
53 | */
54 | public $wrapped;
55 |
56 | /**
57 | * The highlighted code, not wrapped.
58 | *
59 | * @var string
60 | */
61 | public $highlighted;
62 |
63 | /**
64 | * Classes that should be applied to the code tag.
65 | *
66 | * @var string
67 | */
68 | public $classes;
69 |
70 | /**
71 | * Styles that should be applied to the code tag.
72 | *
73 | * @var string
74 | */
75 | public $styles;
76 |
77 | /**
78 | * Attributes to apply to the code tag.
79 | *
80 | * @var array
81 | */
82 | public $attrs = [];
83 |
84 | /**
85 | * The unique ID for the block.
86 | *
87 | * @var string
88 | */
89 | protected $id;
90 |
91 | /**
92 | * @var array
93 | */
94 | protected $clones = [];
95 |
96 | /**
97 | * @param null|string $id
98 | * @return static
99 | */
100 | public static function make($id = null)
101 | {
102 | return new static($id);
103 | }
104 |
105 | /**
106 | * @param null|string $id
107 | */
108 | public function __construct($id = null)
109 | {
110 | // Generate a unique UUID.
111 | $this->id = $id ?? $this->generateId();
112 |
113 | // Set a default theme.
114 | $this->theme(Torchlight::config('theme'));
115 | }
116 |
117 | /**
118 | * @return string
119 | */
120 | public function id()
121 | {
122 | return $this->id;
123 | }
124 |
125 | /**
126 | * @return string
127 | */
128 | protected function generateId()
129 | {
130 | $id = is_callable(static::$generateIdsUsing) ? call_user_func(static::$generateIdsUsing) : Str::uuid();
131 |
132 | return (string)$id;
133 | }
134 |
135 | /**
136 | * @return string
137 | */
138 | public function hash()
139 | {
140 | return md5(
141 | $this->language
142 | . $this->theme
143 | . $this->code
144 | . Torchlight::config('bust')
145 | . json_encode(Torchlight::config('options'))
146 | );
147 | }
148 |
149 | /**
150 | * @return array
151 | */
152 | public function clones()
153 | {
154 | return $this->clones;
155 | }
156 |
157 | /**
158 | * @param $num
159 | * @return $this
160 | */
161 | public function cloned($num)
162 | {
163 | $this->id = Str::finish($this->id, "_clone_$num");
164 |
165 | return $this;
166 | }
167 |
168 | /**
169 | * @param string $extra
170 | * @return string
171 | */
172 | public function placeholder($extra = '')
173 | {
174 | if ($extra) {
175 | $extra = "_$extra";
176 | }
177 |
178 | return "__torchlight-block-[{$this->id()}]{$extra}__";
179 | }
180 |
181 | /**
182 | * @param $language
183 | * @return $this
184 | */
185 | public function language($language)
186 | {
187 | $this->language = $language;
188 |
189 | return $this;
190 | }
191 |
192 | /**
193 | * @param $theme
194 | * @return $this
195 | */
196 | public function theme($theme)
197 | {
198 | $theme = $this->normalizeArrayTheme($theme);
199 |
200 | if ($theme) {
201 | $this->theme = $theme;
202 | }
203 |
204 | return $this;
205 | }
206 |
207 | /**
208 | * @param $code
209 | * @return $this
210 | */
211 | public function code($code)
212 | {
213 | $this->code = $this->clean($code);
214 |
215 | return $this;
216 | }
217 |
218 | /**
219 | * @return string
220 | */
221 | public function attrsAsString()
222 | {
223 | $attrs = [];
224 |
225 | foreach ($this->attrs as $key => $value) {
226 | $value = addslashes($value ?? '');
227 | $attrs[] = "$key=\"$value\"";
228 | }
229 |
230 | if (count($attrs)) {
231 | return implode(' ', $attrs) . ' ';
232 | }
233 | }
234 |
235 | /**
236 | * @param $processor
237 | * @return $this
238 | */
239 | public function addPostProcessor($processor)
240 | {
241 | if ($processor) {
242 | $this->postProcessors[] = Torchlight::validatedPostProcessor($processor);
243 | }
244 |
245 | return $this;
246 | }
247 |
248 | /**
249 | * @param $wrapped
250 | * @return $this
251 | */
252 | public function wrapped($wrapped)
253 | {
254 | $this->wrapped = $wrapped;
255 |
256 | return $this;
257 | }
258 |
259 | /**
260 | * @return Block[]
261 | */
262 | public function spawnClones()
263 | {
264 | $this->clones = [];
265 |
266 | $themes = explode(',', $this->theme ?? '');
267 |
268 | // Set the theme for the current block, so that we
269 | // don't break the reference to it.
270 | $this->theme(array_shift($themes));
271 |
272 | // Then generate any clones for the remaining themes.
273 | $this->clones = collect($themes)->map(function ($theme, $num) {
274 | return (clone $this)->theme($theme)->cloned($num);
275 | })->toArray();
276 |
277 | return $this->clones;
278 | }
279 |
280 | /**
281 | * @return array
282 | */
283 | public function toRequestParams()
284 | {
285 | return [
286 | 'id' => $this->id(),
287 | 'hash' => $this->hash(),
288 | 'language' => $this->language,
289 | 'theme' => $this->theme,
290 | 'code' => $this->code,
291 | ];
292 | }
293 |
294 | /**
295 | * @param $theme
296 | * @return mixed|string
297 | */
298 | protected function normalizeArrayTheme($theme)
299 | {
300 | if (!is_array($theme)) {
301 | return $theme;
302 | }
303 |
304 | if (Arr::isAssoc($theme)) {
305 | return collect($theme)->map(function ($name, $label) {
306 | return "$label:$name";
307 | })->join(',');
308 | }
309 |
310 | return implode(',', $theme);
311 | }
312 |
313 | /**
314 | * @param $code
315 | * @return string
316 | */
317 | protected function clean($code)
318 | {
319 | $code = rtrim($code);
320 | $code = $this->replaceTabs($code);
321 |
322 | return $this->dedent($code);
323 | }
324 |
325 | /**
326 | * @param $code
327 | * @return string
328 | */
329 | protected function replaceTabs($code)
330 | {
331 | $multiplier = Torchlight::config('tab_width', 4);
332 |
333 | if ($multiplier === false) {
334 | return $code;
335 | }
336 |
337 | return str_replace("\t", str_repeat(' ', $multiplier), $code);
338 | }
339 |
340 | /**
341 | * @param $code
342 | * @return string
343 | */
344 | protected function dedent($code)
345 | {
346 | $lines = explode("\n", $code);
347 |
348 | $dedent = collect($lines)
349 | ->map(function ($line) {
350 | if (!$line || $line === "\n") {
351 | return false;
352 | }
353 |
354 | // Figure out how many spaces are at the start of the line.
355 | return strlen($line) - strlen(ltrim($line, ' '));
356 | })
357 | ->reject(function ($count) {
358 | return $count === false;
359 | })
360 | // Take the smallest number of left-spaces. We'll
361 | // dedent everything by that amount.
362 | ->min();
363 |
364 | // Make the string out of the right number of spaces.
365 | $dedent = str_repeat(' ', $dedent);
366 |
367 | return collect($lines)
368 | ->map(function ($line) use ($dedent) {
369 | $line = rtrim($line);
370 |
371 | // Replace the first n-many spaces that
372 | // are common to every line.
373 | return Str::replaceFirst($dedent, '', $line);
374 | })
375 | ->implode("\n");
376 | }
377 | }
378 |
--------------------------------------------------------------------------------
/src/Client.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight;
7 |
8 | use Illuminate\Http\Client\ConnectionException;
9 | use Illuminate\Support\Arr;
10 | use Illuminate\Support\Collection;
11 | use Illuminate\Support\Facades\Http;
12 | use Throwable;
13 | use Torchlight\Exceptions\ConfigurationException;
14 | use Torchlight\Exceptions\RequestException;
15 | use Torchlight\Exceptions\TorchlightException;
16 |
17 | class Client
18 | {
19 | public function highlight($blocks)
20 | {
21 | $blocks = Arr::wrap($blocks);
22 |
23 | $blocks = $this->collectionOfBlocks($blocks)->values();
24 | $blocks = $blocks->merge($blocks->map->spawnClones())->flatten();
25 | $blocks = $blocks->keyBy->id();
26 |
27 | // First set the html from the cache if it is already stored.
28 | $this->setBlocksFromCache($blocks);
29 |
30 | // Then reject all the blocks that already have the html, which
31 | // will leave us with only the blocks we need to request.
32 | $needed = $blocks->reject->wrapped;
33 |
34 | // If there are any blocks that don't have html yet,
35 | // we fire a request.
36 | if ($needed->count()) {
37 | // This method will set the html on the block objects,
38 | // so we don't do anything with the return value.
39 | $this->request($needed);
40 | }
41 |
42 | return $blocks->values()->toArray();
43 | }
44 |
45 | protected function request(Collection $blocks)
46 | {
47 | try {
48 | $host = Torchlight::config('host', 'https://api.torchlight.dev');
49 | $timeout = Torchlight::config('request_timeout', 5);
50 |
51 | $response = Http::baseUrl($host)
52 | ->timeout($timeout)
53 | ->withToken($this->getToken())
54 | ->post('highlight', [
55 | 'blocks' => $this->blocksAsRequestParam($blocks)->values()->toArray(),
56 | 'options' => $this->getOptions(),
57 | ]);
58 |
59 | if ($response->failed()) {
60 | $this->potentiallyThrowRequestException($response->toException());
61 | $response = [];
62 | } else {
63 | $response = $response->json();
64 | }
65 | } catch (Throwable $e) {
66 | $e instanceof ConnectionException
67 | ? $this->potentiallyThrowRequestException($e)
68 | : $this->throwUnlessProduction($e);
69 |
70 | $response = [];
71 | }
72 |
73 | $response = Arr::get($response, 'blocks', []);
74 | $response = collect($response)->keyBy('id');
75 |
76 | $blocks->each(function (Block $block) use ($response) {
77 | $blockFromResponse = Arr::get($response, "{$block->id()}", $this->defaultResponse($block));
78 |
79 | foreach ($this->applyDirectlyFromResponse() as $key) {
80 | if (Arr::has($blockFromResponse, $key)) {
81 | $block->{$key} = $blockFromResponse[$key];
82 | }
83 | }
84 | });
85 |
86 | // Only store the ones we got back from the API.
87 | $this->setCacheFromBlocks($blocks, $response->keys());
88 |
89 | return $blocks;
90 | }
91 |
92 | protected function collectionOfBlocks($blocks)
93 | {
94 | return collect($blocks)->each(function ($block) {
95 | if (!$block instanceof Block) {
96 | throw new TorchlightException('Block not instance of ' . Block::class);
97 | }
98 | });
99 | }
100 |
101 | protected function getToken()
102 | {
103 | $token = Torchlight::config('token');
104 |
105 | if (!$token) {
106 | $this->throwUnlessProduction(
107 | new ConfigurationException('No Torchlight token configured.')
108 | );
109 | }
110 |
111 | return $token;
112 | }
113 |
114 | protected function getOptions()
115 | {
116 | $options = Torchlight::config('options', []);
117 |
118 | if (!is_array($options)) {
119 | $options = [];
120 | }
121 |
122 | return $options;
123 | }
124 |
125 | protected function potentiallyThrowRequestException($exception)
126 | {
127 | if ($exception) {
128 | $wrapped = new RequestException('A Torchlight request exception has occurred.', 0, $exception);
129 |
130 | $this->throwUnlessProduction($wrapped);
131 | }
132 | }
133 |
134 | protected function throwUnlessProduction($exception)
135 | {
136 | throw_unless(Torchlight::environment() === 'production', $exception);
137 | }
138 |
139 | public function cachePrefix()
140 | {
141 | return 'torchlight::';
142 | }
143 |
144 | public function cacheKey(Block $block)
145 | {
146 | return $this->cachePrefix() . 'block-' . $block->hash();
147 | }
148 |
149 | protected function blocksAsRequestParam(Collection $blocks)
150 | {
151 | return $blocks->map(function (Block $block) {
152 | return $block->toRequestParams();
153 | });
154 | }
155 |
156 | protected function applyDirectlyFromResponse()
157 | {
158 | return ['wrapped', 'highlighted', 'styles', 'classes', 'attrs'];
159 | }
160 |
161 | protected function setCacheFromBlocks(Collection $blocks, Collection $ids)
162 | {
163 | $keys = $this->applyDirectlyFromResponse();
164 |
165 | $blocks->only($ids)->each(function (Block $block) use ($keys) {
166 | $value = [];
167 |
168 | foreach ($keys as $key) {
169 | if ($block->{$key}) {
170 | $value[$key] = $block->{$key};
171 | }
172 | }
173 |
174 | if (count($value)) {
175 | $seconds = Torchlight::config('cache_seconds', 7 * 24 * 60 * 60);
176 |
177 | if (is_null($seconds)) {
178 | Torchlight::cache()->forever($this->cacheKey($block), $value);
179 | } else {
180 | Torchlight::cache()->put($this->cacheKey($block), $value, (int)$seconds);
181 | }
182 | }
183 | });
184 | }
185 |
186 | protected function setBlocksFromCache(Collection $blocks)
187 | {
188 | $keys = $this->applyDirectlyFromResponse();
189 |
190 | $blocks->each(function (Block $block) use ($keys) {
191 | if (!$cached = Torchlight::cache()->get($this->cacheKey($block))) {
192 | return;
193 | }
194 |
195 | if (is_string($cached)) {
196 | return;
197 | }
198 |
199 | foreach ($keys as $key) {
200 | if (Arr::has($cached, $key)) {
201 | $block->{$key} = $cached[$key];
202 | }
203 | }
204 | });
205 | }
206 |
207 | /**
208 | * In the case where nothing returns from the API, we have to show _something_.
209 | *
210 | * @param Block $block
211 | * @return array
212 | */
213 | protected function defaultResponse(Block $block)
214 | {
215 | $lines = array_map(function ($line) {
216 | return "{$highlighted}
",
230 | ];
231 | }
232 | }
233 |
--------------------------------------------------------------------------------
/src/Commands/Install.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Commands;
7 |
8 | use Illuminate\Console\Command;
9 | use Illuminate\Support\Facades\Artisan;
10 | use Torchlight\TorchlightServiceProvider;
11 |
12 | class Install extends Command
13 | {
14 | /**
15 | * The name and signature of the console command.
16 | *
17 | * @var string
18 | */
19 | protected $signature = 'torchlight:install';
20 |
21 | /**
22 | * The console command description.
23 | *
24 | * @var string
25 | */
26 | protected $description = 'Install the Torchlight config file into your app.';
27 |
28 | public function __construct()
29 | {
30 | parent::__construct();
31 |
32 | if (file_exists(config_path('torchlight.php'))) {
33 | $this->setHidden(true);
34 | }
35 | }
36 |
37 | /**
38 | * @throws Exception
39 | */
40 | public function handle()
41 | {
42 | Artisan::call('vendor:publish', [
43 | '--provider' => TorchlightServiceProvider::class
44 | ]);
45 |
46 | $this->info('Config file published!');
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Contracts/PostProcessor.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Contracts;
7 |
8 | use Torchlight\Block;
9 |
10 | interface PostProcessor
11 | {
12 | public function process(Block $block);
13 | }
14 |
--------------------------------------------------------------------------------
/src/Exceptions/ConfigurationException.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Exceptions;
7 |
8 | class ConfigurationException extends TorchlightException
9 | {
10 | }
11 |
--------------------------------------------------------------------------------
/src/Exceptions/RequestException.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Exceptions;
7 |
8 | class RequestException extends TorchlightException
9 | {
10 | }
11 |
--------------------------------------------------------------------------------
/src/Exceptions/TorchlightException.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Exceptions;
7 |
8 | class TorchlightException extends \Exception
9 | {
10 | }
11 |
--------------------------------------------------------------------------------
/src/Manager.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight;
7 |
8 | use Illuminate\Contracts\Cache\Repository;
9 | use Illuminate\Support\Arr;
10 | use Illuminate\Support\Facades\Cache;
11 | use Illuminate\Support\Str;
12 | use Illuminate\Support\Traits\Macroable;
13 | use Torchlight\Contracts\PostProcessor;
14 | use Torchlight\Exceptions\ConfigurationException;
15 |
16 | class Manager
17 | {
18 | use Macroable;
19 |
20 | /**
21 | * @var null|callable
22 | */
23 | protected $getConfigUsing;
24 |
25 | /**
26 | * @var Repository
27 | */
28 | protected $cache;
29 |
30 | /**
31 | * @var Client
32 | */
33 | protected $client;
34 |
35 | /**
36 | * @var null|string
37 | */
38 | protected $environment;
39 |
40 | /**
41 | * @var array
42 | */
43 | protected $postProcessors = [];
44 |
45 | /**
46 | * @var bool
47 | */
48 | protected $currentlyCompilingViews = false;
49 |
50 | /**
51 | * @param Client $client
52 | * @return Manager
53 | */
54 | public function setClient(Client $client)
55 | {
56 | $this->client = $client;
57 |
58 | return $this;
59 | }
60 |
61 | /**
62 | * @return Client
63 | */
64 | public function client()
65 | {
66 | if (!$this->client) {
67 | $this->client = new Client;
68 | }
69 |
70 | return $this->client;
71 | }
72 |
73 | /**
74 | * @param $value
75 | */
76 | public function currentlyCompilingViews($value)
77 | {
78 | $this->currentlyCompilingViews = $value;
79 | }
80 |
81 | /**
82 | * @param $blocks
83 | * @return mixed
84 | */
85 | public function highlight($blocks)
86 | {
87 | $blocks = $this->client()->highlight($blocks);
88 |
89 | $this->postProcessBlocks($blocks);
90 |
91 | return $blocks;
92 | }
93 |
94 | /**
95 | * @return string
96 | */
97 | public function environment()
98 | {
99 | return $this->environment ?? app()->environment();
100 | }
101 |
102 | /**
103 | * @param string|null $environment
104 | */
105 | public function overrideEnvironment($environment = null)
106 | {
107 | $this->environment = $environment;
108 | }
109 |
110 | /**
111 | * @param array|string $classes
112 | */
113 | public function addPostProcessors($classes)
114 | {
115 | $classes = Arr::wrap($classes);
116 |
117 | foreach ($classes as $class) {
118 | $this->postProcessors[] = $this->validatedPostProcessor($class);
119 | }
120 | }
121 |
122 | /**
123 | * @param $blocks
124 | */
125 | public function postProcessBlocks($blocks)
126 | {
127 | // Global post-processors
128 | foreach ($this->postProcessors as $processor) {
129 | if ($this->shouldSkipProcessor($processor)) {
130 | continue;
131 | }
132 |
133 | foreach ($blocks as $block) {
134 | $processor->process($block);
135 | }
136 | }
137 |
138 | // Block specific post-processors
139 | foreach ($blocks as $block) {
140 | foreach ($block->postProcessors as $processor) {
141 | if ($this->shouldSkipProcessor($processor)) {
142 | continue;
143 | }
144 |
145 | $processor->process($block);
146 | }
147 | }
148 | }
149 |
150 | public function processFileContents($file)
151 | {
152 | if (Str::startsWith($file, '##LARAVEL_TRIM_FIXER##')) {
153 | return false;
154 | }
155 |
156 | $directories = $this->config('snippet_directories', []);
157 |
158 | // Add a blank path to account for absolute paths.
159 | array_unshift($directories, '');
160 |
161 | foreach ($directories as $directory) {
162 | if (!empty($directory)) {
163 | $directory = Str::finish($directory, DIRECTORY_SEPARATOR);
164 | }
165 |
166 | if (is_file($directory . $file)) {
167 | return file_get_contents($directory . $file);
168 | }
169 | }
170 |
171 | return false;
172 | }
173 |
174 | /**
175 | * Get an item out of the config using dot notation.
176 | *
177 | * @param $key
178 | * @param null $default
179 | * @return mixed
180 | */
181 | public function config($key, $default = null)
182 | {
183 | // Default to Laravel's config method.
184 | $method = $this->getConfigUsing ?? 'config';
185 |
186 | // If we are using Laravel's config method, then we'll prepend
187 | // the key with `torchlight` if it isn't already there.
188 | if ($method === 'config') {
189 | $key = Str::start($key, 'torchlight.');
190 | }
191 |
192 | return call_user_func($method, $key, $default);
193 | }
194 |
195 | /**
196 | * A callback function used to access configuration. By default this
197 | * is null, which will fall through to Laravel's `config` function.
198 | *
199 | * @param $callback
200 | */
201 | public function getConfigUsing($callback)
202 | {
203 | if (is_array($callback)) {
204 | $callback = function ($key, $default) use ($callback) {
205 | return Arr::get($callback, $key, $default);
206 | };
207 | }
208 |
209 | $this->getConfigUsing = $callback;
210 | }
211 |
212 | /**
213 | * Set the cache implementation directly instead of using a driver.
214 | *
215 | * @param Repository $cache
216 | */
217 | public function setCacheInstance(Repository $cache)
218 | {
219 | $this->cache = $cache;
220 | }
221 |
222 | /**
223 | * The cache store to use.
224 | *
225 | * @return Repository
226 | */
227 | public function cache()
228 | {
229 | if ($this->cache) {
230 | return $this->cache;
231 | }
232 |
233 | // If the developer has requested a particular store, we'll use it.
234 | // If the config value is null, the default cache will be used.
235 | return Cache::store($this->config('cache'));
236 | }
237 |
238 | /**
239 | * Return all the Torchlight IDs in a given string.
240 | *
241 | * @param string $content
242 | * @return array
243 | */
244 | public function findTorchlightIds($content)
245 | {
246 | preg_match_all('/__torchlight-block-\[(.+?)\]/', $content, $matches);
247 |
248 | return array_values(array_unique(Arr::get($matches, 1, [])));
249 | }
250 |
251 | /**
252 | * @param $processor
253 | * @return PostProcessor
254 | *
255 | * @throws ConfigurationException
256 | */
257 | public function validatedPostProcessor($processor)
258 | {
259 | if (is_string($processor)) {
260 | $processor = app($processor);
261 | }
262 |
263 | if (!in_array(PostProcessor::class, class_implements($processor))) {
264 | $class = get_class($processor);
265 | throw new ConfigurationException("Post-processor '$class' does not implement " . PostProcessor::class);
266 | }
267 |
268 | return $processor;
269 | }
270 |
271 | protected function shouldSkipProcessor($processor)
272 | {
273 | // By default we do _not_ run post-processors when Laravel is compiling
274 | // views, because it could lead to data leaks if a post-processor swaps
275 | // user data in. If the developer understands this, they can turn
276 | // `processEvenWhenCompiling` on and we'll happily run them.
277 | $processWhenCompiling = property_exists($processor, 'processEvenWhenCompiling')
278 | && $processor->processEvenWhenCompiling;
279 |
280 | return $this->currentlyCompilingViews && !$processWhenCompiling;
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/src/Middleware/RenderTorchlight.php:
--------------------------------------------------------------------------------
1 | handleLivewireRequest($response);
27 | }
28 |
29 | // Must be a regular, HTML response.
30 | if (!$response instanceof Response || !Str::contains($response->headers->get('content-type'), 'html')) {
31 | return $response;
32 | }
33 |
34 | $response = BladeManager::renderResponse($response);
35 |
36 | // Clear blocks from memory to prevent memory leak when using Laravel Octane
37 | BladeManager::clearBlocks();
38 |
39 | return $response;
40 | }
41 |
42 | protected function handleLivewireRequest(JsonResponse $response)
43 | {
44 | if (!BladeManager::getBlocks()) {
45 | return $response;
46 | }
47 |
48 | $data = $response->getData();
49 |
50 | if (data_get($data, 'effects.html')) {
51 | // Livewire v2
52 | $html = BladeManager::renderContent(data_get($data, 'effects.html'));
53 |
54 | data_set($data, 'effects.html', $html);
55 | } else {
56 | // Livewire v3
57 | foreach (data_get($data, 'components.*.effects.html') as $componentIndex => $componentHtml) {
58 | $html = BladeManager::renderContent($componentHtml);
59 | data_set($data, "components.$componentIndex.effects.html", $html);
60 | }
61 | }
62 |
63 | return $response->setData($data);
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/PostProcessors/SimpleSwapProcessor.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\PostProcessors;
7 |
8 | use Torchlight\Block;
9 | use Torchlight\Contracts\PostProcessor;
10 |
11 | class SimpleSwapProcessor implements PostProcessor
12 | {
13 | public $swap = [];
14 |
15 | public static function make($swap)
16 | {
17 | return new static($swap);
18 | }
19 |
20 | public function __construct($swap)
21 | {
22 | $this->swap = $swap;
23 | }
24 |
25 | public function process(Block $block)
26 | {
27 | $block->highlighted = str_replace(array_keys($this->swap), array_values($this->swap), $block->highlighted);
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Torchlight.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight;
7 |
8 | use Illuminate\Support\Facades\Facade;
9 |
10 | class Torchlight extends Facade
11 | {
12 | /**
13 | * @return string
14 | *
15 | * @see Manager
16 | */
17 | protected static function getFacadeAccessor()
18 | {
19 | return Manager::class;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/TorchlightServiceProvider.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight;
7 |
8 | use Illuminate\Support\ServiceProvider;
9 | use Torchlight\Blade\BladeManager;
10 | use Torchlight\Blade\CodeComponent;
11 | use Torchlight\Blade\EngineDecorator;
12 | use Torchlight\Commands\Install;
13 | use Torchlight\Middleware\RenderTorchlight;
14 |
15 | class TorchlightServiceProvider extends ServiceProvider
16 | {
17 | public function boot()
18 | {
19 | $this->bindManagerSingleton();
20 | $this->registerCommands();
21 | $this->publishConfig();
22 | $this->registerBladeComponent();
23 | $this->registerLivewire();
24 | $this->decorateGrahamCampbellEngines();
25 | }
26 |
27 | public function bindManagerSingleton()
28 | {
29 | $this->app->singleton(Manager::class, function () {
30 | return new Manager;
31 | });
32 | }
33 |
34 | public function registerCommands()
35 | {
36 | if ($this->app->runningInConsole()) {
37 | $this->commands([
38 | Install::class,
39 | ]);
40 | }
41 | }
42 |
43 | public function publishConfig()
44 | {
45 | $this->publishes([
46 | __DIR__ . '/../config/torchlight.php' => config_path('torchlight.php')
47 | ], 'config');
48 | }
49 |
50 | public function registerBladeComponent()
51 | {
52 | if (!Torchlight::config('torchlight.blade_components')) {
53 | return;
54 | }
55 |
56 | // Laravel before 8.23.0 has a bug that adds extra spaces around components.
57 | // Obviously this is a problem if your component is wrapped in
58 | // tags, which ours usually is.
59 | // See https://github.com/laravel/framework/blob/8.x/CHANGELOG-8.x.md#v8230-2021-01-19.
60 | BladeManager::$affectedBySpacingBug = version_compare(app()->version(), '8.23.0', '<');
61 |
62 | $this->loadViewComponentsAs('torchlight', [
63 | 'code' => CodeComponent::class
64 | ]);
65 | }
66 |
67 | public function registerLivewire()
68 | {
69 | // Check for the Livewire Facade.
70 | if (!class_exists('\\Livewire\\Livewire')) {
71 | return;
72 | }
73 |
74 | // Livewire 1.x does not have the `addPersistentMiddleware` method.
75 | if (method_exists(\Livewire\LivewireManager::class, 'addPersistentMiddleware')) {
76 | \Livewire\Livewire::addPersistentMiddleware([
77 | RenderTorchlight::class,
78 | ]);
79 | }
80 | }
81 |
82 | /**
83 | * Graham Campbell's Markdown package is a common (and excellent) package that many
84 | * Laravel developers use for markdown. It registers a few view engines so you can
85 | * just return e.g. `view("file.md")` and the markdown will get rendered to HTML.
86 | *
87 | * The markdown file will get parsed *once* and saved to the disk, which could lead
88 | * to data leaks if you're using a post processor that injects some sort of user
89 | * details. The first user that hits the page will have their information saved
90 | * into the compiled views.
91 | *
92 | * We decorate the engines that Graham uses so we can alert our post processors
93 | * not to run when the views are being compiled.
94 | */
95 | public function decorateGrahamCampbellEngines()
96 | {
97 | if (!class_exists('\\GrahamCampbell\\Markdown\\MarkdownServiceProvider')) {
98 | return;
99 | }
100 |
101 | // The engines won't be registered if this is false.
102 | if (!$this->app->config->get('markdown.views')) {
103 | return;
104 | }
105 |
106 | // Decorate all the engines that Graham's package registers.
107 | $this->decorateEngine('md');
108 | $this->decorateEngine('phpmd');
109 | $this->decorateEngine('blademd');
110 | }
111 |
112 | /**
113 | * Decorate a single view engine.
114 | *
115 | * @param $name
116 | */
117 | protected function decorateEngine($name)
118 | {
119 | // No engine registered.
120 | if (!$resolved = $this->app->view->getEngineResolver()->resolve($name)) {
121 | return;
122 | }
123 |
124 | // Wrap the existing engine in our decorator.
125 | $this->app->view->getEngineResolver()->register($name, function () use ($resolved) {
126 | return new EngineDecorator($resolved);
127 | });
128 | }
129 |
130 | public function register()
131 | {
132 | $this->mergeConfigFrom(__DIR__ . '/../config/torchlight.php', 'torchlight');
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/tests/BaseTestCase.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use GuzzleHttp\Exception\ConnectException;
9 | use GuzzleHttp\Exception\TransferException;
10 | use GuzzleHttp\Promise\FulfilledPromise;
11 | use http\Client\Response;
12 | use Illuminate\Http\Client\Request;
13 | use Illuminate\Support\Arr;
14 | use Illuminate\Support\Facades\Http;
15 | use Livewire\LivewireServiceProvider;
16 | use Orchestra\Testbench\TestCase;
17 | use Torchlight\TorchlightServiceProvider;
18 |
19 | abstract class BaseTestCase extends TestCase
20 | {
21 | protected $apiFaked = false;
22 |
23 | protected $fakeResponseBlocks = [];
24 |
25 | protected function setUp(): void
26 | {
27 | parent::setUp();
28 | }
29 |
30 | protected function getPackageProviders($app)
31 | {
32 | $providers = [
33 | TorchlightServiceProvider::class,
34 | ];
35 |
36 | if (class_exists('\\Livewire\\LivewireServiceProvider')) {
37 | $providers[] = LivewireServiceProvider::class;
38 | }
39 |
40 | return $providers;
41 | }
42 |
43 | protected function fakeApi()
44 | {
45 | $this->apiFaked = true;
46 |
47 | $this->fakeResponseBlocks = [];
48 |
49 | Http::fake([
50 | 'api.torchlight.dev/*' => function (Request $request) {
51 | $response = [];
52 |
53 | foreach ($request->data()['blocks'] as $block) {
54 | if (!Arr::has($this->fakeResponseBlocks, $block['id'])) {
55 | throw new TransferException('Torchlight block response not set for ' . $block['id']);
56 | }
57 |
58 | $fake = $this->fakeResponseBlocks[$block['id']];
59 |
60 | if (is_array($fake)) {
61 | $highlighted = "$highlighted
",
71 | 'highlighted' => $highlighted,
72 | ], $fake);
73 | }
74 |
75 | if ($fake === ConnectException::class) {
76 | throw new ConnectException('Connection timed out', $request->toPsrRequest());
77 | }
78 |
79 | if ($fake instanceof Response || $fake instanceof FulfilledPromise) {
80 | return $fake;
81 | }
82 | }
83 |
84 | return Http::response([
85 | 'duration' => 100,
86 | 'engine' => 1,
87 | 'blocks' => $response
88 | ], 200);
89 | },
90 | ]);
91 | }
92 |
93 | protected function fakeSuccessfulResponse($id, $response = [])
94 | {
95 | $this->addFake($id, $response);
96 | }
97 |
98 | protected function fakeTimeout($id)
99 | {
100 | $this->addFake($id, ConnectException::class);
101 | }
102 |
103 | protected function fakeNullResponse($id)
104 | {
105 | $this->addFake($id, Http::response(null, 200));
106 | }
107 |
108 | protected function addFake($id, $response)
109 | {
110 | if (!$this->apiFaked) {
111 | $this->fakeApi();
112 | }
113 |
114 | $this->fakeResponseBlocks[$id] = $response;
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/tests/BlockTest.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use Torchlight\Block;
9 | use Torchlight\Torchlight;
10 |
11 | class BlockTest extends BaseTestCase
12 | {
13 | /** @test */
14 | public function it_dedents_code()
15 | {
16 | $block = Block::make();
17 |
18 | $code = <<echo hello
');
94 |
95 | Torchlight::highlight($block);
96 |
97 | Http::assertNothingSent();
98 | }
99 |
100 | /** @test */
101 | public function only_blocks_without_html_get_sent()
102 | {
103 | $this->fakeSuccessfulResponse('1');
104 | $this->fakeSuccessfulResponse('2');
105 |
106 | $shouldNotSend = Block::make('1')->language('php')->code('echo "hello world";');
107 | // Fake HTML, as if it had already been rendered.
108 | $shouldNotSend->wrapped('echo hello
');
109 |
110 | $shouldSend = Block::make('2')->language('php')->code('echo "hello world";');
111 |
112 | Torchlight::highlight([
113 | $shouldNotSend,
114 | $shouldSend
115 | ]);
116 |
117 | Http::assertSent(function ($request) {
118 | // Only 1 block
119 | return count($request['blocks']) === 1
120 | // And only the second block
121 | && $request['blocks'][0]['id'] === '2';
122 | });
123 | }
124 |
125 | /** @test */
126 | public function a_block_gets_its_html_set()
127 | {
128 | $this->fakeSuccessfulResponse('success');
129 |
130 | $block = Block::make('success')->language('php')->code('echo "hello world";');
131 |
132 | $this->assertNull($block->wrapped);
133 |
134 | Torchlight::highlight($block);
135 |
136 | $this->assertNotNull($block->wrapped);
137 | }
138 |
139 | /** @test */
140 | public function cache_gets_set()
141 | {
142 | $this->fakeSuccessfulResponse('success');
143 |
144 | $block = Block::make('success')->language('php')->code('echo "hello world";');
145 |
146 | $client = new Client;
147 |
148 | $cacheKey = $client->cacheKey($block);
149 |
150 | $this->assertNull(Cache::get($cacheKey));
151 |
152 | $client->highlight($block);
153 |
154 | $this->assertNotNull(Cache::get($cacheKey));
155 | }
156 |
157 | /** @test */
158 | public function already_cached_doesnt_get_sent_again()
159 | {
160 | $this->fakeSuccessfulResponse('success');
161 |
162 | $block = Block::make('success')->language('php')->code('echo "hello world";');
163 |
164 | Torchlight::highlight(clone $block);
165 | Torchlight::highlight(clone $block);
166 | Torchlight::highlight(clone $block);
167 | Torchlight::highlight(clone $block);
168 | Torchlight::highlight(clone $block);
169 |
170 | // One request to set the cache, none after that.
171 | Http::assertSentCount(1);
172 | }
173 |
174 | /** @test */
175 | public function if_theres_no_response_then_it_sets_a_default()
176 | {
177 | $this->fakeNullResponse('unknown_id');
178 |
179 | $block = Block::make('unknown_id')->language('php')->code('echo "hello world";');
180 |
181 | Torchlight::highlight($block);
182 |
183 | $this->assertEquals('echo "hello world";
', $block->wrapped);
185 | }
186 |
187 | /** @test */
188 | public function a_500_error_returns_a_default_in_production()
189 | {
190 | Torchlight::overrideEnvironment('production');
191 |
192 | $this->addFake('unknown_id', Http::response(null, 500));
193 |
194 | $block = Block::make('unknown_id')->language('php')->code('echo "hello world";');
195 |
196 | Torchlight::highlight($block);
197 |
198 | $this->assertEquals('echo "hello world";
', $block->wrapped);
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/tests/ClientTimeoutTest.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use Torchlight\Block;
9 | use Torchlight\Exceptions\RequestException;
10 | use Torchlight\Torchlight;
11 |
12 | class ClientTimeoutTest extends BaseTestCase
13 | {
14 | public function getEnvironmentSetUp($app)
15 | {
16 | config()->set('torchlight', [
17 | 'theme' => 'material',
18 | 'token' => 'token',
19 | ]);
20 | }
21 |
22 | /** @test */
23 | public function it_catches_the_connect_exception()
24 | {
25 | $this->fakeTimeout('timeout');
26 |
27 | // Our exception, not the default Laravel one.
28 | $this->expectException(RequestException::class);
29 |
30 | Torchlight::highlight(
31 | Block::make('timeout')->language('php')->code('echo "hello world";')
32 | );
33 | }
34 |
35 | /** @test */
36 | public function it_catches_the_connect_exception_in_prod()
37 | {
38 | $this->fakeTimeout('timeout');
39 |
40 | Torchlight::overrideEnvironment('production');
41 |
42 | Torchlight::highlight(
43 | Block::make('timeout')->language('php')->code('echo "hello world";')
44 | );
45 |
46 | // Just want to make sure we got past the highlight with no exception.
47 | $this->assertTrue(true);
48 | }
49 |
50 | /** @test */
51 | public function it_catches_a_real_connection_exception()
52 | {
53 | config()->set('torchlight.host', 'https://nonexistent.torchlight.dev');
54 |
55 | // Our exception, not the default Laravel one.
56 | $this->expectException(RequestException::class);
57 |
58 | Torchlight::highlight(
59 | Block::make('timeout')->language('php')->code('echo "hello world";')
60 | );
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/tests/CustomizationTest.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use Illuminate\Support\Arr;
9 | use Illuminate\Support\Facades\Cache;
10 | use Torchlight\Torchlight;
11 |
12 | class CustomizationTest extends BaseTestCase
13 | {
14 | public function getEnvironmentSetUp($app)
15 | {
16 | config()->set('torchlight.token', 'token from config');
17 | }
18 |
19 | /** @test */
20 | public function you_can_use_your_own_config_callback()
21 | {
22 | $this->assertEquals('token from config', Torchlight::config('token'));
23 |
24 | Torchlight::getConfigUsing(function ($key, $default) {
25 | return Arr::get([
26 | 'token' => 'token from callback'
27 | ], $key);
28 | });
29 |
30 | $this->assertEquals('token from callback', Torchlight::config('token'));
31 | }
32 |
33 | /** @test */
34 | public function prefixing_default_config_with_torchlight_is_ok()
35 | {
36 | $this->assertEquals('token from config', Torchlight::config('torchlight.token'));
37 | $this->assertEquals('token from config', Torchlight::config('token'));
38 | }
39 |
40 | /** @test */
41 | public function cache_implementation_can_be_set()
42 | {
43 | // The default store will be the file store.
44 | config()->set('torchlight.cache', 'file');
45 | // Grab an instance of it so we can use it in the test.
46 | $originalStore = Cache::store('file');
47 |
48 | // This is the one we'll swap in.
49 | $newStore = Cache::store('array');
50 |
51 | Torchlight::cache()->set('original_key', 1, 60);
52 |
53 | // Swap in the new cache instance
54 | Torchlight::setCacheInstance($newStore);
55 | Torchlight::cache()->put('new_key', 1, 60);
56 |
57 | $this->assertTrue($originalStore->has('original_key'));
58 | $this->assertFalse($originalStore->has('new_key'));
59 |
60 | $this->assertFalse($newStore->has('original_key'));
61 | $this->assertTrue($newStore->has('new_key'));
62 | }
63 |
64 | /** @test */
65 | public function environment_can_be_set()
66 | {
67 | $this->assertEquals('testing', Torchlight::environment());
68 |
69 | Torchlight::overrideEnvironment('production');
70 |
71 | $this->assertEquals('production', Torchlight::environment());
72 |
73 | Torchlight::overrideEnvironment(null);
74 |
75 | $this->assertEquals('testing', Torchlight::environment());
76 | }
77 |
78 | /** @test */
79 | public function config_can_be_array()
80 | {
81 | $this->assertEquals('token from config', Torchlight::config('token'));
82 |
83 | Torchlight::getConfigUsing([
84 | 'token' => 'plain ol array'
85 | ]);
86 |
87 | $this->assertEquals('plain ol array', Torchlight::config('token'));
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/tests/DualThemeTest.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use Illuminate\Support\Facades\Route;
9 | use Illuminate\Support\Facades\View;
10 | use Torchlight\Middleware\RenderTorchlight;
11 |
12 | class DualThemeTest extends BaseTestCase
13 | {
14 | public function getEnvironmentSetUp($app)
15 | {
16 | config()->set('torchlight.blade_components', true);
17 | config()->set('torchlight.token', 'token');
18 | config()->set('torchlight.theme', [
19 | 'github-dark',
20 | 'github-light'
21 | ]);
22 | }
23 |
24 | protected function getView($view)
25 | {
26 | // This helps when testing multiple Laravel versions locally.
27 | $this->artisan('view:clear');
28 |
29 | Route::get('/torchlight', function () use ($view) {
30 | return View::file(__DIR__ . '/Support/' . $view);
31 | })->middleware(RenderTorchlight::class);
32 |
33 | return $this->call('GET', 'torchlight');
34 | }
35 |
36 | /** @test */
37 | public function multiple_themes_with_comma()
38 | {
39 | config()->set('torchlight.theme', [
40 | 'github-dark,github-light'
41 | ]);
42 |
43 | $this->assertDarkLight('github-dark', 'github-light');
44 | }
45 |
46 | /** @test */
47 | public function multiple_themes_no_labels()
48 | {
49 | config()->set('torchlight.theme', [
50 | 'github-dark',
51 | 'github-light'
52 | ]);
53 |
54 | $this->assertDarkLight('github-dark', 'github-light');
55 | }
56 |
57 | /** @test */
58 | public function multiple_themes_with_labels()
59 | {
60 | config()->set('torchlight.theme', [
61 | 'dark' => 'github-dark',
62 | 'light' => 'github-light'
63 | ]);
64 |
65 | $this->assertDarkLight('dark:github-dark', 'light:github-light');
66 | }
67 |
68 | protected function assertDarkLight($theme1, $theme2)
69 | {
70 | $this->fakeSuccessfulResponse('component', [
71 | 'classes' => 'torchlight1',
72 | 'styles' => 'background-color: #111111;',
73 | 'highlighted' => 'response 1',
74 | ]);
75 |
76 | $this->fakeSuccessfulResponse('component_clone_0', [
77 | 'classes' => 'torchlight2',
78 | 'styles' => 'background-color: #222222;',
79 | 'highlighted' => 'response 2',
80 | ]);
81 |
82 | $response = $this->getView('simple-php-hello-world.blade.php');
83 |
84 | $this->assertEquals(
85 | "", 86 | $response->content() 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/FindIdsTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace Torchlight\Tests; 7 | 8 | use Torchlight\Block; 9 | use Torchlight\Torchlight; 10 | 11 | class FindIdsTest extends BaseTestCase 12 | { 13 | /** @test */ 14 | public function it_will_find_all_the_ids() 15 | { 16 | $standard = Block::make(); 17 | $custom1 = Block::make('custom-id'); 18 | $custom2 = Block::make('custom-1234'); 19 | 20 | $content = <<response 1
response 2
{$custom2->placeholder()}
28 | EOT;
29 |
30 | $found = Torchlight::findTorchlightIds($content);
31 |
32 | $this->assertContains($standard->id(), $found);
33 | $this->assertContains('custom-id', $found);
34 | $this->assertContains('custom-1234', $found);
35 | }
36 |
37 | /** @test */
38 | public function it_only_returns_one_per()
39 | {
40 | $standard = Block::make();
41 |
42 | $content = <<echo "hello world";
',
43 | $response->content()
44 | );
45 |
46 | Http::assertSent(function ($request) {
47 | return $request['blocks'][0] === [
48 | 'id' => 'component',
49 | 'hash' => '66192c35bf8a710bee532ac328c76977',
50 | 'language' => 'php',
51 | 'theme' => 'material-theme-palenight',
52 | 'code' => 'echo "hello world";',
53 | ];
54 | });
55 | }
56 |
57 | /** @test */
58 | public function it_sends_a_simple_request_with_highlighted_response()
59 | {
60 | $this->fakeSuccessfulResponse('component', [
61 | 'classes' => 'torchlight',
62 | 'styles' => 'background-color: #292D3E;',
63 | 'highlighted' => 'this is the highlighted response from the server',
64 | ]);
65 |
66 | $response = $this->getView('simple-php-hello-world.blade.php');
67 |
68 | $this->assertEquals(
69 | 'this is the highlighted response from the server
',
70 | $response->content()
71 | );
72 | }
73 |
74 | /** @test */
75 | public function it_sends_a_simple_request_with_style()
76 | {
77 | $this->fakeSuccessfulResponse('component', [
78 | 'classes' => 'torchlight',
79 | 'styles' => 'background-color: #292D3E;',
80 | 'highlighted' => 'this is the highlighted response from the server',
81 | ]);
82 |
83 | $response = $this->getView('simple-php-hello-world-with-style.blade.php');
84 |
85 | $this->assertEquals(
86 | '', 87 | $response->content() 88 | ); 89 | } 90 | 91 | /** @test */ 92 | public function no_attrs_no_trailing_space() 93 | { 94 | $this->fakeSuccessfulResponse('component', [ 95 | 'classes' => 'torchlight', 96 | 'styles' => 'background-color: #292D3E;', 97 | 'highlighted' => 'this is the highlighted response from the server', 98 | 'attrs' => [] 99 | ]); 100 | 101 | $response = $this->getView('simple-php-hello-world-with-style.blade.php'); 102 | 103 | $this->assertEquals( 104 | '
', 105 | $response->content() 106 | ); 107 | } 108 | 109 | /** @test */ 110 | public function classes_get_merged() 111 | { 112 | $this->fakeSuccessfulResponse('component', [ 113 | 'classes' => 'torchlight', 114 | 'styles' => 'background-color: #292D3E;', 115 | 'highlighted' => 'this is the highlighted response from the server', 116 | ]); 117 | 118 | $response = $this->getView('simple-php-hello-world-with-classes.blade.php'); 119 | 120 | $this->assertEquals( 121 | '
this is the highlighted response from the server
',
122 | $response->content()
123 | );
124 | }
125 |
126 | /** @test */
127 | public function attributes_are_preserved()
128 | {
129 | $this->fakeSuccessfulResponse('component', [
130 | 'classes' => 'torchlight',
131 | 'styles' => 'background-color: #292D3E;',
132 | 'highlighted' => 'this is the highlighted response from the server',
133 | ]);
134 |
135 | $response = $this->getView('simple-php-hello-world-with-attributes.blade.php');
136 |
137 | $this->assertEquals(
138 | 'this is the highlighted response from the server
',
139 | $response->content()
140 | );
141 | }
142 |
143 | /** @test */
144 | public function inline_keeps_its_spaces()
145 | {
146 | $this->fakeSuccessfulResponse('component', [
147 | 'classes' => 'torchlight',
148 | 'styles' => 'background-color: #292D3E;',
149 | 'highlighted' => 'this is the highlighted response from the server',
150 | ]);
151 |
152 | $response = $this->getView('an-inline-component.blade.php');
153 |
154 | $this->assertEquals(
155 | 'this is this is the highlighted response from the server
inline',
156 | $response->content()
157 | );
158 | }
159 |
160 | /** @test */
161 | public function inline_swaps_run()
162 | {
163 | $this->fakeSuccessfulResponse('component', [
164 | 'classes' => 'torchlight',
165 | 'styles' => 'background-color: #292D3E;',
166 | 'highlighted' => 'echo "hello world"',
167 | ]);
168 |
169 | $response = $this->getView('an-inline-component-with-swaps.blade.php');
170 |
171 | $this->assertEquals(
172 | 'this is echo "goodbye world"
inline',
173 | $response->content()
174 | );
175 | }
176 |
177 | /** @test */
178 | public function inline_processors_run()
179 | {
180 | $this->fakeSuccessfulResponse('component', [
181 | 'classes' => 'torchlight',
182 | 'styles' => 'background-color: #292D3E;',
183 | 'highlighted' => 'echo "hello world"',
184 | ]);
185 |
186 | $response = $this->getView('an-inline-component-with-post-processors.blade.php');
187 |
188 | $this->assertEquals(
189 | 'this is echo "goodbye world"
inline',
190 | $response->content()
191 | );
192 | }
193 |
194 | /** @test */
195 | public function language_can_be_set_via_component()
196 | {
197 | $this->fakeNullResponse('component');
198 |
199 | $this->getView('simple-js-hello-world.blade.php');
200 |
201 | Http::assertSent(function ($request) {
202 | return $request['blocks'][0]['language'] === 'javascript';
203 | });
204 | }
205 |
206 | /** @test */
207 | public function theme_can_be_set_via_component()
208 | {
209 | $this->fakeNullResponse('component');
210 |
211 | $this->getView('simple-php-hello-world-new-theme.blade.php');
212 |
213 | Http::assertSent(function ($request) {
214 | return $request['blocks'][0]['theme'] === 'a new theme';
215 | });
216 | }
217 |
218 | /** @test */
219 | public function code_contents_can_be_a_file()
220 | {
221 | $this->fakeNullResponse('component');
222 |
223 | $this->getView('contents-via-file.blade.php');
224 |
225 | Http::assertSent(function ($request) {
226 | return $request['blocks'][0]['code'] === rtrim(file_get_contents(config_path('app.php'), '\n'));
227 | });
228 | }
229 |
230 | /** @test */
231 | public function code_contents_can_be_a_file_2()
232 | {
233 | $this->fakeNullResponse('component');
234 |
235 | $this->getView('contents-via-file-2.blade.php');
236 |
237 | Http::assertSent(function ($request) {
238 | return $request['blocks'][0]['code'] === rtrim(file_get_contents(config_path('app.php'), '\n'));
239 | });
240 | }
241 |
242 | /** @test */
243 | public function file_must_be_passed_via_contents()
244 | {
245 | $this->fakeNullResponse('component');
246 |
247 | $this->getView('file-must-be-passed-through-contents.blade.php');
248 |
249 | Http::assertSent(function ($request) {
250 | return $request['blocks'][0]['code'] === config_path('app.php');
251 | });
252 | }
253 |
254 | /** @test */
255 | public function dedent_works_properly()
256 | {
257 | $this->withoutExceptionHandling();
258 | $this->fakeNullResponse('component');
259 |
260 | $response = $this->getView('dedent_works_properly.blade.php');
261 |
262 | $result = "public function { // test}
";
263 |
264 | if (BladeManager::$affectedBySpacingBug) {
265 | $this->assertEquals(
266 | "\n $result\n\n
$result\n
$result", 267 | $response->content() 268 | ); 269 | } else { 270 | $this->assertEquals( 271 | "
\n $result\n
$result\n
$result", 272 | $response->content() 273 | ); 274 | } 275 | } 276 | 277 | /** @test */ 278 | public function two_code_in_one_pre() 279 | { 280 | $this->withoutExceptionHandling(); 281 | $this->fakeNullResponse('component'); 282 | 283 | $response = $this->getView('two-codes-in-one-tag.blade.php'); 284 | 285 | $result = "
public function { // test}
";
286 |
287 | if (BladeManager::$affectedBySpacingBug) {
288 | $this->assertEquals(
289 | "\n {$result}\n {$result}\n", 290 | $response->content() 291 | ); 292 | } else { 293 | $this->assertEquals( 294 | "
\n $result $result", 295 | $response->content() 296 | ); 297 | } 298 | } 299 | 300 | /** @test */ 301 | public function two_components_work() 302 | { 303 | $this->fakeSuccessfulResponse('component1', [ 304 | 'id' => 'component1', 305 | 'classes' => 'torchlight1', 306 | 'styles' => 'background-color: #111111;', 307 | 'highlighted' => 'response 1', 308 | ]); 309 | 310 | $this->fakeSuccessfulResponse('component2', [ 311 | 'id' => 'component2', 312 | 'classes' => 'torchlight2', 313 | 'styles' => 'background-color: #222222;', 314 | 'highlighted' => 'response 2', 315 | ]); 316 | 317 | $response = $this->getView('two-simple-php-hello-world.blade.php'); 318 | 319 | $expected = <<
response 1
321 |
322 | response 2
323 | EOT;
324 |
325 | $this->assertEquals($expected, $response->content());
326 | }
327 | }
328 |
--------------------------------------------------------------------------------
/tests/PostProcessorTest.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use Torchlight\Block;
9 | use Torchlight\Contracts\PostProcessor;
10 | use Torchlight\Exceptions\ConfigurationException;
11 | use Torchlight\PostProcessors\SimpleSwapProcessor;
12 | use Torchlight\Torchlight;
13 |
14 | class PostProcessorTest extends BaseTestCase
15 | {
16 | public function getEnvironmentSetUp($app)
17 | {
18 | config()->set('torchlight', [
19 | 'theme' => 'material',
20 | 'token' => 'token',
21 | ]);
22 | }
23 |
24 | /** @test */
25 | public function it_runs_post_processors()
26 | {
27 | $this->fakeSuccessfulResponse('id');
28 |
29 | Torchlight::addPostProcessors([
30 | GoodbyePostProcessor::class
31 | ]);
32 |
33 | $blocks = Torchlight::highlight(
34 | Block::make('id')->language('php')->code('echo "hello world";')
35 | );
36 |
37 | $this->assertEquals($blocks[0]->highlighted, 'echo "hello world";
',
66 | $response->content()
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/Support/an-inline-component-with-post-processors.blade.php:
--------------------------------------------------------------------------------
1 | @php($p = \Torchlight\PostProcessors\SimpleSwapProcessor::make(['hello' => 'goodbye']))
2 | this is 2 |8 |3 | public function { 4 | // test 5 | } 6 | 7 |
13 |9 | public function { 10 | // test 11 | } 12 |
-------------------------------------------------------------------------------- /tests/Support/file-must-be-passed-through-contents.blade.php: -------------------------------------------------------------------------------- 1 |public function { 14 | // test 15 | } 16 |
-------------------------------------------------------------------------------- /tests/Support/simple-php-hello-world.blade.php: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /tests/Support/two-codes-in-one-tag.blade.php: -------------------------------------------------------------------------------- 1 |2 | echo "hello world"; 3 |
2 |-------------------------------------------------------------------------------- /tests/Support/two-simple-php-hello-world.blade.php: -------------------------------------------------------------------------------- 1 |3 | public function { 4 | // test 5 | } 6 | 7 |8 | public function { 9 | // test 10 | } 11 | 12 |
4 | 5 |2 | echo "hello world 1"; 3 |
--------------------------------------------------------------------------------6 | echo "hello world 2"; 7 |