Compare commits

..

18 commits

Author SHA1 Message Date
Benjamin Nussbaum
418f0cb59f fix: lazy load catalog 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
be05a20df3 fix: lazy load plugin images 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
e1db2a04e8 chore: OSS catalog, archive import are now beta 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
791815b77b fix: center recipe items vertically 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
cb39c7e8ca feat: reposition filter button 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
77bf4f1c96 fix: increase cache 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
f0e5e6261a feat: rearrange Add Recipe context menu 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
a077a76049 feat: add plugin funnel button to reveal search and sort options 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
54a5aa649e feat: set icon url on import 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
e980928a96 feat: show plugin icon from url 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
ca9532b6e9 fix: check arg length (external liquid renderer) 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
964d015087 fix: require trmnl-liquid to install recipes from TRMNL catalog 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
cf7ea6cd3c feat: set preferred_renderer when installing from catalog 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
aa80e944f9 feat: add trmnl-liquid renderer option 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
a7a541da42 feat: add installation function 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
73eabe8262 strip tags 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
0e9a74965b feat: add TRMNL recipe catalog 2025-11-13 16:16:35 +01:00
Benjamin Nussbaum
66f876976b feat: add TRMNL recipe catalog 2025-11-13 16:16:35 +01:00
5 changed files with 12 additions and 282 deletions

View file

@ -18,8 +18,6 @@ ENV TRMNL_LIQUID_ENABLED=1
# Switch to the root user so we can do root things # Switch to the root user so we can do root things
USER root USER root
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
# Set the working directory # Set the working directory
WORKDIR /var/www/html WORKDIR /var/www/html
@ -53,5 +51,7 @@ FROM base AS production
# Copy the assets from the assets image # Copy the assets from the assets image
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/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=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 # Drop back to the www-data user
USER www-data USER www-data

View file

