From 0d6079db8bf475702a579228ea5277d2001c4e6d Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 9 Jan 2026 20:16:33 +0100 Subject: [PATCH 1/4] feat(#150): add trmnlp settings modal --- ...make_trmnlp_id_unique_in_plugins_table.php | 60 ++++++++++ .../views/livewire/plugins/recipe.blade.php | 9 +- .../plugins/recipes/settings.blade.php | 84 +++++++++++++ .../Livewire/Plugins/RecipeSettingsTest.php | 112 ++++++++++++++++++ 4 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php create mode 100644 resources/views/livewire/plugins/recipes/settings.blade.php create mode 100644 tests/Feature/Livewire/Plugins/RecipeSettingsTest.php diff --git a/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php new file mode 100644 index 0000000..9769505 --- /dev/null +++ b/database/migrations/2026_01_11_121809_make_trmnlp_id_unique_in_plugins_table.php @@ -0,0 +1,60 @@ +select('user_id', 'trmnlp_id', DB::raw('COUNT(*) as count')) + ->whereNotNull('trmnlp_id') + ->groupBy('user_id', 'trmnlp_id') + ->having('count', '>', 1) + ->get(); + + // For each duplicate combination, keep the first one (by id) and set others to null + foreach ($duplicates as $duplicate) { + $plugins = DB::table('plugins') + ->where('user_id', $duplicate->user_id) + ->where('trmnlp_id', $duplicate->trmnlp_id) + ->orderBy('id') + ->get(); + + // Keep the first one, set the rest to null + $keepFirst = true; + foreach ($plugins as $plugin) { + if ($keepFirst) { + $keepFirst = false; + + continue; + } + + DB::table('plugins') + ->where('id', $plugin->id) + ->update(['trmnlp_id' => null]); + } + } + + Schema::table('plugins', function (Blueprint $table) { + $table->unique(['user_id', 'trmnlp_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropUnique(['user_id', 'trmnlp_id']); + }); + } +}; diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 19ee3a7..c9a1442 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -478,7 +478,6 @@ HTML; - @@ -488,6 +487,10 @@ HTML; + + Recipe Settings + + Duplicate Plugin Delete Plugin @@ -646,6 +649,8 @@ HTML; + +
@@ -734,7 +739,7 @@ HTML; @endif
- Configuration + Configuration Fields
@endif diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php new file mode 100644 index 0000000..cb8028f --- /dev/null +++ b/resources/views/livewire/plugins/recipes/settings.blade.php @@ -0,0 +1,84 @@ +loadData(); + } + + public function loadData(): void + { + $this->resetErrorBag(); + // Reload data + $this->plugin = $this->plugin->fresh(); + $this->trmnlp_id = $this->plugin->trmnlp_id; + $this->uuid = $this->plugin->uuid; + } + + public function saveTrmnlpId(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + $this->validate([ + 'trmnlp_id' => [ + 'nullable', + 'string', + 'max:255', + Rule::unique('plugins', 'trmnlp_id') + ->where('user_id', auth()->id()) + ->ignore($this->plugin->id), + ], + ]); + + $this->plugin->update([ + 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, + ]); + + //$this->loadData(); // Reload to ensure we have the latest data + Flux::modal('trmnlp-settings')->close(); + } +};?> + + +
+
+ Recipe Settings +
+ +
+
+ {{-- --}} + + TRMNLP Recipe ID + + + Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with trmnlp. + +
+ +
+ + + Cancel + + Save +
+
+
+
diff --git a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php new file mode 100644 index 0000000..a04815f --- /dev/null +++ b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php @@ -0,0 +1,112 @@ +create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => null, + ]); + + $trmnlpId = (string) Str::uuid(); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('trmnlp_id', $trmnlpId) + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId); +}); + +test('recipe settings validates trmnlp_id is unique per user', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $existingPlugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => 'existing-id-123', + ]); + + $newPlugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => null, + ]); + + Volt::test('plugins.recipes.settings', ['plugin' => $newPlugin]) + ->set('trmnlp_id', 'existing-id-123') + ->call('saveTrmnlpId') + ->assertHasErrors(['trmnlp_id' => 'unique']); + + expect($newPlugin->fresh()->trmnlp_id)->toBeNull(); +}); + +test('recipe settings allows same trmnlp_id for different users', function (): void { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $plugin1 = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'trmnlp_id' => 'shared-id-123', + ]); + + $plugin2 = Plugin::factory()->create([ + 'user_id' => $user2->id, + 'trmnlp_id' => null, + ]); + + $this->actingAs($user2); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin2]) + ->set('trmnlp_id', 'shared-id-123') + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin2->fresh()->trmnlp_id)->toBe('shared-id-123'); +}); + +test('recipe settings allows same trmnlp_id for the same plugin', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $trmnlpId = (string) Str::uuid(); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => $trmnlpId, + ]); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('trmnlp_id', $trmnlpId) + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId); +}); + +test('recipe settings can clear trmnlp_id', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'trmnlp_id' => 'some-id', + ]); + + Volt::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('trmnlp_id', '') + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + expect($plugin->fresh()->trmnlp_id)->toBeNull(); +}); From 3f98a70ad9714165aff154c52efc1b14ddc4746e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 11 Jan 2026 19:34:11 +0100 Subject: [PATCH 2/4] feat(#102): added support for Alias plugin --- app/Models/Plugin.php | 3 +- app/Services/ImageGenerationService.php | 102 +++++++++++++----- ...1_11_173757_add_alias_to_plugins_table.php | 28 +++++ .../plugins/recipes/settings.blade.php | 32 ++++-- routes/api.php | 89 ++++++++++++++- 5 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 524f26a..68f8e7e 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -46,6 +46,7 @@ class Plugin extends Model 'dark_mode' => 'boolean', 'preferred_renderer' => 'string', 'plugin_type' => 'string', + 'alias' => 'boolean', ]; protected static function boot() @@ -153,7 +154,7 @@ class Plugin extends Model public function updateDataPayload(): void { - if ($this->data_strategy !== 'polling' || !$this->polling_url) { + if ($this->data_strategy !== 'polling' || ! $this->polling_url) { return; } $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index b8269a3..405ea3f 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -26,11 +26,44 @@ class ImageGenerationService public static function generateImage(string $markup, $deviceId): string { $device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId); + $uuid = self::generateImageFromModel( + markup: $markup, + deviceModel: $device->deviceModel, + user: $device->user, + palette: $device->palette ?? $device->deviceModel?->palette, + device: $device + ); + + $device->update(['current_screen_image' => $uuid]); + Log::info("Device $device->id: updated with new image: $uuid"); + + return $uuid; + } + + /** + * Generate an image from markup using a DeviceModel + * + * @param string $markup The HTML markup to render + * @param DeviceModel|null $deviceModel The device model to use for image generation + * @param \App\Models\User|null $user Optional user for timezone settings + * @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette + * @param Device|null $device Optional device for legacy devices without DeviceModel + * @return string The UUID of the generated image + */ + public static function generateImageFromModel( + string $markup, + ?DeviceModel $deviceModel = null, + ?\App\Models\User $user = null, + ?\App\Models\DevicePalette $palette = null, + ?Device $device = null + ): string { $uuid = Uuid::uuid4()->toString(); try { - // Get image generation settings from DeviceModel if available, otherwise use device settings - $imageSettings = self::getImageSettings($device); + // Get image generation settings from DeviceModel or Device (for legacy devices) + $imageSettings = $deviceModel + ? self::getImageSettingsFromModel($deviceModel) + : ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null)); $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); @@ -45,7 +78,7 @@ class ImageGenerationService $browserStage->html($markup); // Set timezone from user or fall back to app timezone - $timezone = $device->user->timezone ?? config('app.timezone'); + $timezone = $user?->timezone ?? config('app.timezone'); $browserStage->timezone($timezone); if (config('app.puppeteer_window_size_strategy') === 'v2') { @@ -65,12 +98,12 @@ class ImageGenerationService $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); } - // Get palette from device or fallback to device model's default palette - $palette = $device->palette ?? $device->deviceModel?->palette; + // Get palette from parameter or fallback to device model's default palette $colorPalette = null; - if ($palette && $palette->colors) { $colorPalette = $palette->colors; + } elseif ($deviceModel?->palette && $deviceModel->palette->colors) { + $colorPalette = $deviceModel->palette->colors; } $imageStage = new ImageStage(); @@ -107,8 +140,7 @@ class ImageGenerationService throw new RuntimeException('Image file is empty: '.$outputPath); } - $device->update(['current_screen_image' => $uuid]); - Log::info("Device $device->id: updated with new image: $uuid"); + Log::info("Generated image: $uuid"); return $uuid; @@ -125,22 +157,7 @@ class ImageGenerationService { // If device has a DeviceModel, use its settings if ($device->deviceModel) { - /** @var DeviceModel $model */ - $model = $device->deviceModel; - - return [ - 'width' => $model->width, - 'height' => $model->height, - 'colors' => $model->colors, - 'bit_depth' => $model->bit_depth, - 'scale_factor' => $model->scale_factor, - 'rotation' => $model->rotation, - 'mime_type' => $model->mime_type, - 'offset_x' => $model->offset_x, - 'offset_y' => $model->offset_y, - 'image_format' => self::determineImageFormatFromModel($model), - 'use_model_settings' => true, - ]; + return self::getImageSettingsFromModel($device->deviceModel); } // Fallback to device settings @@ -164,6 +181,43 @@ class ImageGenerationService ]; } + /** + * Get image generation settings from a DeviceModel + */ + private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array + { + if ($deviceModel) { + return [ + 'width' => $deviceModel->width, + 'height' => $deviceModel->height, + 'colors' => $deviceModel->colors, + 'bit_depth' => $deviceModel->bit_depth, + 'scale_factor' => $deviceModel->scale_factor, + 'rotation' => $deviceModel->rotation, + 'mime_type' => $deviceModel->mime_type, + 'offset_x' => $deviceModel->offset_x, + 'offset_y' => $deviceModel->offset_y, + 'image_format' => self::determineImageFormatFromModel($deviceModel), + 'use_model_settings' => true, + ]; + } + + // Default settings if no device model provided + return [ + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'image_format' => ImageFormat::AUTO->value, + 'use_model_settings' => false, + ]; + } + /** * Determine the appropriate ImageFormat based on DeviceModel settings */ diff --git a/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php new file mode 100644 index 0000000..0a527d7 --- /dev/null +++ b/database/migrations/2026_01_11_173757_add_alias_to_plugins_table.php @@ -0,0 +1,28 @@ +boolean('alias')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('alias'); + }); + } +}; diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php index cb8028f..d07a243 100644 --- a/resources/views/livewire/plugins/recipes/settings.blade.php +++ b/resources/views/livewire/plugins/recipes/settings.blade.php @@ -11,21 +11,18 @@ new class extends Component { public Plugin $plugin; public string|null $trmnlp_id = null; public string|null $uuid = null; + public bool $alias = false; public int $resetIndex = 0; public function mount(): void - { - $this->loadData(); - } - - public function loadData(): void { $this->resetErrorBag(); // Reload data $this->plugin = $this->plugin->fresh(); $this->trmnlp_id = $this->plugin->trmnlp_id; $this->uuid = $this->plugin->uuid; + $this->alias = $this->plugin->alias ?? false; } public function saveTrmnlpId(): void @@ -41,15 +38,21 @@ new class extends Component { ->where('user_id', auth()->id()) ->ignore($this->plugin->id), ], + 'alias' => 'boolean', ]); $this->plugin->update([ 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, + 'alias' => $this->alias, ]); - //$this->loadData(); // Reload to ensure we have the latest data Flux::modal('trmnlp-settings')->close(); } + + public function getAliasUrlProperty(): string + { + return url("/api/display/{$this->uuid}/alias"); + } };?> @@ -70,6 +73,23 @@ new class extends Component { Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with trmnlp. + + + + Enable a public alias URL for this recipe. + + + @if($alias) + + Alias URL + + Use this URL to access the recipe image directly. Add ?device-model=name to specify a device model. + + @endif
diff --git a/routes/api.php b/routes/api.php index 5700a43..f3a31a1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -613,7 +613,7 @@ Route::post('plugin_settings/{uuid}/image', function (Request $request, string $ } // Generate a new UUID for each image upload to prevent device caching - $imageUuid = \Illuminate\Support\Str::uuid()->toString(); + $imageUuid = Str::uuid()->toString(); $filename = $imageUuid.'.'.$extension; $path = 'images/generated/'.$filename; @@ -678,3 +678,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s ], ]); })->middleware('auth:sanctum'); + +Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) { + $plugin = Plugin::where('uuid', $uuid)->firstOrFail(); + + // Check if alias is active + if (! $plugin->alias) { + return response()->json([ + 'message' => 'Alias is not active for this plugin', + ], 403); + } + + // Get device model name from query parameter, default to 'og_png' + $deviceModelName = $request->query('device-model', 'og_png'); + $deviceModel = DeviceModel::where('name', $deviceModelName)->first(); + + if (! $deviceModel) { + return response()->json([ + 'message' => "Device model '{$deviceModelName}' not found", + ], 404); + } + + // Check if we can use cached image (only for og_png and if data is not stale) + $useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null; + + if ($useCache) { + // Return cached image + $imageUuid = $plugin->current_image; + $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; + $imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension; + + // Check if image exists, otherwise fall back to generation + if (Storage::disk('public')->exists($imagePath)) { + return response()->file(Storage::disk('public')->path($imagePath), [ + 'Content-Type' => $deviceModel->mime_type, + ]); + } + } + + // Generate new image + try { + // Update data if needed + if ($plugin->isDataStale()) { + $plugin->updateDataPayload(); + $plugin->refresh(); + } + + // Load device model with palette relationship + $deviceModel->load('palette'); + + // Create a virtual device for rendering (Plugin::render needs a Device object) + $virtualDevice = new Device(); + $virtualDevice->setRelation('deviceModel', $deviceModel); + $virtualDevice->setRelation('user', $plugin->user); + $virtualDevice->setRelation('palette', $deviceModel->palette); + + // Render the plugin markup + $markup = $plugin->render(device: $virtualDevice); + + // Generate image using the new method that doesn't require a device + $imageUuid = ImageGenerationService::generateImageFromModel( + markup: $markup, + deviceModel: $deviceModel, + user: $plugin->user, + palette: $deviceModel->palette + ); + + // Update plugin cache if using og_png + if ($deviceModelName === 'og_png') { + $plugin->update(['current_image' => $imageUuid]); + } + + // Return the generated image + $fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png'; + $imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension); + + return response()->file($imagePath, [ + 'Content-Type' => $deviceModel->mime_type, + ]); + } catch (Exception $e) { + Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); + + return response()->json([ + 'message' => 'Failed to generate image', + 'error' => $e->getMessage(), + ], 500); + } +})->name('api.display.alias'); From 7d1e74183d3d5239ced95c14ddfae747d4e288d3 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 11 Jan 2026 20:41:12 +0100 Subject: [PATCH 3/4] fix: recipe with shared.liquid template only should pass validation --- app/Services/PluginImportService.php | 143 ++++++++++++++++++++------- tests/Feature/PluginImportTest.php | 69 +++++++++++-- 2 files changed, 166 insertions(+), 46 deletions(-) diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index eeb5835..49dce99 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -20,12 +20,13 @@ class PluginImportService /** * Validate YAML settings * - * @param array $settings The parsed YAML settings + * @param array $settings The parsed YAML settings + * * @throws Exception */ private function validateYAML(array $settings): void { - if (!isset($settings['custom_fields']) || !is_array($settings['custom_fields'])) { + if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) { return; } @@ -43,6 +44,7 @@ class PluginImportService } } } + /** * Import a plugin from a ZIP file * @@ -73,12 +75,17 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php) + // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files - if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { - throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php + if (! $filePaths['settingsYamlPath']) { + throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); + } + + // Validate that we have at least one template file + if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) { + throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); } // Parse settings.yml @@ -86,20 +93,37 @@ class PluginImportService $settings = Yaml::parse($settingsYaml); $this->validateYAML($settings); - // Read full.liquid content - $fullLiquid = File::get($filePaths['fullLiquidPath']); - - // Prepend shared.liquid content if available - if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { - $sharedLiquid = File::get($filePaths['sharedLiquidPath']); - $fullLiquid = $sharedLiquid."\n".$fullLiquid; - } - - // Check if the file ends with .liquid to set markup language + // Determine which template file to use and read its content + $templatePath = null; $markupLanguage = 'blade'; - if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { + + if ($filePaths['fullLiquidPath']) { + $templatePath = $filePaths['fullLiquidPath']; + $fullLiquid = File::get($templatePath); + + // Prepend shared.liquid or shared.blade.php content if available + if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { + $sharedLiquid = File::get($filePaths['sharedLiquidPath']); + $fullLiquid = $sharedLiquid."\n".$fullLiquid; + } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { + $sharedBlade = File::get($filePaths['sharedBladePath']); + $fullLiquid = $sharedBlade."\n".$fullLiquid; + } + + // Check if the file ends with .liquid to set markup language + if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { + $markupLanguage = 'liquid'; + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } + } elseif ($filePaths['sharedLiquidPath']) { + $templatePath = $filePaths['sharedLiquidPath']; + $fullLiquid = File::get($templatePath); $markupLanguage = 'liquid'; $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } elseif ($filePaths['sharedBladePath']) { + $templatePath = $filePaths['sharedBladePath']; + $fullLiquid = File::get($templatePath); + $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -204,12 +228,17 @@ class PluginImportService $zip->extractTo($tempDir); $zip->close(); - // Find the required files (settings.yml and full.liquid/full.blade.php) + // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); // Validate that we found the required files - if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { - throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); + if (! $filePaths['settingsYamlPath']) { + throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); + } + + // Validate that we have at least one template file + if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) { + throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); } // Parse settings.yml @@ -217,20 +246,37 @@ class PluginImportService $settings = Yaml::parse($settingsYaml); $this->validateYAML($settings); - // Read full.liquid content - $fullLiquid = File::get($filePaths['fullLiquidPath']); - - // Prepend shared.liquid content if available - if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { - $sharedLiquid = File::get($filePaths['sharedLiquidPath']); - $fullLiquid = $sharedLiquid."\n".$fullLiquid; - } - - // Check if the file ends with .liquid to set markup language + // Determine which template file to use and read its content + $templatePath = null; $markupLanguage = 'blade'; - if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { + + if ($filePaths['fullLiquidPath']) { + $templatePath = $filePaths['fullLiquidPath']; + $fullLiquid = File::get($templatePath); + + // Prepend shared.liquid or shared.blade.php content if available + if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { + $sharedLiquid = File::get($filePaths['sharedLiquidPath']); + $fullLiquid = $sharedLiquid."\n".$fullLiquid; + } elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { + $sharedBlade = File::get($filePaths['sharedBladePath']); + $fullLiquid = $sharedBlade."\n".$fullLiquid; + } + + // Check if the file ends with .liquid to set markup language + if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { + $markupLanguage = 'liquid'; + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } + } elseif ($filePaths['sharedLiquidPath']) { + $templatePath = $filePaths['sharedLiquidPath']; + $fullLiquid = File::get($templatePath); $markupLanguage = 'liquid'; $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + } elseif ($filePaths['sharedBladePath']) { + $templatePath = $filePaths['sharedBladePath']; + $fullLiquid = File::get($templatePath); + $markupLanguage = 'blade'; } // Ensure custom_fields is properly formatted @@ -310,6 +356,7 @@ class PluginImportService $settingsYamlPath = null; $fullLiquidPath = null; $sharedLiquidPath = null; + $sharedBladePath = null; // If zipEntryPath is specified, look for files in that specific directory first if ($zipEntryPath) { @@ -327,6 +374,8 @@ class PluginImportService if (File::exists($targetDir.'/shared.liquid')) { $sharedLiquidPath = $targetDir.'/shared.liquid'; + } elseif (File::exists($targetDir.'/shared.blade.php')) { + $sharedBladePath = $targetDir.'/shared.blade.php'; } } @@ -342,15 +391,18 @@ class PluginImportService if (File::exists($targetDir.'/src/shared.liquid')) { $sharedLiquidPath = $targetDir.'/src/shared.liquid'; + } elseif (File::exists($targetDir.'/src/shared.blade.php')) { + $sharedBladePath = $targetDir.'/src/shared.blade.php'; } } // If we found the required files in the target directory, return them - if ($settingsYamlPath && $fullLiquidPath) { + if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) { return [ 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, + 'sharedBladePath' => $sharedBladePath, ]; } } @@ -367,9 +419,11 @@ class PluginImportService $fullLiquidPath = $tempDir.'/src/full.blade.php'; } - // Check for shared.liquid in the same directory + // Check for shared.liquid or shared.blade.php in the same directory if (File::exists($tempDir.'/src/shared.liquid')) { $sharedLiquidPath = $tempDir.'/src/shared.liquid'; + } elseif (File::exists($tempDir.'/src/shared.blade.php')) { + $sharedBladePath = $tempDir.'/src/shared.blade.php'; } } else { // Search for the files in the extracted directory structure @@ -386,20 +440,24 @@ class PluginImportService $fullLiquidPath = $filepath; } elseif ($filename === 'shared.liquid') { $sharedLiquidPath = $filepath; + } elseif ($filename === 'shared.blade.php') { + $sharedBladePath = $filepath; } } - // Check if shared.liquid exists in the same directory as full.liquid - if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) { + // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid + if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) { $fullLiquidDir = dirname((string) $fullLiquidPath); if (File::exists($fullLiquidDir.'/shared.liquid')) { $sharedLiquidPath = $fullLiquidDir.'/shared.liquid'; + } elseif (File::exists($fullLiquidDir.'/shared.blade.php')) { + $sharedBladePath = $fullLiquidDir.'/shared.blade.php'; } } // If we found the files but they're not in the src folder, // check if they're in the root of the ZIP or in a subfolder - if ($settingsYamlPath && $fullLiquidPath) { + if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) { // If the files are in the root of the ZIP, create a src folder and move them there $srcDir = dirname((string) $settingsYamlPath); @@ -410,17 +468,25 @@ class PluginImportService // Copy the files to the src directory File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); - File::copy($fullLiquidPath, $newSrcDir.'/full.liquid'); - // Copy shared.liquid if it exists + // Copy full.liquid or full.blade.php if it exists + if ($fullLiquidPath) { + $extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION); + File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension); + $fullLiquidPath = $newSrcDir.'/full.'.$extension; + } + + // Copy shared.liquid or shared.blade.php if it exists if ($sharedLiquidPath) { File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid'); $sharedLiquidPath = $newSrcDir.'/shared.liquid'; + } elseif ($sharedBladePath) { + File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php'); + $sharedBladePath = $newSrcDir.'/shared.blade.php'; } // Update the paths $settingsYamlPath = $newSrcDir.'/settings.yml'; - $fullLiquidPath = $newSrcDir.'/full.liquid'; } } } @@ -429,6 +495,7 @@ class PluginImportService 'settingsYamlPath' => $settingsYamlPath, 'fullLiquidPath' => $fullLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath, + 'sharedBladePath' => $sharedBladePath, ]; } diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index fae28a8..f3ef1fa 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -83,19 +83,34 @@ it('throws exception for invalid zip file', function (): void { ->toThrow(Exception::class, 'Could not open the ZIP file.'); }); -it('throws exception for missing required files', function (): void { +it('throws exception for missing settings.yml', function (): void { $user = User::factory()->create(); $zipContent = createMockZipFile([ - 'src/settings.yml' => getValidSettingsYaml(), - // Missing full.liquid + 'src/full.liquid' => getValidFullLiquid(), + // Missing settings.yml ]); $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); $pluginImportService = new PluginImportService(); expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); + ->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.'); +}); + +it('throws exception for missing template files', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + // Missing all template files + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.'); }); it('sets default values when settings are missing', function (): void { @@ -431,7 +446,7 @@ it('throws exception when multi_string default value contains a comma', function $user = User::factory()->create(); // YAML with a comma in the 'default' field of a multi_string - $invalidYaml = << $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, "Validation Error: The default value for multistring fields like `api_key` cannot contain commas."); + ->toThrow(Exception::class, 'Validation Error: The default value for multistring fields like `api_key` cannot contain commas.'); }); it('throws exception when multi_string placeholder contains a comma', function (): void { $user = User::factory()->create(); // YAML with a comma in the 'placeholder' field - $invalidYaml = << $pluginImportService->importFromZip($zipFile, $user)) - ->toThrow(Exception::class, "Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas."); + ->toThrow(Exception::class, 'Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas.'); +}); + +it('imports plugin with only shared.liquid file', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/shared.liquid' => '
{{ data.title }}
', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->markup_language)->toBe('liquid') + ->and($plugin->render_markup)->toContain('
') + ->and($plugin->render_markup)->toContain('
{{ data.title }}
'); +}); + +it('imports plugin with only shared.blade.php file', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/shared.blade.php' => '
{{ $data["title"] }}
', + ]); + + $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromZip($zipFile, $user); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->markup_language)->toBe('blade') + ->and($plugin->render_markup)->toBe('
{{ $data["title"] }}
') + ->and($plugin->render_markup)->not->toContain('
'); }); // Helper methods From 131d99a2e3610da801804c09a19f62fa04366efa Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 11 Jan 2026 21:50:35 +0100 Subject: [PATCH 4/4] feat(#154): add support for trusted proxies --- config/trustedproxy.php | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 config/trustedproxy.php diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 0000000..8557288 --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,6 @@ + ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)), +];