From 6c719d4b6457a1cbd12960b5aacdce635baf54ff Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 15 Jan 2024 16:20:15 +0100 Subject: [PATCH 1/3] Support for Vue Components import + passthrough --- app/package-lock.json | 50 +++++++++++++++++-- app/package.json | 7 +-- .../views/component-import.blade.php | 3 ++ .../components/component-import.blade.php | 23 +++++++++ .../dynamic-component-import.blade.php | 23 +++++++++ app/routes/web.php | 1 + app/tests/Browser/ComponentImportTest.php | 22 ++++++++ .../Unit/Console/BuildComponentsTest.php | 2 + ...entsTest__it_builds_the_components__13.vue | 23 +++++++++ ...entsTest__it_builds_the_components__14.vue | 23 +++++++++ app/tests/Unit/ScriptParserTest.php | 17 +++++++ src/BladeViewExtractor.php | 24 ++++++++- src/ScriptParser.php | 34 +++++++++++-- 13 files changed, 239 insertions(+), 13 deletions(-) create mode 100644 app/resources/views/component-import.blade.php create mode 100644 app/resources/views/components/component-import.blade.php create mode 100644 app/resources/views/components/dynamic-component-import.blade.php create mode 100644 app/tests/Browser/ComponentImportTest.php create mode 100644 app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__13.vue create mode 100644 app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue diff --git a/app/package-lock.json b/app/package-lock.json index 46101dd..be1536d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "devDependencies": { - "@protonemedia/laravel-splade-core": "file:../protonemedia-laravel-splade-core-1.0.0.tgz", + "@headlessui/vue": "^1.7.17", + "@protonemedia/laravel-splade-core": "file:../protonemedia-laravel-splade-core-1.1.0.tgz", "@protonemedia/laravel-splade-vite": "^1.0.3", "@vitejs/plugin-vue": "^4.3.4", "axios": "^1.1.2", @@ -450,6 +451,21 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@headlessui/vue": { + "version": "1.7.17", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.17.tgz", + "integrity": "sha512-hmJChv8HzKorxd9F70RGnECAwZfkvmmwOqreuKLWY/19d5qbWnSdw+DNbuA/Uo6X5rb4U5B3NrT+qBKPmjhRqw==", + "dev": true, + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", @@ -545,9 +561,9 @@ } }, "node_modules/@protonemedia/laravel-splade-core": { - "version": "1.0.0", - "resolved": "file:../protonemedia-laravel-splade-core-1.0.0.tgz", - "integrity": "sha512-bUwbWUgLHAcMI4vbwgnZXlSxgJ0dPqB/wYT8rSB09FmuQAm1auzdunE4BYATZQNX3gNRmv7Ai1NxyhRpccXApQ==", + "version": "1.1.0", + "resolved": "file:../protonemedia-laravel-splade-core-1.1.0.tgz", + "integrity": "sha512-u7ok60ivN45MlaPUbuwt9bLMia8VjsCRSBGQH/VgBRf2mW0SVwmE3EJSqqST+gLbmnZCPk9ffqP0A8WQRv0xWQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -561,6 +577,32 @@ "integrity": "sha512-DeQtPLiSuqPKNbvDxjxZ7Mpn+XDcBzeJT2OTk0NxDHXFmXYFga5d0SvKsqyTEJldh0V6wESYoyyKK5q3/wNE/A==", "dev": true }, + "node_modules/@tanstack/virtual-core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.0.0.tgz", + "integrity": "sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.0.1.tgz", + "integrity": "sha512-85Cyi8m7h1xzGB2FyXMurPVFOZvatycVU7OfhQ8QFk27E4tQ7ISNfYEMrakTTaE0ZyNsKRFlAzHuwL1Bv1vuMw==", + "dev": true, + "dependencies": { + "@tanstack/virtual-core": "3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.4.0.tgz", diff --git a/app/package.json b/app/package.json index 9338a68..e3877ab 100644 --- a/app/package.json +++ b/app/package.json @@ -6,19 +6,20 @@ "build": "vite build" }, "devDependencies": { + "@headlessui/vue": "^1.7.17", "@protonemedia/laravel-splade-core": "file:../protonemedia-laravel-splade-core-1.1.0.tgz", "@protonemedia/laravel-splade-vite": "^1.0.3", "@vitejs/plugin-vue": "^4.3.4", "axios": "^1.1.2", + "eslint": "^8.51.0", "eslint-config-prettier": "^9.0.0", - "eslint-plugin-prettier-vue": "^5.0.0", "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-prettier-vue": "^5.0.0", "eslint-plugin-vue": "^9.17.0", - "eslint": "^8.51.0", "flatpickr": "^4.6.13", "laravel-vite-plugin": "^0.8.0", "prettier": "^3.0.3", "vite": "^4.0.0", "vue": "^3.3.4" } -} \ No newline at end of file +} diff --git a/app/resources/views/component-import.blade.php b/app/resources/views/component-import.blade.php new file mode 100644 index 0000000..493ba8a --- /dev/null +++ b/app/resources/views/component-import.blade.php @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/resources/views/components/component-import.blade.php b/app/resources/views/components/component-import.blade.php new file mode 100644 index 0000000..428c174 --- /dev/null +++ b/app/resources/views/components/component-import.blade.php @@ -0,0 +1,23 @@ + + +
+ + + + + + +

