├── .gitignore
├── tests
├── Support
│ ├── contents-via-file.blade.php
│ ├── contents-via-file-2.blade.php
│ ├── an-inline-component.blade.php
│ ├── simple-js-hello-world.blade.php
│ ├── simple-php-hello-world.blade.php
│ ├── file-must-be-passed-through-contents.blade.php
│ ├── simple-php-hello-world-with-attributes.blade.php
│ ├── simple-php-hello-world-with-classes.blade.php
│ ├── simple-php-hello-world-new-theme.blade.php
│ ├── an-inline-component-with-swaps.blade.php
│ ├── simple-php-hello-world-with-style.blade.php
│ ├── an-inline-component-with-post-processors.blade.php
│ ├── two-simple-php-hello-world.blade.php
│ ├── two-codes-in-one-tag.blade.php
│ └── dedent_works_properly.blade.php
├── LivewireTest.php
├── FindIdsTest.php
├── ClientTimeoutTest.php
├── RealClientTest.php
├── CustomizationTest.php
├── DualThemeTest.php
├── BaseTestCase.php
├── BlockTest.php
├── PostProcessorTest.php
├── ClientTest.php
└── MiddlewareAndComponentTest.php
├── .styleci.yml
├── src
├── Exceptions
│ ├── TorchlightException.php
│ ├── RequestException.php
│ └── ConfigurationException.php
├── Contracts
│ └── PostProcessor.php
├── Torchlight.php
├── PostProcessors
│ └── SimpleSwapProcessor.php
├── Blade
│ ├── EngineDecorator.php
│ ├── CodeComponent.php
│ └── BladeManager.php
├── Commands
│ └── Install.php
├── Middleware
│ └── RenderTorchlight.php
├── TorchlightServiceProvider.php
├── Client.php
├── Manager.php
└── Block.php
├── phpunit.xml.dist
├── LICENSE
├── composer.json
├── config
└── torchlight.php
├── README.md
├── CHANGELOG.md
└── .github
└── workflows
└── tests.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | .env
3 | .phpunit.result.cache
4 | composer.lock
--------------------------------------------------------------------------------
/tests/Support/contents-via-file.blade.php:
--------------------------------------------------------------------------------
1 |
-------------------------------------------------------------------------------- /tests/Support/file-must-be-passed-through-contents.blade.php: -------------------------------------------------------------------------------- 1 |2 | echo "hello world"; 3 |
-------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /src/Exceptions/TorchlightException.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace Torchlight\Exceptions; 7 | 8 | class TorchlightException extends \Exception 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/ConfigurationException.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace Torchlight\Exceptions; 7 | 8 | class ConfigurationException extends TorchlightException 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /tests/Support/an-inline-component-with-post-processors.blade.php: -------------------------------------------------------------------------------- 1 | @php($p = \Torchlight\PostProcessors\SimpleSwapProcessor::make(['hello' => 'goodbye'])) 2 | this is
4 | 5 |2 | echo "hello world 1"; 3 |
-------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/Support/two-codes-in-one-tag.blade.php: -------------------------------------------------------------------------------- 1 |6 | echo "hello world 2"; 7 |
2 |-------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/Support/dedent_works_properly.blade.php: -------------------------------------------------------------------------------- 1 |3 | public function { 4 | // test 5 | } 6 | 7 |8 | public function { 9 | // test 10 | } 11 | 12 |
2 |8 |3 | public function { 4 | // test 5 | } 6 | 7 |
13 |9 | public function { 10 | // test 11 | } 12 |
-------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/LivewireTest.php: -------------------------------------------------------------------------------- 1 | 4 | */ 5 | 6 | namespace Torchlight\Tests; 7 | 8 | use Composer\InstalledVersions; 9 | use Livewire\Livewire; 10 | use Torchlight\Middleware\RenderTorchlight; 11 | 12 | class LivewireTest extends BaseTestCase 13 | { 14 | /** @test */ 15 | public function livewire_registers_a_middleware() 16 | { 17 | // Check for the Livewire Facade. 18 | if (!class_exists('\\Livewire\\Livewire')) { 19 | return $this->markTestSkipped('Livewire not installed.'); 20 | } 21 | 22 | $this->assertTrue(in_array( 23 | RenderTorchlight::class, Livewire::getPersistentMiddleware() 24 | )); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 |public function { 14 | // test 15 | } 16 |
{$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";',
66 | $response->content()
67 | );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/config/torchlight.php:
--------------------------------------------------------------------------------
1 | env('TORCHLIGHT_CACHE_DRIVER'),
8 |
9 | // Cache blocks for 30 days. Set null to store permanently
10 | 'cache_seconds' => env('TORCHLIGHT_CACHE_TTL', 60 * 60 * 24 * 30),
11 |
12 | // Which theme you want to use. You can find all of the themes at
13 | // https://torchlight.dev/docs/themes.
14 | 'theme' => env('TORCHLIGHT_THEME', 'material-theme-palenight'),
15 |
16 | // If you want to use two separate themes for dark and light modes,
17 | // you can use an array to define both themes. Torchlight renders
18 | // both on the page, and you will be responsible for hiding one
19 | // or the other depending on the dark / light mode via CSS.
20 | // 'theme' => [
21 | // 'dark' => 'github-dark',
22 | // 'light' => 'github-light',
23 | // ],
24 |
25 | // Your API token from torchlight.dev.
26 | 'token' => env('TORCHLIGHT_TOKEN'),
27 |
28 | // If you want to register the blade directives, set this to true.
29 | 'blade_components' => true,
30 |
31 | // The Host of the API.
32 | 'host' => env('TORCHLIGHT_HOST', 'https://api.torchlight.dev'),
33 |
34 | // We replace tabs in your code blocks with spaces in HTML. Set
35 | // the number of spaces you'd like to use per tab. Set to
36 | // `false` to leave literal tabs in the HTML.
37 | 'tab_width' => 4,
38 |
39 | // If you pass a filename to the code component or in a markdown
40 | // block, Torchlight will look for code snippets in the
41 | // following directories.
42 | 'snippet_directories' => [
43 | resource_path()
44 | ],
45 |
46 | // Global options to control blocks-level settings.
47 | // https://torchlight.dev/docs/options
48 | 'options' => [
49 | // Turn line numbers on or off globally.
50 | // 'lineNumbers' => false,
51 |
52 | // Control the `style` attribute applied to line numbers.
53 | // 'lineNumbersStyle' => '',
54 |
55 | // Turn on +/- diff indicators.
56 | // 'diffIndicators' => true,
57 |
58 | // If there are any diff indicators for a line, put them
59 | // in place of the line number to save horizontal space.
60 | // 'diffIndicatorsInPlaceOfLineNumbers' => true,
61 |
62 | // When lines are collapsed, this is the text that will
63 | // be shown to indicate that they can be expanded.
64 | // 'summaryCollapsedIndicator' => '...',
65 | ]
66 | ];
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Laravel Torchlight Client
2 |
3 | [](https://github.com/torchlight-api/torchlight-laravel/actions/workflows/tests.yml) [](//packagist.org/packages/torchlight/torchlight-laravel) [](//packagist.org/packages/torchlight/torchlight-laravel) [](//packagist.org/packages/torchlight/torchlight-laravel)
4 |
5 |
6 | A [Torchlight](https://torchlight.dev) syntax highlighting extension for the [Laravel](https://laravel.com/) framework.
7 |
8 | Torchlight is a VS Code-compatible syntax highlighter that requires no JavaScript, supports every language, every VS Code theme, line highlighting, git diffing, and more.
9 |
10 | ## Installation
11 |
12 | To install, require the package from composer:
13 |
14 | ```
15 | composer require torchlight/torchlight-laravel
16 | ```
17 |
18 | ## Configuration
19 |
20 | Once the package is downloaded, you can run the following command to publish your configuration file:
21 |
22 | ```
23 | php artisan torchlight:install
24 | ```
25 |
26 | Once run, you should see a new file `torchlight.php` in you `config` folder, with contents that look like this:
27 |
28 | ```php
29 | env('TORCHLIGHT_CACHE_DRIVER'),
34 |
35 | // Which theme you want to use. You can find all of the themes at
36 | // https://torchlight.dev/themes, or you can provide your own.
37 | 'theme' => env('TORCHLIGHT_THEME', 'material-theme-palenight'),
38 |
39 | // Your API token from torchlight.dev.
40 | 'token' => env('TORCHLIGHT_TOKEN'),
41 |
42 | // If you want to register the blade directives, set this to true.
43 | 'blade_components' => true,
44 |
45 | // The Host of the API.
46 | 'host' => env('TORCHLIGHT_HOST', 'https://api.torchlight.dev'),
47 | ];
48 |
49 | ```
50 | ### Cache
51 |
52 | Set the cache driver that Torchlight will use.
53 |
54 | ### Theme
55 |
56 | You can change the theme of all your code blocks by adjusting the `theme` key in your configuration.
57 |
58 | ### Token
59 |
60 | This is your API token from [torchlight.dev](https://torchlight.dev). (Torchlight is completely free for personal and open source projects.)
61 |
62 | ### Blade Components
63 |
64 | By default Torchlight works by using a [custom Laravel component](https://laravel.com/docs/master/blade#components). If you'd like to disable the registration of the component for whatever reason, you can turn this to false.
65 |
66 | ### Host
67 |
68 | You can change the host where your API requests are sent. Not sure why you'd ever want to do that, but you can!
--------------------------------------------------------------------------------
/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 | -------------------------------------------------------------------------------- /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 `response 1response 2
$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 |
--------------------------------------------------------------------------------
/src/Blade/CodeComponent.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Blade;
7 |
8 | use Illuminate\Support\Arr;
9 | use Illuminate\Support\Str;
10 | use Illuminate\View\Component;
11 | use Torchlight\Block;
12 | use Torchlight\PostProcessors\SimpleSwapProcessor;
13 | use Torchlight\Torchlight;
14 |
15 | class CodeComponent extends Component
16 | {
17 | public $language;
18 |
19 | public $theme;
20 |
21 | public $contents;
22 |
23 | public $block;
24 |
25 | protected $trimFixDelimiter = '##LARAVEL_TRIM_FIXER##';
26 |
27 | /**
28 | * Create a new component instance.
29 | *
30 | * @param $language
31 | * @param null $theme
32 | * @param null $contents
33 | * @param null $torchlightId
34 | */
35 | public function __construct($language, $theme = null, $contents = null, $swap = null, $postProcessors = [], $torchlightId = null)
36 | {
37 | $this->language = $language;
38 | $this->theme = $theme;
39 | $this->contents = $contents;
40 |
41 | $this->block = Block::make($torchlightId)->language($this->language)->theme($this->theme);
42 |
43 | $postProcessors = Arr::wrap($postProcessors);
44 |
45 | if ($swap) {
46 | $postProcessors[] = SimpleSwapProcessor::make($swap);
47 | }
48 |
49 | foreach ($postProcessors as $processor) {
50 | $this->block->addPostProcessor($processor);
51 | }
52 | }
53 |
54 | public function withAttributes(array $attributes)
55 | {
56 | // By default Laravel trims slot content in the ManagesComponents
57 | // trait. The line that does the trimming looks like this:
58 | // `$defaultSlot = new HtmlString(trim(ob_get_clean()));`
59 |
60 | // The problem with this is that when you have a Blade Component
61 | // that is indented in this way:
62 |
63 | // 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/tests/MiddlewareAndComponentTest.php:
--------------------------------------------------------------------------------
1 |
4 | */
5 |
6 | namespace Torchlight\Tests;
7 |
8 | use Illuminate\Support\Facades\Http;
9 | use Illuminate\Support\Facades\Route;
10 | use Illuminate\Support\Facades\View;
11 | use Torchlight\Blade\BladeManager;
12 | use Torchlight\Middleware\RenderTorchlight;
13 |
14 | class MiddlewareAndComponentTest extends BaseTestCase
15 | {
16 | public function getEnvironmentSetUp($app)
17 | {
18 | config()->set('torchlight.blade_components', true);
19 | config()->set('torchlight.token', 'token');
20 | }
21 |
22 | protected function getView($view)
23 | {
24 | // This helps when testing multiple Laravel versions locally.
25 | $this->artisan('view:clear');
26 |
27 | Route::get('/torchlight', function () use ($view) {
28 | return View::file(__DIR__ . '/Support/' . $view);
29 | })->middleware(RenderTorchlight::class);
30 |
31 | return $this->call('GET', 'torchlight');
32 | }
33 |
34 | /** @test */
35 | public function it_sends_a_simple_request_with_no_response()
36 | {
37 | $this->fakeNullResponse('component');
38 |
39 | $response = $this->getView('simple-php-hello-world.blade.php');
40 |
41 | $this->assertEquals(
42 | '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 |
--------------------------------------------------------------------------------