feat: recipes zip import support, add trmnlp compatible recipe configuration

* recipes zip import support
* add trmnlp compatible recipe configuration
* support for multiple polling urls
This commit is contained in:
Benjamin Nussbaum 2025-06-13 12:23:52 +02:00
parent a927c0fb97
commit 5d39fe89e3
17 changed files with 2409 additions and 125 deletions

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Liquid\FileSystems;
use Keepsuit\Liquid\Contracts\LiquidFileSystem;
/**
* A file system that allows registering inline templates defined with the template tag
*/
class InlineTemplatesFileSystem implements LiquidFileSystem
{
/**
* @var array<string, string>
*/
protected array $templates = [];
/**
* Register a template with the given name and content
*/
public function register(string $name, string $content): void
{
$this->templates[$name] = $content;
}
/**
* Check if a template exists
*/
public function hasTemplate(string $templateName): bool
{
return isset($this->templates[$templateName]);
}
/**
* Get all registered template names
*
* @return array<string>
*/
public function getTemplateNames(): array
{
return array_keys($this->templates);
}
/**
* Clear all registered templates
*/
public function clear(): void
{
$this->templates = [];
}
public function readTemplateFile(string $templateName): string
{
if (!isset($this->templates[$templateName])) {
throw new \InvalidArgumentException("Template '{$templateName}' not found in inline templates");
}
return $this->templates[$templateName];
}
}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Liquid\Tags;
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
use Keepsuit\Liquid\Exceptions\SyntaxException;
use Keepsuit\Liquid\Nodes\BodyNode;
use Keepsuit\Liquid\Nodes\Raw;
use Keepsuit\Liquid\Nodes\VariableLookup;
use Keepsuit\Liquid\Parse\TagParseContext;
use Keepsuit\Liquid\Render\RenderContext;
use Keepsuit\Liquid\TagBlock;
/**
* The {% template [name] %} tag block is used to define custom templates within the context of the current Liquid template.
* These templates are registered with the InlineTemplatesFileSystem and can be rendered using the render tag.
*/
class TemplateTag extends TagBlock
{
protected string $templateName;
protected Raw $body;
public static function tagName(): string
{
return 'template';
}
public static function hasRawBody(): bool
{
return true;
}
public function parse(TagParseContext $context): static
{
// Get the template name from the tag parameters
$templateNameExpression = $context->params->expression();
$this->templateName = match (true) {
is_string($templateNameExpression) => trim($templateNameExpression),
is_numeric($templateNameExpression) => (string) $templateNameExpression,
$templateNameExpression instanceof VariableLookup => (string) $templateNameExpression,
default => throw new SyntaxException("Template name must be a string, number, or variable"),
};
// Validate template name (letters, numbers, underscores, and slashes only)
if (!preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) {
throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes");
}
$context->params->assertEnd();
assert($context->body instanceof BodyNode);
$body = $context->body->children()[0] ?? null;
$this->body = match (true) {
$body instanceof Raw => $body,
default => throw new SyntaxException('template tag must have a single raw body'),
};
// Register the template with the file system during parsing
$fileSystem = $context->getParseContext()->environment->fileSystem;
if ($fileSystem instanceof InlineTemplatesFileSystem) {
// Store the raw content for later rendering
$fileSystem->register($this->templateName, $this->body->value);
}
return $this;
}
public function render(RenderContext $context): string
{
// Get the file system from the environment
$fileSystem = $context->environment->fileSystem;
if (!$fileSystem instanceof InlineTemplatesFileSystem) {
// If no inline file system is available, just return empty string
// This allows the template to be used in contexts where inline templates aren't supported
return '';
}
// Register the template with the file system
$fileSystem->register($this->templateName, $this->body->render($context));
// Return empty string as template tags don't output anything
return '';
}
public function getTemplateName(): string
{
return $this->templateName;
}
public function getBody(): Raw
{
return $this->body;
}
}

View file

@ -2,18 +2,23 @@
namespace App\Models; namespace App\Models;
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
use App\Liquid\Filters\Data; use App\Liquid\Filters\Data;
use App\Liquid\Filters\Localization; use App\Liquid\Filters\Localization;
use App\Liquid\Filters\Numbers; use App\Liquid\Filters\Numbers;
use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness; use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Extensions\StandardExtension;
class Plugin extends Model class Plugin extends Model
{ {
@ -26,6 +31,8 @@ class Plugin extends Model
'data_payload_updated_at' => 'datetime', 'data_payload_updated_at' => 'datetime',
'is_native' => 'boolean', 'is_native' => 'boolean',
'markup_language' => 'string', 'markup_language' => 'string',
'configuration' => 'json',
'configuration_template' => 'json',
]; ];
protected static function boot() protected static function boot()
@ -39,6 +46,49 @@ class Plugin extends Model
}); });
} }
public function user()
{
return $this->belongsTo(User::class);
}
public function hasMissingRequiredConfigurationFields(): bool
{
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
return false;
}
foreach ($this->configuration_template['custom_fields'] as $field) {
// Skip fields as they are informational only
if ($field['field_type'] === 'author_bio') {
continue;
}
if ($field['field_type'] === 'copyable') {
continue;
}
if ($field['field_type'] === 'copyable_webhook_url') {
continue;
}
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
// Check if field is required (not marked as optional)
$isRequired = ! isset($field['optional']) || $field['optional'] !== true;
if ($isRequired) {
$currentValue = $this->configuration[$fieldKey] ?? null;
// If the field has a default value and no current value is set, it's not missing
if (($currentValue === null || $currentValue === '' || (is_array($currentValue) && empty($currentValue))) && ! isset($field['default'])) {
return true; // Found a required field that is not set and has no default
}
}
}
return false; // All required fields are set
}
public function isDataStale(): bool public function isDataStale(): bool
{ {
if ($this->data_strategy === 'webhook') { if ($this->data_strategy === 'webhook') {
@ -59,7 +109,9 @@ class Plugin extends Model
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
if ($this->polling_header) { if ($this->polling_header) {
$headerLines = explode("\n", trim($this->polling_header)); // Resolve Liquid variables in the polling header
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", trim($resolvedHeader));
foreach ($headerLines as $line) { foreach ($headerLines as $line) {
$parts = explode(':', $line, 2); $parts = explode(':', $line, 2);
if (count($parts) === 2) { if (count($parts) === 2) {
@ -68,26 +120,138 @@ class Plugin extends Model
} }
} }
$httpRequest = Http::withHeaders($headers); // Split URLs by newline and filter out empty lines
$urls = array_filter(
array_map('trim', explode("\n", $this->polling_url)),
fn ($url) => ! empty($url)
);
if ($this->polling_verb === 'post' && $this->polling_body) { // If only one URL, use the original logic without nesting
$httpRequest = $httpRequest->withBody($this->polling_body); if (count($urls) === 1) {
$url = reset($urls);
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try {
// Make the request based on the verb
if ($this->polling_verb === 'post') {
$response = $httpRequest->post($resolvedUrl)->json();
} else {
$response = $httpRequest->get($resolvedUrl)->json();
}
$this->update([
'data_payload' => $response,
'data_payload_updated_at' => now(),
]);
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$this->update([
'data_payload' => ['error' => 'Failed to fetch data'],
'data_payload_updated_at' => now(),
]);
}
return;
} }
// Make the request based on the verb // Multiple URLs - use nested response logic
if ($this->polling_verb === 'post') { $combinedResponse = [];
$response = $httpRequest->post($this->polling_url)->json();
} else { foreach ($urls as $index => $url) {
$response = $httpRequest->get($this->polling_url)->json(); $httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try {
// Make the request based on the verb
if ($this->polling_verb === 'post') {
$response = $httpRequest->post($resolvedUrl)->json();
} else {
$response = $httpRequest->get($resolvedUrl)->json();
}
// Check if response is an array at root level
if (is_array($response) && array_keys($response) === range(0, count($response) - 1)) {
// Response is a sequential array, nest under .data
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
// Response is an object or associative array, keep as is
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
// Log error and continue with other URLs
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
} }
$this->update([ $this->update([
'data_payload' => $response, 'data_payload' => $combinedResponse,
'data_payload_updated_at' => now(), 'data_payload_updated_at' => now(),
]); ]);
} }
} }
/**
* Apply Liquid template replacements (converts 'with' syntax to comma syntax)
*/
private function applyLiquidReplacements(string $template): string
{
$replacements = [
'date: "%N"' => 'date: "u"',
'%-m/%-d/%Y' => 'm/d/Y',
];
// Apply basic replacements
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
$template = preg_replace(
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
'{% render $1, ',
$template
);
return $template;
}
/**
* Resolve Liquid variables in a template string using the Liquid template engine
*
* @param string $template The template string containing Liquid variables
* @return string The resolved template with variables replaced with their values
*
* @throws LiquidException
*/
public function resolveLiquidVariables(string $template): string
{
// Get configuration variables - make them available at root level
$variables = $this->configuration ?? [];
// Use the Liquid template engine to resolve variables
$environment = App::make('liquid.environment');
$liquidTemplate = $environment->parseString($template);
$context = $environment->newRenderContext(data: $variables);
return $liquidTemplate->render($context);
}
/** /**
* Render the plugin's markup * Render the plugin's markup
* *
@ -99,7 +263,12 @@ class Plugin extends Model
$renderedContent = ''; $renderedContent = '';
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
$environment = App::make('liquid.environment'); // Create a custom environment with inline templates support
$inlineFileSystem = new InlineTemplatesFileSystem();
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension()]
);
// Register all custom filters // Register all custom filters
$environment->filterRegistry->register(Numbers::class); $environment->filterRegistry->register(Numbers::class);
@ -108,11 +277,47 @@ class Plugin extends Model
$environment->filterRegistry->register(Uniqueness::class); $environment->filterRegistry->register(Uniqueness::class);
$environment->filterRegistry->register(Localization::class); $environment->filterRegistry->register(Localization::class);
$template = $environment->parseString($this->render_markup); // Register the template tag for inline templates
$context = $environment->newRenderContext(data: ['size' => $size, 'data' => $this->data_payload]); $environment->tagRegistry->register(TemplateTag::class);
// Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
$template = $environment->parseString($processedMarkup);
$context = $environment->newRenderContext(
data: [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
...(is_array($this->data_payload) ? $this->data_payload : []),
'trmnl' => [
'user' => [
'utc_offset' => '0',
'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en',
'time_zone_iana' => config('app.timezone'),
],
'plugin_settings' => [
'instance_name' => $this->name,
'strategy' => $this->data_strategy,
'dark_mode' => 'no',
'no_screen_padding' => 'no',
'polling_headers' => $this->polling_header,
'polling_url' => $this->polling_url,
'custom_fields_values' => [
...(is_array($this->configuration) ? $this->configuration : []),
],
],
],
]
);
$renderedContent = $template->render($context); $renderedContent = $template->render($context);
} else { } else {
$renderedContent = Blade::render($this->render_markup, ['size' => $size, 'data' => $this->data_payload]); $renderedContent = Blade::render($this->render_markup, [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
]);
} }
if ($standalone) { if ($standalone) {
@ -130,6 +335,7 @@ class Plugin extends Model
'slot' => view($this->render_markup_view, [ 'slot' => view($this->render_markup_view, [
'size' => $size, 'size' => $size,
'data' => $this->data_payload, 'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render(), ])->render(),
])->render(); ])->render();
} }
@ -137,10 +343,19 @@ class Plugin extends Model
return view($this->render_markup_view, [ return view($this->render_markup_view, [
'size' => $size, 'size' => $size,
'data' => $this->data_payload, 'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render(); ])->render();
} }
return '<p>No render markup yet defined for this plugin.</p>'; return '<p>No render markup yet defined for this plugin.</p>';
} }
/**
* Get a configuration value by key
*/
public function getConfiguration(string $key, $default = null)
{
return $this->configuration[$key] ?? $default;
}
} }

View file

@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use App\Services\OidcProvider; use App\Services\OidcProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
@ -26,6 +27,17 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https'); URL::forceScheme('https');
} }
Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) {
$https = clone $this;
$https->server->set('HTTPS', 'on');
$http = clone $this;
$http->server->set('HTTPS', 'off');
return URL::hasValidSignature($https, $absolute, $ignoreQuery)
|| URL::hasValidSignature($http, $absolute, $ignoreQuery);
});
// Register OIDC provider with Socialite // Register OIDC provider with Socialite
Socialite::extend('oidc', function ($app) { Socialite::extend('oidc', function ($app) {
$config = $app['config']['services.oidc'] ?? []; $config = $app['config']['services.oidc'] ?? [];

View file

@ -0,0 +1,206 @@
<?php
namespace App\Services;
use App\Models\Plugin;
use App\Models\User;
use Exception;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Symfony\Component\Yaml\Yaml;
use ZipArchive;
class PluginImportService
{
/**
* Import a plugin from a ZIP file
*
* @param UploadedFile $zipFile The uploaded ZIP file
* @param User $user The user importing the plugin
* @return Plugin The created plugin instance
*
* @throws Exception If the ZIP file is invalid or required files are missing
*/
public function importFromZip(UploadedFile $zipFile, User $user): Plugin
{
// Create a temporary directory using Laravel's temporary directory helper
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
Storage::makeDirectory($tempDirName);
$tempDir = Storage::path($tempDirName);
try {
// Get the real path of the temporary file
$zipFullPath = $zipFile->getRealPath();
// Extract the ZIP file using ZipArchive
$zip = new ZipArchive();
if ($zip->open($zipFullPath) !== true) {
throw new Exception('Could not open the ZIP file.');
}
$zip->extractTo($tempDir);
$zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php)
$filePaths = $this->findRequiredFiles($tempDir);
// Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
}
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
// Read full.liquid content
$fullLiquid = File::get($filePaths['fullLiquidPath']);
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
// Prepend shared.liquid content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
$markupLanguage = 'blade';
if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
}
// Ensure custom_fields is properly formatted
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
$settings['custom_fields'] = [];
}
// Create configuration template with the custom fields
$configurationTemplate = [
'custom_fields' => $settings['custom_fields'],
];
// Extract default values from custom_fields and populate configuration
$configuration = [];
if (isset($settings['custom_fields']) && is_array($settings['custom_fields'])) {
foreach ($settings['custom_fields'] as $field) {
if (isset($field['keyname']) && isset($field['default'])) {
$configuration[$field['keyname']] = $field['default'];
}
}
}
// Create a new plugin
$plugin = Plugin::create([
'user_id' => $user->id,
'name' => $settings['name'] ?? 'Imported Plugin',
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
'data_strategy' => $settings['strategy'] ?? 'static',
'polling_url' => $settings['polling_url'] ?? null,
'polling_verb' => $settings['polling_verb'] ?? 'get',
'polling_header' => isset($settings['polling_headers'])
? str_replace('=', ':', $settings['polling_headers'])
: null,
'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate,
'configuration' => $configuration,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
]);
return $plugin;
} finally {
// Clean up temporary directory
Storage::deleteDirectory($tempDirName);
}
}
/**
* Find required files in the extracted ZIP directory
*
* @param string $tempDir The temporary directory path
* @return array Array containing paths to required files
*/
private function findRequiredFiles(string $tempDir): array
{
$settingsYamlPath = null;
$fullLiquidPath = null;
$sharedLiquidPath = null;
// First, check if files are directly in the src folder
if (File::exists($tempDir.'/src/settings.yml')) {
$settingsYamlPath = $tempDir.'/src/settings.yml';
// Check for full.liquid or full.blade.php
if (File::exists($tempDir.'/src/full.liquid')) {
$fullLiquidPath = $tempDir.'/src/full.liquid';
} elseif (File::exists($tempDir.'/src/full.blade.php')) {
$fullLiquidPath = $tempDir.'/src/full.blade.php';
}
// Check for shared.liquid in the same directory
if (File::exists($tempDir.'/src/shared.liquid')) {
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
}
} else {
// Search for the files in the extracted directory structure
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($directories);
foreach ($files as $file) {
$filename = $file->getFilename();
$filepath = $file->getPathname();
if ($filename === 'settings.yml') {
$settingsYamlPath = $filepath;
} elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') {
$fullLiquidPath = $filepath;
} elseif ($filename === 'shared.liquid') {
$sharedLiquidPath = $filepath;
}
// If we found both required files, break the loop
if ($settingsYamlPath && $fullLiquidPath) {
break;
}
}
// If we found the files but they're not in the src folder,
// check if they're in the root of the ZIP or in a subfolder
if ($settingsYamlPath && $fullLiquidPath) {
// If the files are in the root of the ZIP, create a src folder and move them there
$srcDir = dirname($settingsYamlPath);
// If the parent directory is not named 'src', create a src directory
if (basename($srcDir) !== 'src') {
$newSrcDir = $tempDir.'/src';
File::makeDirectory($newSrcDir, 0755, true);
// Copy the files to the src directory
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
// Copy shared.liquid if it exists
if ($sharedLiquidPath) {
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
}
// Update the paths
$settingsYamlPath = $newSrcDir.'/settings.yml';
$fullLiquidPath = $newSrcDir.'/full.liquid';
}
}
}
return [
'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath,
];
}
}

