Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 136 additions & 1 deletion src/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
use Native\Mobile\Traits\InstallsIos;
use Native\Mobile\Traits\PlatformFileOperations;

use function Laravel\Prompts\confirm;
use function Laravel\Prompts\error;
use function Laravel\Prompts\intro;
use function Laravel\Prompts\note;
use function Laravel\Prompts\outro;
use function Laravel\Prompts\select;
use function Laravel\Prompts\text;
use function Laravel\Prompts\warning;

class InstallCommand extends Command
{
Expand All @@ -27,7 +29,8 @@ class InstallCommand extends Command
{--F|fresh : Overwrite existing files (alias for --force)}
{--with-icu : Include ICU support for Android (adds ~30MB)}
{--without-icu : Exclude ICU support for Android}
{--skip-php : Do not download the PHP binaries}';
{--skip-php : Do not download the PHP binaries}
{--with-vite : Auto-configure the NativePHP Vite plugin}';

protected $description = 'Install all of the NativePHP resources';

Expand Down Expand Up @@ -109,6 +112,8 @@ public function handle(): void

$this->callSilently('vendor:publish', ['--tag' => 'nativephp-mobile-config']);

$this->configureVitePlugin();

if ($installAndroid) {
$this->setupAndroid();
}
Expand Down Expand Up @@ -202,6 +207,136 @@ protected function getRandomWords(int $count): string
return implode('', $selected);
}

protected function configureVitePlugin(): void
{
// Only relevant for Inertia (React/Vue) projects
$packageJsonPath = base_path('package.json');
if (! file_exists($packageJsonPath) || ! str_contains(file_get_contents($packageJsonPath), '@inertiajs/')) {
return;
}

$viteConfigPath = base_path('vite.config.js');

if (! file_exists($viteConfigPath)) {
$viteConfigPath = base_path('vite.config.ts');

if (! file_exists($viteConfigPath)) {
return;
}
}

$contents = file_get_contents($viteConfigPath);

// Already configured — skip silently
if (str_contains($contents, 'nativephpMobile') || str_contains($contents, 'nativephp-mobile')) {
return;
}

if (! $this->shouldConfigureVite()) {
$this->showViteManualInstructions();

return;
}

$modified = $contents;

// 1. Inject import after the last import statement
$importLine = "import { nativephpMobile, nativephpHotFile } from './vendor/nativephp/mobile/resources/js/vite-plugin.js';";

if (preg_match('/^import\s.+$/m', $modified)) {
// Find the position after the last import line
preg_match_all('/^import\s.+$/m', $modified, $matches, PREG_OFFSET_CAPTURE);
$lastImport = end($matches[0]);
$insertPos = $lastImport[1] + strlen($lastImport[0]);
$modified = substr($modified, 0, $insertPos)."\n".$importLine.substr($modified, $insertPos);
} else {
$this->showViteManualInstructions();

return;
}

// 2. Inject hotFile into laravel() call
if (preg_match('/(laravel\(\{\s*\n)/', $modified)) {
$modified = preg_replace(
'/(laravel\(\{\s*\n)/',
"$1 hotFile: nativephpHotFile(),\n",
$modified,
1
);
} else {
$this->showViteManualInstructions();

return;
}

// 3. Inject nativephpMobile() into plugins array
// Match the last plugin entry (closing paren of a plugin call) before the array's closing bracket
if (preg_match('/(\bplugins\s*:\s*\[)/s', $modified)) {
// Find the closing ] of the plugins array by matching the pattern:
// some plugin call ending with ), followed by whitespace/newline, then ]
$modified = preg_replace(
'/(laravel\(\{[^}]*\}\)\s*,?)(\s*\n\s*\])/',
"$1\n nativephpMobile(),$2",
$modified,
1
);

// Verify our injection worked
if (! str_contains($modified, 'nativephpMobile()')) {
$this->showViteManualInstructions();

return;
}
} else {
$this->showViteManualInstructions();

return;
}

// Write to a temp file and verify syntax with Node before overwriting
$tempPath = sprintf('%s/vite.config.nativephp-tmp.%s', dirname($viteConfigPath), pathinfo($viteConfigPath, PATHINFO_EXTENSION));
file_put_contents($tempPath, $modified);

$nodeCheck = exec('node --check '.escapeshellarg($tempPath).' 2>&1', $output, $exitCode);

if ($exitCode !== 0) {
@unlink($tempPath);
$this->showViteManualInstructions();

return;
}

rename($tempPath, $viteConfigPath);

$this->components->info('Vite config updated with NativePHP plugin.');
}

protected function shouldConfigureVite(): bool
{
return $this->option('with-vite') || confirm('Would you like to auto-configure the NativePHP Vite plugin?', true);
}

protected function showViteManualInstructions(): void
{
warning('Could not auto-configure Vite. Please add the following manually to your vite config:');
note(<<<'NOTE'
1. Import at the top of the file:
import { nativephpMobile, nativephpHotFile } from './vendor/nativephp/mobile/resources/js/vite-plugin.js';

2. Add hotFile to the laravel() plugin:
laravel({
input: [...],
hotFile: nativephpHotFile(),
})

3. Add nativephpMobile() to the plugins array:
plugins: [
laravel({...}),
nativephpMobile(),
]
NOTE);
}

