feat: add TRMNL recipe catalog

This commit is contained in:
Benjamin Nussbaum 2025-10-25 20:51:06 +02:00
parent 41baff51a6
commit a8f3232ccc
10 changed files with 664 additions and 59 deletions

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,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,

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;
}
}