View file

@ -12,6 +12,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-imagick": "*", "ext-imagick": "*",
"ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "1.2.*", "bnussbau/laravel-trmnl-blade": "1.2.*",
"intervention/image": "^3.11", "intervention/image": "^3.11",
"keepsuit/laravel-liquid": "^0.5.2", "keepsuit/laravel-liquid": "^0.5.2",
@ -22,6 +23,7 @@
"livewire/flux": "^2.0", "livewire/flux": "^2.0",
"livewire/volt": "^1.7", "livewire/volt": "^1.7",
"spatie/browsershot": "^5.0", "spatie/browsershot": "^5.0",
"symfony/yaml": "^7.3",
"wnx/sidecar-browsershot": "^2.6" "wnx/sidecar-browsershot": "^2.6"
}, },
"require-dev": { "require-dev": {

157
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": "9143c36674f3ae13a9e9bad15014d508", "content-hash": "fea763810f7e3a912c2221d2fe0a751e",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -7405,6 +7405,82 @@
], ],
"time": "2025-07-10T08:47:49+00:00" "time": "2025-07-10T08:47:49+00:00"
}, },
{
"name": "symfony/yaml",
"version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.3.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-10T08:47:49+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.3.0", "version": "v2.3.0",
@ -11377,82 +11453,6 @@
], ],
"time": "2024-10-20T05:08:20+00:00" "time": "2024-10-20T05:08:20+00:00"
}, },
{
"name": "symfony/yaml",
"version": "v7.3.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.3.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-07-10T08:47:49+00:00"
},
{ {
"name": "ta-tikoma/phpunit-architecture-test", "name": "ta-tikoma/phpunit-architecture-test",
"version": "0.8.5", "version": "0.8.5",
@ -11570,7 +11570,8 @@
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2", "php": "^8.2",
"ext-imagick": "*" "ext-imagick": "*",
"ext-zip": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"

View file

@ -0,0 +1,30 @@
<?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->json('configuration_template')->nullable();
$table->json('configuration')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn('configuration_template');
$table->dropColumn('configuration');
});
}
};

View file

@ -0,0 +1,42 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')
->add(match($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
});
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>

View file

