mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Compare commits
19 commits
799e66675c
...
7b642c7eeb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b642c7eeb | ||
|
|
3ff0a6d8aa | ||
|
|
cf7285b64f | ||
|
|
e1ae0b4d3b | ||
|
|
34157cf4e5 | ||
|
|
d1776e59e3 | ||
|
|
471340ac16 | ||
|
|
e427932dd0 | ||
|
|
9c37352c17 | ||
|
|
02e695fe4d | ||
|
|
b98cda881e | ||
|
|
3ae8a610a0 | ||
|
|
03c74f9575 | ||
|
|
0da18e0e5a | ||
|
|
de0ecab67e | ||
|
|
ee3df85c2f | ||
|
|
e53c584eed | ||
|
|
1ccaa8382b | ||
|
|
36f783ac60 |
14 changed files with 818 additions and 209 deletions
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||
|
||||
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||
It allows you to manage TRMNL devices, generate screens using native plugins, recipes (55+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, it’s the most popular community-driven BYOS.
|
||||
It allows you to manage TRMNL devices, generate screens using native plugins, recipes (100+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, it’s the most popular community-driven BYOS.
|
||||
|
||||

|
||||

|
||||
|
|
|
|||
|
|
@ -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,59 +424,67 @@ 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: [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||
'trmnl' => [
|
||||
'system' => [
|
||||
'timestamp_utc' => now()->utc()->timestamp,
|
||||
],
|
||||
'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' => $this->dark_mode ? 'yes' : 'no',
|
||||
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
|
||||
'polling_headers' => $this->polling_header,
|
||||
'polling_url' => $this->polling_url,
|
||||
'custom_fields_values' => [
|
||||
...(is_array($this->configuration) ? $this->configuration : []),
|
||||
],
|
||||
// Build render context
|
||||
$context = [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||
'trmnl' => [
|
||||
'system' => [
|
||||
'timestamp_utc' => now()->utc()->timestamp,
|
||||
],
|
||||
'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' => $this->dark_mode ? 'yes' : 'no',
|
||||
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
|
||||
'polling_headers' => $this->polling_header,
|
||||
'polling_url' => $this->polling_url,
|
||||
'custom_fields_values' => [
|
||||
...(is_array($this->configuration) ? $this->configuration : []),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$renderedContent = $template->render($context);
|
||||
],
|
||||
];
|
||||
|
||||
// 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()]
|
||||
);
|
||||
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
261
composer.lock
generated
261
composer.lock
generated
|
|
@ -62,16 +62,16 @@
|
|||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.359.1",
|
||||
"version": "3.359.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "40543e3993fc5094094ac9f9bdc4434bf81cca2d"
|
||||
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/40543e3993fc5094094ac9f9bdc4434bf81cca2d",
|
||||
"reference": "40543e3993fc5094094ac9f9bdc4434bf81cca2d",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5",
|
||||
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5",
|
||||
"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.1"
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.6"
|
||||
},
|
||||
"time": "2025-10-29T20:13:06+00:00"
|
||||
"time": "2025-11-05T19:08:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bnussbau/laravel-trmnl-blade",
|
||||
|
|
@ -685,29 +685,28 @@
|
|||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.4.0",
|
||||
"version": "v3.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dragonmantank/cron-expression.git",
|
||||
"reference": "8c784d071debd117328803d86b2097615b457500"
|
||||
"reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
|
||||
"reference": "8c784d071debd117328803d86b2097615b457500",
|
||||
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013",
|
||||
"reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2|^8.0",
|
||||
"webmozart/assert": "^1.0"
|
||||
"php": "^8.2|^8.3|^8.4|^8.5"
|
||||
},
|
||||
"replace": {
|
||||
"mtdowling/cron-expression": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpunit/phpunit": "^7.0|^8.0|^9.0"
|
||||
"phpstan/extension-installer": "^1.4.3",
|
||||
"phpstan/phpstan": "^1.12.32|^2.1.31",
|
||||
"phpunit/phpunit": "^8.5.48|^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
|
|
@ -738,7 +737,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/dragonmantank/cron-expression/issues",
|
||||
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
|
||||
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -746,7 +745,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-10-09T13:47:03+00:00"
|
||||
"time": "2025-10-31T18:51:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "egulias/email-validator",
|
||||
|
|
@ -1618,16 +1617,16 @@
|
|||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.36.1",
|
||||
"version": "v12.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8"
|
||||
"reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/cad110d7685fbab990a6bb8184d0cfd847d7c4d8",
|
||||
"reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/3c3c4ad30f5b528b164a7c09aa4ad03118c4c125",
|
||||
"reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -1833,7 +1832,7 @@
|
|||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2025-10-29T14:20:57+00:00"
|
||||
"time": "2025-11-04T15:39:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/prompts",
|
||||
|
|
@ -2984,16 +2983,16 @@
|
|||
},
|
||||
{
|
||||
"name": "livewire/volt",
|
||||
"version": "v1.8.0",
|
||||
"version": "v1.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/livewire/volt.git",
|
||||
"reference": "2d9783a340d612d32f4ffd38070780ca7d7e9205"
|
||||
"reference": "4b289eef2f15398987a923d9f813cad6a6a19ea4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/livewire/volt/zipball/2d9783a340d612d32f4ffd38070780ca7d7e9205",
|
||||
"reference": "2d9783a340d612d32f4ffd38070780ca7d7e9205",
|
||||
"url": "https://api.github.com/repos/livewire/volt/zipball/4b289eef2f15398987a923d9f813cad6a6a19ea4",
|
||||
"reference": "4b289eef2f15398987a923d9f813cad6a6a19ea4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -3052,7 +3051,7 @@
|
|||
"issues": "https://github.com/livewire/volt/issues",
|
||||
"source": "https://github.com/livewire/volt"
|
||||
},
|
||||
"time": "2025-10-29T15:52:35+00:00"
|
||||
"time": "2025-10-30T02:46:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
|
|
@ -3408,25 +3407,25 @@
|
|||
},
|
||||
{
|
||||
"name": "nette/schema",
|
||||
"version": "v1.3.2",
|
||||
"version": "v1.3.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nette/schema.git",
|
||||
"reference": "da801d52f0354f70a638673c4a0f04e16529431d"
|
||||
"reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
|
||||
"reference": "da801d52f0354f70a638673c4a0f04e16529431d",
|
||||
"url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004",
|
||||
"reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"nette/utils": "^4.0",
|
||||
"php": "8.1 - 8.4"
|
||||
"php": "8.1 - 8.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"nette/tester": "^2.5.2",
|
||||
"phpstan/phpstan-nette": "^1.0",
|
||||
"phpstan/phpstan-nette": "^2.0@stable",
|
||||
"tracy/tracy": "^2.8"
|
||||
},
|
||||
"type": "library",
|
||||
|
|
@ -3436,6 +3435,9 @@
|
|||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nette\\": "src"
|
||||
},
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
|
|
@ -3464,9 +3466,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nette/schema/issues",
|
||||
"source": "https://github.com/nette/schema/tree/v1.3.2"
|
||||
"source": "https://github.com/nette/schema/tree/v1.3.3"
|
||||
},
|
||||
"time": "2024-10-06T23:10:23+00:00"
|
||||
"time": "2025-10-30T22:57:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/utils",
|
||||
|
|
@ -7732,64 +7734,6 @@
|
|||
],
|
||||
"time": "2024-11-21T01:49:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "1.12.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
|
||||
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-date": "*",
|
||||
"ext-filter": "*",
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "",
|
||||
"ext-simplexml": "",
|
||||
"ext-spl": ""
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.10-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webmozart\\Assert\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Bernhard Schussek",
|
||||
"email": "bschussek@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Assertions to validate method input/output with nice error messages.",
|
||||
"keywords": [
|
||||
"assert",
|
||||
"check",
|
||||
"validate"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/webmozarts/assert/issues",
|
||||
"source": "https://github.com/webmozarts/assert/tree/1.12.1"
|
||||
},
|
||||
"time": "2025-10-29T15:56:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "wnx/sidecar-browsershot",
|
||||
"version": "v2.6.1",
|
||||
|
|
@ -7880,16 +7824,16 @@
|
|||
"packages-dev": [
|
||||
{
|
||||
"name": "brianium/paratest",
|
||||
"version": "v7.14.1",
|
||||
"version": "v7.14.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paratestphp/paratest.git",
|
||||
"reference": "e1a93c38a94f4808faf75552e835666d3a6f8bb2"
|
||||
"reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/e1a93c38a94f4808faf75552e835666d3a6f8bb2",
|
||||
"reference": "e1a93c38a94f4808faf75552e835666d3a6f8bb2",
|
||||
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/de06de1ae1203b11976c6ca01d6a9081c8b33d45",
|
||||
"reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -7903,7 +7847,7 @@
|
|||
"phpunit/php-code-coverage": "^12.4.0",
|
||||
"phpunit/php-file-iterator": "^6",
|
||||
"phpunit/php-timer": "^8",
|
||||
"phpunit/phpunit": "^12.4.0",
|
||||
"phpunit/phpunit": "^12.4.1",
|
||||
"sebastian/environment": "^8.0.3",
|
||||
"symfony/console": "^6.4.20 || ^7.3.4",
|
||||
"symfony/process": "^6.4.20 || ^7.3.4"
|
||||
|
|
@ -7913,7 +7857,7 @@
|
|||
"ext-pcntl": "*",
|
||||
"ext-pcov": "*",
|
||||
"ext-posix": "*",
|
||||
"phpstan/phpstan": "^2.1.30",
|
||||
"phpstan/phpstan": "^2.1.31",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0.3",
|
||||
"phpstan/phpstan-phpunit": "^2.0.7",
|
||||
"phpstan/phpstan-strict-rules": "^2.0.7",
|
||||
|
|
@ -7957,7 +7901,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/paratestphp/paratest/issues",
|
||||
"source": "https://github.com/paratestphp/paratest/tree/v7.14.1"
|
||||
"source": "https://github.com/paratestphp/paratest/tree/v7.14.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -7969,7 +7913,7 @@
|
|||
"type": "paypal"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-06T08:26:52+00:00"
|
||||
"time": "2025-10-24T07:20:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
|
|
@ -8368,16 +8312,16 @@
|
|||
},
|
||||
{
|
||||
"name": "larastan/larastan",
|
||||
"version": "v3.7.2",
|
||||
"version": "v3.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/larastan/larastan.git",
|
||||
"reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae"
|
||||
"reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/a761859a7487bd7d0cb8b662a7538a234d5bb5ae",
|
||||
"reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae",
|
||||
"url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e",
|
||||
"reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -8391,7 +8335,7 @@
|
|||
"illuminate/pipeline": "^11.44.2 || ^12.4.1",
|
||||
"illuminate/support": "^11.44.2 || ^12.4.1",
|
||||
"php": "^8.2",
|
||||
"phpstan/phpstan": "^2.1.28"
|
||||
"phpstan/phpstan": "^2.1.29"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^13",
|
||||
|
|
@ -8404,7 +8348,8 @@
|
|||
"phpunit/phpunit": "^10.5.35 || ^11.5.15"
|
||||
},
|
||||
"suggest": {
|
||||
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench"
|
||||
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
|
||||
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
|
||||
},
|
||||
"type": "phpstan-extension",
|
||||
"extra": {
|
||||
|
|
@ -8445,7 +8390,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/larastan/larastan/issues",
|
||||
"source": "https://github.com/larastan/larastan/tree/v3.7.2"
|
||||
"source": "https://github.com/larastan/larastan/tree/v3.8.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -8453,20 +8398,20 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-19T09:03:05+00:00"
|
||||
"time": "2025-10-27T23:09:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v1.6.0",
|
||||
"version": "v1.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/boost.git",
|
||||
"reference": "29d1c7c5a816d2b55c39f50bb07bdbca6c595b09"
|
||||
"reference": "355f7c27952862aab3f61adec27773fd4d41a582"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/29d1c7c5a816d2b55c39f50bb07bdbca6c595b09",
|
||||
"reference": "29d1c7c5a816d2b55c39f50bb07bdbca6c595b09",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/355f7c27952862aab3f61adec27773fd4d41a582",
|
||||
"reference": "355f7c27952862aab3f61adec27773fd4d41a582",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -8475,7 +8420,7 @@
|
|||
"illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
|
||||
"laravel/mcp": "^0.2.0|^0.3.0",
|
||||
"laravel/mcp": "^0.3.2",
|
||||
"laravel/prompts": "0.1.25|^0.3.6",
|
||||
"laravel/roster": "^0.2.9",
|
||||
"php": "^8.1"
|
||||
|
|
@ -8519,7 +8464,7 @@
|
|||
"issues": "https://github.com/laravel/boost/issues",
|
||||
"source": "https://github.com/laravel/boost"
|
||||
},
|
||||
"time": "2025-10-28T17:43:53+00:00"
|
||||
"time": "2025-11-05T21:41:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
|
|
@ -9107,16 +9052,16 @@
|
|||
},
|
||||
{
|
||||
"name": "pestphp/pest",
|
||||
"version": "v4.1.2",
|
||||
"version": "v4.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pestphp/pest.git",
|
||||
"reference": "08b09f2e98fc6830050c0237968b233768642d46"
|
||||
"reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/pestphp/pest/zipball/08b09f2e98fc6830050c0237968b233768642d46",
|
||||
"reference": "08b09f2e98fc6830050c0237968b233768642d46",
|
||||
"url": "https://api.github.com/repos/pestphp/pest/zipball/477d20a54fd9329ddfb0f8d4eb90dca7bc81b027",
|
||||
"reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -9128,12 +9073,12 @@
|
|||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.1.0",
|
||||
"php": "^8.3.0",
|
||||
"phpunit/phpunit": "^12.4.0",
|
||||
"phpunit/phpunit": "^12.4.1",
|
||||
"symfony/process": "^7.3.4"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.4.0",
|
||||
"phpunit/phpunit": ">12.4.1",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
|
|
@ -9207,7 +9152,7 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/pestphp/pest/issues",
|
||||
"source": "https://github.com/pestphp/pest/tree/v4.1.2"
|
||||
"source": "https://github.com/pestphp/pest/tree/v4.1.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -9219,7 +9164,7 @@
|
|||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-05T19:09:49+00:00"
|
||||
"time": "2025-10-29T22:45:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pestphp/pest-plugin",
|
||||
|
|
@ -10365,16 +10310,16 @@
|
|||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "12.4.0",
|
||||
"version": "12.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "f62aab5794e36ccd26860db2d1bbf89ac19028d9"
|
||||
"reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f62aab5794e36ccd26860db2d1bbf89ac19028d9",
|
||||
"reference": "f62aab5794e36ccd26860db2d1bbf89ac19028d9",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc5413a2e6d240d2f6d9317bdf7f0a24e73de194",
|
||||
"reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -10442,7 +10387,7 @@
|
|||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.0"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -10466,7 +10411,7 @@
|
|||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-03T04:28:03+00:00"
|
||||
"time": "2025-10-09T14:08:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
|
|
@ -11585,6 +11530,64 @@
|
|||
}
|
||||
],
|
||||
"time": "2024-03-03T12:36:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "1.12.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webmozarts/assert.git",
|
||||
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
|
||||
"reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-date": "*",
|
||||
"ext-filter": "*",
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "",
|
||||
"ext-simplexml": "",
|
||||
"ext-spl": ""
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.10-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Webmozart\\Assert\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Bernhard Schussek",
|
||||
"email": "bschussek@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Assertions to validate method input/output with nice error messages.",
|
||||
"keywords": [
|
||||
"assert",
|
||||
"check",
|
||||
"validate"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/webmozarts/assert/issues",
|
||||
"source": "https://github.com/webmozarts/assert/tree/1.12.1"
|
||||
},
|
||||
"time": "2025-10-29T15:56:20+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
233
resources/views/livewire/catalog/trmnl.blade.php
Normal file
233
resources/views/livewire/catalog/trmnl.blade.php
Normal 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::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 }}" 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>
|
||||
|
|
@ -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,28 +166,37 @@ 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 & 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>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:button.group>
|
||||
<flux:modal.trigger name="add-plugin">
|
||||
<flux:button icon="plus" variant="primary">Add Recipe</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:dropdown>
|
||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
||||
<flux:menu>
|
||||
<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:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:button.group>
|
||||
<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: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,7 +282,7 @@ 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>
|
||||
|
|
@ -280,6 +290,17 @@ new class extends Component {
|
|||
</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 />
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
<flux:modal name="add-plugin" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
|
|
@ -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">
|
||||
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
145
tests/Feature/Volt/CatalogTrmnlTest.php
Normal file
145
tests/Feature/Volt/CatalogTrmnlTest.php
Normal 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
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue