framework icon indicating copy to clipboard operation
framework copied to clipboard

[9.x] Add configurable paths to Vite

Open iamgergo opened this issue 3 years ago • 1 comments

This PR is a followup of #43546.

As discussed, the following new functions should be added to the Vite class:

  1. New func withEntryPoints that accepts entry points and is merged into an array on the class.
  2. New func useHotFile that accepts a path.
  3. New func useBuildDirectory that accepts a path.
  4. Make the Vite class Htmlable, with toHtml invoking the class.

  • [x] Add new functions
  • [x] Add tests

iamgergo avatar Aug 09 '22 15:08 iamgergo

@timacdonald @jessarcher I think it's ready for review. Thanks!

iamgergo avatar Aug 10 '22 06:08 iamgergo

Looking good! Just gonna mark as draft until we have the vite plugin PR ready. Gonna work on that and test everything out locally now. Thanks again for your work on this one.

The PR is dependent on: https://github.com/laravel/vite-plugin/pull/118

timacdonald avatar Aug 10 '22 23:08 timacdonald

@iamgergo github isn't allow me to push changes to your branch for some reason, so I just applied some formatting adjustments via the UI.

Could you finally update the test with the following file. It brings the whole test filecloser to reality and allows us to remove some code we didn't actually need.

Updated test file
<?php

namespace Illuminate\Tests\Foundation;

use Illuminate\Foundation\Vite;
use Illuminate\Support\Facades\Vite as ViteFacade;
use Illuminate\Support\Str;
use Orchestra\Testbench\TestCase as TestbenchTestCase;