@ -1,8 +1,13 @@
<?php <?php
use App\Console\Commands\ExampleRecipesSeederCommand;
use App\Services\PluginImportService;
use Livewire\Volt\Component; use Livewire\Volt\Component;
use Livewire\WithFileUploads;
use Illuminate\Support\Str;
new class extends Component { new class extends Component {
use WithFileUploads;
public string $name; public string $name;
public int $data_stale_minutes = 60; public int $data_stale_minutes = 60;
@ -12,6 +17,7 @@ new class extends Component {
public $polling_header; public $polling_header;
public $polling_body; public $polling_body;
public array $plugins; public array $plugins;
public $zipFile;
public array $native_plugins = [ public array $native_plugins = [
'markup' => 'markup' =>
@ -50,7 +56,7 @@ new class extends Component {
$this->validate(); $this->validate();
\App\Models\Plugin::create([ \App\Models\Plugin::create([
'uuid' => \Illuminate\Support\Str::uuid(), 'uuid' => Str::uuid(),
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'name' => $this->name, 'name' => $this->name,
'data_stale_minutes' => $this->data_stale_minutes, 'data_stale_minutes' => $this->data_stale_minutes,
@ -69,8 +75,32 @@ new class extends Component {
public function seedExamplePlugins(): void public function seedExamplePlugins(): void
{ {
\Artisan::call(\App\Console\Commands\ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]);
$this->refreshPlugins(); $this->refreshPlugins();
}
public function importZip(PluginImportService $pluginImportService): void
{
abort_unless(auth()->user() !== null, 403);
$this->validate([
'zipFile' => 'required|file|mimes:zip|max:10240', // 10MB max
]);
try {
$plugin = $pluginImportService->importFromZip($this->zipFile, auth()->user());
$this->refreshPlugins();
$this->reset(['zipFile']);
Flux::modal('import-zip')->close();
$this->dispatch('notify', ['type' => 'success', 'message' => 'Plugin imported successfully!']);
} catch (\Exception $e) {
$this->dispatch('notify', ['type' => 'error', 'message' => 'Error importing plugin: ' . $e->getMessage()]);
}
} }
}; };
@ -89,15 +119,10 @@ new class extends Component {
<flux:dropdown> <flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button> <flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu> <flux:menu>
<flux:modal.trigger name="import-zip">
<flux:menu.item icon="archive-box">Import Recipe</flux:menu.item>
</flux:modal.trigger>
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item> <flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
{{-- <flux:menu.separator/>--}}
{{-- <flux:modal.trigger name="import-recipe">--}}
{{-- <flux:menu.item icon="paper-clip">Import Recipe ZIP File</flux:menu.item>--}}
{{-- </flux:modal.trigger>--}}
{{-- <flux:menu.separator/>--}}
{{-- <flux:modal.trigger name="add-native-plugin">--}}
{{-- <flux:menu.item icon="code-bracket">New Native Plugin</flux:menu.item>--}}
{{-- </flux:modal.trigger>--}}
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
</flux:button.group> </flux:button.group>
@ -105,6 +130,62 @@ new class extends Component {
</div> </div>
<flux:modal name="import-zip" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Import Recipe
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading>
<flux:subheading>Upload a ZIP archive containing a TRMNL recipe either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading>
</div>
<div class="mb-4">
<flux:text>The archive must at least contain <code>settings.yml</code> and <code>full.liquid</code> files.</flux:text>
{{-- <p>The ZIP file should contain the following structure:</p>--}}
{{-- <pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto">--}}
{{--.--}}
{{--├── src--}}
{{-- ├── full.liquid (required)--}}
{{-- ├── settings.yml (required)--}}
{{-- └── ...--}}
{{--└── ...--}}
{{-- </pre>--}}
</div>
<div class="mb-4">
<flux:heading size="sm">Limitations</flux:heading>
<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>API responses in formats other than JSON are not yet supported</flux:text></li>
{{-- <ul class="list-disc pl-5 mt-2">--}}
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
{{-- </ul>--}}
</ul>
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
</div>
<form wire:submit="importZip">
<div class="mb-4">
<label for="zipFile" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">.zip Archive</label>
<input
type="file"
wire:model="zipFile"
id="zipFile"
accept=".zip"
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 p-2.5"
/>
@error('zipFile') <span class="text-red-500 text-xs mt-1">{{ $message }}</span> @enderror
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Import</flux:button>
</div>
</form>
</div>
</flux:modal>
<flux:modal name="add-plugin" class="md:w-96"> <flux:modal name="add-plugin" class="md:w-96">
<div class="space-y-6"> <div class="space-y-6">
<div> <div>

View file

@ -2,8 +2,10 @@
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Livewire\Volt\Component; use Livewire\Volt\Component;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Arr;
new class extends Component { new class extends Component {
public Plugin $plugin; public Plugin $plugin;
@ -28,10 +30,15 @@ new class extends Component {
public string $selected_playlist = ''; public string $selected_playlist = '';
public string $mashup_layout = 'full'; public string $mashup_layout = 'full';
public array $mashup_plugins = []; public array $mashup_plugins = [];
public array $configuration_template = [];
public array $configuration = [];
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);
$this->blade_code = $this->plugin->render_markup;
$this->configuration_template = $this->plugin->configuration_template ?? [];
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
if ($this->plugin->render_markup_view) { if ($this->plugin->render_markup_view) {
try { try {
@ -86,7 +93,7 @@ new class extends Component {
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'data_stale_minutes' => 'required|integer|min:1', 'data_stale_minutes' => 'required|integer|min:1',
'data_strategy' => 'required|string|in:polling,webhook,static', 'data_strategy' => 'required|string|in:polling,webhook,static',
'polling_url' => 'required_if:data_strategy,polling|nullable|url', 'polling_url' => 'required_if:data_strategy,polling|nullable',
'polling_verb' => 'required|string|in:get,post', 'polling_verb' => 'required|string|in:get,post',
'polling_header' => 'nullable|string|max:255', 'polling_header' => 'nullable|string|max:255',
'polling_body' => 'nullable|string', 'polling_body' => 'nullable|string',
@ -104,11 +111,30 @@ new class extends Component {
public function editSettings() public function editSettings()
{ {
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
// Custom validation for polling_url with Liquid variable resolution
$this->validatePollingUrl();
$validated = $this->validate(); $validated = $this->validate();
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true); $validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
$this->plugin->update($validated); $this->plugin->update($validated);
} }
protected function validatePollingUrl(): void
{
if ($this->data_strategy === 'polling' && !empty($this->polling_url)) {
try {
$resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url);
if (!filter_var($resolvedUrl, FILTER_VALIDATE_URL)) {
$this->addError('polling_url', 'The polling URL must be a valid URL after resolving configuration variables.');
}
} catch (\Exception $e) {
$this->addError('polling_url', 'Error resolving Liquid variables: ' . $e->getMessage());
}
}
}
public function updateData(): void public function updateData(): void
{ {
if ($this->plugin->data_strategy === 'polling') { if ($this->plugin->data_strategy === 'polling') {
@ -197,11 +223,39 @@ new class extends Component {
Flux::modal('add-to-playlist')->close(); Flux::modal('add-to-playlist')->close();
} }
public function saveConfiguration()
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$configurationValues = [];
if (isset($this->configuration_template['custom_fields'])) {
foreach ($this->configuration_template['custom_fields'] as $field) {
$fieldKey = $field['keyname'];
if (isset($this->configuration[$fieldKey])) {
$configurationValues[$fieldKey] = $this->configuration[$fieldKey];
}
}
}
$this->plugin->update([
'configuration' => $configurationValues
]);
Flux::modal('configuration-modal')->close();
}
public function getDevicePlaylists($deviceId) public function getDevicePlaylists($deviceId)
{ {
return \App\Models\Playlist::where('device_id', $deviceId)->get(); return \App\Models\Playlist::where('device_id', $deviceId)->get();
} }
public function getConfigurationValue($key, $default = null)
{
return $this->configuration[$key] ?? $default;
}
public function renderExample(string $example) public function renderExample(string $example)
{ {
switch ($example) { switch ($example) {
@ -270,9 +324,16 @@ HTML;
{ {
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
// If data strategy is polling and data_payload is null, fetch the data first
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
$this->updateData();
}
try { try {
$previewMarkup = $this->plugin->render($size); $previewMarkup = $this->plugin->render($size);
$this->dispatch('preview-updated', preview: $previewMarkup); $this->dispatch('preview-updated', preview: $previewMarkup);
} catch (LiquidException $e) {
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('preview-error', message: $e->getMessage()); $this->dispatch('preview-error', message: $e->getMessage());
} }
@ -297,23 +358,23 @@ HTML;
<flux:button.group> <flux:button.group>
<flux:modal.trigger name="preview-plugin"> <flux:modal.trigger name="preview-plugin">
<flux:button icon="eye" wire:click="renderPreview">Preview</flux:button> <flux:button icon="eye" wire:click="renderPreview" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Preview</flux:button>
</flux:modal.trigger> </flux:modal.trigger>
<flux:dropdown> <flux:dropdown>
<flux:button icon="chevron-down"></flux:button> <flux:button icon="chevron-down" :disabled="$plugin->hasMissingRequiredConfigurationFields()"></flux:button>
<flux:menu> <flux:menu>
<flux:modal.trigger name="preview-plugin"> <flux:modal.trigger name="preview-plugin">
<flux:menu.item icon="mashup-1Tx1B" wire:click="renderPreview('half_horizontal')">Half-Horizontal <flux:menu.item icon="mashup-1Tx1B" wire:click="renderPreview('half_horizontal')" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Half-Horizontal
</flux:menu.item> </flux:menu.item>
</flux:modal.trigger> </flux:modal.trigger>
<flux:modal.trigger name="preview-plugin"> <flux:modal.trigger name="preview-plugin">
<flux:menu.item icon="mashup-1Lx1R" wire:click="renderPreview('half_vertical')">Half-Vertical <flux:menu.item icon="mashup-1Lx1R" wire:click="renderPreview('half_vertical')" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Half-Vertical
</flux:menu.item> </flux:menu.item>
</flux:modal.trigger> </flux:modal.trigger>
<flux:modal.trigger name="preview-plugin"> <flux:modal.trigger name="preview-plugin">
<flux:menu.item icon="mashup-2x2" wire:click="renderPreview('quadrant')">Quadrant</flux:menu.item> <flux:menu.item icon="mashup-2x2" wire:click="renderPreview('quadrant')" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Quadrant</flux:menu.item>
</flux:modal.trigger> </flux:modal.trigger>
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
@ -321,7 +382,7 @@ HTML;
</flux:button.group> </flux:button.group>
<flux:button.group> <flux:button.group>
<flux:modal.trigger name="add-to-playlist"> <flux:modal.trigger name="add-to-playlist">
<flux:button icon="play" variant="primary">Add to Playlist</flux:button> <flux:button icon="play" variant="primary" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Add to Playlist</flux:button>
</flux:modal.trigger> </flux:modal.trigger>
<flux:dropdown> <flux:dropdown>
@ -429,7 +490,7 @@ HTML;
<div class="flex"> <div class="flex">
<flux:spacer/> <flux:spacer/>
<flux:button type="submit" variant="primary">Add to Playlist</flux:button> <flux:button type="submit" variant="primary" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Add to Playlist</flux:button>
</div> </div>
</form> </form>
</div> </div>
@ -461,6 +522,143 @@ HTML;
</div> </div>
</flux:modal> </flux:modal>
<flux:modal name="configuration-modal" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Configuration</flux:heading>
<flux:subheading>Configure your plugin settings</flux:subheading>
</div>
<form wire:submit="saveConfiguration">
@if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields']))
@foreach($configuration_template['custom_fields'] as $field)
@php
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
$currentValue = $configuration[$fieldKey] ?? '';
@endphp
<div class="mb-8">
@if($field['field_type'] === 'author_bio')
@continue
@endif
@if($field['field_type'] === 'copyable_webhook_url')
@continue
@endif
@if($field['field_type'] === 'string' || $field['field_type'] === 'url')
<flux:input
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'password')
<flux:input
type="password"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
viewable
/>
@elseif($field['field_type'] === 'copyable')
<flux:input
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
value="{{ $field['value'] }}"
copyable
/>
@elseif($field['field_type'] === 'time_zone')
<flux:select
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
>
<option value="">Select timezone...</option>
@foreach(timezone_identifiers_list() as $timezone)
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
@endforeach
</flux:select>
@elseif($field['field_type'] === 'number')
<flux:input
type="number"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'boolean')
<flux:checkbox
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
:checked="$currentValue"
/>
@elseif($field['field_type'] === 'date')
<flux:input
type="date"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'select')
@if(isset($field['multiple']) && $field['multiple'] === true)
<flux:checkbox.group
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
@endforeach
@else
<flux:checkbox label="{{ $option }}" value="{{ $option }}"/>
@endif
@endforeach
@endif
</flux:checkbox.group>
@else
<flux:select
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
>
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
@endif
@else
<p>{{ $field['name'] }}: Field type "{{ $field['field_type'] }}" not yet supported</p>
@endif
</div>
@endforeach
@endif
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Save Configuration</flux:button>
</div>
</form>
</div>
</flux:modal>
<div class="mt-5 mb-5"> <div class="mt-5 mb-5">
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3> <h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
</div> </div>
@ -472,6 +670,85 @@ HTML;
name="name" autofocus/> name="name" autofocus/>
</div> </div>
@php
$authorField = null;
if (isset($configuration_template['custom_fields'])) {
foreach ($configuration_template['custom_fields'] as $field) {
if ($field['field_type'] === 'author_bio') {
$authorField = $field;
break;
}
}
}
@endphp
@if($authorField)
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4">
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ $authorField['description'] }}
</div>
@if(isset($authorField['github_url']) || isset($authorField['learn_more_url']) || isset($authorField['email_address']))
<div class="mt-4 flex flex-wrap gap-2">
@if(isset($authorField['github_url']))
@php
$githubUrl = $authorField['github_url'];
$githubUsername = null;
// Extract username from various GitHub URL formats
if (preg_match('/github\.com\/([^\/\?]+)/', $githubUrl, $matches)) {
$githubUsername = $matches[1];
}
@endphp
@if($githubUsername)<flux:label badge="{{ $githubUsername }}"/>@endif
@endif
@if(isset($authorField['learn_more_url']))
<flux:button
size="sm"
variant="ghost"
icon:trailing="arrow-up-right"
href="{{ $authorField['learn_more_url'] }}"
target="_blank"
>
Learn More
</flux:button>
@endif
@if(isset($authorField['github_url']))
<flux:button
size="sm"
icon="github"
variant="ghost"
href="{{ $authorField['github_url'] }}"
target="_blank"
>
</flux:button>
@endif
@if(isset($authorField['email_address']))
<flux:button
size="sm"
variant="ghost"
icon="envelope"
href="mailto:{{ $authorField['email_address'] }}"
>
</flux:button>
@endif
</div>
@endif
</div>
@endif
@if(isset($configuration_template['custom_fields']) && !empty($configuration_template['custom_fields']))
@if($plugin->hasMissingRequiredConfigurationFields())
<flux:callout class="mb-2" variant="warning" icon="exclamation-circle" heading="Please set required configuration fields." />
@endif
<div class="mb-4">
<flux:modal.trigger name="configuration-modal">
<flux:button icon="cog" class="block mt-1 w-full">Configuration</flux:button>
</flux:modal.trigger>
</div>
@endif
<div class="mb-4"> <div class="mb-4">
<flux:radio.group wire:model.live="data_strategy" label="Data Strategy" variant="segmented"> <flux:radio.group wire:model.live="data_strategy" label="Data Strategy" variant="segmented">
<flux:radio value="polling" label="Polling"/> <flux:radio value="polling" label="Polling"/>
@ -482,15 +759,13 @@ HTML;
@if($data_strategy === 'polling') @if($data_strategy === 'polling')
<div class="mb-4"> <div class="mb-4">
<flux:input label="Polling URL" wire:model="polling_url" id="polling_url" <flux:textarea label="Polling URL" description="You can use configuration variables with Liquid syntax. Supports multiple requests via line break separation" wire:model="polling_url" id="polling_url"
placeholder="https://example.com/api" placeholder="https://example.com/api"
class="block mt-1 w-full" type="text" name="polling_url" autofocus> class="block w-full" type="text" name="polling_url" autofocus>
<x-slot name="iconTrailing">
<flux:button size="sm" variant="subtle" icon="cloud-arrow-down"
wire:click="updateData"
tooltip="Fetch data now" class="-mr-1"/>
</x-slot>
</flux:input> </flux:input>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
Fetch data now
</flux:button>
</div> </div>
<div class="mb-4"> <div class="mb-4">
@ -533,6 +808,7 @@ HTML;
<div class="mb-4"> <div class="mb-4">
<flux:input <flux:input
label="Webhook URL" label="Webhook URL"
descriptionTrailing="Send JSON payload with key <code>merge_variables</code> to the webhook URL. The payload will be merged with the plugin data."
:value="route('api.custom_plugins.webhook', ['plugin_uuid' => $plugin->uuid])" :value="route('api.custom_plugins.webhook', ['plugin_uuid' => $plugin->uuid])"
class="block mt-1 w-full font-mono" class="block mt-1 w-full font-mono"
readonly readonly
@ -540,19 +816,13 @@ HTML;
> >
</flux:input> </flux:input>
</div> </div>
<div>
<p>Send JSON payload with key <code>merge_variables</code> to the webhook URL. The payload
will be merged with the plugin data.</p>
</div>
@elseif($data_strategy === 'static') @elseif($data_strategy === 'static')
<div> <flux:text class="mb-2">Enter static JSON data in the Data Payload field.</flux:text>
<p>Enter static JSON data in the Data Payload field.</p>
</div>
@endif @endif
<div class="flex"> <div class="flex">
<flux:spacer/> <flux:spacer/>
<flux:button type="submit" variant="primary">Save</flux:button> <flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
</div> </div>
</form> </form>
</div> </div>
@ -627,6 +897,8 @@ HTML;
</div> </div>
</div> </div>
@script @script
<script> <script>
$wire.on('preview-updated', ({preview}) => { $wire.on('preview-updated', ({preview}) => {

View file

@ -0,0 +1,126 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
test('plugin import extracts default values from custom_fields and stores in configuration', function () {
// Create a user
$user = User::factory()->create();
// Test the functionality directly by creating a plugin with the expected configuration
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'reading_days',
'field_type' => 'string',
'name' => 'Reading Days',
'description' => 'Select days of the week to read',
'default' => 'Monday,Friday,Saturday,Sunday'
],
[
'keyname' => 'refresh_interval',
'field_type' => 'number',
'name' => 'Refresh Interval',
'description' => 'How often to refresh data',
'default' => 15
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone'
// No default value
]
]
];
// Extract default values from custom_fields and populate configuration
$configuration = [];
if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) {
foreach ($configurationTemplate['custom_fields'] as $field) {
if (isset($field['keyname']) && isset($field['default'])) {
$configuration[$field['keyname']] = $field['default'];
}
}
}
// Create the plugin directly
$plugin = Plugin::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin with Defaults',
'data_stale_minutes' => 30,
'data_strategy' => 'static',
'configuration_template' => $configurationTemplate,
'configuration' => $configuration,
]);
// Assert the plugin was created with correct configuration
expect($plugin)->not->toBeNull();
expect($plugin->configuration)->toBeArray();
expect($plugin->configuration)->toHaveKey('reading_days');
expect($plugin->configuration)->toHaveKey('refresh_interval');
expect($plugin->configuration)->not->toHaveKey('timezone');
expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday');
expect($plugin->getConfiguration('refresh_interval'))->toBe(15);
expect($plugin->getConfiguration('timezone'))->toBeNull();
// Verify configuration template was stored correctly
expect($plugin->configuration_template)->toBeArray();
expect($plugin->configuration_template['custom_fields'])->toHaveCount(3);
});
test('plugin import handles custom_fields without default values', function () {
// Create a user
$user = User::factory()->create();
// Test the functionality directly by creating a plugin with no default values
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone'
]
]
];
// Extract default values from custom_fields and populate configuration
$configuration = [];
if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) {
foreach ($configurationTemplate['custom_fields'] as $field) {
if (isset($field['keyname']) && isset($field['default'])) {
$configuration[$field['keyname']] = $field['default'];
}
}
}
// Create the plugin directly
$plugin = Plugin::create([
'uuid' => \Illuminate\Support\Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin No Defaults',
'data_stale_minutes' => 30,
'data_strategy' => 'static',
'configuration_template' => $configurationTemplate,
'configuration' => $configuration,
]);
// Assert the plugin was created with empty configuration
expect($plugin)->not->toBeNull();
expect($plugin->configuration)->toBeArray();
expect($plugin->configuration)->toBeEmpty();
// Verify configuration template was stored correctly
expect($plugin->configuration_template)->toBeArray();
expect($plugin->configuration_template['custom_fields'])->toHaveCount(1);
});

View file

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
use App\Models\Plugin;
use App\Models\User;
use App\Services\PluginImportService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('local');
});
it('imports plugin from valid zip file', function () {
$user = User::factory()->create();
// Create a mock ZIP file with the required structure
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->user_id)->toBe($user->id)
->and($plugin->name)->toBe('Test Plugin')
->and($plugin->data_stale_minutes)->toBe(30)
->and($plugin->data_strategy)->toBe('static')
->and($plugin->markup_language)->toBe('liquid')
->and($plugin->configuration_template)->toHaveKey('custom_fields')
->and($plugin->configuration)->toHaveKey('api_key')
->and($plugin->configuration['api_key'])->toBe('default-api-key');
});
it('imports plugin with shared.liquid file', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
'src/shared.liquid' => '{% comment %}Shared styles{% endcomment %}',
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">');
});
it('imports plugin with files in root directory', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'settings.yml' => getValidSettingsYaml(),
'full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->name)->toBe('Test Plugin');
});
it('throws exception for invalid zip file', function () {
$user = User::factory()->create();
$zipFile = UploadedFile::fake()->createWithContent('invalid.zip', 'not a zip file');
$pluginImportService = new PluginImportService();
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Could not open the ZIP file.');
});
it('throws exception for missing required files', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
// Missing full.liquid
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
});
it('sets default values when settings are missing', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => "name: Minimal Plugin\n",
'src/full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->name)->toBe('Minimal Plugin')
->and($plugin->data_stale_minutes)->toBe(15) // default value
->and($plugin->data_strategy)->toBe('static') // default value
->and($plugin->polling_verb)->toBe('get'); // default value
});
it('handles blade markup language correctly', function () {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.blade.php' => '<div>Blade template</div>',
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->markup_language)->toBe('blade');
});
// Helper methods
function createMockZipFile(array $files): string
{
$zip = new ZipArchive();
$tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
$zip->open($tempFile, ZipArchive::CREATE);
foreach ($files as $path => $content) {
$zip->addFromString($path, $content);
}
$zip->close();
$content = file_get_contents($tempFile);
unlink($tempFile);
return $content;
}
function getValidSettingsYaml(): string
{
return <<<'YAML'
name: Test Plugin
refresh_interval: 30
strategy: static
polling_verb: get
static_data: '{"test": "data"}'
custom_fields:
- keyname: api_key
field_type: text
default: default-api-key
label: API Key
YAML;
}
function getValidFullLiquid(): string
{
return <<<'LIQUID'
<div class="plugin-content">
<h1>{{ data.title }}</h1>
<p>{{ data.description }}</p>
</div>
LIQUID;
}

View file

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Models\Plugin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PluginInlineTemplatesTest extends TestCase
{
use RefreshDatabase;
public function test_plugin_with_inline_templates(): void
{
$plugin = Plugin::factory()->create([
'name' => 'Test Plugin',
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% assign min = 1 %}
{% assign max = facts | size %}
{% assign diff = max | minus: min %}
{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %}
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% template title_bar %}
<div class="title_bar">
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
<span class="instance">{{ instance }}</span>
</div>
{% endtemplate %}
<div class="view view--{{ size }}">
{% render "session",
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
{% render "title_bar",
trmnl: trmnl,
instance: "Please try to enjoy each fact equally."
%}
</div>
LIQUID
,
'data_payload' => [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
],
]);
$result = $plugin->render('full');
// Should render both templates
// Check for any of the facts (since random number generation is non-deterministic)
$this->assertTrue(
str_contains($result, 'Fact 1') ||
str_contains($result, 'Fact 2') ||
str_contains($result, 'Fact 3')
);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
}
public function test_plugin_with_inline_templates_using_with_syntax(): void
{
$plugin = Plugin::factory()->create([
'name' => 'Test Plugin',
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% assign min = 1 %}
{% assign max = facts | size %}
{% assign diff = max | minus: min %}
{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %}
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% template title_bar %}
<div class="title_bar">
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
<span class="instance">{{ instance }}</span>
</div>
{% endtemplate %}
<div class="view view--{{ size }}">
{% render "session" with
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
{% render "title_bar" with
trmnl: trmnl,
instance: "Please try to enjoy each fact equally."
%}
</div>
LIQUID
,
'data_payload' => [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
],
]);
$result = $plugin->render('full');
// Should render both templates
// Check for any of the facts (since random number generation is non-deterministic)
$this->assertTrue(
str_contains($result, 'Fact 1') ||
str_contains($result, 'Fact 2') ||
str_contains($result, 'Fact 3')
);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
}
public function test_plugin_with_simple_inline_template(): void
{
$plugin = Plugin::factory()->create([
'markup_language' => 'liquid',
'render_markup' => <<<'LIQUID'
{% template simple %}
<div class="simple">
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</div>
{% endtemplate %}
{% render "simple",
title: "Hello World",
content: "This is a test"
%}
LIQUID
,
]);
$result = $plugin->render('full');
$this->assertStringContainsString('Hello World', $result);
$this->assertStringContainsString('This is a test', $result);
$this->assertStringContainsString('class="simple"', $result);
}
}

View file

@ -0,0 +1,218 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('hasMissingRequiredConfigurationFields returns true when required field is null', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone',
'optional' => true // Marked as optional
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'timezone' => 'UTC' // Only timezone is set, api_key is missing
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone',
'optional' => true // Marked as optional
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'api_key' => 'test-api-key', // Required field is set
'timezone' => 'UTC'
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function () {
$user = User::factory()->create();
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => [],
'configuration' => []
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'api_key' => null // Explicitly set to null
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
// Not marked as optional, so it's required
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'api_key' => '' // Empty string
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'selected_items',
'field_type' => 'select',
'name' => 'Selected Items',
'description' => 'Select items',
'multiple' => true,
// Not marked as optional, so it's required
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'selected_items' => [] // Empty array
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
});
test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'author_bio',
'name' => 'About This Plugin',
'field_type' => 'author_bio',
],
[
'keyname' => 'plugin_field',
'name' => 'Field Name',
'field_type' => 'string',
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [
'plugin_field' => 'set' // Required field is set
]
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});
test('hasMissingRequiredConfigurationFields returns false when field has default value', function () {
$user = User::factory()->create();
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
'name' => 'API Key',
'description' => 'Your API key',
'default' => 'default-api-key' // Has default value
]
]
];
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => $configurationTemplate,
'configuration' => [] // Empty configuration, but field has default
]);
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
});