protected function setEnvValue(string $key, string $value): void
{
$envPath = base_path('.env');
Expand Down
202 changes: 202 additions & 0 deletions tests/Unit/Commands/ConfigureVitePluginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
<?php

namespace Tests\Unit\Commands;

use Illuminate\Console\View\Components\Factory;
use Illuminate\Support\Facades\File;
use Native\Mobile\Commands\InstallCommand;
use Symfony\Component\Console\Output\NullOutput;
use Tests\TestCase;

class ConfigureVitePluginTest extends TestCase
{
protected string $basePath;

protected function setUp(): void
{
parent::setUp();

$this->basePath = sprintf('%s/nativephp_vite_test_%s', sys_get_temp_dir(), uniqid());
File::makeDirectory($this->basePath, 0755, true);
app()->setBasePath($this->basePath);
}

protected function tearDown(): void
{
File::deleteDirectory($this->basePath);
parent::tearDown();
}

protected function configure(bool $confirm = true): object
{
$command = new class($confirm) extends InstallCommand
{
public function __construct(private bool $autoConfirm)
{
parent::__construct();
$this->components = new Factory(new NullOutput);
}

public function executeConfigureVite(): void
{
$this->configureVitePlugin();
}

protected function shouldConfigureVite(): bool
{
return $this->autoConfirm;
}

public bool $manualInstructionsShown = false;

protected function showViteManualInstructions(): void
{
$this->manualInstructionsShown = true;
}
};

$command->setLaravel($this->app);
$command->executeConfigureVite();

return $command;
}

protected function writePackageJson(bool $withInertia = true): void
{
$deps = $withInertia ? '"@inertiajs/react": "^1.0.0"' : '"vue": "^3.0.0"';
File::put(sprintf('%s/package.json', $this->basePath), sprintf('{"dependencies": {%s}}', $deps));
}

protected function writeViteConfig(string $ext = 'js'): string
{
$path = sprintf('%s/vite.config.%s', $this->basePath, $ext);
File::put($path, $this->standardConfig());

return $path;
}

protected function viteConfigPath(string $ext = 'js'): string
{
return sprintf('%s/vite.config.%s', $this->basePath, $ext);
}

protected function standardConfig(): string
{
return <<<'JS'
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [
laravel({
input: 'resources/js/app.tsx',
refresh: true,
}),
],
});
JS;
}

public function test_skips_without_package_json()
{
$path = $this->writeViteConfig();
$this->configure();
$this->assertEquals($this->standardConfig(), File::get($path));
}

public function test_skips_without_inertia()
{
$this->writePackageJson(withInertia: false);
$path = $this->writeViteConfig();
$this->configure();
$this->assertEquals($this->standardConfig(), File::get($path));
}

public function test_skips_without_vite_config()
{
$this->writePackageJson();
$this->configure();
$this->assertFileDoesNotExist($this->viteConfigPath());
}

public function test_skips_when_already_configured()
{
$this->writePackageJson();
$config = str_replace('import react', "import { nativephpMobile } from 'x';\nimport react", $this->standardConfig());
File::put($this->viteConfigPath(), $config);

$this->configure();
$this->assertEquals($config, File::get($this->viteConfigPath()));
}

public function test_shows_manual_instructions_when_declined()
{
$this->writePackageJson();
$this->writeViteConfig();
$command = $this->configure(confirm: false);

$this->assertTrue($command->manualInstructionsShown);
$this->assertEquals($this->standardConfig(), File::get($this->viteConfigPath()));
}

public function test_injects_all_three_modifications()
{
$this->writePackageJson();
$this->writeViteConfig();
$this->configure();

$result = File::get($this->viteConfigPath());
$this->assertStringContainsString("import { nativephpMobile, nativephpHotFile } from './vendor/nativephp/mobile/resources/js/vite-plugin.js';", $result);
$this->assertStringContainsString('hotFile: nativephpHotFile(),', $result);
$this->assertStringContainsString('nativephpMobile(),', $result);
}

public function test_works_with_typescript_config()
{
$this->writePackageJson();
$this->writeViteConfig('ts');
$this->configure();

$result = File::get($this->viteConfigPath('ts'));
$this->assertStringContainsString('nativephpMobile()', $result);
$this->assertStringContainsString('hotFile: nativephpHotFile(),', $result);
}

public function test_import_placed_after_last_import()
{
$this->writePackageJson();
$this->writeViteConfig();
$this->configure();

$lines = explode("\n", File::get($this->viteConfigPath()));
$lastImportIndex = 0;
foreach ($lines as $i => $line) {
if (str_starts_with($line, 'import ')) {
$lastImportIndex = $i;
}
}
$this->assertStringContainsString('nativephpMobile', $lines[$lastImportIndex]);
}

public function test_is_idempotent()
{
$this->writePackageJson();
$this->writeViteConfig();

$this->configure();
$first = File::get($this->viteConfigPath());
$this->configure();

$this->assertEquals($first, File::get($this->viteConfigPath()));
}

public function test_no_temp_file_left_behind()
{
$this->writePackageJson();
$this->writeViteConfig();
$this->configure();

$this->assertFileDoesNotExist(sprintf('%s/vite.config.nativephp-tmp.js', $this->basePath));
}
}