fix: recipe with shared.liquid template only should pass validation

This commit is contained in:
Benjamin Nussbaum 2026-01-11 20:41:12 +01:00
parent 3f98a70ad9
commit 7d1e74183d
2 changed files with 166 additions and 46 deletions

View file

@ -20,12 +20,13 @@ class PluginImportService
/** /**
* Validate YAML settings * Validate YAML settings
* *
* @param array $settings The parsed YAML settings * @param array $settings The parsed YAML settings
*
* @throws Exception * @throws Exception
*/ */
private function validateYAML(array $settings): void 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; return;
} }
@ -43,6 +44,7 @@ class PluginImportService
} }
} }
} }
/** /**
* Import a plugin from a ZIP file * Import a plugin from a ZIP file
* *
@ -73,12 +75,17 @@ class PluginImportService
$zip->extractTo($tempDir); $zip->extractTo($tempDir);
$zip->close(); $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); $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files // Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { if (! $filePaths['settingsYamlPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php 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 // Parse settings.yml
@ -86,20 +93,37 @@ class PluginImportService
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings); $this->validateYAML($settings);
// Read full.liquid content // Determine which template file to use and read its content
$fullLiquid = File::get($filePaths['fullLiquidPath']); $templatePath = null;
// 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
$markupLanguage = 'blade'; $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 = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid'; $markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade';
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -204,12 +228,17 @@ class PluginImportService
$zip->extractTo($tempDir); $zip->extractTo($tempDir);
$zip->close(); $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); $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files // Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { if (! $filePaths['settingsYamlPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); 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 // Parse settings.yml
@ -217,20 +246,37 @@ class PluginImportService
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings); $this->validateYAML($settings);
// Read full.liquid content // Determine which template file to use and read its content
$fullLiquid = File::get($filePaths['fullLiquidPath']); $templatePath = null;
// 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
$markupLanguage = 'blade'; $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 = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid'; $markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade';
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -310,6 +356,7 @@ class PluginImportService
$settingsYamlPath = null; $settingsYamlPath = null;
$fullLiquidPath = null; $fullLiquidPath = null;
$sharedLiquidPath = null; $sharedLiquidPath = null;
$sharedBladePath = null;
// If zipEntryPath is specified, look for files in that specific directory first // If zipEntryPath is specified, look for files in that specific directory first
if ($zipEntryPath) { if ($zipEntryPath) {
@ -327,6 +374,8 @@ class PluginImportService
if (File::exists($targetDir.'/shared.liquid')) { if (File::exists($targetDir.'/shared.liquid')) {
$sharedLiquidPath = $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')) { if (File::exists($targetDir.'/src/shared.liquid')) {
$sharedLiquidPath = $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 we found the required files in the target directory, return them
if ($settingsYamlPath && $fullLiquidPath) { if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
return [ return [
'settingsYamlPath' => $settingsYamlPath, 'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
]; ];
} }
} }
@ -367,9 +419,11 @@ class PluginImportService
$fullLiquidPath = $tempDir.'/src/full.blade.php'; $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')) { if (File::exists($tempDir.'/src/shared.liquid')) {
$sharedLiquidPath = $tempDir.'/src/shared.liquid'; $sharedLiquidPath = $tempDir.'/src/shared.liquid';
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
$sharedBladePath = $tempDir.'/src/shared.blade.php';
} }
} else { } else {
// Search for the files in the extracted directory structure // Search for the files in the extracted directory structure
@ -386,20 +440,24 @@ class PluginImportService
$fullLiquidPath = $filepath; $fullLiquidPath = $filepath;
} elseif ($filename === 'shared.liquid') { } elseif ($filename === 'shared.liquid') {
$sharedLiquidPath = $filepath; $sharedLiquidPath = $filepath;
} elseif ($filename === 'shared.blade.php') {
$sharedBladePath = $filepath;
} }
} }
// Check if shared.liquid exists in the same directory as full.liquid // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) { if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
$fullLiquidDir = dirname((string) $fullLiquidPath); $fullLiquidDir = dirname((string) $fullLiquidPath);
if (File::exists($fullLiquidDir.'/shared.liquid')) { if (File::exists($fullLiquidDir.'/shared.liquid')) {
$sharedLiquidPath = $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, // 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 // 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 // If the files are in the root of the ZIP, create a src folder and move them there
$srcDir = dirname((string) $settingsYamlPath); $srcDir = dirname((string) $settingsYamlPath);
@ -410,17 +468,25 @@ class PluginImportService
// Copy the files to the src directory // Copy the files to the src directory
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); 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) { if ($sharedLiquidPath) {
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid'); File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
$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 // Update the paths
$settingsYamlPath = $newSrcDir.'/settings.yml'; $settingsYamlPath = $newSrcDir.'/settings.yml';
$fullLiquidPath = $newSrcDir.'/full.liquid';
} }
} }
} }
@ -429,6 +495,7 @@ class PluginImportService
'settingsYamlPath' => $settingsYamlPath, 'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
]; ];
} }

View file

@ -83,19 +83,34 @@ it('throws exception for invalid zip file', function (): void {
->toThrow(Exception::class, 'Could not open the ZIP file.'); ->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(); $user = User::factory()->create();
$zipContent = createMockZipFile([ $zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(), 'src/full.liquid' => getValidFullLiquid(),
// Missing full.liquid // Missing settings.yml
]); ]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent); $zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user)) 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 { 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(); $user = User::factory()->create();
// YAML with a comma in the 'default' field of a multi_string // YAML with a comma in the 'default' field of a multi_string
$invalidYaml = <<<YAML $invalidYaml = <<<'YAML'
name: Test Plugin name: Test Plugin
refresh_interval: 30 refresh_interval: 30
strategy: static strategy: static
@ -453,14 +468,14 @@ YAML;
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) expect(fn () => $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 { it('throws exception when multi_string placeholder contains a comma', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
// YAML with a comma in the 'placeholder' field // YAML with a comma in the 'placeholder' field
$invalidYaml = <<<YAML $invalidYaml = <<<'YAML'
name: Test Plugin name: Test Plugin
refresh_interval: 30 refresh_interval: 30
strategy: static strategy: static
@ -483,7 +498,45 @@ YAML;
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) expect(fn () => $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' => '<div class="shared-content">{{ data.title }}</div>',
]);
$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('<div class="view view--{{ size }}">')
->and($plugin->render_markup)->toContain('<div class="shared-content">{{ data.title }}</div>');
});
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' => '<div class="shared-content">{{ $data["title"] }}</div>',
]);
$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('<div class="shared-content">{{ $data["title"] }}</div>')
->and($plugin->render_markup)->not->toContain('<div class="view view--{{ size }}">');
}); });
// Helper methods // Helper methods