View file

@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Liquid;
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
use App\Liquid\Tags\TemplateTag;
use Keepsuit\Liquid\Environment;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Tags\RenderTag;
use PHPUnit\Framework\TestCase;
class InlineTemplatesTest extends TestCase
{
protected Environment $environment;
protected InlineTemplatesFileSystem $fileSystem;
protected function setUp(): void
{
parent::setUp();
$this->fileSystem = new InlineTemplatesFileSystem();
$this->environment = new Environment(
fileSystem: $this->fileSystem
);
$this->environment->tagRegistry->register(TemplateTag::class);
$this->environment->tagRegistry->register(RenderTag::class);
}
public function test_template_tag_registers_template(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
LIQUID
);
$context = $this->environment->newRenderContext(
data: [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'size_mod' => '--large',
]
);
$result = $template->render($context);
// Template tag should not output anything
$this->assertEquals('', $result);
// Template should be registered in the file system
$this->assertTrue($this->fileSystem->hasTemplate('session'));
$registeredTemplate = $this->fileSystem->readTemplateFile('session');
$this->assertStringContainsString('{{ facts[randomNumber] }}', $registeredTemplate);
$this->assertStringContainsString('{{ size_mod }}', $registeredTemplate);
}
public function test_template_tag_with_render_tag(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% render "session",
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
LIQUID
);
$context = $this->environment->newRenderContext(
data: [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']],
]
);
$result = $template->render($context);
// Should render the template content
$this->assertStringContainsString('Fact 2', $result); // facts[1]
$this->assertStringContainsString('class="layout"', $result);
$this->assertStringContainsString('class="value text--center"', $result);
}
public function test_apply_liquid_replacements_converts_with_syntax(): void
{
// This test simulates the applyLiquidReplacements method from the Plugin model
$originalLiquid = <<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% render "session" with
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
LIQUID;
// Apply the same replacement logic as in Plugin::applyLiquidReplacements
$convertedLiquid = preg_replace(
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
'{% render $1, ',
$originalLiquid
);
// Verify the conversion worked
$this->assertStringContainsString('{% render "session",', $convertedLiquid);
$this->assertStringNotContainsString('{% render "session" with', $convertedLiquid);
// Verify the rest of the content is preserved
$this->assertStringContainsString('trmnl: trmnl,', $convertedLiquid);
$this->assertStringContainsString('facts: facts,', $convertedLiquid);
}
public function test_template_tag_with_render_with_tag(): void
{
$originalLiquid = <<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% render "session" with
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
LIQUID;
// Apply the same replacement logic as in applyLiquidReplacements
$convertedLiquid = preg_replace(
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
'{% render $1, ',
$originalLiquid
);
$template = $this->environment->parseString($convertedLiquid);
$context = $this->environment->newRenderContext(
data: [
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']],
]
);
$result = $template->render($context);
// Should render the template content
$this->assertStringContainsString('Fact 2', $result); // facts[1]
$this->assertStringContainsString('class="layout"', $result);
$this->assertStringContainsString('class="value text--center"', $result);
}
public function test_template_tag_with_multiple_templates(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div class="layout">
<div class="columns">
<div class="column">
<div class="markdown gap--large">
<div class="value{{ size_mod }} text--center">
{{ facts[randomNumber] }}
</div>
</div>
</div>
</div>
</div>
{% endtemplate %}
{% template title_bar %}
<div class="title_bar">
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
<span class="instance">{{ instance }}</span>
</div>
{% endtemplate %}
<div class="view view--{{ size }}">
{% render "session",
trmnl: trmnl,
facts: facts,
randomNumber: randomNumber,
size_mod: ""
%}
{% render "title_bar",
trmnl: trmnl,
instance: "Please try to enjoy each fact equally."
%}
</div>
LIQUID
);
$context = $this->environment->newRenderContext(
data: [
'size' => 'full',
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
'randomNumber' => 1,
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test Plugin']],
]
);
$result = $template->render($context);
// Should render both templates
$this->assertStringContainsString('Fact 2', $result);
$this->assertStringContainsString('Test Plugin', $result);
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
$this->assertStringContainsString('class="view view--full"', $result);
}
public function test_template_tag_invalid_name(): void
{
$this->expectException(LiquidException::class);
$template = $this->environment->parseString(<<<'LIQUID'
{% template invalid-name %}
<div>Content</div>
{% endtemplate %}
LIQUID
);
$context = $this->environment->newRenderContext();
$template->render($context);
}
public function test_template_tag_without_file_system(): void
{
$template = $this->environment->parseString(<<<'LIQUID'
{% template session %}
<div>Content</div>
{% endtemplate %}
LIQUID
);
$context = $this->environment->newRenderContext();
$result = $template->render($context);
// Should not throw an error and should return empty string
$this->assertEquals('', $result);
}
}

