feat: support additional markup layouts

This commit is contained in:
Benjamin Nussbaum 2026-01-28 16:02:14 +01:00
parent a57feabe95
commit 7ebfa586c1
7 changed files with 505 additions and 128 deletions

View file

@ -60,8 +60,14 @@ class Plugin extends Model
}); });
static::updating(function ($model): void { static::updating(function ($model): void {
// Reset image cache when markup changes // Reset image cache when any markup changes
if ($model->isDirty('render_markup')) { if ($model->isDirty([
'render_markup',
'render_markup_half_horizontal',
'render_markup_half_vertical',
'render_markup_quadrant',
'render_markup_shared',
])) {
$model->current_image = null; $model->current_image = null;
} }
}); });
@ -421,7 +427,9 @@ class Plugin extends Model
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.'); throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
} }
if ($this->render_markup) { $markup = $this->getMarkupForSize($size);
if ($markup) {
$renderedContent = ''; $renderedContent = '';
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
@ -471,7 +479,7 @@ class Plugin extends Model
// Check if external renderer should be used // Check if external renderer should be used
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) { if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
// Use external Ruby renderer - pass raw template without preprocessing // Use external Ruby renderer - pass raw template without preprocessing
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context); $renderedContent = $this->renderWithExternalLiquidRenderer($markup, $context);
} else { } else {
// Use PHP keepsuit/liquid renderer // Use PHP keepsuit/liquid renderer
// Create a custom environment with inline templates support // Create a custom environment with inline templates support
@ -493,14 +501,14 @@ class Plugin extends Model
$environment->tagRegistry->register(TemplateTag::class); $environment->tagRegistry->register(TemplateTag::class);
// Apply Liquid replacements (including 'with' syntax conversion) // Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup); $processedMarkup = $this->applyLiquidReplacements($markup);
$template = $environment->parseString($processedMarkup); $template = $environment->parseString($processedMarkup);
$liquidContext = $environment->newRenderContext(data: $context); $liquidContext = $environment->newRenderContext(data: $context);
$renderedContent = $template->render($liquidContext); $renderedContent = $template->render($liquidContext);
} }
} else { } else {
$renderedContent = Blade::render($this->render_markup, [ $renderedContent = Blade::render($markup, [
'size' => $size, 'size' => $size,
'data' => $this->data_payload, 'data' => $this->data_payload,
'config' => $this->configuration ?? [], 'config' => $this->configuration ?? [],
@ -581,6 +589,30 @@ class Plugin extends Model
return $this->configuration[$key] ?? $default; return $this->configuration[$key] ?? $default;
} }
/**
* Get the appropriate markup for a given size, including shared prepending logic
*
* @param string $size The layout size (full, half_horizontal, half_vertical, quadrant)
* @return string|null The markup code for the given size, with shared prepended if available
*/
public function getMarkupForSize(string $size): ?string
{
$markup = match ($size) {
'full' => $this->render_markup,
'half_horizontal' => $this->render_markup_half_horizontal ?? $this->render_markup,
'half_vertical' => $this->render_markup_half_vertical ?? $this->render_markup,
'quadrant' => $this->render_markup_quadrant ?? $this->render_markup,
default => $this->render_markup,
};
// Prepend shared markup if it exists
if ($markup && $this->render_markup_shared) {
$markup = $this->render_markup_shared."\n".$markup;
}
return $markup;
}
public function getPreviewMashupLayoutForSize(string $size): string public function getPreviewMashupLayoutForSize(string $size): string
{ {
return match ($size) { return match ($size) {

View file

@ -51,17 +51,35 @@ class PluginExportService
$settings = $this->generateSettingsYaml($plugin); $settings = $this->generateSettingsYaml($plugin);
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
File::put($tempDir.'/settings.yml', $settingsYaml); File::put($tempDir.'/settings.yml', $settingsYaml);
// Generate full template content
$fullTemplate = $this->generateFullTemplate($plugin);
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php'; $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
// Export full template if it exists
if ($plugin->render_markup) {
$fullTemplate = $this->generateLayoutTemplate($plugin->render_markup);
File::put($tempDir.'/full.'.$extension, $fullTemplate); File::put($tempDir.'/full.'.$extension, $fullTemplate);
// Generate shared.liquid if needed (for liquid templates)
if ($plugin->markup_language === 'liquid') {
$sharedTemplate = $this->generateSharedTemplate();
/** @phpstan-ignore-next-line */
if ($sharedTemplate) {
File::put($tempDir.'/shared.liquid', $sharedTemplate);
} }
// Export layout-specific templates if they exist
if ($plugin->render_markup_half_horizontal) {
$halfHorizontalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_horizontal);
File::put($tempDir.'/half_horizontal.'.$extension, $halfHorizontalTemplate);
}
if ($plugin->render_markup_half_vertical) {
$halfVerticalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_vertical);
File::put($tempDir.'/half_vertical.'.$extension, $halfVerticalTemplate);
}
if ($plugin->render_markup_quadrant) {
$quadrantTemplate = $this->generateLayoutTemplate($plugin->render_markup_quadrant);
File::put($tempDir.'/quadrant.'.$extension, $quadrantTemplate);
}
// Export shared template if it exists
if ($plugin->render_markup_shared) {
$sharedTemplate = $this->generateLayoutTemplate($plugin->render_markup_shared);
File::put($tempDir.'/shared.'.$extension, $sharedTemplate);
} }
// Create ZIP file // Create ZIP file
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip'; $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
@ -124,29 +142,21 @@ class PluginExportService
} }
/** /**
* Generate the full template content * Generate template content from markup, removing wrapper divs if present
*/ */
private function generateFullTemplate(Plugin $plugin): string private function generateLayoutTemplate(?string $markup): string
{ {
$markup = $plugin->render_markup; if (! $markup) {
return '';
}
// Remove the wrapper div if it exists (it will be added during import) // Remove the wrapper div if it exists (it will be added during import for liquid)
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup); $markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup); $markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
return mb_trim($markup); return mb_trim($markup);
} }
/**
* Generate the shared template content (for liquid templates)
*/
private function generateSharedTemplate(): null
{
// For now, we don't have a way to store shared templates separately
// TODO - add support for shared templates
return null;
}
/** /**
* Add a directory and its contents to a ZIP file * Add a directory and its contents to a ZIP file
*/ */

View file

@ -93,37 +93,59 @@ class PluginImportService
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings); $this->validateYAML($settings);
// Determine which template file to use and read its content // Determine markup language from the first available file
$templatePath = null;
$markupLanguage = 'blade'; $markupLanguage = 'blade';
$firstTemplatePath = $filePaths['fullLiquidPath']
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
?? ($filePaths['quadrantLiquidPath'] ?? null)
?? ($filePaths['sharedLiquidPath'] ?? null)
?? ($filePaths['sharedBladePath'] ?? null);
if ($filePaths['fullLiquidPath']) { if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
$templatePath = $filePaths['fullLiquidPath']; $markupLanguage = 'liquid';
$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 // Read full markup (don't prepend shared - it will be prepended at render time)
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { $fullLiquid = null;
$markupLanguage = 'liquid'; if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
$fullLiquid = File::get($filePaths['fullLiquidPath']);
if ($markupLanguage === 'liquid') {
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} }
} elseif ($filePaths['sharedLiquidPath']) { }
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath); // Read shared markup separately
$markupLanguage = 'liquid'; $sharedMarkup = null;
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
} elseif ($filePaths['sharedBladePath']) { $sharedMarkup = File::get($filePaths['sharedLiquidPath']);
$templatePath = $filePaths['sharedBladePath']; } elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$fullLiquid = File::get($templatePath); $sharedMarkup = File::get($filePaths['sharedBladePath']);
$markupLanguage = 'blade'; }
// Read layout-specific markups
$halfHorizontalMarkup = null;
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
}
}
$halfVerticalMarkup = null;
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
}
}
$quadrantMarkup = null;
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
if ($markupLanguage === 'liquid') {
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
}
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -160,6 +182,10 @@ class PluginImportService
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid ?? null,
'render_markup_half_horizontal' => $halfHorizontalMarkup,
'render_markup_half_vertical' => $halfVerticalMarkup,
'render_markup_quadrant' => $quadrantMarkup,
'render_markup_shared' => $sharedMarkup,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
]); ]);
@ -246,37 +272,59 @@ class PluginImportService
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings); $this->validateYAML($settings);
// Determine which template file to use and read its content // Determine markup language from the first available file
$templatePath = null;
$markupLanguage = 'blade'; $markupLanguage = 'blade';
$firstTemplatePath = $filePaths['fullLiquidPath']
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
?? ($filePaths['quadrantLiquidPath'] ?? null)
?? ($filePaths['sharedLiquidPath'] ?? null)
?? ($filePaths['sharedBladePath'] ?? null);
if ($filePaths['fullLiquidPath']) { if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
$templatePath = $filePaths['fullLiquidPath']; $markupLanguage = 'liquid';
$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 // Read full markup (don't prepend shared - it will be prepended at render time)
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { $fullLiquid = null;
$markupLanguage = 'liquid'; if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
$fullLiquid = File::get($filePaths['fullLiquidPath']);
if ($markupLanguage === 'liquid') {
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} }
} elseif ($filePaths['sharedLiquidPath']) { }
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath); // Read shared markup separately
$markupLanguage = 'liquid'; $sharedMarkup = null;
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
} elseif ($filePaths['sharedBladePath']) { $sharedMarkup = File::get($filePaths['sharedLiquidPath']);
$templatePath = $filePaths['sharedBladePath']; } elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$fullLiquid = File::get($templatePath); $sharedMarkup = File::get($filePaths['sharedBladePath']);
$markupLanguage = 'blade'; }
// Read layout-specific markups
$halfHorizontalMarkup = null;
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
}
}
$halfVerticalMarkup = null;
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
}
}
$quadrantMarkup = null;
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
if ($markupLanguage === 'liquid') {
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
}
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -322,6 +370,10 @@ class PluginImportService
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid ?? null,
'render_markup_half_horizontal' => $halfHorizontalMarkup,
'render_markup_half_vertical' => $halfVerticalMarkup,
'render_markup_quadrant' => $quadrantMarkup,
'render_markup_shared' => $sharedMarkup,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
'preferred_renderer' => $preferredRenderer, 'preferred_renderer' => $preferredRenderer,
@ -357,6 +409,9 @@ class PluginImportService
$fullLiquidPath = null; $fullLiquidPath = null;
$sharedLiquidPath = null; $sharedLiquidPath = null;
$sharedBladePath = null; $sharedBladePath = null;
$halfHorizontalLiquidPath = null;
$halfVerticalLiquidPath = null;
$quadrantLiquidPath = 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) {
@ -377,6 +432,25 @@ class PluginImportService
} elseif (File::exists($targetDir.'/shared.blade.php')) { } elseif (File::exists($targetDir.'/shared.blade.php')) {
$sharedBladePath = $targetDir.'/shared.blade.php'; $sharedBladePath = $targetDir.'/shared.blade.php';
} }
// Check for layout-specific files
if (File::exists($targetDir.'/half_horizontal.liquid')) {
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.liquid';
} elseif (File::exists($targetDir.'/half_horizontal.blade.php')) {
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.blade.php';
}
if (File::exists($targetDir.'/half_vertical.liquid')) {
$halfVerticalLiquidPath = $targetDir.'/half_vertical.liquid';
} elseif (File::exists($targetDir.'/half_vertical.blade.php')) {
$halfVerticalLiquidPath = $targetDir.'/half_vertical.blade.php';
}
if (File::exists($targetDir.'/quadrant.liquid')) {
$quadrantLiquidPath = $targetDir.'/quadrant.liquid';
} elseif (File::exists($targetDir.'/quadrant.blade.php')) {
$quadrantLiquidPath = $targetDir.'/quadrant.blade.php';
}
} }
// Check if files are in src subdirectory of target directory // Check if files are in src subdirectory of target directory
@ -394,6 +468,25 @@ class PluginImportService
} elseif (File::exists($targetDir.'/src/shared.blade.php')) { } elseif (File::exists($targetDir.'/src/shared.blade.php')) {
$sharedBladePath = $targetDir.'/src/shared.blade.php'; $sharedBladePath = $targetDir.'/src/shared.blade.php';
} }
// Check for layout-specific files in src
if (File::exists($targetDir.'/src/half_horizontal.liquid')) {
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.liquid';
} elseif (File::exists($targetDir.'/src/half_horizontal.blade.php')) {
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.blade.php';
}
if (File::exists($targetDir.'/src/half_vertical.liquid')) {
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.liquid';
} elseif (File::exists($targetDir.'/src/half_vertical.blade.php')) {
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.blade.php';
}
if (File::exists($targetDir.'/src/quadrant.liquid')) {
$quadrantLiquidPath = $targetDir.'/src/quadrant.liquid';
} elseif (File::exists($targetDir.'/src/quadrant.blade.php')) {
$quadrantLiquidPath = $targetDir.'/src/quadrant.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
@ -425,6 +518,25 @@ class PluginImportService
} elseif (File::exists($tempDir.'/src/shared.blade.php')) { } elseif (File::exists($tempDir.'/src/shared.blade.php')) {
$sharedBladePath = $tempDir.'/src/shared.blade.php'; $sharedBladePath = $tempDir.'/src/shared.blade.php';
} }
// Check for layout-specific files
if (File::exists($tempDir.'/src/half_horizontal.liquid')) {
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.liquid';
} elseif (File::exists($tempDir.'/src/half_horizontal.blade.php')) {
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.blade.php';
}
if (File::exists($tempDir.'/src/half_vertical.liquid')) {
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.liquid';
} elseif (File::exists($tempDir.'/src/half_vertical.blade.php')) {
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.blade.php';
}
if (File::exists($tempDir.'/src/quadrant.liquid')) {
$quadrantLiquidPath = $tempDir.'/src/quadrant.liquid';
} elseif (File::exists($tempDir.'/src/quadrant.blade.php')) {
$quadrantLiquidPath = $tempDir.'/src/quadrant.blade.php';
}
} else { } else {
// Search for the files in the extracted directory structure // Search for the files in the extracted directory structure
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS); $directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
@ -442,6 +554,12 @@ class PluginImportService
$sharedLiquidPath = $filepath; $sharedLiquidPath = $filepath;
} elseif ($filename === 'shared.blade.php') { } elseif ($filename === 'shared.blade.php') {
$sharedBladePath = $filepath; $sharedBladePath = $filepath;
} elseif ($filename === 'half_horizontal.liquid' || $filename === 'half_horizontal.blade.php') {
$halfHorizontalLiquidPath = $filepath;
} elseif ($filename === 'half_vertical.liquid' || $filename === 'half_vertical.blade.php') {
$halfVerticalLiquidPath = $filepath;
} elseif ($filename === 'quadrant.liquid' || $filename === 'quadrant.blade.php') {
$quadrantLiquidPath = $filepath;
} }
} }
@ -485,6 +603,25 @@ class PluginImportService
$sharedBladePath = $newSrcDir.'/shared.blade.php'; $sharedBladePath = $newSrcDir.'/shared.blade.php';
} }
// Copy layout-specific files if they exist
if ($halfHorizontalLiquidPath) {
$extension = pathinfo((string) $halfHorizontalLiquidPath, PATHINFO_EXTENSION);
File::copy($halfHorizontalLiquidPath, $newSrcDir.'/half_horizontal.'.$extension);
$halfHorizontalLiquidPath = $newSrcDir.'/half_horizontal.'.$extension;
}
if ($halfVerticalLiquidPath) {
$extension = pathinfo((string) $halfVerticalLiquidPath, PATHINFO_EXTENSION);
File::copy($halfVerticalLiquidPath, $newSrcDir.'/half_vertical.'.$extension);
$halfVerticalLiquidPath = $newSrcDir.'/half_vertical.'.$extension;
}
if ($quadrantLiquidPath) {
$extension = pathinfo((string) $quadrantLiquidPath, PATHINFO_EXTENSION);
File::copy($quadrantLiquidPath, $newSrcDir.'/quadrant.'.$extension);
$quadrantLiquidPath = $newSrcDir.'/quadrant.'.$extension;
}
// Update the paths // Update the paths
$settingsYamlPath = $newSrcDir.'/settings.yml'; $settingsYamlPath = $newSrcDir.'/settings.yml';
} }
@ -496,6 +633,9 @@ class PluginImportService
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath, 'sharedBladePath' => $sharedBladePath,
'halfHorizontalLiquidPath' => $halfHorizontalLiquidPath,
'halfVerticalLiquidPath' => $halfVerticalLiquidPath,
'quadrantLiquidPath' => $quadrantLiquidPath,
]; ];
} }

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->text('render_markup_half_horizontal')->nullable()->after('render_markup');
$table->text('render_markup_half_vertical')->nullable()->after('render_markup_half_horizontal');
$table->text('render_markup_quadrant')->nullable()->after('render_markup_half_vertical');
$table->text('render_markup_shared')->nullable()->after('render_markup_quadrant');
$table->text('transform_code')->nullable()->after('render_markup_shared');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn([
'render_markup_half_horizontal',
'render_markup_half_vertical',
'render_markup_quadrant',
'render_markup_shared',
'transform_code',
]);
});
}
};

