Compare commits

...

19 commits

Author SHA1 Message Date
Benjamin Nussbaum
b4d817bc84 fix: lazy load catalog 2025-11-12 18:43:26 +01:00
Benjamin Nussbaum
87a73046c5 fix: lazy load plugin images 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
b2eeb91719 chore: OSS catalog, archive import are now beta 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
4b93bde507 fix: center recipe items vertically 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
059377b9de feat: reposition filter button 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
51e8abcc02 fix: increase cache 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
3937b48546 feat: rearrange Add Recipe context menu 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
5873d648d0 feat: add plugin funnel button to reveal search and sort options 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
a2d069e6e5 feat: set icon url on import 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
33d2311176 feat: show plugin icon from url 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
b9fd81299d fix: check arg length (external liquid renderer) 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
c61acf10ce fix: require trmnl-liquid to install recipes from TRMNL catalog 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
0f5b95301c feat: set preferred_renderer when installing from catalog 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
d29e88a7c4 feat: add trmnl-liquid renderer option 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
fdfc3bd341 feat: add installation function 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
2b109d6013 strip tags 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
96d1b2174a feat: add TRMNL recipe catalog 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
3e6826106f feat: add TRMNL recipe catalog 2025-11-12 18:30:26 +01:00
Benjamin Nussbaum
f0f6b28107 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2025-11-12 18:26:01 +01:00
12 changed files with 791 additions and 173 deletions

View file

@ -12,6 +12,9 @@ ENV APP_VERSION=${APP_VERSION}
ENV AUTORUN_ENABLED="true"
# Mark trmnl-liquid-cli as installed
ENV TRMNL_LIQUID_ENABLED=1
# Switch to the root user so we can do root things
USER root
@ -49,5 +52,6 @@ FROM base AS production
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:latest /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
# Drop back to the www-data user
USER www-data

View file