class FoundationViteTest extends TestbenchTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        $this->app['config']->set('app.asset_url', 'https://example.com');
    }

    protected function tearDown(): void
    {
        $this->cleanViteManifest();
        $this->cleanViteHotFile();
    }

    public function testViteWithJsOnly()
    {
        $this->makeViteManifest();

        $result = app(Vite::class)('resources/js/app.js');

        $this->assertSame('<script type="module" src="https://example.com/build/assets/app.versioned.js"></script>', $result->toHtml());
    }

    public function testViteWithCssAndJs()
    {
        $this->makeViteManifest();

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/app.versioned.css" />'
            .'<script type="module" src="https://example.com/build/assets/app.versioned.js"></script>',
            $result->toHtml()
        );
    }

    public function testViteWithCssImport()
    {
        $this->makeViteManifest();

        $result = app(Vite::class)('resources/js/app-with-css-import.js');

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/imported-css.versioned.css" />'
            .'<script type="module" src="https://example.com/build/assets/app-with-css-import.versioned.js"></script>',
            $result->toHtml()
        );
    }

    public function testViteWithSharedCssImport()
    {
        $this->makeViteManifest();

        $result = app(Vite::class)(['resources/js/app-with-shared-css.js']);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/shared-css.versioned.css" />'
            .'<script type="module" src="https://example.com/build/assets/app-with-shared-css.versioned.js"></script>',
            $result->toHtml()
        );
    }

    public function testViteHotModuleReplacementWithJsOnly()
    {
        $this->makeViteHotFile();

        $result = app(Vite::class)('resources/js/app.js');

        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client"></script>'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js"></script>',
            $result->toHtml()
        );
    }

    public function testViteHotModuleReplacementWithJsAndCss()
    {
        $this->makeViteHotFile();

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client"></script>'
            .'<link rel="stylesheet" href="http://localhost:3000/resources/css/app.css" />'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js"></script>',
            $result->toHtml()
        );
    }

    public function testItCanGenerateCspNonceWithHotFile()
    {
        Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}");
        $this->makeViteHotFile();

        $nonce = ViteFacade::useCspNonce();
        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame('random-string-with-length:40', $nonce);
        $this->assertSame('random-string-with-length:40', ViteFacade::cspNonce());
        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client" nonce="random-string-with-length:40"></script>'
            .'<link rel="stylesheet" href="http://localhost:3000/resources/css/app.css" nonce="random-string-with-length:40" />'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js" nonce="random-string-with-length:40"></script>',
            $result->toHtml()
        );

        Str::createRandomStringsNormally();
    }

    public function testItCanGenerateCspNonceWithManifest()
    {
        Str::createRandomStringsUsing(fn ($length) => "random-string-with-length:{$length}");
        $this->makeViteManifest();

        $nonce = ViteFacade::useCspNonce();
        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame('random-string-with-length:40', $nonce);
        $this->assertSame('random-string-with-length:40', ViteFacade::cspNonce());
        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/app.versioned.css" nonce="random-string-with-length:40" />'
            .'<script type="module" src="https://example.com/build/assets/app.versioned.js" nonce="random-string-with-length:40"></script>',
            $result->toHtml()
        );

        Str::createRandomStringsNormally();
    }

    public function testItCanSpecifyCspNonceWithHotFile()
    {
        $this->makeViteHotFile();

        $nonce = ViteFacade::useCspNonce('expected-nonce');
        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame('expected-nonce', $nonce);
        $this->assertSame('expected-nonce', ViteFacade::cspNonce());
        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client" nonce="expected-nonce"></script>'
            .'<link rel="stylesheet" href="http://localhost:3000/resources/css/app.css" nonce="expected-nonce" />'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js" nonce="expected-nonce"></script>',
            $result->toHtml()
        );
    }

    public function testItCanSpecifyCspNonceWithManifest()
    {
        $this->makeViteManifest();

        $nonce = ViteFacade::useCspNonce('expected-nonce');
        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame('expected-nonce', $nonce);
        $this->assertSame('expected-nonce', ViteFacade::cspNonce());
        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/app.versioned.css" nonce="expected-nonce" />'
            .'<script type="module" src="https://example.com/build/assets/app.versioned.js" nonce="expected-nonce"></script>',
            $result->toHtml()
        );
    }

    public function testItCanInjectIntegrityWhenPresentInManifest()
    {
        $buildDir = Str::random();
        $this->makeViteManifest([
            'resources/js/app.js' => [
                'file' => 'assets/app.versioned.js',
                'integrity' => 'expected-app.js-integrity',
            ],
            'resources/css/app.css' => [
                'file' => 'assets/app.versioned.css',
                'integrity' => 'expected-app.css-integrity',
            ],
        ], $buildDir);

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/'.$buildDir.'/assets/app.versioned.css" integrity="expected-app.css-integrity" />'
            .'<script type="module" src="https://example.com/'.$buildDir.'/assets/app.versioned.js" integrity="expected-app.js-integrity"></script>',
            $result->toHtml()
        );

        unlink(public_path("{$buildDir}/manifest.json"));
        rmdir(public_path($buildDir));
    }

    public function testItCanInjectIntegrityWhenPresentInManifestForImportedCss()
    {
        $buildDir = Str::random();
        $this->makeViteManifest([
            'resources/js/app.js' => [
                'file' => 'assets/app.versioned.js',
                'imports' => [
                    '_import.versioned.js',
                ],
                'integrity' => 'expected-app.js-integrity',
            ],
            '_import.versioned.js' => [
                'file' => 'assets/import.versioned.js',
                'css' => [
                    'assets/imported-css.versioned.css',
                ],
                'integrity' => 'expected-import.js-integrity',
            ],
            'imported-css.css' => [
                'file' => 'assets/imported-css.versioned.css',
                'integrity' => 'expected-imported-css.css-integrity',
            ],
        ], $buildDir);

        $result = app(Vite::class)('resources/js/app.js', $buildDir);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/'.$buildDir.'/assets/imported-css.versioned.css" integrity="expected-imported-css.css-integrity" />'
            .'<script type="module" src="https://example.com/'.$buildDir.'/assets/app.versioned.js" integrity="expected-app.js-integrity"></script>',
            $result->toHtml()
        );

        unlink(public_path("{$buildDir}/manifest.json"));
        rmdir(public_path($buildDir));
    }

    public function testItCanSpecifyIntegrityKey()
    {
        $buildDir = Str::random();
        $this->makeViteManifest([
            'resources/js/app.js' => [
                'file' => 'assets/app.versioned.js',
                'different-integrity-key' => 'expected-app.js-integrity',
            ],
            'resources/css/app.css' => [
                'file' => 'assets/app.versioned.css',
                'different-integrity-key' => 'expected-app.css-integrity',
            ],
        ], $buildDir);
        ViteFacade::useIntegrityKey('different-integrity-key');

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js'], $buildDir);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/'.$buildDir.'/assets/app.versioned.css" integrity="expected-app.css-integrity" />'
            .'<script type="module" src="https://example.com/'.$buildDir.'/assets/app.versioned.js" integrity="expected-app.js-integrity"></script>',
            $result->toHtml()
        );

        unlink(public_path("{$buildDir}/manifest.json"));
        rmdir(public_path($buildDir));
    }

    public function testItCanSpecifyArbitraryAttributesForScriptTagsWhenBuilt()
    {
        $this->makeViteManifest();
        ViteFacade::useScriptTagAttributes([
            'general' => 'attribute',
        ]);
        ViteFacade::useScriptTagAttributes(function ($src, $url, $chunk, $manifest) {
            $this->assertSame('resources/js/app.js', $src);
            $this->assertSame('https://example.com/build/assets/app.versioned.js', $url);
            $this->assertSame(['file' => 'assets/app.versioned.js'], $chunk);
            $this->assertSame([
                'resources/js/app.js' => [
                    'file' => 'assets/app.versioned.js',
                ],
                'resources/js/app-with-css-import.js' => [
                    'file' => 'assets/app-with-css-import.versioned.js',
                    'css' => [
                        'assets/imported-css.versioned.css',
                    ],
                ],
                'resources/css/imported-css.css' => [
                    'file' => 'assets/imported-css.versioned.css',
                ],
                'resources/js/app-with-shared-css.js' => [
                    'file' => 'assets/app-with-shared-css.versioned.js',
                    'imports' => [
                        '_someFile.js',
                    ],
                ],
                'resources/css/app.css' => [
                    'file' => 'assets/app.versioned.css',
                ],
                '_someFile.js' => [
                    'css' => [
                        'assets/shared-css.versioned.css',
                    ],
                ],
                'resources/css/shared-css' => [
                    'file' => 'assets/shared-css.versioned.css',
                ],
            ], $manifest);

            return [
                'crossorigin',
                'data-persistent-across-pages' => 'YES',
                'remove-me' => false,
                'keep-me' => true,
                'null' => null,
                'empty-string' => '',
                'zero' => 0,
            ];
        });

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/app.versioned.css" />'
            .'<script type="module" src="https://example.com/build/assets/app.versioned.js" general="attribute" crossorigin data-persistent-across-pages="YES" keep-me empty-string="" zero="0"></script>',
            $result->toHtml()
        );
    }

    public function testItCanSpecifyArbitraryAttributesForStylesheetTagsWhenBuild()
    {
        $this->makeViteManifest();
        ViteFacade::useStyleTagAttributes([
            'general' => 'attribute',
        ]);
        ViteFacade::useStyleTagAttributes(function ($src, $url, $chunk, $manifest) {
            $this->assertSame('resources/css/app.css', $src);
            $this->assertSame('https://example.com/build/assets/app.versioned.css', $url);
            $this->assertSame(['file' => 'assets/app.versioned.css'], $chunk);
            $this->assertSame([
                'resources/js/app.js' => [
                    'file' => 'assets/app.versioned.js',
                ],
                'resources/js/app-with-css-import.js' => [
                    'file' => 'assets/app-with-css-import.versioned.js',
                    'css' => [
                        'assets/imported-css.versioned.css',
                    ],
                ],
                'resources/css/imported-css.css' => [
                    'file' => 'assets/imported-css.versioned.css',
                ],
                'resources/js/app-with-shared-css.js' => [
                    'file' => 'assets/app-with-shared-css.versioned.js',
                    'imports' => [
                        '_someFile.js',
                    ],
                ],
                'resources/css/app.css' => [
                    'file' => 'assets/app.versioned.css',
                ],
                '_someFile.js' => [
                    'css' => [
                        'assets/shared-css.versioned.css',
                    ],
                ],
                'resources/css/shared-css' => [
                    'file' => 'assets/shared-css.versioned.css',
                ],
            ], $manifest);

            return [
                'crossorigin',
                'data-persistent-across-pages' => 'YES',
                'remove-me' => false,
                'keep-me' => true,
            ];
        });

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<link rel="stylesheet" href="https://example.com/build/assets/app.versioned.css" general="attribute" crossorigin data-persistent-across-pages="YES" keep-me />'
            .'<script type="module" src="https://example.com/build/assets/app.versioned.js"></script>',
            $result->toHtml()
        );
    }

    public function testItCanSpecifyArbitraryAttributesForScriptTagsWhenHotModuleReloading()
    {
        $this->makeViteHotFile();
        ViteFacade::useScriptTagAttributes([
            'general' => 'attribute',
        ]);
        $expectedArguments = [
            ['src' => '@vite/client', 'url' => 'http://localhost:3000/@vite/client'],
            ['src' => 'resources/js/app.js', 'url' => 'http://localhost:3000/resources/js/app.js'],
        ];
        ViteFacade::useScriptTagAttributes(function ($src, $url, $chunk, $manifest) use (&$expectedArguments) {
            $args = array_shift($expectedArguments);

            $this->assertSame($args['src'], $src);
            $this->assertSame($args['url'], $url);
            $this->assertNull($chunk);
            $this->assertNull($manifest);

            return [
                'crossorigin',
                'data-persistent-across-pages' => 'YES',
                'remove-me' => false,
                'keep-me' => true,
            ];
        });

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client" general="attribute" crossorigin data-persistent-across-pages="YES" keep-me></script>'
            .'<link rel="stylesheet" href="http://localhost:3000/resources/css/app.css" />'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js" general="attribute" crossorigin data-persistent-across-pages="YES" keep-me></script>',
            $result->toHtml()
        );
    }

    public function testItCanSpecifyArbitraryAttributesForStylesheetTagsWhenHotModuleReloading()
    {
        $this->makeViteHotFile();
        ViteFacade::useStyleTagAttributes([
            'general' => 'attribute',
        ]);
        ViteFacade::useStyleTagAttributes(function ($src, $url, $chunk, $manifest) {
            $this->assertSame('resources/css/app.css', $src);
            $this->assertSame('http://localhost:3000/resources/css/app.css', $url);
            $this->assertNull($chunk);
            $this->assertNull($manifest);

            return [
                'crossorigin',
                'data-persistent-across-pages' => 'YES',
                'remove-me' => false,
                'keep-me' => true,
            ];
        });

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client"></script>'
            .'<link rel="stylesheet" href="http://localhost:3000/resources/css/app.css" general="attribute" crossorigin data-persistent-across-pages="YES" keep-me />'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js"></script>',
            $result->toHtml()
        );
    }

    public function testItCanOverrideAllAttributes()
    {
        $this->makeViteManifest();
        ViteFacade::useStyleTagAttributes([
            'rel' => 'expected-rel',
            'href' => 'expected-href',
        ]);
        ViteFacade::useScriptTagAttributes([
            'type' => 'expected-type',
            'src' => 'expected-src',
        ]);

        $result = app(Vite::class)(['resources/css/app.css', 'resources/js/app.js']);

        $this->assertSame(
            '<link rel="expected-rel" href="expected-href" />'
            .'<script type="expected-type" src="expected-src"></script>',
            $result->toHtml()
        );
    }

    public function testViteCanMergeEntryPoints()
    {
        $this->makeViteManifest();

        $vite = app(Vite::class);

        $this->assertSame('', $vite->toHtml());

        $vite->withEntryPoints(['resources/js/app.js']);

        $this->assertSame(
            '<script type="module" src="https://example.com/build/assets/app.versioned.js"></script>',
            $vite->toHtml()
        );
    }

    public function testViteCanOverrideBuildDirectory()
    {
        $this->makeViteManifest(null, 'custom-build');

        $vite = app(Vite::class);

        $vite->withEntryPoints(['resources/js/app.js'])->useBuildDirectory('custom-build');

        $this->assertSame(
            '<script type="module" src="https://example.com/custom-build/assets/app.versioned.js"></script>',
            $vite->toHtml()
        );

        $this->cleanViteManifest('custom-build');
    }

    public function testViteCanOverrideHotFilePath()
    {
        $this->makeViteManifest();
        $this->makeViteHotFile('build/hot');

        $vite = app(Vite::class);

        $vite->withEntryPoints(['resources/js/app.js'])->useHotFile(public_path('build/hot'));

        $this->assertSame(
            '<script type="module" src="http://localhost:3000/@vite/client"></script>'
            .'<script type="module" src="http://localhost:3000/resources/js/app.js"></script>',
            $vite->toHtml()
        );

        $this->cleanViteHotFile('build/hot');
    }

    protected function makeViteManifest($contents = null, $path = 'build')
    {
        app()->singleton('path.public', fn () => __DIR__);

        if (! file_exists(public_path($path))) {
            mkdir(public_path($path));
        }

        $manifest = json_encode($contents ?? [
            'resources/js/app.js' => [
                'file' => 'assets/app.versioned.js',
            ],
            'resources/js/app-with-css-import.js' => [
                'file' => 'assets/app-with-css-import.versioned.js',
                'css' => [
                    'assets/imported-css.versioned.css',
                ],
            ],
            'resources/css/imported-css.css' => [
                'file' => 'assets/imported-css.versioned.css',
            ],
            'resources/js/app-with-shared-css.js' => [
                'file' => 'assets/app-with-shared-css.versioned.js',
                'imports' => [
                    '_someFile.js',
                ],
            ],
            'resources/css/app.css' => [
                'file' => 'assets/app.versioned.css',
            ],
            '_someFile.js' => [
                'css' => [
                    'assets/shared-css.versioned.css',
                ],
            ],
            'resources/css/shared-css' => [
                'file' => 'assets/shared-css.versioned.css',
            ],
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

        file_put_contents(public_path("{$path}/manifest.json"), $manifest);
    }

    protected function cleanViteManifest($path = 'build')
    {
        if (file_exists(public_path("{$path}/manifest.json"))) {
            unlink(public_path("{$path}/manifest.json"));
        }

        if (file_exists(public_path($path))) {
            rmdir(public_path($path));
        }
    }

    protected function makeViteHotFile($path = 'hot')
    {
        app()->singleton('path.public', fn () => __DIR__);

        file_put_contents(public_path($path), 'http://localhost:3000');
    }

    protected function cleanViteHotFile($path = 'hot')
    {
        if (file_exists(public_path($path))) {
            unlink(public_path($path));
        }
    }
}

timacdonald avatar Aug 11 '22 01:08 timacdonald

github isn't allow me to push changes to your branch for some reason, so I just applied some formatting adjustments via the UI.

Hm..that's strange. I updated the test file, thanks!

iamgergo avatar Aug 11 '22 04:08 iamgergo

One additional thing we now need to consider...

public function boot()
{
    Vite::useBuildDirectory('foo');
}
@vite('app.js', 'bar')

The build directory will now be ignored, so we will need to handle that as I believe the value passed through will always need to be respected as it is unexpected for it to be ignored when actually specified (which is the current behaviour)

I think we will also need to check how many arguments are passed through to the __invoke function to determine what build directory to utilise:

$buildDirectory = func_get_args()[1] ?? $this->buildDirectory ?? 'build';

Demo: https://3v4l.org/J6XeM

timacdonald avatar Aug 15 '22 00:08 timacdonald

And what if we just simply do this:

public function __invoke($entryPoints, $buildDirectory = null)
{
    $buildDirectory ??= $this->buildDirectory ?? 'build';

    // ...
}

Or do you think it is a breaking change?

iamgergo avatar Aug 15 '22 06:08 iamgergo

Love it and because we don't have types on the function I don't believe this is a breaking change. We will need to update the docblock to allow for string|null, but I do prefer your suggestion.

I'll try and take some time tomorrow (Tuesday my time) to review this thoroughly with the team and see if we can get it merged for Tuesday's release.

timacdonald avatar Aug 15 '22 07:08 timacdonald

Awesome, I've updated the __invoke and toHtml methods then.

Thank you!

iamgergo avatar Aug 15 '22 07:08 iamgergo

@iamgergo would it be possible to have you add me to your fork of the framework so I can push some final tweaks? I've also resolved the conflicts for us.

timacdonald avatar Aug 18 '22 01:08 timacdonald

@timacdonald Okay, I sent you an invitation.

iamgergo avatar Aug 18 '22 06:08 iamgergo

Thank you!

timacdonald avatar Aug 18 '22 06:08 timacdonald

Just gave this one a final test against the plugin PR and all is working as expected. Thanks so much for your work on this @iamgergo and thanks for your patience with the feedback.

timacdonald avatar Aug 22 '22 03:08 timacdonald

Documentation PR: https://github.com/laravel/docs/pull/8146

timacdonald avatar Aug 22 '22 04:08 timacdonald

@timacdonald Great, thank you! I'm happy I could help a little!

iamgergo avatar Aug 22 '22 08:08 iamgergo