View file

@ -258,7 +258,6 @@ new class extends Component
<div class="mb-4"> <div class="mb-4">
<flux:heading size="sm">Limitations</flux:heading> <flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2"> <ul class="list-disc pl-5 mt-2">
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
<li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li> <li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li>
<li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li> <li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li>
{{-- <ul class="list-disc pl-5 mt-2">--}} {{-- <ul class="list-disc pl-5 mt-2">--}}
@ -312,7 +311,6 @@ new class extends Component
<flux:callout class="mb-4 mt-4" color="yellow"> <flux:callout class="mb-4 mt-4" color="yellow">
<flux:heading size="sm">Limitations</flux:heading> <flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2"> <ul class="list-disc pl-5 mt-2">
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li> <li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li>
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li> <li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li> <li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>

View file

@ -65,6 +65,12 @@ new class extends Component
public string $preview_size = 'full'; public string $preview_size = 'full';
public array $markup_layouts = [];
public array $active_tabs = [];
public string $active_tab = 'full';
public function mount(): void public function mount(): void
{ {
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
@ -91,7 +97,24 @@ new class extends Component
$this->view_content = null; $this->view_content = null;
} }
} else { } else {
$this->markup_code = $this->plugin->render_markup; // Initialize layout markups from plugin columns
$this->markup_layouts = [
'full' => $this->plugin->render_markup ?? '',
'half_horizontal' => $this->plugin->render_markup_half_horizontal ?? '',
'half_vertical' => $this->plugin->render_markup_half_vertical ?? '',
'quadrant' => $this->plugin->render_markup_quadrant ?? '',
'shared' => $this->plugin->render_markup_shared ?? '',
];
// Set active tabs based on which layouts have content
$this->active_tabs = ['full']; // Full is always active
foreach (['half_horizontal', 'half_vertical', 'quadrant', 'shared'] as $layout) {
if (! empty($this->markup_layouts[$layout])) {
$this->active_tabs[] = $layout;
}
}
$this->markup_code = $this->markup_layouts['full'];
$this->markup_language = $this->plugin->markup_language ?? 'blade'; $this->markup_language = $this->plugin->markup_language ?? 'blade';
} }
@ -125,12 +148,108 @@ new class extends Component
{ {
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->validate(); $this->validate();
// Update markup_code for the active tab
if (isset($this->markup_layouts[$this->active_tab])) {
$this->markup_layouts[$this->active_tab] = $this->markup_code ?? '';
}
// Save all layout markups to respective columns
$this->plugin->update([ $this->plugin->update([
'render_markup' => $this->markup_code ?? null, 'render_markup' => $this->markup_layouts['full'] ?? null,
'render_markup_half_horizontal' => ! empty($this->markup_layouts['half_horizontal']) ? $this->markup_layouts['half_horizontal'] : null,
'render_markup_half_vertical' => ! empty($this->markup_layouts['half_vertical']) ? $this->markup_layouts['half_vertical'] : null,
'render_markup_quadrant' => ! empty($this->markup_layouts['quadrant']) ? $this->markup_layouts['quadrant'] : null,
'render_markup_shared' => ! empty($this->markup_layouts['shared']) ? $this->markup_layouts['shared'] : null,
'markup_language' => $this->markup_language ?? null, 'markup_language' => $this->markup_language ?? null,
]); ]);
} }
public function addLayoutTab(string $layout): void
{
if (! in_array($layout, $this->active_tabs, true)) {
$this->active_tabs[] = $layout;
if (! isset($this->markup_layouts[$layout])) {
$this->markup_layouts[$layout] = '';
}
$this->switchTab($layout);
}
}
public function removeLayoutTab(string $layout): void
{
if ($layout !== 'full') {
$this->active_tabs = array_values(array_filter($this->active_tabs, fn ($tab) => $tab !== $layout));
if (isset($this->markup_layouts[$layout])) {
$this->markup_layouts[$layout] = '';
}
if ($this->active_tab === $layout) {
$this->active_tab = 'full';
$this->markup_code = $this->markup_layouts['full'] ?? '';
}
}
}
public function switchTab(string $layout): void
{
if (in_array($layout, $this->active_tabs, true)) {
// Save current tab's content before switching
if (isset($this->markup_layouts[$this->active_tab])) {
$this->markup_layouts[$this->active_tab] = $this->markup_code ?? '';
}
$this->active_tab = $layout;
$this->markup_code = $this->markup_layouts[$layout] ?? '';
}
}
public function toggleLayoutTab(string $layout): void
{
if ($layout === 'full') {
return;
}
if (in_array($layout, $this->active_tabs, true)) {
$this->removeLayoutTab($layout);
} else {
$this->addLayoutTab($layout);
}
}
public function getAvailableLayouts(): array
{
return [
'half_horizontal' => 'Half Horizontal',
'half_vertical' => 'Half Vertical',
'quadrant' => 'Quadrant',
'shared' => 'Shared',
];
}
public function getLayoutLabel(string $layout): string
{
return match ($layout) {
'full' => $this->getFullTabLabel(),
'half_horizontal' => 'Half Horizontal',
'half_vertical' => 'Half Vertical',
'quadrant' => 'Quadrant',
'shared' => 'Shared',
default => ucfirst($layout),
};
}
public function getFullTabLabel(): string
{
// Return "Full" if any layout-specific markup exists, otherwise "Responsive"
if (! empty($this->markup_layouts['half_horizontal'])
|| ! empty($this->markup_layouts['half_vertical'])
|| ! empty($this->markup_layouts['quadrant'])) {
return 'Full';
}
return 'Responsive';
}
protected array $rules = [ protected array $rules = [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'data_stale_minutes' => 'required|integer|min:1', 'data_stale_minutes' => 'required|integer|min:1',
@ -1018,9 +1137,42 @@ HTML;
@if(!$plugin->render_markup_view) @if(!$plugin->render_markup_view)
<form wire:submit="saveMarkup"> <form wire:submit="saveMarkup">
<div class="mb-4"> <div class="mb-4">
<div>
<div class="flex items-end">
@foreach($active_tabs as $tab)
<button
type="button"
wire:click="switchTab('{{ $tab }}')"
class="tab-button {{ $active_tab === $tab ? 'is-active' : '' }}"
wire:key="tab-{{ $tab }}"
>
{{ $this->getLayoutLabel($tab) }}
</button>
@endforeach
<flux:dropdown>
<flux:button icon="plus" variant="ghost" size="sm" class="m-0.5"></flux:button>
<flux:menu>
@foreach($this->getAvailableLayouts() as $layout => $label)
<flux:menu.item wire:click="toggleLayoutTab('{{ $layout }}')">
<div class="flex items-center gap-2">
@if(in_array($layout, $active_tabs, true))
<flux:icon.check class="size-4" />
@else
<span class="inline-block w-4 h-4"></span>
@endif
<span>{{ $label }}</span>
</div>
</flux:menu.item>
@endforeach
</flux:menu>
</flux:dropdown>
</div>
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
<flux:field> <flux:field>
@php @php
$textareaId = 'code-' . uniqid(); $textareaId = 'code-' . $plugin->id;
@endphp @endphp
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label> <flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label>
<flux:textarea <flux:textarea
@ -1033,7 +1185,7 @@ HTML;
<div <div
x-data="codeEditorFormComponent({ x-data="codeEditorFormComponent({
isDisabled: false, isDisabled: false,
language: 'liquid', language: @js($markup_language === 'liquid' ? 'liquid' : 'html'),
state: $wire.entangle('markup_code'), state: $wire.entangle('markup_code'),
textareaId: @js($textareaId) textareaId: @js($textareaId)
})" })"
@ -1052,7 +1204,8 @@ HTML;
<div x-show="!isLoading" x-ref="editor" class="h-full"></div> <div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div> </div>
</flux:field> </flux:field>
</div>
</div>
</div> </div>
<div class="flex"> <div class="flex">

View file

@ -52,8 +52,10 @@ it('imports plugin with shared.liquid file', function (): void {
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user); $plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}') expect($plugin->render_markup_shared)->toBe('{% comment %}Shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">'); ->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Shared styles{% endcomment %}')
->and($plugin->getMarkupForSize('full'))->toContain('<div class="view view--{{ size }}">');
}); });
it('imports plugin with files in root directory', function (): void { it('imports plugin with files in root directory', function (): void {
@ -202,8 +204,10 @@ it('imports plugin from monorepo with shared.liquid in subdirectory', function (
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user); $plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->render_markup)->toContain('{% comment %}Monorepo shared styles{% endcomment %}') expect($plugin->render_markup_shared)->toBe('{% comment %}Monorepo shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">'); ->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Monorepo shared styles{% endcomment %}')
->and($plugin->getMarkupForSize('full'))->toContain('<div class="view view--{{ size }}">');
}); });
it('imports plugin from URL with zip_entry_path parameter', function (): void { it('imports plugin from URL with zip_entry_path parameter', function (): void {
@ -352,8 +356,10 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu
expect($plugin)->toBeInstanceOf(Plugin::class) expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->user_id)->toBe($user->id) ->and($plugin->user_id)->toBe($user->id)
->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin ->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin
->and($plugin->render_markup)->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}') ->and($plugin->render_markup_shared)->toBe('{% comment %}Plugin 2 shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>'); ->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>')
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}')
->and($plugin->getMarkupForSize('full'))->toContain('<div class="plugin2-content">Plugin 2 content</div>');
}); });
it('sets icon_url when importing from URL with iconUrl parameter', function (): void { it('sets icon_url when importing from URL with iconUrl parameter', function (): void {
@ -516,8 +522,8 @@ it('imports plugin with only shared.liquid file', function (): void {
expect($plugin)->toBeInstanceOf(Plugin::class) expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->markup_language)->toBe('liquid') ->and($plugin->markup_language)->toBe('liquid')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">') ->and($plugin->render_markup_shared)->toBe('<div class="shared-content">{{ data.title }}</div>')
->and($plugin->render_markup)->toContain('<div class="shared-content">{{ data.title }}</div>'); ->and($plugin->render_markup)->toBeNull();
}); });
it('imports plugin with only shared.blade.php file', function (): void { it('imports plugin with only shared.blade.php file', function (): void {
@ -535,8 +541,8 @@ it('imports plugin with only shared.blade.php file', function (): void {
expect($plugin)->toBeInstanceOf(Plugin::class) expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->markup_language)->toBe('blade') ->and($plugin->markup_language)->toBe('blade')
->and($plugin->render_markup)->toBe('<div class="shared-content">{{ $data["title"] }}</div>') ->and($plugin->render_markup_shared)->toBe('<div class="shared-content">{{ $data["title"] }}</div>')
->and($plugin->render_markup)->not->toContain('<div class="view view--{{ size }}">'); ->and($plugin->render_markup)->toBeNull();
}); });
// Helper methods // Helper methods