@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters;
use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
use App\Services\PluginImportService;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -19,6 +20,7 @@ use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
use Keepsuit\Liquid\Exceptions\LiquidException;
@ -40,6 +42,7 @@ class Plugin extends Model
'configuration_template' => 'json',
'no_bleed' => 'boolean',
'dark_mode' => 'boolean',
'preferred_renderer' => 'string',
];
protected static function boot()
@ -363,6 +366,53 @@ class Plugin extends Model
return $liquidTemplate->render($context);
}
/**
* Render template using external Ruby liquid renderer
*
* @param string $template The liquid template string
* @param array $context The render context data
* @return string The rendered HTML
*
* @throws Exception
*/
private function renderWithExternalLiquidRenderer(string $template, array $context): string
{
$liquidPath = config('services.trmnl.liquid_path');
if (empty($liquidPath)) {
throw new Exception('External liquid renderer path is not configured');
}
// HTML encode the template
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
// Encode context as JSON
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($jsonContext === false) {
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
}
// Validate argument sizes
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
// Execute the external renderer
$process = Process::run([
$liquidPath,
'--template',
$encodedTemplate,
'--context',
$jsonContext,
]);
if (! $process->successful()) {
$errorOutput = $process->errorOutput() ?: $process->output();
throw new Exception('External liquid renderer failed: '.$errorOutput);
}
return $process->output();
}
/**
* Render the plugin's markup
*
@ -374,30 +424,8 @@ class Plugin extends Model
$renderedContent = '';
if ($this->markup_language === 'liquid') {
// Create a custom environment with inline templates support
$inlineFileSystem = new InlineTemplatesFileSystem();
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
);
// Register all custom filters
$environment->filterRegistry->register(Data::class);
$environment->filterRegistry->register(Date::class);
$environment->filterRegistry->register(Localization::class);
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
// Register the template tag for inline templates
$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: [
// Build render context
$context = [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
@ -424,9 +452,39 @@ class Plugin extends Model
],
],
],
]
];
// Check if external renderer should be used
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
// Use external Ruby renderer - pass raw template without preprocessing
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
} else {
// Use PHP keepsuit/liquid renderer
// Create a custom environment with inline templates support
$inlineFileSystem = new InlineTemplatesFileSystem();
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
);
$renderedContent = $template->render($context);
// Register all custom filters
$environment->filterRegistry->register(Data::class);
$environment->filterRegistry->register(Date::class);
$environment->filterRegistry->register(Localization::class);
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
// Register the template tag for inline templates
$environment->tagRegistry->register(TemplateTag::class);
// Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
$template = $environment->parseString($processedMarkup);
$liquidContext = $environment->newRenderContext(data: $context);
$renderedContent = $template->render($liquidContext);
}
} else {
$renderedContent = Blade::render($this->render_markup, [
'size' => $size,

View file

@ -139,11 +139,13 @@ class PluginImportService
* @param string $zipUrl The URL to the ZIP file
* @param User $user The user importing the plugin
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
* @param string|null $iconUrl Optional icon URL to set on the plugin
* @return Plugin The created plugin instance
*
* @throws Exception If the ZIP file is invalid or required files are missing
*/
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null): Plugin
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
{
// Download the ZIP file
$response = Http::timeout(60)->get($zipUrl);
@ -232,6 +234,8 @@ class PluginImportService
'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
'preferred_renderer' => $preferredRenderer,
'icon_url' => $iconUrl,
]);
if (! $plugin_updated) {
@ -380,4 +384,58 @@ class PluginImportService
'sharedLiquidPath' => $sharedLiquidPath,
];
}
/**
* Validate that template and context are within command-line argument limits
*
* @param string $template The liquid template string
* @param string $jsonContext The JSON-encoded context
* @param string $liquidPath The path to the liquid renderer executable
*
* @throws Exception If the template or context exceeds argument limits
*/
public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void
{
// MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments
// ARG_MAX is the total size of all arguments (typically 2MB on modern systems)
$maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit
$maxTotalArgLength = $this->getMaxArgumentLength();
// Check individual argument sizes (template and context are the largest)
if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) {
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
}
// Calculate total size of all arguments (path + flags + template + context)
// Add overhead for path, flags, and separators (conservative estimate: ~200 bytes)
$totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template)
+ mb_strlen('--context') + mb_strlen($jsonContext) + 200;
if ($totalArgSize > $maxTotalArgLength) {
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
}
}
/**
* Get the maximum argument length for command-line arguments
*
* @return int Maximum argument length in bytes
*/
private function getMaxArgumentLength(): int
{
// Try to get ARG_MAX from system using getconf
$argMax = null;
if (function_exists('shell_exec')) {
$result = @shell_exec('getconf ARG_MAX 2>/dev/null');
if ($result !== null && is_numeric(mb_trim($result))) {
$argMax = (int) mb_trim($result);
}
}
// Use conservative fallback if ARG_MAX cannot be determined
// ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB)
// We use 200KB as a conservative limit that works on both systems
// Note: ARG_MAX includes environment variables, so we leave headroom
return $argMax !== null ? min($argMax, 204800) : 204800;
}
}

196
composer.lock generated
View file