View file

@ -72,6 +72,109 @@ test('updateDataPayload sends POST request with body when polling_verb is post',
}); });
}); });
test('updateDataPayload handles multiple URLs with IDX_ prefixes', function () {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/weather\nhttps://api3.example.com/news",
'polling_verb' => 'get',
'configuration' => [
'api_key' => 'test123',
],
]);
// Mock HTTP responses
Http::fake([
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
'https://api2.example.com/weather' => Http::response(['temp' => 25], 200),
'https://api3.example.com/news' => Http::response(['headline' => 'test'], 200),
]);
$plugin->updateDataPayload();
expect($plugin->data_payload)->toHaveKey('IDX_0');
expect($plugin->data_payload)->toHaveKey('IDX_1');
expect($plugin->data_payload)->toHaveKey('IDX_2');
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
expect($plugin->data_payload['IDX_1'])->toBe(['temp' => 25]);
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
});
test('updateDataPayload handles single URL without nesting', function () {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
'polling_verb' => 'get',
'configuration' => [
'api_key' => 'test123',
],
]);
// Mock HTTP response
Http::fake([
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
]);
$plugin->updateDataPayload();
expect($plugin->data_payload)->toBe(['data' => 'test']);
expect($plugin->data_payload)->not->toHaveKey('IDX_0');
});
test('updateDataPayload resolves Liquid variables in polling_header', function () {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
'polling_verb' => 'get',
'polling_header' => "Authorization: Bearer {{ api_key }}\nX-Custom-Header: {{ custom_value }}",
'configuration' => [
'api_key' => 'test123',
'custom_value' => 'custom_header_value',
],
]);
// Mock HTTP response
Http::fake([
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
]);
$plugin->updateDataPayload();
Http::assertSent(function ($request) {
return $request->url() === 'https://api.example.com/data' &&
$request->method() === 'GET' &&
$request->header('Authorization')[0] === 'Bearer test123' &&
$request->header('X-Custom-Header')[0] === 'custom_header_value';
});
});
test('updateDataPayload resolves Liquid variables in polling_body', function () {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
'polling_verb' => 'post',
'polling_body' => '{"query": "query { user { id name } }", "api_key": "{{ api_key }}", "user_id": "{{ user_id }}"}',
'configuration' => [
'api_key' => 'test123',
'user_id' => '456',
],
]);
// Mock HTTP response
Http::fake([
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
]);
$plugin->updateDataPayload();
Http::assertSent(function ($request) {
$expectedBody = '{"query": "query { user { id name } }", "api_key": "test123", "user_id": "456"}';
return $request->url() === 'https://api.example.com/data' &&
$request->method() === 'POST' &&
$request->body() === $expectedBody;
});
});
test('webhook plugin is stale if webhook event occurred', function () { test('webhook plugin is stale if webhook event occurred', function () {
$plugin = Plugin::factory()->create([ $plugin = Plugin::factory()->create([
'data_strategy' => 'webhook', 'data_strategy' => 'webhook',
@ -93,3 +196,168 @@ test('webhook plugin data not stale if no webhook event occurred for 1 hour', fu
expect($plugin->isDataStale())->toBeFalse(); expect($plugin->isDataStale())->toBeFalse();
}); });
test('plugin configuration is cast to array', function () {
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
$plugin = Plugin::factory()->create(['configuration' => $config]);
expect($plugin->configuration)
->toBeArray()
->toBe($config);
});
test('plugin can get configuration value by key', function () {
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
$plugin = Plugin::factory()->create(['configuration' => $config]);
expect($plugin->getConfiguration('timezone'))->toBe('UTC');
expect($plugin->getConfiguration('refresh_interval'))->toBe(30);
expect($plugin->getConfiguration('nonexistent', 'default'))->toBe('default');
});
test('plugin configuration template is cast to array', function () {
$template = [
'custom_fields' => [
[
'name' => 'Timezone',
'keyname' => 'timezone',
'field_type' => 'time_zone',
'description' => 'Select your timezone',
],
],
];
$plugin = Plugin::factory()->create(['configuration_template' => $template]);
expect($plugin->configuration_template)
->toBeArray()
->toBe($template);
});
test('resolveLiquidVariables resolves variables from configuration', function () {
$plugin = Plugin::factory()->create([
'configuration' => [
'api_key' => '12345',
'username' => 'testuser',
'count' => 42,
],
]);
// Test simple variable replacement
$template = 'API Key: {{ api_key }}';
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBe('API Key: 12345');
// Test multiple variables
$template = 'User: {{ username }}, Count: {{ count }}';
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBe('User: testuser, Count: 42');
// Test with missing variable (should keep original)
$template = 'Missing: {{ missing }}';
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBe('Missing: ');
// Test with Liquid control structures
$template = '{% if count > 40 %}High{% else %}Low{% endif %}';
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBe('High');
});
test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function () {
$plugin = Plugin::factory()->create([
'configuration' => [
'api_key' => '12345',
],
]);
// Test with unclosed Liquid tag (should throw exception)
$template = 'Unclosed tag: {{ config.api_key';
expect(fn () => $plugin->resolveLiquidVariables($template))
->toThrow(Keepsuit\Liquid\Exceptions\SyntaxException::class);
});
test('plugin can extract default values from custom fields configuration template', function () {
$configurationTemplate = [
'custom_fields' => [
[
'keyname' => 'reading_days',
'field_type' => 'string',
'name' => 'Reading Days',
'description' => 'Select days of the week to read',
'default' => 'Monday,Friday,Saturday,Sunday',
],
[
'keyname' => 'refresh_interval',
'field_type' => 'number',
'name' => 'Refresh Interval',
'description' => 'How often to refresh data',
'default' => 30,
],
[
'keyname' => 'timezone',
'field_type' => 'time_zone',
'name' => 'Timezone',
'description' => 'Select your timezone',
// No default value
],
],
];
$plugin = Plugin::factory()->create([
'configuration_template' => $configurationTemplate,
'configuration' => [
'reading_days' => 'Monday,Friday,Saturday,Sunday',
'refresh_interval' => 30,
],
]);
expect($plugin->configuration)
->toBeArray()
->toHaveKey('reading_days')
->toHaveKey('refresh_interval')
->not->toHaveKey('timezone');
expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday');
expect($plugin->getConfiguration('refresh_interval'))->toBe(30);
expect($plugin->getConfiguration('timezone'))->toBeNull();
});
test('resolveLiquidVariables resolves configuration variables correctly', function () {
$plugin = Plugin::factory()->create([
'configuration' => [
'Latitude' => '48.2083',
'Longitude' => '16.3731',
'api_key' => 'test123',
],
]);
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}';
$expected = 'https://suntracker.me/?lat=48.2083&lon=16.3731';
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});
test('resolveLiquidVariables handles missing variables gracefully', function () {
$plugin = Plugin::factory()->create([
'configuration' => [
'Latitude' => '48.2083',
],
]);
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}&key={{ api_key }}';
$expected = 'https://suntracker.me/?lat=48.2083&lon=&key=';
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});
test('resolveLiquidVariables handles empty configuration', function () {
$plugin = Plugin::factory()->create([
'configuration' => [],
]);
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}';
$expected = 'https://suntracker.me/?lat=&lon=';
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
});