Skip to content

Commit

Permalink
Merge pull request #15 from gwleuverink/feature/initable-exports
Browse files Browse the repository at this point in the history
Feature/initable exports
  • Loading branch information
gwleuverink authored Jun 20, 2024
2 parents 3e64d03 + 347b35e commit de30d2b
Show file tree
Hide file tree
Showing 17 changed files with 1,582 additions and 659 deletions.
1 change: 1 addition & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ba11d6aba92e038053ced776acbea2162480bced
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ jobs:
paths_ignore: '["**/README.md", "**/docs/**"]'

workbench-tests:
needs: skip-duplicates
if: needs.skip-duplicates.outputs.should_skip != 'true'
# needs: skip-duplicates
# if: needs.skip-duplicates.outputs.should_skip != 'true'

runs-on: ubuntu-latest

Expand Down
1,867 changes: 1,303 additions & 564 deletions composer.lock

Large diffs are not rendered by default.

26 changes: 8 additions & 18 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,22 @@ Bun doesn't ship with a CSS loader. They have it on [the roadmap](https://github

Plugin support is a feature we'd like to experiment with. If that is released before Bun's builtin css loader does, it might be possible to write your own plugin to achieve this.

## Initable exports
## ✅ Initable exports

**_Added in [v0.5](https://github.com/gwleuverink/bundle/releases/tag/v0.5.0)_**

When importing a local module, the only method to immediately invoke some code is by using the [IIFE export](https://laravel-bundle.dev/local-modules.html#iife-exports) method.

An alternative API is possible that would make it a bit easier to structure your code.
Consider the following example script in `resources/js/some-module.js`. (needs a jsconfig.json for path remapping)

```javascript
export default {
// Some properties here

init: function () {
// What will be executed immediately
},

// Some methods here
export default () => {
// Some bootstrapping code that needs to be invoked immediately
};
```

By using `init` on the import component you'll instruct Bundle to run that method immidiatly. You don't need a `as` alias in this case.
By using `init` on the import component you'll instruct Bundle to run that function immidiatly. You don't need a `as` alias in this case.

```html
<x-import module="~/some-module" init />
Expand All @@ -50,14 +46,8 @@ By using `init` on the import component you'll instruct Bundle to run that metho
This approach will also make it possible to use named exports in combination with a init function.

```javascript
export default {
// Some properties here

init: function () {
// What will be executed immediately
},

// Some methods here
export default () => {
// Some bootstrapping code that needs to be invoked immediately
};

export function someFunction() {
Expand Down
25 changes: 19 additions & 6 deletions src/BundleManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,22 @@ public function __construct(BundlerContract $bundler)
$this->bundler = $bundler;
}

public function bundle(string $script): SplFileInfo
public function bundle(string $script, array $config = []): SplFileInfo
{
$min = $this->config()->get('minify')
$config = $this->mergeConfig($config);

$init = $config->get('init')
? '-init'
: '';

$min = $config->get('minify')
? '.min'
: '';

$file = "{$this->hash($script)}{$min}.js";
$file = "{$this->hash($script)}{$init}{$min}.js";

// Return cached file if available
if ($this->config()->get('caching') && $cached = $this->fromDisk($file)) {
if ($config->get('caching') && $cached = $this->fromDisk($file)) {
return $cached;
}

Expand All @@ -48,8 +54,8 @@ public function bundle(string $script): SplFileInfo
// Attempt bundling & cleanup
try {
$processed = $this->bundler->build(
sourcemaps: $this->config()->get('sourcemaps'),
minify: $this->config()->get('minify'),
sourcemaps: $config->get('sourcemaps'),
minify: $config->get('minify'),
inputPath: $this->tempDisk()->path(''),
outputPath: $this->buildDisk()->path(''),
fileName: $file,
Expand All @@ -72,6 +78,13 @@ public function config(): RepositoryContract
return new ConfigRepository(config('bundle'));
}

public function mergeConfig(array $config = []): RepositoryContract
{
return new ConfigRepository(
array_merge($this->config()->all(), $config)
);
}

public function tempDisk(): FilesystemContract
{
return Storage::build([
Expand Down
28 changes: 24 additions & 4 deletions src/Components/Import.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ class Import extends Component
public function __construct(
public string $module,
public ?string $as = null,
public bool $inline = false
public bool $inline = false,
public bool $init = false
) {
}

Expand All @@ -32,7 +33,9 @@ protected function bundle()

// Render script tag with bundled code
return view('x-import::script', [
'bundle' => $this->manager()->bundle($js),
'bundle' => $this->manager()->bundle($js, [
'init' => $this->init,
]),
]);
}

Expand Down Expand Up @@ -73,18 +76,35 @@ protected function import(): string
//--------------------------------------------------------------------------
(() => {
// Import was marked as invokable
if('{$this->init}') {
// Note: don't return, since we might need to still register the module
import('{$this->module}')
.then(invokable => {
if(typeof invokable.default !== 'function') {
throw `BUNDLING ERROR: '{$this->module}' not invokable - default export is not a function`
}
try {
invokable.default()
} catch(e) {
throw `BUNDLING ERROR: unable to invoke '{$this->module}' - '\${e}'`
}
})
}
// Check if module is already loaded under a different alias
const previous = document.querySelector(`script[data-module="{$this->module}"]`)
// Was previously loaded & needs to be pushed to import map
if(previous && '{$this->as}') {
// Throw error improve debugging experience
// Throw error when previously imported under different alias. Otherwise continue
if(previous.dataset.alias !== '{$this->as}') {
throw `BUNDLING ERROR: '{$this->as}' already imported as '\${previous.dataset.alias}'`
}
}
// Handle CSS injection & return early (no need to add css to import map)
// Handle CSS injection
if('{$this->module}'.endsWith('.css') || '{$this->module}'.endsWith('.scss')) {
return import('{$this->module}').then(result => {
window.x_inject_styles(result.default, previous)
Expand Down
7 changes: 5 additions & 2 deletions src/Contracts/BundleManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ interface BundleManager
{
public function __construct(Bundler $bundler);

/** Bundles a given script */
public function bundle(string $script): SplFileInfo;
/** Bundles a given script, accepts a optional config argument that overrides the package config */
public function bundle(string $script, array $config = []): SplFileInfo;

/** Get the bundle config */
public function config(): RepositoryContract;

/** Merge the given config with the default config */
public function mergeConfig(array $config = []): RepositoryContract;

/** Get an instance of the temporary disk the bundler reads from */
public function tempDisk(): Filesystem;

Expand Down
66 changes: 56 additions & 10 deletions tests/Browser/AlpineInteropTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,49 +13,95 @@ class AlpineInteropTest extends DuskTestCase
public function it_can_bootstrap_alpine_via_iife_import()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine" />
<x-import module="~/bootstrap/alpine-iife" />
<div
id="component"
x-text="message"
x-data="{
message: 'Hello World!'
message: 'Alpine loaded!'
}"
></div>
HTML);

// Doesn't raise console errors
$this->assertEmpty($browser->driver->manage()->getLog('browser'));

$browser->waitForTextIn('#component', 'Hello World!');
$browser->waitForTextIn('#component', 'Alpine loaded!');
}

/** @test */
public function it_can_bootstrap_plugins_via_iife_import()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine" />
<x-import module="~/bootstrap/alpine-iife-with-plugin" />
<div
id="component"
x-text="message"
x-data="{
message: 'Hello World!'
message: typeof Alpine.persist === 'function'
? 'Plugin loaded!'
: false
}"
></div>
HTML);

// Doesn't raise console errors
$this->assertEmpty($browser->driver->manage()->getLog('browser'));

$browser->waitForTextIn('#component', 'Hello World!');
$browser->waitForTextIn('#component', 'Plugin loaded!');
}

/** @test */
public function it_can_bootstrap_alpine_via_initable_import()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine-init" init />
<div
id="component"
x-text="message"
x-data="{
message: 'Alpine loaded!'
}"
></div>
HTML);

// Doesn't raise console errors
$this->assertEmpty($browser->driver->manage()->getLog('browser'));

$browser->waitForTextIn('#component', 'Alpine loaded!');
}

/** @test */
public function it_can_bootstrap_plugins_via_initable_import()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine-init-with-plugin" init />
<div
id="component"
x-text="message"
x-data="{
message: typeof Alpine.persist === 'function'
? 'Plugin loaded!'
: false
}"
></div>
HTML);

// Doesn't raise console errors
$this->assertEmpty($browser->driver->manage()->getLog('browser'));

$browser->waitForTextIn('#component', 'Plugin loaded!');
}

/** @test */
public function it_can_use_imports_from_x_init()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine" />
<x-import module="~/bootstrap/alpine-init" init />
<x-import module="lodash/filter" as="filter" />
<div
Expand Down Expand Up @@ -88,7 +134,7 @@ public function it_can_use_imports_from_x_data()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine" />
<x-import module="~/bootstrap/alpine-init" init />
<x-import module="lodash/filter" as="filter" />
<div
Expand Down Expand Up @@ -121,7 +167,7 @@ public function it_can_use_imports_from_x_data()
public function it_can_use_imports_from_x_click_listener()
{
$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine" />
<x-import module="~/bootstrap/alpine-init" init />
<x-import module="lodash/filter" as="filter" />
<button
Expand Down Expand Up @@ -160,7 +206,7 @@ public function it_supports_backed_components_with_alpine_data()

$browser = $this->blade(<<< 'HTML'
<x-import module="~/bootstrap/alpine" />
<x-import module="~/bootstrap/alpine-init" init />
<x-import module="~/components/hello-world" />
<div
Expand Down
73 changes: 73 additions & 0 deletions tests/Browser/InitableImportsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Leuverink\Bundle\Tests\Browser;

use Leuverink\Bundle\BundleManager;
use Leuverink\Bundle\Tests\DuskTestCase;

// Pest & Workbench Dusk don't play nicely together
// We need to fall back to PHPUnit syntax.

class InitableImportsTest extends DuskTestCase
{
/** @test */
public function it_invokes_imports_with_init_prop()
{
$this->blade(<<< 'HTML'
<x-import module="~/default-function" init />
HTML)
->assertScript('window.test_evaluated', true);
}

/** @test */
public function it_doesnt_invoke_imports_without_init_prop()
{
$this->blade(<<< 'HTML'
<x-import module="~/default-function" />
HTML)
->assertScript('window.test_evaluated', null);
}

/** @test */
public function it_still_registers_an_aliased_module_when_the_default_export_is_invoked()
{
$this->markTestIncomplete('TODO: Priority!');
}

/** @test */
public function it_appends_init_to_the_bundle_filename_when_import_is_invoked()
{
$bundle = BundleManager::new()->bundle(<<< 'JS'
alert('Hello World!')
JS, ['init' => true]);

expect($bundle)->getFilename()->toContain('init');
}

/** @test */
public function it_doesnt_append_init_to_the_bundle_filename_when_import_is_not_invoked()
{
$bundle = BundleManager::new()->bundle(<<< 'JS'
alert('Hello World!')
JS);

expect($bundle)->getFilename()->not->toContain('init');
}

/** @test */
public function it_raises_a_console_error_when_invokable_import_is_not_a_function()
{
$this->markTestIncomplete("can't inspect console for thrown errors");

// $this->blade(<<< 'HTML'
// <x-import module="~/default-object" />
// HTML)
// ->assertScript('window.test_evaluated', null);
}

/** @test */
public function it_raises_a_console_error_when_invokable_import_is_not_javascript_file()
{
$this->markTestIncomplete("can't inspect console for thrown errors");
}
}
Loading

0 comments on commit de30d2b

Please sign in to comment.