@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.359.6",
"version": "3.359.10",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5"
"reference": "10989892e99083c73e8421b85b5d6f7d2ca0f2f5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5",
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/10989892e99083c73e8421b85b5d6f7d2ca0f2f5",
"reference": "10989892e99083c73e8421b85b5d6f7d2ca0f2f5",
"shasum": ""
},
"require": {
@ -153,9 +153,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.6"
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.10"
},
"time": "2025-11-05T19:08:10+00:00"
"time": "2025-11-11T19:08:54+00:00"
},
{
"name": "bnussbau/laravel-trmnl-blade",
@ -1617,16 +1617,16 @@
},
{
"name": "laravel/framework",
"version": "v12.37.0",
"version": "v12.38.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125"
"reference": "1c30f547a3117bac99dc62a0afe767810cb112fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/3c3c4ad30f5b528b164a7c09aa4ad03118c4c125",
"reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125",
"url": "https://api.github.com/repos/laravel/framework/zipball/1c30f547a3117bac99dc62a0afe767810cb112fa",
"reference": "1c30f547a3117bac99dc62a0afe767810cb112fa",
"shasum": ""
},
"require": {
@ -1744,7 +1744,7 @@
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1",
"predis/predis": "^2.3|^3.0",
"resend/resend-php": "^0.10.0",
"resend/resend-php": "^0.10.0|^1.0",
"symfony/cache": "^7.2.0",
"symfony/http-client": "^7.2.0",
"symfony/psr-http-message-bridge": "^7.2.0",
@ -1778,7 +1778,7 @@
"predis/predis": "Required to use the predis connector (^2.3|^3.0).",
"psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
"pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).",
"resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).",
"resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^7.2).",
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).",
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).",
@ -1832,7 +1832,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-11-04T15:39:33+00:00"
"time": "2025-11-12T16:51:30+00:00"
},
{
"name": "laravel/prompts",
@ -2347,16 +2347,16 @@
},
{
"name": "league/flysystem",
"version": "3.30.1",
"version": "3.30.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da"
"reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/c139fd65c1f796b926f4aec0df37f6caa959a8da",
"reference": "c139fd65c1f796b926f4aec0df37f6caa959a8da",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277",
"reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277",
"shasum": ""
},
"require": {
@ -2424,22 +2424,22 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.30.1"
"source": "https://github.com/thephpleague/flysystem/tree/3.30.2"
},
"time": "2025-10-20T15:35:26+00:00"
"time": "2025-11-10T17:13:11+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.30.0",
"version": "3.30.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-local.git",
"reference": "6691915f77c7fb69adfb87dcd550052dc184ee10"
"reference": "ab4f9d0d672f601b102936aa728801dd1a11968d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10",
"reference": "6691915f77c7fb69adfb87dcd550052dc184ee10",
"url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d",
"reference": "ab4f9d0d672f601b102936aa728801dd1a11968d",
"shasum": ""
},
"require": {
@ -2473,9 +2473,9 @@
"local"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0"
"source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2"
},
"time": "2025-05-21T10:34:19+00:00"
"time": "2025-11-10T11:23:37+00:00"
},
{
"name": "league/mime-type-detection",
@ -4963,16 +4963,16 @@
},
{
"name": "symfony/console",
"version": "v7.3.5",
"version": "v7.3.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7"
"reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/cdb80fa5869653c83cfe1a9084a673b6daf57ea7",
"reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7",
"url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a",
"reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a",
"shasum": ""
},
"require": {
@ -5037,7 +5037,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.3.5"
"source": "https://github.com/symfony/console/tree/v7.3.6"
},
"funding": [
{
@ -5057,20 +5057,20 @@
"type": "tidelift"
}
],
"time": "2025-10-14T15:46:26+00:00"
"time": "2025-11-04T01:21:42+00:00"
},
{
"name": "symfony/css-selector",
"version": "v7.3.0",
"version": "v7.3.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2"
"reference": "84321188c4754e64273b46b406081ad9b18e8614"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2",
"reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614",
"reference": "84321188c4754e64273b46b406081ad9b18e8614",
"shasum": ""
},
"require": {
@ -5106,7 +5106,7 @@
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v7.3.0"
"source": "https://github.com/symfony/css-selector/tree/v7.3.6"
},
"funding": [
{
@ -5117,12 +5117,16 @@
"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": "2024-09-25T14:21:43+00:00"
"time": "2025-10-29T17:24:25+00:00"
},
{
"name": "symfony/deprecation-contracts",
@ -5193,16 +5197,16 @@
},
{
"name": "symfony/error-handler",
"version": "v7.3.4",
"version": "v7.3.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4"
"reference": "bbe40bfab84323d99dab491b716ff142410a92a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
"reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/bbe40bfab84323d99dab491b716ff142410a92a8",
"reference": "bbe40bfab84323d99dab491b716ff142410a92a8",
"shasum": ""
},
"require": {
@ -5250,7 +5254,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v7.3.4"
"source": "https://github.com/symfony/error-handler/tree/v7.3.6"
},
"funding": [
{
@ -5270,7 +5274,7 @@
"type": "tidelift"
}
],
"time": "2025-09-11T10:12:26+00:00"
"time": "2025-10-31T19:12:50+00:00"
},
{
"name": "symfony/event-dispatcher",
@ -5502,16 +5506,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.3.5",
"version": "v7.3.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "ce31218c7cac92eab280762c4375fb70a6f4f897"
"reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/ce31218c7cac92eab280762c4375fb70a6f4f897",
"reference": "ce31218c7cac92eab280762c4375fb70a6f4f897",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/db488a62f98f7a81d5746f05eea63a74e55bb7c4",
"reference": "db488a62f98f7a81d5746f05eea63a74e55bb7c4",
"shasum": ""
},
"require": {
@ -5561,7 +5565,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.3.5"
"source": "https://github.com/symfony/http-foundation/tree/v7.3.7"
},
"funding": [
{
@ -5581,20 +5585,20 @@
"type": "tidelift"
}
],
"time": "2025-10-24T21:42:11+00:00"
"time": "2025-11-08T16:41:12+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.3.5",
"version": "v7.3.7",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "24fd3f123532e26025f49f1abefcc01a69ef15ab"
"reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/24fd3f123532e26025f49f1abefcc01a69ef15ab",
"reference": "24fd3f123532e26025f49f1abefcc01a69ef15ab",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/10b8e9b748ea95fa4539c208e2487c435d3c87ce",
"reference": "10b8e9b748ea95fa4539c208e2487c435d3c87ce",
"shasum": ""
},
"require": {
@ -5679,7 +5683,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.3.5"
"source": "https://github.com/symfony/http-kernel/tree/v7.3.7"
},
"funding": [
{
@ -5699,7 +5703,7 @@
"type": "tidelift"
}
],
"time": "2025-10-28T10:19:01+00:00"
"time": "2025-11-12T11:38:40+00:00"
},
{
"name": "symfony/mailer",
@ -6769,16 +6773,16 @@
},
{
"name": "symfony/routing",
"version": "v7.3.4",
"version": "v7.3.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c"
"reference": "c97abe725f2a1a858deca629a6488c8fc20c3091"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c",
"reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c",
"url": "https://api.github.com/repos/symfony/routing/zipball/c97abe725f2a1a858deca629a6488c8fc20c3091",
"reference": "c97abe725f2a1a858deca629a6488c8fc20c3091",
"shasum": ""
},
"require": {
@ -6830,7 +6834,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.3.4"
"source": "https://github.com/symfony/routing/tree/v7.3.6"
},
"funding": [
{
@ -6850,20 +6854,20 @@
"type": "tidelift"
}
],
"time": "2025-09-11T10:12:26+00:00"
"time": "2025-11-05T07:57:47+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.6.0",
"version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4"
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
"reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
"reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
"shasum": ""
},
"require": {
@ -6917,7 +6921,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.6.0"
"source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
},
"funding": [
{
@ -6928,12 +6932,16 @@
"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-04-25T09:37:31+00:00"
"time": "2025-07-15T11:30:57+00:00"
},
{
"name": "symfony/string",
@ -7127,16 +7135,16 @@
},
{
"name": "symfony/translation-contracts",
"version": "v3.6.0",
"version": "v3.6.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d"
"reference": "65a8bc82080447fae78373aa10f8d13b38338977"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
"reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
"reference": "65a8bc82080447fae78373aa10f8d13b38338977",
"shasum": ""
},
"require": {
@ -7185,7 +7193,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.0"
"source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
},
"funding": [
{
@ -7196,12 +7204,16 @@
"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": "2024-09-27T08:32:26+00:00"
"time": "2025-07-15T13:41:35+00:00"
},
{
"name": "symfony/uid",
@ -8402,16 +8414,16 @@
},
{
"name": "laravel/boost",
"version": "v1.7.1",
"version": "v1.8.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "355f7c27952862aab3f61adec27773fd4d41a582"
"reference": "3475be16be7552b11c57ce18a0c5e204d696da50"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/355f7c27952862aab3f61adec27773fd4d41a582",
"reference": "355f7c27952862aab3f61adec27773fd4d41a582",
"url": "https://api.github.com/repos/laravel/boost/zipball/3475be16be7552b11c57ce18a0c5e204d696da50",
"reference": "3475be16be7552b11c57ce18a0c5e204d696da50",
"shasum": ""
},
"require": {
@ -8464,20 +8476,20 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2025-11-05T21:41:46+00:00"
"time": "2025-11-11T14:15:11+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.3.2",
"version": "v0.3.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "dc722a4c388f172365dec70461f0413ac366f360"
"reference": "feb475f819809e7db0a46e9f2cbcee6d77af2a14"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/dc722a4c388f172365dec70461f0413ac366f360",
"reference": "dc722a4c388f172365dec70461f0413ac366f360",
"url": "https://api.github.com/repos/laravel/mcp/zipball/feb475f819809e7db0a46e9f2cbcee6d77af2a14",
"reference": "feb475f819809e7db0a46e9f2cbcee6d77af2a14",
"shasum": ""
},
"require": {
@ -8537,7 +8549,7 @@
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2025-10-29T14:26:01+00:00"
"time": "2025-11-11T22:50:25+00:00"
},
{
"name": "laravel/pail",
@ -8747,16 +8759,16 @@
},
{
"name": "laravel/sail",
"version": "v1.47.0",
"version": "v1.48.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
"reference": "9a11e822238167ad8b791e4ea51155d25cf4d8f2"
"reference": "1bf3b8870b72a258a3b6b5119435835ece522e8a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/9a11e822238167ad8b791e4ea51155d25cf4d8f2",
"reference": "9a11e822238167ad8b791e4ea51155d25cf4d8f2",
"url": "https://api.github.com/repos/laravel/sail/zipball/1bf3b8870b72a258a3b6b5119435835ece522e8a",
"reference": "1bf3b8870b72a258a3b6b5119435835ece522e8a",
"shasum": ""
},
"require": {
@ -8806,7 +8818,7 @@
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
"time": "2025-10-28T13:55:29+00:00"
"time": "2025-11-09T14:46:21+00:00"
},
{
"name": "mockery/mockery",
@ -9923,11 +9935,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.31",
"version": "2.1.32",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96",
"reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227",
"reference": "e126cad1e30a99b137b8ed75a85a676450ebb227",
"shasum": ""
},
"require": {
@ -9972,7 +9984,7 @@
"type": "github"
}
],
"time": "2025-10-10T14:14:11+00:00"
"time": "2025-11-11T15:18:17+00:00"
},
{
"name": "phpunit/php-code-coverage",

View file

@ -41,6 +41,8 @@ return [
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false),
'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices
'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false),
'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'),
],
'webhook' => [

View file

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

View file

@ -23,7 +23,7 @@ new class extends Component {
$this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) {
try {
$response = Http::get($catalogUrl);
$response = Http::timeout(10)->get($catalogUrl);
$catalogContent = $response->body();
$catalog = Yaml::parse($catalogContent);
@ -83,7 +83,13 @@ new class extends Component {
$this->installingPlugin = $pluginId;
try {
$importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null);
$importedPlugin = $pluginImportService->importFromUrl(
$plugin['zip_url'],
auth()->user(),
$plugin['zip_entry_path'] ?? null,
null,
$plugin['logo_url'] ?? null
);
$this->dispatch('plugin-installed');
Flux::modal('import-from-catalog')->close();
@ -113,7 +119,7 @@ new class extends Component {
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
<div class="flex items-start space-x-4">
@if($plugin['logo_url'])
<img src="{{ $plugin['logo_url'] }}" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />

View file

@ -0,0 +1,233 @@
<?php
use Livewire\Volt\Component;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use App\Services\PluginImportService;
use Illuminate\Support\Facades\Auth;
new class extends Component {
public array $recipes = [];
public string $search = '';
public bool $isSearching = false;
public string $installingPlugin = '';
public function mount(): void
{
$this->loadNewest();
}
private function loadNewest(): void
{
try {
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () {
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
'sort-by' => 'newest',
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to fetch TRMNL recipes');
}
$json = $response->json();
$data = $json['data'] ?? [];
return $this->mapRecipes($data);
});
} catch (\Throwable $e) {
Log::error('TRMNL catalog load error: ' . $e->getMessage());
$this->recipes = [];
}
}
private function searchRecipes(string $term): void
{
$this->isSearching = true;
try {
$cacheKey = 'trmnl_recipes_search_' . md5($term);
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) {
$response = Http::get('https://usetrmnl.com/recipes.json', [
'search' => $term,
'sort-by' => 'newest',
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to search TRMNL recipes');
}
$json = $response->json();
$data = $json['data'] ?? [];
return $this->mapRecipes($data);
});
} catch (\Throwable $e) {
Log::error('TRMNL catalog search error: ' . $e->getMessage());
$this->recipes = [];
} finally {
$this->isSearching = false;
}
}
public function updatedSearch(): void
{
$term = trim($this->search);
if ($term === '') {
$this->loadNewest();
return;
}
if (strlen($term) < 2) {
// Require at least 2 chars to avoid noisy calls
return;
}
$this->searchRecipes($term);
}
public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void
{
abort_unless(auth()->user() !== null, 403);
$this->installingPlugin = $recipeId;
try {
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive";
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
$plugin = $pluginImportService->importFromUrl(
$zipUrl,
auth()->user(),
null,
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
$recipe['icon_url'] ?? null
);
$this->dispatch('plugin-installed');
Flux::modal('import-from-trmnl-catalog')->close();
} catch (\Exception $e) {
Log::error('Plugin installation failed: ' . $e->getMessage());
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
} finally {
$this->installingPlugin = '';
}
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private function mapRecipes(array $items): array
{
return collect($items)
->map(function (array $item) {
return [
'id' => $item['id'] ?? null,
'name' => $item['name'] ?? 'Untitled',
'icon_url' => $item['icon_url'] ?? null,
'screenshot_url' => $item['screenshot_url'] ?? null,
'author_bio' => is_array($item['author_bio'] ?? null)
? strip_tags($item['author_bio']['description'] ?? null)
: null,
'stats' => [
'installs' => data_get($item, 'stats.installs'),
'forks' => data_get($item, 'stats.forks'),
],
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null,
];
})
->toArray();
}
}; ?>
<div class="space-y-4">
<div class="flex items-center gap-3">
<div class="flex-1">
<flux:input
wire:model.live.debounce.400ms="search"
placeholder="Search TRMNL recipes (min 2 chars)..."
icon="magnifying-glass"
/>
</div>
<flux:badge color="gray">Newest</flux:badge>
</div>
@error('installation')
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}" />
@enderror
@if(empty($recipes))
<div class="text-center py-8">
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
<flux:heading class="mt-2">No recipes found</flux:heading>
<flux:subheading>Try a different search term</flux:subheading>
</div>
@else
<div class="grid grid-cols-1 gap-4">
@foreach($recipes as $recipe)
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
<div class="flex items-start space-x-4">
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
@if($thumb)
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
</div>
@endif
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $recipe['name'] }}</h3>
@if(data_get($recipe, 'stats.installs'))
<p class="text-sm text-gray-500 dark:text-gray-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</p>
@endif
</div>
<div class="flex items-center space-x-2">
@if($recipe['detail_url'])
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
</a>
@endif
</div>
</div>
@if($recipe['author_bio'])
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $recipe['author_bio'] }}</p>
@endif
<div class="mt-4 flex items-center space-x-3">
@if($recipe['id'])
@if($installingPlugin === $recipe['id'])
<flux:button
wire:click="installPlugin('{{ $recipe['id'] }}')"
variant="primary"
disabled>
<flux:icon name="arrow-path" class="w-4 h-4 animate-spin" />
</flux:button>
@else
<flux:button
wire:click="installPlugin('{{ $recipe['id'] }}')"
variant="primary">
Install
</flux:button>
@endif
@endif
@if($recipe['detail_url'])
<flux:button
href="{{ $recipe['detail_url'] }}"
target="_blank"
variant="subtle">
View on TRMNL
</flux:button>
@endif
</div>
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>