Dialog

+
+
+
+
+
\ No newline at end of file diff --git a/app/resources/views/components/dynamic-component-import.blade.php b/app/resources/views/components/dynamic-component-import.blade.php new file mode 100644 index 0000000..daf81e5 --- /dev/null +++ b/app/resources/views/components/dynamic-component-import.blade.php @@ -0,0 +1,23 @@ + + +
+ + + + + + +

Dialog

+
+
+
+
+
\ No newline at end of file diff --git a/app/routes/web.php b/app/routes/web.php index f50add0..9c1003c 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -25,6 +25,7 @@ Route::view('/base-view', 'base-view'); Route::view('/blade-method', 'blade-method'); Route::view('/blade-method-callbacks', 'blade-method-callbacks'); +Route::view('/component-import', 'component-import'); Route::view('/change-blade-prop', 'change-blade-prop'); Route::view('/dynamic', 'dynamic')->withoutMiddleware(SubstituteBindings::class); Route::view('/form', 'form'); diff --git a/app/tests/Browser/ComponentImportTest.php b/app/tests/Browser/ComponentImportTest.php new file mode 100644 index 0000000..611eefc --- /dev/null +++ b/app/tests/Browser/ComponentImportTest.php @@ -0,0 +1,22 @@ +browse(function (Browser $browser) { + $browser->visit('/component-import') + ->assertMissing('h2') + ->assertMissing('#headlessui-portal-root') + ->press('Open Dialog') + ->assertSeeIn('h2', 'Dialog') + ->assertPresent('#headlessui-portal-root'); + }); + } +} diff --git a/app/tests/Unit/Console/BuildComponentsTest.php b/app/tests/Unit/Console/BuildComponentsTest.php index 32d949c..d6cb4cd 100644 --- a/app/tests/Unit/Console/BuildComponentsTest.php +++ b/app/tests/Unit/Console/BuildComponentsTest.php @@ -32,6 +32,8 @@ public function it_builds_the_components() 'IncludedView', 'RegularView', 'ComponentToVueProp', + 'ComponentComponentImport', + 'ComponentDynamicComponentImport', ] as $component) { $this->assertFileExists(resource_path('js/splade/Splade'.$component.'.vue')); $this->assertMatchesVueSnapshot($filesytem->get(resource_path('js/splade/Splade'.$component.'.vue'))); diff --git a/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__13.vue b/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__13.vue new file mode 100644 index 0000000..bd8e082 --- /dev/null +++ b/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__13.vue @@ -0,0 +1,23 @@ + + diff --git a/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue b/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue new file mode 100644 index 0000000..7fa4931 --- /dev/null +++ b/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue @@ -0,0 +1,23 @@ + + diff --git a/app/tests/Unit/ScriptParserTest.php b/app/tests/Unit/ScriptParserTest.php index 34e948f..3fa0c21 100644 --- a/app/tests/Unit/ScriptParserTest.php +++ b/app/tests/Unit/ScriptParserTest.php @@ -179,4 +179,21 @@ function greet() { $this->assertEquals(['age', 'baz', 'city', 'country', 'foo', 'greet', 'name', 'quux'], $parser->getVariables()->toArray()); } + + /** @test */ + public function it_returns_an_array_with_all_js_imports() + { + $script = <<<'JS' +import { Dialog, DialogPanel, TransitionRoot, TransitionChild } from "@headlessui/vue"; +JS; + + $parser = new ScriptParser($script); + + $this->assertEquals([ + 'Dialog' => '@headlessui/vue', + 'DialogPanel' => '@headlessui/vue', + 'TransitionRoot' => '@headlessui/vue', + 'TransitionChild' => '@headlessui/vue', + ], $parser->getImports()); + } } diff --git a/src/BladeViewExtractor.php b/src/BladeViewExtractor.php index 6915dcd..8e83de7 100644 --- a/src/BladeViewExtractor.php +++ b/src/BladeViewExtractor.php @@ -109,6 +109,7 @@ public function handle(Filesystem $filesystem): string $this->splitOriginalView(); $this->scriptParser = new ScriptParser($this->originalScript); + $this->scriptParser->getImports(); // Some pre-processing of the view. $this->viewWithoutScriptTag = $this->replaceComponentMethodLoadingStates($this->viewWithoutScriptTag); @@ -481,8 +482,27 @@ protected function renderSpladeRenderFunction(): string ->when($this->viewUsesElementRefs(), fn (Collection $collection) => $collection->push('setSpladeRef')) ->implode(','); - $componentsObject = $this->isComponent() ? <<<'JS' -components: { GenericSpladeComponent }, + $components = Collection::make( + $this->isComponent() ? $this->scriptParser->getImports() : [] + ) + ->keys() + ->filter(function (string $import) { + if (Str::contains($this->viewWithoutScriptTag, "<{$import}")) { + return true; + } + + // match anything in :is="" (e.g.: :is="true ? A : B") attribute + preg_match_all('/:is="(.+?)"/', $this->viewWithoutScriptTag, $matches); + + return Collection::make($matches[1] ?? []) + ->flatMap(fn (string $match) => explode(' ', $match)) + ->contains('TransitionRoot'); + }) + ->prepend('GenericSpladeComponent') + ->implode(','); + + $componentsObject = $this->isComponent() ? <<scriptWithoutImports = trim($this->removeImports($script)); + $this->script = $script; - $this->rootNode = Peast::latest($this->scriptWithoutImports)->parse(); + $this->rootNode = Peast::latest($this->script, [ + 'sourceType' => \Peast\Peast::SOURCE_TYPE_MODULE, + ])->parse(); } /** @@ -108,7 +112,7 @@ public function getDefineProps(array $mergeWith = []): array { foreach ($this->rootNode->query('CallExpression[callee.name="defineProps"]') as $node) { /** @var CallExpression $node */ - $definePropsScript = collect(explode(PHP_EOL, $this->scriptWithoutImports)) + $definePropsScript = collect(explode(PHP_EOL, $this->script)) ->filter(function (string $contents, int $line) use ($node) { return $line >= ($node->getLocation()->getStart()->getLine() - 1) && $line <= ($node->getLocation()->getEnd()->getLine() - 1); @@ -199,4 +203,26 @@ public function getVariables(Collection|array $additionalItems = []): Collection return $variables->merge($additionalItems)->unique()->sort()->values(); } + + /** + * Returns an array of all imports. + */ + public function getImports(): array + { + $imports = []; + + // find ImportDeclaration + foreach ($this->rootNode->query('ImportDeclaration') as $node) { + /** @var ImportDeclaration $node */ + $source = $node->getSource()->getValue(); + + // find ImportSpecifier + foreach ($node->getSpecifiers() as $specifier) { + /** @var ImportSpecifier $specifier */ + $imports[$specifier->getLocal()->getName()] = $source; + } + } + + return $imports; + } } From c1e5a2cf66187f8b1fb583626fb0aa12f4a3d724 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 15 Jan 2024 16:21:59 +0100 Subject: [PATCH 2/3] Update component-import.blade.php --- app/resources/views/component-import.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/resources/views/component-import.blade.php b/app/resources/views/component-import.blade.php index 493ba8a..7c5b8ff 100644 --- a/app/resources/views/component-import.blade.php +++ b/app/resources/views/component-import.blade.php @@ -1,3 +1,3 @@ - + \ No newline at end of file From 0e83be3cb6f3ab7678d9ee84759124eab7ca9574 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 15 Jan 2024 21:18:55 +0100 Subject: [PATCH 3/3] Support for dynamic components --- .../views/dynamic-component-import.blade.php | 3 + app/routes/web.php | 1 + app/tests/Browser/ComponentImportTest.php | 19 +++-- ...entsTest__it_builds_the_components__14.vue | 12 +++- src/BladeViewExtractor.php | 69 ++++++++++++++----- src/ScriptParser.php | 13 +--- 6 files changed, 80 insertions(+), 37 deletions(-) create mode 100644 app/resources/views/dynamic-component-import.blade.php diff --git a/app/resources/views/dynamic-component-import.blade.php b/app/resources/views/dynamic-component-import.blade.php new file mode 100644 index 0000000..493ba8a --- /dev/null +++ b/app/resources/views/dynamic-component-import.blade.php @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/routes/web.php b/app/routes/web.php index 9c1003c..e9f9140 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -26,6 +26,7 @@ Route::view('/blade-method', 'blade-method'); Route::view('/blade-method-callbacks', 'blade-method-callbacks'); Route::view('/component-import', 'component-import'); +Route::view('/dynamic-component-import', 'dynamic-component-import'); Route::view('/change-blade-prop', 'change-blade-prop'); Route::view('/dynamic', 'dynamic')->withoutMiddleware(SubstituteBindings::class); Route::view('/form', 'form'); diff --git a/app/tests/Browser/ComponentImportTest.php b/app/tests/Browser/ComponentImportTest.php index 611eefc..8770da4 100644 --- a/app/tests/Browser/ComponentImportTest.php +++ b/app/tests/Browser/ComponentImportTest.php @@ -7,11 +7,22 @@ class ComponentImportTest extends DuskTestCase { - /** @test */ - public function it_handles_js_imports() + public static function urls() { - $this->browse(function (Browser $browser) { - $browser->visit('/component-import') + return [ + ['/component-import'], + ['/dynamic-component-import'], + ]; + } + + /** + * @dataProvider urls + * + * @test */ + public function it_handles_js_imports($url) + { + $this->browse(function (Browser $browser) use ($url) { + $browser->visit($url) ->assertMissing('h2') ->assertMissing('#headlessui-portal-root') ->press('Open Dialog') diff --git a/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue b/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue index 7fa4931..bdf1110 100644 --- a/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue +++ b/app/tests/Unit/Console/__snapshots__/BuildComponentsTest__it_builds_the_components__14.vue @@ -1,6 +1,6 @@ diff --git a/src/BladeViewExtractor.php b/src/BladeViewExtractor.php index 8e83de7..59d908d 100644 --- a/src/BladeViewExtractor.php +++ b/src/BladeViewExtractor.php @@ -22,6 +22,8 @@ class BladeViewExtractor protected ScriptParser $scriptParser; + protected ?array $importedComponents = null; + public function __construct( protected readonly string $originalView, protected readonly array $data, @@ -353,6 +355,7 @@ protected function renderImports(): string { $vueFunctionsImports = $this->scriptParser->getVueFunctions() ->push('h') + ->when($this->getImportedComponents()['dynamic']->isNotEmpty(), fn ($collection) => $collection->push('markRaw')) ->when($this->needsSpladeBridge(), fn ($collection) => $collection->push('ref')) ->when($this->isRefreshable(), fn ($collection) => $collection->push('inject')) ->when($this->isComponent() && ! empty($this->getBladeProperties()), fn ($collection) => $collection->push('computed')) @@ -465,6 +468,46 @@ protected function renderElementRefStoreAndSetter(): string JS; } + /** + * Returns the imported components. + */ + protected function getImportedComponents(): array + { + if ($this->importedComponents) { + return $this->importedComponents; + } + + $this->importedComponents = [ + 'static' => Collection::make([]), + 'dynamic' => Collection::make([]), + ]; + + if (! $this->isComponent()) { + return $this->importedComponents; + } + + Collection::make($this->scriptParser->getImports()) + ->keys() + ->each(function (string $import) { + if (Str::contains($this->viewWithoutScriptTag, "<{$import}")) { + return $this->importedComponents['static'][] = $import; + } + + // match anything in :is="" (e.g.: :is="true ? A : B") attribute + preg_match_all('/:is="(.+?)"/', $this->viewWithoutScriptTag, $matches); + + $isDynamic = Collection::make($matches[1] ?? []) + ->flatMap(fn (string $match) => explode(' ', $match)) + ->contains($import); + + if ($isDynamic) { + return $this->importedComponents['dynamic'][] = $import; + } + }); + + return $this->importedComponents; + } + /** * Renders the SpladeRender 'h'-function. */ @@ -474,32 +517,22 @@ protected function renderSpladeRenderFunction(): string inheritAttrs: false, JS : ''; + $importedComponents = $this->getImportedComponents(); + $dataObject = Collection::make(['...props']) ->merge($this->getBladeProperties()) ->merge($this->scriptParser->getVariables()->reject(fn ($variable) => $variable === 'props')) ->merge($this->getBladeFunctions()) ->when($this->isRefreshable(), fn (Collection $collection) => $collection->push('refreshComponent')) ->when($this->viewUsesElementRefs(), fn (Collection $collection) => $collection->push('setSpladeRef')) + ->merge($importedComponents['dynamic']->map(function (string $component) { + return "{$component}: markRaw({$component})"; + })) ->implode(','); - $components = Collection::make( - $this->isComponent() ? $this->scriptParser->getImports() : [] - ) - ->keys() - ->filter(function (string $import) { - if (Str::contains($this->viewWithoutScriptTag, "<{$import}")) { - return true; - } - - // match anything in :is="" (e.g.: :is="true ? A : B") attribute - preg_match_all('/:is="(.+?)"/', $this->viewWithoutScriptTag, $matches); - - return Collection::make($matches[1] ?? []) - ->flatMap(fn (string $match) => explode(' ', $match)) - ->contains('TransitionRoot'); - }) - ->prepend('GenericSpladeComponent') - ->implode(','); + $components = Collection::make([ + 'GenericSpladeComponent', ...$importedComponents['static'], + ])->implode(','); $componentsObject = $this->isComponent() ? <<parse(); } - /** - * Removes the import statements from the script. - */ - protected function removeImports(string $script): string - { - return Collection::make(explode(PHP_EOL, $script)) - ->filter(fn ($line) => ! str_starts_with(trim($line), 'import ')) - ->implode(PHP_EOL); - } - /** * Returns all Vue functions that are used in the script. */