feat: add TRMNL custom Liquid filters

This commit is contained in:
Benjamin Nussbaum 2025-07-15 15:05:08 +02:00
parent 227f0e51c2
commit 895d126ab7
12 changed files with 519 additions and 0 deletions

View file

@ -0,0 +1,22 @@
<?php
namespace App\Liquid\Filters;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
* Data filters for Liquid templates
*/
class Data extends FiltersProvider
{
/**
* Convert a variable to JSON
*
* @param mixed $value The variable to convert
* @return string JSON representation of the variable
*/
public function json(mixed $value): string
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Liquid\Filters;
use DateTimeInterface;
use Illuminate\Support\Carbon;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
* Localization filters for Liquid templates
*
* Uses Laravel's translator for word translations. Translation files are located in the
* lang/{locale}/custom_plugins.php files.
*/
class Localization extends FiltersProvider
{
/**
* Localize a date with strftime syntax
*
* @param mixed $date The date to localize (string or DateTime)
* @param string $format The strftime format string
* @param string|null $locale The locale to use for localization
* @return string The localized date string
*/
public function l_date(mixed $date, string $format = 'Y-m-d', ?string $locale = null): string
{
$carbon = $date instanceof DateTimeInterface ? Carbon::instance($date) : Carbon::parse($date);
if ($locale) {
$carbon->locale($locale);
}
return $carbon->translatedFormat($format);
}
/**
* Translate a common word to another language
*
* @param string $word The word to translate
* @param string $locale The locale to translate to
* @return string The translated word
*/
public function l_word(string $word, string $locale): string
{
$translation = trans('custom_plugins.'.mb_strtolower($word), locale: $locale);
if ($translation === 'custom_plugins.'.mb_strtolower($word)) {
return $word;
}
return $translation;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Liquid\Filters;
use Illuminate\Support\Number;
use Keepsuit\Liquid\Filters\FiltersProvider;
class Numbers extends FiltersProvider
{
/**
* Format a number with delimiters (default: comma)
*
* @param mixed $value The number to format
* @param string $delimiter The delimiter to use (default: comma)
* @param string $separator The separator for decimal part (default: period)
*/
public function number_with_delimiter(mixed $value, string $delimiter = ',', string $separator = '.'): string
{
// 2 decimal places for floats, 0 for integers
$decimal = is_float($value + 0) ? 2 : 0;
return number_format($value, decimals: $decimal, decimal_separator: $separator, thousands_separator: $delimiter);
}
/**
* Format a number as currency
*
* @param mixed $value The number to format
* @param string $currency Currency symbol or locale code
* @param string $delimiter The delimiter to use (default: comma)
* @param string $separator The separator for decimal part (default: period)
*/
public function number_to_currency(mixed $value, string $currency = 'USD', string $delimiter = ',', string $separator = '.'): string
{
if ($currency === '$') {
$currency = 'USD';
} elseif ($currency === '€') {
$currency = 'EUR';
} elseif ($currency === '£') {
$currency = 'GBP';
}
if ($delimiter === '.' && $separator === ',') {
$locale = 'de';
} else {
$locale = 'en';
}
// 2 decimal places for floats, 0 for integers
$decimal = is_float($value + 0) ? 2 : 0;
return Number::currency($value, in: $currency, precision: $decimal, locale: $locale);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Liquid\Filters;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Keepsuit\Liquid\Filters\FiltersProvider;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Exception\CommonMarkException;
/**
* String, Markup, and HTML filters for Liquid templates
*/
class StringMarkup extends FiltersProvider
{
/**
* Pluralize a word based on count
*
* @param string $word The word to pluralize
* @param int $count The count to determine pluralization
* @return string The pluralized word with count
*/
public function pluralize(string $word, int $count = 2): string
{
if ($count === 1) {
return "{$count} {$word}";
}
return "{$count} ".Str::plural($word, $count);
}
/**
* Convert markdown to HTML
*
* @param string $markdown The markdown text to convert
* @return string The HTML representation of the markdown
*/
public function markdown_to_html(string $markdown): ?string
{
$converter = new CommonMarkConverter();
try {
return $converter->convert($markdown);
} catch (CommonMarkException $e) {
Log::error('Markdown conversion error: '.$e->getMessage());
}
return null;
}
/**
* Strip HTML tags from a string
*
* @param string $html The HTML string to strip
* @return string The string without HTML tags
*/
public function strip_html(string $html): string
{
return strip_tags($html);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Liquid\Filters;
use Keepsuit\Liquid\Concerns\ContextAware;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
* Uniqueness filters for Liquid templates
*/
class Uniqueness extends FiltersProvider
{
use ContextAware;
/**
* Append a random string to ensure uniqueness within a template
*
* @param string $prefix The prefix to append the random string to
* @return string The prefix with a random string appended
*/
public function append_random(string $prefix): string
{
return $prefix.$this->generateRandomString();
}
/**
* Generate a random string
*
* @param int $length The length of the random string
* @return string A random string
*/
private function generateRandomString(int $length = 4): string
{
$characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
$randomString = '';
for ($i = 0; $i < $length; ++$i) {
$randomString .= $characters[rand(0, mb_strlen($characters) - 1)];
}
return $randomString;
}
}

View file

@ -2,6 +2,11 @@
namespace App\Models;
use App\Liquid\Filters\Data;
use App\Liquid\Filters\Localization;
use App\Liquid\Filters\Numbers;
use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
@ -95,6 +100,14 @@ class Plugin extends Model
if ($this->markup_language === 'liquid') {
$environment = App::make('liquid.environment');
// Register all custom filters
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(Data::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
$environment->filterRegistry->register(Localization::class);
$template = $environment->parseString($this->render_markup);
$context = $environment->newRenderContext(data: ['size' => $size, 'data' => $this->data_payload]);
$renderedContent = $template->render($context);

View file

@ -0,0 +1,7 @@
<?php
return [
'today' => 'heute',
'tomorrow' => 'morgen',
'yesterday' => 'gestern',
];

View file

@ -0,0 +1,55 @@
<?php
use App\Liquid\Filters\Data;
test('json filter converts arrays to JSON', function () {
$filter = new Data();
$array = ['foo' => 'bar', 'baz' => 'qux'];
expect($filter->json($array))->toBe('{"foo":"bar","baz":"qux"}');
});
test('json filter converts objects to JSON', function () {
$filter = new Data();
$object = new stdClass();
$object->foo = 'bar';
$object->baz = 'qux';
expect($filter->json($object))->toBe('{"foo":"bar","baz":"qux"}');
});
test('json filter handles nested structures', function () {
$filter = new Data();
$nested = [
'foo' => 'bar',
'nested' => [
'baz' => 'qux',
'items' => [1, 2, 3],
],
];
expect($filter->json($nested))->toBe('{"foo":"bar","nested":{"baz":"qux","items":[1,2,3]}}');
});
test('json filter handles scalar values', function () {
$filter = new Data();
expect($filter->json('string'))->toBe('"string"');
expect($filter->json(123))->toBe('123');
expect($filter->json(true))->toBe('true');
expect($filter->json(null))->toBe('null');
});
test('json filter preserves unicode characters', function () {
$filter = new Data();
$data = ['message' => 'Hello, 世界'];
expect($filter->json($data))->toBe('{"message":"Hello, 世界"}');
});
test('json filter does not escape slashes', function () {
$filter = new Data();
$data = ['url' => 'https://example.com/path'];
expect($filter->json($data))->toBe('{"url":"https://example.com/path"}');
});

View file

@ -0,0 +1,62 @@
<?php
use App\Liquid\Filters\Localization;
test('l_date formats date with default format', function () {
$filter = new Localization();
$date = '2025-01-11';
$result = $filter->l_date($date);
// Default format is 'Y-m-d', which should output something like '2025-01-11'
// The exact output might vary depending on the locale, but it should contain the year, month, and day
expect($result)->toContain('2025');
expect($result)->toContain('01');
expect($result)->toContain('11');
});
test('l_date formats date with custom format', function () {
$filter = new Localization();
$date = '2025-01-11';
$result = $filter->l_date($date, '%y %b');
// Format '%y %b' should output something like '25 Jan'
// The month name might vary depending on the locale
expect($result)->toContain('25');
// We can't check for 'Jan' specifically as it might be localized
});
test('l_date handles DateTime objects', function () {
$filter = new Localization();
$date = new DateTimeImmutable('2025-01-11');
$result = $filter->l_date($date, 'Y-m-d');
expect($result)->toContain('2025-01-11');
});
test('l_word translates common words', function () {
$filter = new Localization();
expect($filter->l_word('today', 'de'))->toBe('heute');
});
test('l_word returns original word if no translation exists', function () {
$filter = new Localization();
expect($filter->l_word('hello', 'es-ES'))->toBe('hello');
expect($filter->l_word('world', 'ko'))->toBe('world');
});
test('l_word is case-insensitive', function () {
$filter = new Localization();
expect($filter->l_word('TODAY', 'de'))->toBe('heute');
});
test('l_word returns original word for unknown locales', function () {
$filter = new Localization();
expect($filter->l_word('today', 'unknown-locale'))->toBe('today');
});

View file

@ -0,0 +1,47 @@
<?php
use App\Liquid\Filters\Numbers;
test('number_with_delimiter formats numbers with commas by default', function () {
$filter = new Numbers();
expect($filter->number_with_delimiter(1234))->toBe('1,234');
expect($filter->number_with_delimiter(1000000))->toBe('1,000,000');
expect($filter->number_with_delimiter(0))->toBe('0');
});
test('number_with_delimiter handles custom delimiters', function () {
$filter = new Numbers();
expect($filter->number_with_delimiter(1234, '.'))->toBe('1.234');
expect($filter->number_with_delimiter(1000000, ' '))->toBe('1 000 000');
});
test('number_with_delimiter handles decimal values with custom separators', function () {
$filter = new Numbers();
expect($filter->number_with_delimiter(1234.57, ' ', ','))->toBe('1 234,57');
expect($filter->number_with_delimiter(1234.5, '.', ','))->toBe('1.234,50');
});
test('number_to_currency formats numbers with dollar sign by default', function () {
$filter = new Numbers();
expect($filter->number_to_currency(1234))->toBe('$1,234');
expect($filter->number_to_currency(1234.5))->toBe('$1,234.50');
expect($filter->number_to_currency(0))->toBe('$0');
});
test('number_to_currency handles custom currency symbols', function () {
$filter = new Numbers();
expect($filter->number_to_currency(1234, '£'))->toBe('£1,234');
expect($filter->number_to_currency(152350.69, '€'))->toBe('€152,350.69');
});
test('number_to_currency handles custom delimiters and separators', function () {
$filter = new Numbers();
expect($filter->number_to_currency(1234.57, '£', '.', ','))->toBe('1.234,57 £');
expect($filter->number_to_currency(1234.57, '€', ',', '.'))->toBe('€1,234.57');
});

View file

@ -0,0 +1,90 @@
<?php
use App\Liquid\Filters\StringMarkup;
test('pluralize returns singular form with count 1', function () {
$filter = new StringMarkup();
expect($filter->pluralize('book', 1))->toBe('1 book');
expect($filter->pluralize('person', 1))->toBe('1 person');
});
test('pluralize returns plural form with count greater than 1', function () {
$filter = new StringMarkup();
expect($filter->pluralize('book', 2))->toBe('2 books');
expect($filter->pluralize('person', 4))->toBe('4 people');
});
test('pluralize handles irregular plurals correctly', function () {
$filter = new StringMarkup();
expect($filter->pluralize('child', 3))->toBe('3 children');
expect($filter->pluralize('sheep', 5))->toBe('5 sheep');
});
test('pluralize uses default count of 2 when not specified', function () {
$filter = new StringMarkup();
expect($filter->pluralize('book'))->toBe('2 books');
expect($filter->pluralize('person'))->toBe('2 people');
});
test('markdown_to_html converts basic markdown to HTML', function () {
$filter = new StringMarkup();
$markdown = 'This is *italic* and **bold**.';
// The exact HTML output might vary depending on the Parsedown implementation
// So we'll check for the presence of HTML tags rather than the exact output
$result = $filter->markdown_to_html($markdown);
expect($result)->toContain('<em>italic</em>');
expect($result)->toContain('<strong>bold</strong>');
});
test('markdown_to_html converts links correctly', function () {
$filter = new StringMarkup();
$markdown = 'This is [a link](https://example.com).';
$result = $filter->markdown_to_html($markdown);
expect($result)->toContain('<a href="https://example.com">a link</a>');
});
test('markdown_to_html handles fallback when Parsedown is not available', function () {
// Create a mock that simulates Parsedown not being available
$filter = new class extends StringMarkup
{
public function markdown_to_html(string $markdown): string
{
// Force the fallback path
return nl2br(htmlspecialchars($markdown));
}
};
$markdown = 'This is *italic* and [a link](https://example.com).';
$result = $filter->markdown_to_html($markdown);
expect($result)->toBe('This is *italic* and [a link](https://example.com).');
});
test('strip_html removes HTML tags', function () {
$filter = new StringMarkup();
$html = '<p>This is <strong>bold</strong> and <em>italic</em>.</p>';
expect($filter->strip_html($html))->toBe('This is bold and italic.');
});
test('strip_html preserves text content', function () {
$filter = new StringMarkup();
$html = '<div>Hello, <span>world</span>!</div>';
expect($filter->strip_html($html))->toBe('Hello, world!');
});
test('strip_html handles nested tags', function () {
$filter = new StringMarkup();
$html = '<div><p>Paragraph <strong>with <em>nested</em> tags</strong>.</p></div>';
expect($filter->strip_html($html))->toBe('Paragraph with nested tags.');
});

View file

@ -0,0 +1,13 @@
<?php
use App\Liquid\Filters\Uniqueness;
test('append_random appends a random string with 4 characters', function () {
$filter = new Uniqueness();
$result = $filter->append_random('chart-');
// Check that the result starts with the prefix
expect($result)->toStartWith('chart-');
// Check that the result is longer than just the prefix (has random part)
expect(mb_strlen($result))->toBe(mb_strlen('chart-') + 4);
});