View file

@ -156,6 +156,7 @@ new class extends Component {
<div class="py-12" x-data="{
searchTerm: '',
showFilters: false,
filterPlugins(plugins) {
if (this.searchTerm.length <= 1) return plugins;
const search = this.searchTerm.toLowerCase();
@ -165,7 +166,8 @@ new class extends Component {
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Plugins &amp; Recipes</h2>
<div class="flex items-center space-x-2">
<flux:button icon="funnel" variant="ghost" @click="showFilters = !showFilters"></flux:button>
<flux:button.group>
<flux:modal.trigger name="add-plugin">
<flux:button icon="plus" variant="primary">Add Recipe</flux:button>
@ -174,19 +176,27 @@ new class extends Component {
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="import-from-catalog">
<flux:menu.item icon="book-open">Import from OSS Catalog</flux:menu.item>
</flux:modal.trigger>
@if(config('services.trmnl.liquid_enabled'))
<flux:modal.trigger name="import-from-trmnl-catalog">
<flux:menu.item icon="book-open">Import from TRMNL Catalog</flux:menu.item>
</flux:modal.trigger>
@endif
<flux:separator />
<flux:modal.trigger name="import-zip">
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
</flux:modal.trigger>
<flux:modal.trigger name="import-from-catalog">
<flux:menu.item icon="book-open">Import from Catalog</flux:menu.item>
</flux:modal.trigger>
<flux:separator />
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div>
</div>
<div class="mb-6 flex flex-col sm:flex-row gap-4">
<div x-show="showFilters" class="mb-6 flex flex-col sm:flex-row gap-4" style="display: none;">
<div class="flex-1">
<flux:input
x-model="searchTerm"
@ -214,7 +224,7 @@ new class extends Component {
<div class="space-y-6">
<div>
<flux:heading size="lg">Import Recipe
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
<flux:badge color="blue" class="ml-2">Beta</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>
@ -272,11 +282,22 @@ new class extends Component {
<div class="space-y-6">
<div>
<flux:heading size="lg">Import from Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
<flux:badge color="blue" class="ml-2">Beta</flux:badge>
</flux:heading>
<flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading>
</div>
<livewire:catalog.index />
<livewire:catalog.index lazy />
</div>
</flux:modal>
<flux:modal name="import-from-trmnl-catalog">
<div class="space-y-6">
<div>
<flux:heading size="lg">Import from TRMNL Recipe Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading>
</div>
<livewire:catalog.trmnl lazy />
</div>
</flux:modal>
@ -359,10 +380,14 @@ new class extends Component {
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
class="block">
<div class="flex items-center space-x-4 px-10 py-8">
class="block h-full">
<div class="flex items-center space-x-4 px-10 py-8 h-full">
@isset($plugin['icon_url'])
<img src="{{ $plugin['icon_url'] }}" class="h-6"/>
@else
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
class="text-4xl text-accent"/>
@endif
<h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3>
</div>
</a>

View file

@ -1082,7 +1082,7 @@ HTML;
})"
wire:ignore
wire:key="cm-{{ $textareaId }}"
class="max-w-2xl min-h-[300px] h-[500px] overflow-hidden resize-y"
class="max-w-2xl min-h-[300px] h-[565px] overflow-hidden resize-y"
>
<!-- Loading state -->
<div x-show="isLoading" class="flex items-center justify-center h-full">

View file

@ -341,6 +341,53 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu
->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>');
});
it('sets icon_url when importing from URL with iconUrl parameter', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
]);
Http::fake([
'https://example.com/plugin.zip' => Http::response($zipContent, 200),
]);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromUrl(
'https://example.com/plugin.zip',
$user,
null,
null,
'https://example.com/icon.png'
);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->icon_url)->toBe('https://example.com/icon.png');
});
it('does not set icon_url when importing from URL without iconUrl parameter', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
]);
Http::fake([
'https://example.com/plugin.zip' => Http::response($zipContent, 200),
]);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromUrl(
'https://example.com/plugin.zip',
$user
);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->icon_url)->toBeNull();
});
// Helper methods
function createMockZipFile(array $files): string
{

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Services\PluginImportService;
use Illuminate\Support\Facades\Http;
use Livewire\Volt\Volt;
use Mockery\MockInterface;
it('loads newest TRMNL recipes on mount', function () {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->assertSee('Install')
->assertSee('Installs: 10');
});
it('searches TRMNL recipes when search term is provided', function () {
Http::fake([
// First call (mount -> newest)
'usetrmnl.com/recipes.json?*' => Http::sequence()
->push([
'data' => [
[
'id' => 1,
'name' => 'Initial Recipe',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 1, 'forks' => 0],
],
],
], 200)
// Second call (search)
->push([
'data' => [
[
'id' => 2,
'name' => 'Weather Search Result',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 3, 'forks' => 1],
],
],
], 200),
]);
Volt::test('catalog.trmnl')
->assertSee('Initial Recipe')
->set('search', 'weather')
->assertSee('Weather Search Result')
->assertSee('Install');
});
it('installs plugin successfully when user is authenticated', function () {
$user = User::factory()->create();
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
]);
$this->actingAs($user);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
});
it('shows error when user is not authenticated', function () {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertStatus(403); // This will return 403 because user is not authenticated
});
it('shows error when plugin installation fails', function () {
$user = User::factory()->create();
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
]);
$this->actingAs($user);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
});