'json', 'data_payload_updated_at' => 'datetime', 'is_native' => 'boolean', 'markup_language' => 'string', 'configuration' => 'json', 'configuration_template' => 'json', 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', 'preferred_renderer' => 'string', ]; protected static function boot() { parent::boot(); static::creating(function ($model): void { if (empty($model->uuid)) { $model->uuid = Str::uuid(); } }); } public function user() { return $this->belongsTo(User::class); } public function hasMissingRequiredConfigurationFields(): bool { if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) { return false; } foreach ($this->configuration_template['custom_fields'] as $field) { // Skip fields as they are informational only if ($field['field_type'] === 'author_bio') { continue; } if ($field['field_type'] === 'copyable') { continue; } if ($field['field_type'] === 'copyable_webhook_url') { continue; } $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; // Check if field is required (not marked as optional) $isRequired = ! isset($field['optional']) || $field['optional'] !== true; if ($isRequired) { $currentValue = $this->configuration[$fieldKey] ?? null; // If the field has a default value and no current value is set, it's not missing if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) { return true; // Found a required field that is not set and has no default } } } return false; // All required fields are set } public function isDataStale(): bool { if ($this->data_strategy === 'webhook') { // Treat as stale if any webhook event has occurred in the past hour return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour()); } if (! $this->data_payload_updated_at || ! $this->data_stale_minutes) { return true; } return $this->data_payload_updated_at->addMinutes($this->data_stale_minutes)->isPast(); } public function updateDataPayload(): void { if ($this->data_strategy === 'polling' && $this->polling_url) { $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; if ($this->polling_header) { // Resolve Liquid variables in the polling header $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); $headerLines = explode("\n", mb_trim($resolvedHeader)); foreach ($headerLines as $line) { $parts = explode(':', $line, 2); if (count($parts) === 2) { $headers[mb_trim($parts[0])] = mb_trim($parts[1]); } } } // Resolve Liquid variables in the entire polling_url field first, then split by newline $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); $urls = array_filter( array_map('trim', explode("\n", $resolvedPollingUrls)), fn ($url): bool => ! empty($url) ); // If only one URL, use the original logic without nesting if (count($urls) === 1) { $url = reset($urls); $httpRequest = Http::withHeaders($headers); if ($this->polling_verb === 'post' && $this->polling_body) { // Resolve Liquid variables in the polling body $resolvedBody = $this->resolveLiquidVariables($this->polling_body); $httpRequest = $httpRequest->withBody($resolvedBody); } // URL is already resolved, use it directly $resolvedUrl = $url; try { // Make the request based on the verb $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); $response = $this->parseResponse($httpResponse); $this->update([ 'data_payload' => $response, 'data_payload_updated_at' => now(), ]); } catch (Exception $e) { Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage()); $this->update([ 'data_payload' => ['error' => 'Failed to fetch data'], 'data_payload_updated_at' => now(), ]); } return; } // Multiple URLs - use nested response logic $combinedResponse = []; foreach ($urls as $index => $url) { $httpRequest = Http::withHeaders($headers); if ($this->polling_verb === 'post' && $this->polling_body) { // Resolve Liquid variables in the polling body $resolvedBody = $this->resolveLiquidVariables($this->polling_body); $httpRequest = $httpRequest->withBody($resolvedBody); } // URL is already resolved, use it directly $resolvedUrl = $url; try { // Make the request based on the verb $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); $response = $this->parseResponse($httpResponse); // Check if response is an array at root level if (array_keys($response) === range(0, count($response) - 1)) { // Response is a sequential array, nest under .data $combinedResponse["IDX_{$index}"] = ['data' => $response]; } else { // Response is an object or associative array, keep as is $combinedResponse["IDX_{$index}"] = $response; } } catch (Exception $e) { // Log error and continue with other URLs Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage()); $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; } } $this->update([ 'data_payload' => $combinedResponse, 'data_payload_updated_at' => now(), ]); } } /** * Parse HTTP response, handling both JSON and XML content types */ private function parseResponse(Response $httpResponse): array { if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) { try { // Convert XML to array and wrap under 'rss' key $xml = simplexml_load_string($httpResponse->body()); if ($xml === false) { throw new Exception('Invalid XML content'); } // Convert SimpleXML directly to array $xmlArray = $this->xmlToArray($xml); return ['rss' => $xmlArray]; } catch (Exception $e) { Log::warning('Failed to parse XML response: '.$e->getMessage()); return ['error' => 'Failed to parse XML response']; } } try { // Attempt to parse it into JSON $json = $httpResponse->json(); if ($json !== null) { return $json; } // Response doesn't seem to be JSON, wrap the response body text as a JSON object return ['data' => $httpResponse->body()]; } catch (Exception $e) { Log::warning('Failed to parse JSON response: '.$e->getMessage()); return ['error' => 'Failed to parse JSON response']; } } /** * Convert SimpleXML object to array recursively */ private function xmlToArray(SimpleXMLElement $xml): array { $array = (array) $xml; foreach ($array as $key => $value) { if ($value instanceof SimpleXMLElement) { $array[$key] = $this->xmlToArray($value); } } return $array; } /** * Apply Liquid template replacements (converts 'with' syntax to comma syntax) */ private function applyLiquidReplacements(string $template): string { $replacements = []; // Apply basic replacements $template = str_replace(array_keys($replacements), array_values($replacements), $template); // Convert Ruby/strftime date formats to PHP date formats $template = $this->convertDateFormats($template); // Convert {% render "template" with %} syntax to {% render "template", %} syntax $template = preg_replace( '/{%\s*render\s+([^}]+?)\s+with\s+/i', '{% render $1, ', $template ); // Convert for loops with filters to use temporary variables // This handles: {% for item in collection | filter: "key", "value" %} // Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %} $template = preg_replace_callback( '/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/', function (array $matches): string { $variableName = mb_trim($matches[1]); $collection = mb_trim($matches[2]); $filter = mb_trim($matches[3]); $tempVarName = '_temp_'.uniqid(); return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}"; }, (string) $template ); return $template; } /** * Convert Ruby/strftime date formats to PHP date formats in Liquid templates */ private function convertDateFormats(string $template): string { // Handle date filter formats: date: "format" or date: 'format' $template = preg_replace_callback( '/date:\s*(["\'])([^"\']+)\1/', function (array $matches): string { $quote = $matches[1]; $format = $matches[2]; $convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format); return 'date: '.$quote.$convertedFormat.$quote; }, $template ); // Handle l_date filter formats: l_date: "format" or l_date: 'format' $template = preg_replace_callback( '/l_date:\s*(["\'])([^"\']+)\1/', function (array $matches): string { $quote = $matches[1]; $format = $matches[2]; $convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format); return 'l_date: '.$quote.$convertedFormat.$quote; }, (string) $template ); return $template; } /** * Check if a template contains a Liquid for loop pattern * * @param string $template The template string to check * @return bool True if the template contains a for loop pattern */ private function containsLiquidForLoop(string $template): bool { return preg_match('/{%-?\s*for\s+/i', $template) === 1; } /** * Resolve Liquid variables in a template string using the Liquid template engine * * Uses the external trmnl-liquid renderer when: * - preferred_renderer is 'trmnl-liquid' * - External renderer is enabled in config * - Template contains a Liquid for loop pattern * * Otherwise uses the internal PHP-based Liquid renderer. * * @param string $template The template string containing Liquid variables * @return string The resolved template with variables replaced with their values * * @throws LiquidException * @throws Exception */ public function resolveLiquidVariables(string $template): string { // Get configuration variables - make them available at root level $variables = $this->configuration ?? []; // Check if external renderer should be used $useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled') && $this->containsLiquidForLoop($template); if ($useExternalRenderer) { // Use external Ruby liquid renderer return $this->renderWithExternalLiquidRenderer($template, $variables); } // Use the Liquid template engine to resolve variables $environment = App::make('liquid.environment'); $environment->filterRegistry->register(StandardFilters::class); $liquidTemplate = $environment->parseString($template); $context = $environment->newRenderContext(data: $variables); 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 * * @throws LiquidException */ public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string { if ($this->render_markup) { $renderedContent = ''; if ($this->markup_language === 'liquid') { // Get timezone from user or fall back to app timezone $timezone = $this->user->timezone ?? config('app.timezone'); // Calculate UTC offset in seconds $utcOffset = (string) Carbon::now($timezone)->getOffset(); // 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' => $utcOffset, 'name' => $this->user->name ?? 'Unknown User', 'locale' => 'en', 'time_zone_iana' => $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 : []), ], ], ], ]; // 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, 'data' => $this->data_payload, 'config' => $this->configuration ?? [], ]); } if ($standalone) { if ($size === 'full') { return view('trmnl-layouts.single', [ 'colorDepth' => $device?->colorDepth(), 'deviceVariant' => $device?->deviceVariant() ?? 'og', 'noBleed' => $this->no_bleed, 'darkMode' => $this->dark_mode, 'scaleLevel' => $device?->scaleLevel(), 'slot' => $renderedContent, ])->render(); } return view('trmnl-layouts.mashup', [ 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'colorDepth' => $device?->colorDepth(), 'deviceVariant' => $device?->deviceVariant() ?? 'og', 'darkMode' => $this->dark_mode, 'scaleLevel' => $device?->scaleLevel(), 'slot' => $renderedContent, ])->render(); } return $renderedContent; } if ($this->render_markup_view) { if ($standalone) { return view('trmnl-layouts.single', [ 'colorDepth' => $device?->colorDepth(), 'deviceVariant' => $device?->deviceVariant() ?? 'og', 'noBleed' => $this->no_bleed, 'darkMode' => $this->dark_mode, 'scaleLevel' => $device?->scaleLevel(), 'slot' => view($this->render_markup_view, [ 'size' => $size, 'data' => $this->data_payload, 'config' => $this->configuration ?? [], ])->render(), ])->render(); } return view($this->render_markup_view, [ 'size' => $size, 'data' => $this->data_payload, 'config' => $this->configuration ?? [], ])->render(); } return '

No render markup yet defined for this plugin.

'; } /** * Get a configuration value by key */ public function getConfiguration(string $key, $default = null) { return $this->configuration[$key] ?? $default; } public function getPreviewMashupLayoutForSize(string $size): string { return match ($size) { 'half_vertical' => '1Lx1R', 'quadrant' => '2x2', default => '1Tx1B', }; } }