@ -130,10 +130,9 @@ class Plugin extends Model
} }
} }
// Resolve Liquid variables in the entire polling_url field first, then split by newline // Split URLs by newline and filter out empty lines
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_filter( $urls = array_filter(
array_map('trim', explode("\n", $resolvedPollingUrls)), array_map('trim', explode("\n", $this->polling_url)),
fn ($url): bool => ! empty($url) fn ($url): bool => ! empty($url)
); );
@ -148,8 +147,8 @@ class Plugin extends Model
$httpRequest = $httpRequest->withBody($resolvedBody); $httpRequest = $httpRequest->withBody($resolvedBody);
} }
// URL is already resolved, use it directly // Resolve Liquid variables in the polling URL
$resolvedUrl = $url; $resolvedUrl = $this->resolveLiquidVariables($url);
try { try {
// Make the request based on the verb // Make the request based on the verb
@ -184,8 +183,8 @@ class Plugin extends Model
$httpRequest = $httpRequest->withBody($resolvedBody); $httpRequest = $httpRequest->withBody($resolvedBody);
} }
// URL is already resolved, use it directly // Resolve Liquid variables in the polling URL
$resolvedUrl = $url; $resolvedUrl = $this->resolveLiquidVariables($url);
try { try {
// Make the request based on the verb // Make the request based on the verb
@ -242,7 +241,7 @@ class Plugin extends Model
try { try {
// Attempt to parse it into JSON // Attempt to parse it into JSON
$json = $httpResponse->json(); $json = $httpResponse->json();
if ($json !== null) { if($json !== null) {
return $json; return $json;
} }
@ -345,48 +344,19 @@ class Plugin extends Model
return $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 * 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 * @param string $template The template string containing Liquid variables
* @return string The resolved template with variables replaced with their values * @return string The resolved template with variables replaced with their values
* *
* @throws LiquidException * @throws LiquidException
* @throws Exception
*/ */
public function resolveLiquidVariables(string $template): string public function resolveLiquidVariables(string $template): string
{ {
// Get configuration variables - make them available at root level // Get configuration variables - make them available at root level
$variables = $this->configuration ?? []; $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 // Use the Liquid template engine to resolve variables
$environment = App::make('liquid.environment'); $environment = App::make('liquid.environment');
$environment->filterRegistry->register(StandardFilters::class); $environment->filterRegistry->register(StandardFilters::class);

View file

@ -130,7 +130,7 @@ return [
'force_https' => env('FORCE_HTTPS', false), 'force_https' => env('FORCE_HTTPS', false),
'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true), 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false),
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), 'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
'notifications' => [ 'notifications' => [

View file

@ -296,16 +296,6 @@ new class extends Component {
<flux:heading size="lg">Import from TRMNL Recipe Catalog <flux:heading size="lg">Import from TRMNL Recipe Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge> <flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading> </flux:heading>
<flux:callout class="mb-4 mt-4" color="yellow">
<flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2">
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span>. (Included in Docker container)</flux:text></li>
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
</ul>
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
</flux:callout>
</div> </div>
<livewire:catalog.trmnl lazy /> <livewire:catalog.trmnl lazy />
</div> </div>

View file

@ -357,233 +357,3 @@ test('resolveLiquidVariables handles empty configuration', function (): void {
expect($plugin->resolveLiquidVariables($template))->toBe($expected); expect($plugin->resolveLiquidVariables($template))->toBe($expected);
}); });
test('resolveLiquidVariables uses external renderer when preferred_renderer is trmnl-liquid and template contains for loop', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: 'https://api1.example.com/data\nhttps://api2.example.com/data',
exitCode: 0
),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$template = <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID;
$result = $plugin->resolveLiquidVariables($template);
// Trim trailing newlines that may be added by the process
expect(mb_trim($result))->toBe('https://api1.example.com/data\nhttps://api2.example.com/data');
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return str_contains($command, 'trmnl-liquid-cli') &&
str_contains($command, '--template') &&
str_contains($command, '--context');
});
});
test('resolveLiquidVariables uses internal renderer when preferred_renderer is not trmnl-liquid', function (): void {
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'php',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$template = <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID;
// Should use internal renderer even with for loop
$result = $plugin->resolveLiquidVariables($template);
// Internal renderer should process the template
expect($result)->toBeString();
});
test('resolveLiquidVariables uses internal renderer when external renderer is disabled', function (): void {
config(['services.trmnl.liquid_enabled' => false]);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$template = <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID;
// Should use internal renderer when external is disabled
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBeString();
});
test('resolveLiquidVariables uses internal renderer when template does not contain for loop', function (): void {
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [
'api_key' => 'test123',
],
]);
$template = 'https://api.example.com/data?key={{ api_key }}';
// Should use internal renderer when no for loop
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBe('https://api.example.com/data?key=test123');
Illuminate\Support\Facades\Process::assertNothingRan();
});
test('resolveLiquidVariables detects for loop with standard opening tag', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: 'resolved',
exitCode: 0
),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [],
]);
// Test {% for pattern
$template = '{% for item in items %}test{% endfor %}';
$plugin->resolveLiquidVariables($template);
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($command, 'trmnl-liquid-cli');
});
});
test('resolveLiquidVariables detects for loop with whitespace stripping tag', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: 'resolved',
exitCode: 0
),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [],
]);
// Test {%- for pattern (with whitespace stripping)
$template = '{%- for item in items %}test{% endfor %}';
$plugin->resolveLiquidVariables($template);
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($command, 'trmnl-liquid-cli');
});
});
test('updateDataPayload resolves entire polling_url field first then splits by newline', function (): void {
Http::fake([
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/data",
'polling_verb' => 'get',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$plugin->updateDataPayload();
// Should have split the multi-line URL and generated two requests
expect($plugin->data_payload)->toHaveKey('IDX_0');
expect($plugin->data_payload)->toHaveKey('IDX_1');
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
});
test('updateDataPayload handles multi-line polling_url with for loop using external renderer', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: "https://api1.example.com/data\nhttps://api2.example.com/data",
exitCode: 0
),
]);
Http::fake([
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'preferred_renderer' => 'trmnl-liquid',
'polling_url' => <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID
,
'polling_verb' => 'get',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$plugin->updateDataPayload();
// Should have used external renderer and generated two URLs
expect($plugin->data_payload)->toHaveKey('IDX_0');
expect($plugin->data_payload)->toHaveKey('IDX_1');
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($command, 'trmnl-liquid-cli');
});
});