Compare commits

...

9 commits

Author SHA1 Message Date
Benjamin Nussbaum
35ca55a90b feat(#169): add mirroring section to device configuration
Some checks are pending
tests / ci (push) Waiting to run
2026-02-07 00:02:48 +01:00
Benjamin Nussbaum
e3ac975321 chore: revert bnussbau/laravel-trmnl-blade upgrade 2026-02-06 23:48:19 +01:00
Benjamin Nussbaum
8beeff754f feat: add update functionality for device palettes in UI 2026-02-06 23:42:14 +01:00
Benjamin Nussbaum
e71d79190a feat: add update functionality for device models in UI 2026-02-06 23:39:29 +01:00
Benjamin Nussbaum
06e6fb0e84 feat: add support for trmnl-liquid renderer in recipe settings 2026-02-06 23:24:07 +01:00
Benjamin Nussbaum
d586ecb1f2 chore: update dependencies 2026-02-06 22:55:08 +01:00
Benjamin Nussbaum
7ebfa586c1 feat: support additional markup layouts 2026-02-06 22:55:08 +01:00
Benjamin Nussbaum
a57feabe95 chore: bump trmnl-liquid-cli to 0.2.0 2026-02-06 22:09:03 +01:00
Benjamin Nussbaum
98c4d9f1bf docs: add trusted proxies 2026-02-06 18:02:44 +01:00
20 changed files with 764 additions and 173 deletions

1
.gitignore vendored
View file

@ -41,3 +41,4 @@ yarn-error.log
/.opencode /.opencode
/build.sh /build.sh
/.junie /.junie
/.agents

View file

@ -18,7 +18,7 @@ ENV TRMNL_LIQUID_ENABLED=1
# Switch to the root user so we can do root things # Switch to the root user so we can do root things
USER root USER root
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/ COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.2.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
# Set the working directory # Set the working directory
WORKDIR /var/www/html WORKDIR /var/www/html

View file

@ -122,6 +122,7 @@ php artisan db:seed --class=ExampleRecipesSeeder
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 | | `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` | | `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 | | `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
| `TRUSTED_PROXIES` | If your server handles SSL termination, allow mixed mode. e.g. `"172.0.0.0/8"` or `*` | null |
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 | | `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 | | `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC | | `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |

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';
File::put($tempDir.'/full.'.$extension, $fullTemplate);
// Generate shared.liquid if needed (for liquid templates) // Export full template if it exists
if ($plugin->markup_language === 'liquid') { if ($plugin->render_markup) {
$sharedTemplate = $this->generateSharedTemplate(); $fullTemplate = $this->generateLayoutTemplate($plugin->render_markup);
/** @phpstan-ignore-next-line */ File::put($tempDir.'/full.'.$extension, $fullTemplate);
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 // Read full markup (don't prepend shared - it will be prepended at render time)
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { $fullLiquid = null;
$sharedLiquid = File::get($filePaths['sharedLiquidPath']); if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
$fullLiquid = $sharedLiquid."\n".$fullLiquid; $fullLiquid = File::get($filePaths['fullLiquidPath']);
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { if ($markupLanguage === 'liquid') {
$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>'; $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 // Read full markup (don't prepend shared - it will be prepended at render time)
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { $fullLiquid = null;
$sharedLiquid = File::get($filePaths['sharedLiquidPath']); if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
$fullLiquid = $sharedLiquid."\n".$fullLiquid; $fullLiquid = File::get($filePaths['fullLiquidPath']);
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) { if ($markupLanguage === 'liquid') {
$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>'; $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

@ -15,7 +15,7 @@
"ext-imagick": "*", "ext-imagick": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"ext-zip": "*", "ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.2.*", "bnussbau/laravel-trmnl-blade": "2.1.1",
"bnussbau/trmnl-pipeline-php": "^0.6.0", "bnussbau/trmnl-pipeline-php": "^0.6.0",
"keepsuit/laravel-liquid": "^0.5.2", "keepsuit/laravel-liquid": "^0.5.2",
"laravel/fortify": "^1.30", "laravel/fortify": "^1.30",

74
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "581bacf794841fc11c540e152c704d16", "content-hash": "60a7e51edd8408cffdb901e4a1c1684a",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.369.27", "version": "3.369.29",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "f844afab2a74eb3cf881970a9c31de460510eb74" "reference": "068195b2980cf5cf4ade2515850d461186db3310"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f844afab2a74eb3cf881970a9c31de460510eb74", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/068195b2980cf5cf4ade2515850d461186db3310",
"reference": "f844afab2a74eb3cf881970a9c31de460510eb74", "reference": "068195b2980cf5cf4ade2515850d461186db3310",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -153,9 +153,9 @@
"support": { "support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions", "forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.369.27" "source": "https://github.com/aws/aws-sdk-php/tree/3.369.29"
}, },
"time": "2026-02-04T19:07:08+00:00" "time": "2026-02-06T19:08:50+00:00"
}, },
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -214,16 +214,16 @@
}, },
{ {
"name": "bnussbau/laravel-trmnl-blade", "name": "bnussbau/laravel-trmnl-blade",
"version": "2.2.1", "version": "2.1.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/bnussbau/laravel-trmnl-blade.git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git",
"reference": "6db8a82a15ccedcaaffd3b37d0d337d276a26669" "reference": "6ad96eba917ebc30ebe550e6fce4a995e94f6b35"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/6db8a82a15ccedcaaffd3b37d0d337d276a26669", "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/6ad96eba917ebc30ebe550e6fce4a995e94f6b35",
"reference": "6db8a82a15ccedcaaffd3b37d0d337d276a26669", "reference": "6ad96eba917ebc30ebe550e6fce4a995e94f6b35",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -278,7 +278,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues",
"source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.2.1" "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.1"
}, },
"funding": [ "funding": [
{ {
@ -294,7 +294,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-02-05T17:57:37+00:00" "time": "2026-01-29T20:40:42+00:00"
}, },
{ {
"name": "bnussbau/trmnl-pipeline-php", "name": "bnussbau/trmnl-pipeline-php",
@ -3194,16 +3194,16 @@
}, },
{ {
"name": "livewire/livewire", "name": "livewire/livewire",
"version": "v4.1.2", "version": "v4.1.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/livewire/livewire.git", "url": "https://github.com/livewire/livewire.git",
"reference": "8adef21f35f4ffa87fd2f3655b350236df0c39a8" "reference": "69c871cb15fb95f10cda5acd1ee7e63cd3c494c8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/livewire/livewire/zipball/8adef21f35f4ffa87fd2f3655b350236df0c39a8", "url": "https://api.github.com/repos/livewire/livewire/zipball/69c871cb15fb95f10cda5acd1ee7e63cd3c494c8",
"reference": "8adef21f35f4ffa87fd2f3655b350236df0c39a8", "reference": "69c871cb15fb95f10cda5acd1ee7e63cd3c494c8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3258,7 +3258,7 @@
"description": "A front-end framework for Laravel.", "description": "A front-end framework for Laravel.",
"support": { "support": {
"issues": "https://github.com/livewire/livewire/issues", "issues": "https://github.com/livewire/livewire/issues",
"source": "https://github.com/livewire/livewire/tree/v4.1.2" "source": "https://github.com/livewire/livewire/tree/v4.1.3"
}, },
"funding": [ "funding": [
{ {
@ -3266,7 +3266,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-02-03T03:01:29+00:00" "time": "2026-02-06T12:19:55+00:00"
}, },
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
@ -9066,16 +9066,16 @@
}, },
{ {
"name": "laravel/boost", "name": "laravel/boost",
"version": "v2.0.6", "version": "v2.1.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/boost.git", "url": "https://github.com/laravel/boost.git",
"reference": "1e1cb76e8e87ca3dd3c3d64deccbc97f4de38215" "reference": "1c7d6f44c96937a961056778b9143218b1183302"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/1e1cb76e8e87ca3dd3c3d64deccbc97f4de38215", "url": "https://api.github.com/repos/laravel/boost/zipball/1c7d6f44c96937a961056778b9143218b1183302",
"reference": "1e1cb76e8e87ca3dd3c3d64deccbc97f4de38215", "reference": "1c7d6f44c96937a961056778b9143218b1183302",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -9128,7 +9128,7 @@
"issues": "https://github.com/laravel/boost/issues", "issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost" "source": "https://github.com/laravel/boost"
}, },
"time": "2026-02-04T10:10:48+00:00" "time": "2026-02-06T10:41:29+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
@ -10484,16 +10484,16 @@
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "12.5.2", "version": "12.5.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d",
"reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -10549,7 +10549,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3"
}, },
"funding": [ "funding": [
{ {
@ -10569,7 +10569,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-24T07:03:04+00:00" "time": "2026-02-06T06:01:44+00:00"
}, },
{ {
"name": "phpunit/php-file-iterator", "name": "phpunit/php-file-iterator",
@ -10935,21 +10935,21 @@
}, },
{ {
"name": "rector/rector", "name": "rector/rector",
"version": "2.3.5", "version": "2.3.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/rectorphp/rector.git", "url": "https://github.com/rectorphp/rector.git",
"reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070" "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/9442f4037de6a5347ae157fe8e6c7cda9d909070", "url": "https://api.github.com/repos/rectorphp/rector/zipball/ca9ebb81d280cd362ea39474dabd42679e32ca6b",
"reference": "9442f4037de6a5347ae157fe8e6c7cda9d909070", "reference": "ca9ebb81d280cd362ea39474dabd42679e32ca6b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.4|^8.0", "php": "^7.4|^8.0",
"phpstan/phpstan": "^2.1.36" "phpstan/phpstan": "^2.1.38"
}, },
"conflict": { "conflict": {
"rector/rector-doctrine": "*", "rector/rector-doctrine": "*",
@ -10983,7 +10983,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/rectorphp/rector/issues", "issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.3.5" "source": "https://github.com/rectorphp/rector/tree/2.3.6"
}, },
"funding": [ "funding": [
{ {
@ -10991,7 +10991,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-01-28T15:22:48+00:00" "time": "2026-02-06T14:25:06+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",

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

@ -1,5 +1,6 @@
<?php <?php
use App\Jobs\FetchDeviceModelsJob;
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\DevicePalette; use App\Models\DevicePalette;
use Livewire\Component; use Livewire\Component;
@ -66,6 +67,14 @@ new class extends Component
public $viewingDeviceModelId; public $viewingDeviceModelId;
public function updateFromApi(): void
{
FetchDeviceModelsJob::dispatchSync();
$this->deviceModels = DeviceModel::all();
$this->devicePalettes = DevicePalette::all();
session()->flash('message', 'Device models updated from API.');
}
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
{ {
if ($deviceModelId) { if ($deviceModelId) {
@ -229,9 +238,17 @@ new class extends Component
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
</div> </div>
<flux:modal.trigger name="device-model-modal"> <flux:button.group>
<flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button> <flux:modal.trigger name="device-model-modal">
</flux:modal.trigger> <flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button>
</flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:menu.item icon="arrow-path" wire:click="updateFromApi">Update from Models API</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div> </div>
@if (session()->has('message')) @if (session()->has('message'))
<div class="mb-4"> <div class="mb-4">

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Jobs\FetchDeviceModelsJob;
use App\Models\DevicePalette; use App\Models\DevicePalette;
use Livewire\Component; use Livewire\Component;
@ -58,6 +59,13 @@ new class extends Component
public $viewingDevicePaletteId; public $viewingDevicePaletteId;
public function updateFromApi(): void
{
FetchDeviceModelsJob::dispatchSync();
$this->devicePalettes = DevicePalette::all();
session()->flash('message', 'Device palettes updated from API.');
}
public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void
{ {
if ($devicePaletteId) { if ($devicePaletteId) {
@ -202,9 +210,17 @@ new class extends Component
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
</div> </div>
<flux:modal.trigger name="device-palette-modal"> <flux:button.group>
<flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button> <flux:modal.trigger name="device-palette-modal">
</flux:modal.trigger> <flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button>
</flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:menu.item icon="arrow-path" wire:click="updateFromApi">Update from API</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div> </div>
@if (session()->has('message')) @if (session()->has('message'))
<div class="mb-4"> <div class="mb-4">

View file

@ -31,6 +31,10 @@ new class extends Component
public $device_model_id; public $device_model_id;
public $is_mirror = false;
public $mirror_device_id = null;
// Signal to device to use high compatibility approaches when redrawing content // Signal to device to use high compatibility approaches when redrawing content
public $maximum_compatibility = false; public $maximum_compatibility = false;
@ -98,6 +102,8 @@ new class extends Component
$this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i'); $this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i');
$this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i'); $this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i');
$this->special_function = $device->special_function; $this->special_function = $device->special_function;
$this->is_mirror = $device->mirror_device_id !== null;
$this->mirror_device_id = $device->mirror_device_id;
return view('livewire.devices.configure', [ return view('livewire.devices.configure', [
'image' => ($current_image_uuid) ? url($current_image_path) : null, 'image' => ($current_image_uuid) ? url($current_image_path) : null,
@ -145,6 +151,7 @@ new class extends Component
'rotate' => 'required|integer|min:0|max:359', 'rotate' => 'required|integer|min:0|max:359',
'image_format' => 'required|string', 'image_format' => 'required|string',
'device_model_id' => 'nullable|exists:device_models,id', 'device_model_id' => 'nullable|exists:device_models,id',
'mirror_device_id' => 'required_if:is_mirror,true',
'maximum_compatibility' => 'boolean', 'maximum_compatibility' => 'boolean',
'sleep_mode_enabled' => 'boolean', 'sleep_mode_enabled' => 'boolean',
'sleep_mode_from' => 'nullable|date_format:H:i', 'sleep_mode_from' => 'nullable|date_format:H:i',
@ -152,6 +159,13 @@ new class extends Component
'special_function' => 'nullable|string', 'special_function' => 'nullable|string',
]); ]);
if ($this->is_mirror) {
$mirrorDevice = auth()->user()->devices()->find($this->mirror_device_id);
abort_unless($mirrorDevice, 403, 'Invalid mirror device selected');
abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device');
abort_if((int) $this->mirror_device_id === (int) $this->device->id, 403, 'Device cannot mirror itself');
}
// Convert empty string to null for custom selection // Convert empty string to null for custom selection
$deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id; $deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id;
@ -165,6 +179,7 @@ new class extends Component
'rotate' => $this->rotate, 'rotate' => $this->rotate,
'image_format' => $this->image_format, 'image_format' => $this->image_format,
'device_model_id' => $deviceModelId, 'device_model_id' => $deviceModelId,
'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null,
'maximum_compatibility' => $this->maximum_compatibility, 'maximum_compatibility' => $this->maximum_compatibility,
'sleep_mode_enabled' => $this->sleep_mode_enabled, 'sleep_mode_enabled' => $this->sleep_mode_enabled,
'sleep_mode_from' => $this->sleep_mode_from, 'sleep_mode_from' => $this->sleep_mode_from,
@ -433,6 +448,18 @@ new class extends Component
@endforeach @endforeach
</flux:select> </flux:select>
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device"/>
@if($is_mirror)
<flux:select wire:model="mirror_device_id" label="Select Device to Mirror">
<flux:select.option value="">Select a device</flux:select.option>
@foreach(auth()->user()->devices->where('mirror_device_id', null)->where('id', '!=', $device->id) as $mirrorOption)
<flux:select.option value="{{ $mirrorOption->id }}">
{{ $mirrorOption->name }} ({{ $mirrorOption->friendly_id }})
</flux:select.option>
@endforeach
</flux:select>
@endif
<flux:checkbox wire:model="maximum_compatibility" label="Maximum Compatibility" description="Resolves display issues caused by certain e-ink driver chips. Disables fast refresh. TRMNL Firmware 1.6.0+ required." /> <flux:checkbox wire:model="maximum_compatibility" label="Maximum Compatibility" description="Resolves display issues caused by certain e-ink driver chips. Disables fast refresh. TRMNL Firmware 1.6.0+ required." />
@if(empty($device_model_id)) @if(empty($device_model_id))

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,41 +1137,75 @@ 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">
<flux:field> <div>
@php <div class="flex items-end">
$textareaId = 'code-' . uniqid(); @foreach($active_tabs as $tab)
@endphp <button
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label> type="button"
<flux:textarea wire:click="switchTab('{{ $tab }}')"
wire:model="markup_code" class="tab-button {{ $active_tab === $tab ? 'is-active' : '' }}"
id="{{ $textareaId }}" wire:key="tab-{{ $tab }}"
placeholder="Enter your HTML code here..." >
rows="25" {{ $this->getLayoutLabel($tab) }}
hidden </button>
/> @endforeach
<div
x-data="codeEditorFormComponent({
isDisabled: false,
language: 'liquid',
state: $wire.entangle('markup_code'),
textareaId: @js($textareaId)
})"
wire:ignore
wire:key="cm-{{ $textareaId }}"
class="min-h-[300px] h-[300px] overflow-hidden resize-y"
>
<!-- Loading state -->
<div x-show="isLoading" class="flex items-center justify-center h-full">
<div class="flex items-center space-x-2">
<flux:icon.loading />
</div>
</div>
<!-- Editor container --> <flux:dropdown>
<div x-show="!isLoading" x-ref="editor" class="h-full"></div> <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>
</flux:field>
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
<flux:field>
@php
$textareaId = 'code-' . $plugin->id;
@endphp
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label>
<flux:textarea
wire:model="markup_code"
id="{{ $textareaId }}"
placeholder="Enter your HTML code here..."
rows="25"
hidden
/>
<div
x-data="codeEditorFormComponent({
isDisabled: false,
language: @js($markup_language === 'liquid' ? 'liquid' : 'html'),
state: $wire.entangle('markup_code'),
textareaId: @js($textareaId)
})"
wire:ignore
wire:key="cm-{{ $textareaId }}"
class="min-h-[300px] h-[300px] overflow-hidden resize-y"
>
<!-- Loading state -->
<div x-show="isLoading" class="flex items-center justify-center h-full">
<div class="flex items-center space-x-2">
<flux:icon.loading />
</div>
</div>
<!-- Editor container -->
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div>
</flux:field>
</div>
</div>
</div> </div>
<div class="flex"> <div class="flex">

View file

@ -17,6 +17,8 @@ new class extends Component
public bool $alias = false; public bool $alias = false;
public bool $use_trmnl_liquid_renderer = false;
public int $resetIndex = 0; public int $resetIndex = 0;
public function mount(): void public function mount(): void
@ -27,6 +29,7 @@ new class extends Component
$this->trmnlp_id = $this->plugin->trmnlp_id; $this->trmnlp_id = $this->plugin->trmnlp_id;
$this->uuid = $this->plugin->uuid; $this->uuid = $this->plugin->uuid;
$this->alias = $this->plugin->alias ?? false; $this->alias = $this->plugin->alias ?? false;
$this->use_trmnl_liquid_renderer = $this->plugin->preferred_renderer === 'trmnl-liquid';
} }
public function saveTrmnlpId(): void public function saveTrmnlpId(): void
@ -43,11 +46,13 @@ new class extends Component
->ignore($this->plugin->id), ->ignore($this->plugin->id),
], ],
'alias' => 'boolean', 'alias' => 'boolean',
'use_trmnl_liquid_renderer' => 'boolean',
]); ]);
$this->plugin->update([ $this->plugin->update([
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
'alias' => $this->alias, 'alias' => $this->alias,
'preferred_renderer' => $this->use_trmnl_liquid_renderer ? 'trmnl-liquid' : null,
]); ]);
Flux::modal('trmnlp-settings')->close(); Flux::modal('trmnlp-settings')->close();
@ -83,6 +88,16 @@ new class extends Component
<flux:description>Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. <a href="https://help.usetrmnl.com/en/articles/10701448-alias-plugin">Docs</a></flux:description> <flux:description>Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. <a href="https://help.usetrmnl.com/en/articles/10701448-alias-plugin">Docs</a></flux:description>
</flux:field> </flux:field>
@if(config('services.trmnl.liquid_enabled') && $plugin->markup_language === 'liquid')
<flux:field>
<flux:checkbox
wire:model.live="use_trmnl_liquid_renderer"
label="Use trmnl-liquid renderer"
/>
<flux:description>trmnl-liquid is a Ruby-based renderer that matches the Core services Liquid behavior for better compatibility.</flux:description>
</flux:field>
@endif
@if($alias) @if($alias)
<flux:field> <flux:field>
<flux:label>Alias URL</flux:label> <flux:label>Alias URL</flux:label>

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
it('allows a user to view the device models page', function (): void { it('allows a user to view the device models page', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
@ -87,3 +89,38 @@ it('redirects unauthenticated users from the device models page', function (): v
$response->assertRedirect('/login'); $response->assertRedirect('/login');
}); });
it('update from API runs job and refreshes device models', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [
[
'name' => 'api-model',
'label' => 'API Model',
'description' => 'From API',
'width' => 800,
'height' => 480,
'colors' => 4,
'bit_depth' => 2,
'scale_factor' => 1.0,
'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'kind' => 'trmnl',
'published_at' => '2023-01-01T00:00:00Z',
],
],
], 200),
]);
$component = Livewire::test('device-models.index')
->call('updateFromApi');
$deviceModels = $component->get('deviceModels');
expect($deviceModels->pluck('name')->toArray())->toContain('api-model');
});

View file

@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\Device; use App\Models\Device;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
@ -23,3 +24,35 @@ test('configure view displays last_refreshed_at timestamp', function (): void {
$response->assertOk() $response->assertOk()
->assertSee('5 minutes ago'); ->assertSee('5 minutes ago');
}); });
test('configure edit modal shows mirror checkbox and allows unchecking mirror', function (): void {
$user = User::factory()->create();
actingAs($user);
$deviceAttributes = [
'user_id' => $user->id,
'width' => 800,
'height' => 480,
'rotate' => 0,
'image_format' => 'png',
'maximum_compatibility' => false,
];
$sourceDevice = Device::factory()->create($deviceAttributes);
$mirrorDevice = Device::factory()->create([
...$deviceAttributes,
'mirror_device_id' => $sourceDevice->id,
]);
$response = $this->get(route('devices.configure', $mirrorDevice));
$response->assertOk()
->assertSee('Mirrors Device')
->assertSee('Select Device to Mirror');
Livewire::test('devices.configure', ['device' => $mirrorDevice])
->set('is_mirror', false)
->call('updateDevice')
->assertHasNoErrors();
$mirrorDevice->refresh();
expect($mirrorDevice->mirror_device_id)->toBeNull();
});

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Models\DevicePalette; use App\Models\DevicePalette;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -570,3 +571,29 @@ test('component refreshes palette list after deleting', function (): void {
expect($palettes)->toHaveCount($initialCount + 1); expect($palettes)->toHaveCount($initialCount + 1);
expect(DevicePalette::count())->toBe($initialCount + 1); expect(DevicePalette::count())->toBe($initialCount + 1);
}); });
test('update from API runs job and refreshes device palettes', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
Http::fake([
'usetrmnl.com/api/palettes' => Http::response([
'data' => [
[
'id' => 'api-palette',
'name' => 'API Palette',
'grays' => 4,
'colors' => null,
'framework_class' => '',
],
],
], 200),
config('services.trmnl.base_url').'/api/models' => Http::response(['data' => []], 200),
]);
$component = Livewire::test('device-palettes.index')
->call('updateFromApi');
$devicePalettes = $component->get('devicePalettes');
expect($devicePalettes->pluck('name')->toArray())->toContain('api-palette');
});

View file

@ -109,3 +109,43 @@ test('recipe settings can clear trmnlp_id', function (): void {
expect($plugin->fresh()->trmnlp_id)->toBeNull(); expect($plugin->fresh()->trmnlp_id)->toBeNull();
}); });
test('recipe settings saves preferred_renderer when liquid enabled and recipe is liquid', function (): void {
config(['services.trmnl.liquid_enabled' => true]);
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'preferred_renderer' => null,
]);
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('use_trmnl_liquid_renderer', true)
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->preferred_renderer)->toBe('trmnl-liquid');
});
test('recipe settings clears preferred_renderer when checkbox unchecked', function (): void {
config(['services.trmnl.liquid_enabled' => true]);
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'preferred_renderer' => 'trmnl-liquid',
]);
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('use_trmnl_liquid_renderer', false)
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->preferred_renderer)->toBeNull();
});

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