From a182b7143a3a59e31cb9df6a4a953e724d1b8657 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:49:10 +0000 Subject: [PATCH 001/209] chore(deps): bump livewire/livewire from 3.6.3 to 3.6.4 Bumps [livewire/livewire](https://github.com/livewire/livewire) from 3.6.3 to 3.6.4. - [Release notes](https://github.com/livewire/livewire/releases) - [Commits](https://github.com/livewire/livewire/compare/v3.6.3...v3.6.4) --- updated-dependencies: - dependency-name: livewire/livewire dependency-version: 3.6.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 1be7eb5..da7185a 100644 --- a/composer.lock +++ b/composer.lock @@ -2708,16 +2708,16 @@ }, { "name": "livewire/livewire", - "version": "v3.6.3", + "version": "v3.6.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "56aa1bb63a46e06181c56fa64717a7287e19115e" + "reference": "ef04be759da41b14d2d129e670533180a44987dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/56aa1bb63a46e06181c56fa64717a7287e19115e", - "reference": "56aa1bb63a46e06181c56fa64717a7287e19115e", + "url": "https://api.github.com/repos/livewire/livewire/zipball/ef04be759da41b14d2d129e670533180a44987dc", + "reference": "ef04be759da41b14d2d129e670533180a44987dc", "shasum": "" }, "require": { @@ -2772,7 +2772,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.6.3" + "source": "https://github.com/livewire/livewire/tree/v3.6.4" }, "funding": [ { @@ -2780,7 +2780,7 @@ "type": "github" } ], - "time": "2025-04-12T22:26:52+00:00" + "time": "2025-07-17T05:12:15+00:00" }, { "name": "livewire/volt", From 227f0e51c2e2958d3bb1e175fc942406c6ab6ba3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Jul 2025 07:25:44 +0000 Subject: [PATCH 002/209] chore(deps): bump form-data from 4.0.2 to 4.0.4 Bumps [form-data](https://github.com/form-data/form-data) from 4.0.2 to 4.0.4. - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 61 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0fdc023..9f5ebf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1013,6 +1013,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.10", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz", @@ -1857,14 +1911,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { From 895d126ab72880e664a0292e6ba574de9b45bf8b Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 15 Jul 2025 15:05:08 +0200 Subject: [PATCH 003/209] feat: add TRMNL custom Liquid filters --- app/Liquid/Filters/Data.php | 22 +++++ app/Liquid/Filters/Localization.php | 52 +++++++++++ app/Liquid/Filters/Numbers.php | 54 +++++++++++ app/Liquid/Filters/StringMarkup.php | 61 +++++++++++++ app/Liquid/Filters/Uniqueness.php | 43 +++++++++ app/Models/Plugin.php | 13 +++ lang/de/custom_plugins.php | 7 ++ tests/Unit/Liquid/Filters/DataTest.php | 55 ++++++++++++ .../Unit/Liquid/Filters/LocalizationTest.php | 62 +++++++++++++ tests/Unit/Liquid/Filters/NumbersTest.php | 47 ++++++++++ .../Unit/Liquid/Filters/StringMarkupTest.php | 90 +++++++++++++++++++ tests/Unit/Liquid/Filters/UniquenessTest.php | 13 +++ 12 files changed, 519 insertions(+) create mode 100644 app/Liquid/Filters/Data.php create mode 100644 app/Liquid/Filters/Localization.php create mode 100644 app/Liquid/Filters/Numbers.php create mode 100644 app/Liquid/Filters/StringMarkup.php create mode 100644 app/Liquid/Filters/Uniqueness.php create mode 100644 lang/de/custom_plugins.php create mode 100644 tests/Unit/Liquid/Filters/DataTest.php create mode 100644 tests/Unit/Liquid/Filters/LocalizationTest.php create mode 100644 tests/Unit/Liquid/Filters/NumbersTest.php create mode 100644 tests/Unit/Liquid/Filters/StringMarkupTest.php create mode 100644 tests/Unit/Liquid/Filters/UniquenessTest.php diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php new file mode 100644 index 0000000..5b1f92f --- /dev/null +++ b/app/Liquid/Filters/Data.php @@ -0,0 +1,22 @@ +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; + } +} diff --git a/app/Liquid/Filters/Numbers.php b/app/Liquid/Filters/Numbers.php new file mode 100644 index 0000000..53d1973 --- /dev/null +++ b/app/Liquid/Filters/Numbers.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/app/Liquid/Filters/Uniqueness.php b/app/Liquid/Filters/Uniqueness.php new file mode 100644 index 0000000..89148c4 --- /dev/null +++ b/app/Liquid/Filters/Uniqueness.php @@ -0,0 +1,43 @@ +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; + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 1d07251..6c17101 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -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); diff --git a/lang/de/custom_plugins.php b/lang/de/custom_plugins.php new file mode 100644 index 0000000..3fd8785 --- /dev/null +++ b/lang/de/custom_plugins.php @@ -0,0 +1,7 @@ + 'heute', + 'tomorrow' => 'morgen', + 'yesterday' => 'gestern', +]; diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php new file mode 100644 index 0000000..ffb4088 --- /dev/null +++ b/tests/Unit/Liquid/Filters/DataTest.php @@ -0,0 +1,55 @@ + '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"}'); +}); diff --git a/tests/Unit/Liquid/Filters/LocalizationTest.php b/tests/Unit/Liquid/Filters/LocalizationTest.php new file mode 100644 index 0000000..384c837 --- /dev/null +++ b/tests/Unit/Liquid/Filters/LocalizationTest.php @@ -0,0 +1,62 @@ +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'); +}); diff --git a/tests/Unit/Liquid/Filters/NumbersTest.php b/tests/Unit/Liquid/Filters/NumbersTest.php new file mode 100644 index 0000000..8ea73bf --- /dev/null +++ b/tests/Unit/Liquid/Filters/NumbersTest.php @@ -0,0 +1,47 @@ +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'); +}); diff --git a/tests/Unit/Liquid/Filters/StringMarkupTest.php b/tests/Unit/Liquid/Filters/StringMarkupTest.php new file mode 100644 index 0000000..4021a07 --- /dev/null +++ b/tests/Unit/Liquid/Filters/StringMarkupTest.php @@ -0,0 +1,90 @@ +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('italic'); + expect($result)->toContain('bold'); +}); + +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 link'); +}); + +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 = '

This is bold and italic.

'; + + expect($filter->strip_html($html))->toBe('This is bold and italic.'); +}); + +test('strip_html preserves text content', function () { + $filter = new StringMarkup(); + $html = '
Hello, world!
'; + + expect($filter->strip_html($html))->toBe('Hello, world!'); +}); + +test('strip_html handles nested tags', function () { + $filter = new StringMarkup(); + $html = '

Paragraph with nested tags.

'; + + expect($filter->strip_html($html))->toBe('Paragraph with nested tags.'); +}); diff --git a/tests/Unit/Liquid/Filters/UniquenessTest.php b/tests/Unit/Liquid/Filters/UniquenessTest.php new file mode 100644 index 0000000..291f312 --- /dev/null +++ b/tests/Unit/Liquid/Filters/UniquenessTest.php @@ -0,0 +1,13 @@ +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); +}); From 4b88726c96a813fd261036951b458dca12aab192 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 24 Jul 2025 19:23:31 +0200 Subject: [PATCH 004/209] chore: update dependencies --- composer.lock | 104 +++--- package-lock.json | 822 ++++++++++++++++++++++------------------------ 2 files changed, 438 insertions(+), 488 deletions(-) diff --git a/composer.lock b/composer.lock index da7185a..d1a1ddb 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.349.3", + "version": "3.351.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b2d4718786398f47626add9c29840fc416175ef2" + "reference": "2f00efa2544d158ea366c1e1174097ef330ec883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b2d4718786398f47626add9c29840fc416175ef2", - "reference": "b2d4718786398f47626add9c29840fc416175ef2", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2f00efa2544d158ea366c1e1174097ef330ec883", + "reference": "2f00efa2544d158ea366c1e1174097ef330ec883", "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.349.3" + "source": "https://github.com/aws/aws-sdk-php/tree/3.351.5" }, - "time": "2025-07-09T18:10:17+00:00" + "time": "2025-07-23T18:04:16+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -1629,16 +1629,16 @@ }, { "name": "laravel/framework", - "version": "v12.20.0", + "version": "v12.21.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff" + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/1b9a00f8caf5503c92aa436279172beae1a484ff", - "reference": "1b9a00f8caf5503c92aa436279172beae1a484ff", + "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", "shasum": "" }, "require": { @@ -1840,7 +1840,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-07-08T15:02:21+00:00" + "time": "2025-07-22T15:41:55+00:00" }, { "name": "laravel/prompts", @@ -1903,16 +1903,16 @@ }, { "name": "laravel/sanctum", - "version": "v4.1.2", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "e4c09e69aecd5a383e0c1b85a6bb501c997d7491" + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/e4c09e69aecd5a383e0c1b85a6bb501c997d7491", - "reference": "e4c09e69aecd5a383e0c1b85a6bb501c997d7491", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", "shasum": "" }, "require": { @@ -1963,7 +1963,7 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2025-07-01T15:49:32+00:00" + "time": "2025-07-09T19:45:24+00:00" }, { "name": "laravel/serializable-closure", @@ -2094,16 +2094,16 @@ }, { "name": "league/commonmark", - "version": "2.7.0", + "version": "2.7.1", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", - "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca", + "reference": "10732241927d3971d28e7ea7b5712721fa2296ca", "shasum": "" }, "require": { @@ -2132,7 +2132,7 @@ "symfony/process": "^5.4 | ^6.0 | ^7.0", "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", - "vimeo/psalm": "^4.24.0 || ^5.0.0" + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, "suggest": { "symfony/yaml": "v2.3+ required if using the Front Matter extension" @@ -2197,7 +2197,7 @@ "type": "tidelift" } ], - "time": "2025-05-05T12:20:28+00:00" + "time": "2025-07-20T12:47:49+00:00" }, { "name": "league/config", @@ -2856,22 +2856,22 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.1.2", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-zlib": "*", - "php-64bit": "^8.2" + "php-64bit": "^8.3" }, "require-dev": { "brianium/paratest": "^7.7", @@ -2880,7 +2880,7 @@ "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^11.0", + "phpunit/phpunit": "^12.0", "vimeo/psalm": "^6.0" }, "suggest": { @@ -2922,7 +2922,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" }, "funding": [ { @@ -2930,7 +2930,7 @@ "type": "github" } ], - "time": "2025-01-27T12:07:53+00:00" + "time": "2025-07-17T11:15:13+00:00" }, { "name": "monolog/monolog", @@ -4333,16 +4333,16 @@ }, { "name": "spatie/laravel-package-tools", - "version": "1.92.6", + "version": "1.92.7", "source": { "type": "git", "url": "https://github.com/spatie/laravel-package-tools.git", - "reference": "afa90e37741a953d33728e7106a1f24a13fdd808" + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/afa90e37741a953d33728e7106a1f24a13fdd808", - "reference": "afa90e37741a953d33728e7106a1f24a13fdd808", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5", + "reference": "f09a799850b1ed765103a4f0b4355006360c49a5", "shasum": "" }, "require": { @@ -4382,7 +4382,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-package-tools/issues", - "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.6" + "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7" }, "funding": [ { @@ -4390,7 +4390,7 @@ "type": "github" } ], - "time": "2025-07-14T08:02:47+00:00" + "time": "2025-07-17T15:46:43+00:00" }, { "name": "spatie/temporary-directory", @@ -7614,16 +7614,16 @@ }, { "name": "larastan/larastan", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "e8ccd73008487ba91da9877b373f8c447743f1ce" + "reference": "6431d010dd383a9279eb8874a76ddb571738564a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/e8ccd73008487ba91da9877b373f8c447743f1ce", - "reference": "e8ccd73008487ba91da9877b373f8c447743f1ce", + "url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a", + "reference": "6431d010dd383a9279eb8874a76ddb571738564a", "shasum": "" }, "require": { @@ -7691,7 +7691,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.5.0" + "source": "https://github.com/larastan/larastan/tree/v3.6.0" }, "funding": [ { @@ -7699,7 +7699,7 @@ "type": "github" } ], - "time": "2025-06-19T22:41:50+00:00" + "time": "2025-07-11T06:52:52+00:00" }, { "name": "laravel/pail", @@ -7851,16 +7851,16 @@ }, { "name": "laravel/sail", - "version": "v1.43.1", + "version": "v1.44.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72" + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/3e7d899232a8c5e3ea4fc6dee7525ad583887e72", - "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72", + "url": "https://api.github.com/repos/laravel/sail/zipball/a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", + "reference": "a09097bd2a8a38e23ac472fa6a6cf5b0d1c1d3fe", "shasum": "" }, "require": { @@ -7910,7 +7910,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-05-19T13:19:21+00:00" + "time": "2025-07-04T16:17:06+00:00" }, { "name": "mockery/mockery", @@ -8963,16 +8963,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.17", + "version": "2.1.19", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" + "reference": "473a8c30e450d87099f76313edcbb90852f9afdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/473a8c30e450d87099f76313edcbb90852f9afdf", + "reference": "473a8c30e450d87099f76313edcbb90852f9afdf", "shasum": "" }, "require": { @@ -9017,7 +9017,7 @@ "type": "github" } ], - "time": "2025-05-21T20:55:28+00:00" + "time": "2025-07-21T19:58:24+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/package-lock.json b/package-lock.json index 9f5ebf8..36cffb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,9 +57,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], @@ -89,9 +89,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], @@ -105,9 +105,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], @@ -121,9 +121,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], @@ -169,9 +169,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], @@ -185,9 +185,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], @@ -201,9 +201,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], @@ -217,9 +217,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], @@ -233,9 +233,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], @@ -249,9 +249,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], @@ -265,9 +265,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], @@ -281,9 +281,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], @@ -297,9 +297,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], @@ -313,9 +313,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], @@ -329,9 +329,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], @@ -361,9 +361,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], @@ -377,9 +377,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], @@ -392,10 +392,26 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], @@ -409,9 +425,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], @@ -425,9 +441,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], @@ -441,9 +457,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], @@ -469,17 +485,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -491,25 +503,16 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -517,9 +520,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.10.5", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz", - "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==", + "version": "2.10.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.6.tgz", + "integrity": "sha512-pHUn6ZRt39bP3698HFQlu2ZHCkS/lPcpv7fVQcGBSzNNygw171UXAKrCUhy+TEMw4lEttOKDgNpb04hwUAJeiQ==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.1", @@ -527,7 +530,7 @@ "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.2", - "tar-fs": "^3.0.8", + "tar-fs": "^3.1.0", "yargs": "^17.7.2" }, "bin": { @@ -538,9 +541,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", - "integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "cpu": [ "arm" ], @@ -551,9 +554,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", - "integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "cpu": [ "arm64" ], @@ -564,9 +567,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", - "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "cpu": [ "arm64" ], @@ -577,9 +580,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", - "integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "cpu": [ "x64" ], @@ -590,9 +593,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", - "integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "cpu": [ "arm64" ], @@ -603,9 +606,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", - "integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "cpu": [ "x64" ], @@ -616,9 +619,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", - "integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "cpu": [ "arm" ], @@ -629,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", - "integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "cpu": [ "arm" ], @@ -642,9 +645,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", - "integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "cpu": [ "arm64" ], @@ -655,9 +658,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", - "integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "cpu": [ "arm64" ], @@ -668,9 +671,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", - "integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "cpu": [ "loong64" ], @@ -681,9 +684,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", - "integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "cpu": [ "ppc64" ], @@ -694,9 +697,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", - "integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "cpu": [ "riscv64" ], @@ -707,9 +710,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", - "integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "cpu": [ "riscv64" ], @@ -720,9 +723,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", - "integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "cpu": [ "s390x" ], @@ -746,9 +749,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", - "integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "cpu": [ "x64" ], @@ -759,9 +762,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", - "integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "cpu": [ "arm64" ], @@ -772,9 +775,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", - "integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "cpu": [ "ia32" ], @@ -785,9 +788,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", - "integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "cpu": [ "x64" ], @@ -798,9 +801,9 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz", - "integrity": "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", + "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -809,13 +812,13 @@ "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.8" + "tailwindcss": "4.1.11" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz", - "integrity": "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", + "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -826,24 +829,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-x64": "4.1.8", - "@tailwindcss/oxide-freebsd-x64": "4.1.8", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.8", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-x64-musl": "4.1.8", - "@tailwindcss/oxide-wasm32-wasi": "4.1.8", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.8", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.8" + "@tailwindcss/oxide-android-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-x64": "4.1.11", + "@tailwindcss/oxide-freebsd-x64": "4.1.11", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-x64-musl": "4.1.11", + "@tailwindcss/oxide-wasm32-wasi": "4.1.11", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz", - "integrity": "sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", + "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", "cpu": [ "arm64" ], @@ -857,9 +860,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz", - "integrity": "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", + "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", "cpu": [ "arm64" ], @@ -873,9 +876,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz", - "integrity": "sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", + "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", "cpu": [ "x64" ], @@ -889,9 +892,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz", - "integrity": "sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", + "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", "cpu": [ "x64" ], @@ -905,9 +908,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz", - "integrity": "sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", + "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", "cpu": [ "arm" ], @@ -921,9 +924,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz", - "integrity": "sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", + "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", "cpu": [ "arm64" ], @@ -937,9 +940,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz", - "integrity": "sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", + "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", "cpu": [ "arm64" ], @@ -953,9 +956,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz", - "integrity": "sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", + "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", "cpu": [ "x64" ], @@ -969,9 +972,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz", - "integrity": "sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", + "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", "cpu": [ "x64" ], @@ -985,9 +988,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz", - "integrity": "sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", + "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1005,7 +1008,7 @@ "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.10", + "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, @@ -1013,64 +1016,10 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.4.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.4.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.10", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.0", - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz", - "integrity": "sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", + "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", "cpu": [ "arm64" ], @@ -1084,9 +1033,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz", - "integrity": "sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", + "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", "cpu": [ "x64" ], @@ -1100,17 +1049,17 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.8.tgz", - "integrity": "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", + "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.8", - "@tailwindcss/oxide": "4.1.8", - "tailwindcss": "4.1.8" + "@tailwindcss/node": "4.1.11", + "@tailwindcss/oxide": "4.1.11", + "tailwindcss": "4.1.11" }, "peerDependencies": { - "vite": "^5.2.0 || ^6" + "vite": "^5.2.0 || ^6 || ^7" } }, "node_modules/@tootallnate/quickjs-emscripten": { @@ -1120,19 +1069,19 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.29", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.29.tgz", - "integrity": "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==", + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", "license": "MIT", "optional": true, "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.8.0" } }, "node_modules/@types/yauzl": { @@ -1146,9 +1095,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -1240,13 +1189,13 @@ } }, "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -1257,16 +1206,16 @@ "license": "Apache-2.0" }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", + "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", "license": "Apache-2.0", "optional": true }, "node_modules/bare-fs": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz", - "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", + "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1338,9 +1287,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "funding": [ { "type": "opencollective", @@ -1357,8 +1306,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -1401,9 +1350,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001720", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", - "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "funding": [ { "type": "opencollective", @@ -1458,9 +1407,9 @@ } }, "node_modules/chromium-bidi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-5.1.0.tgz", - "integrity": "sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-7.2.0.tgz", + "integrity": "sha512-gREyhyBstermK+0RbcJLbFhcQctg92AGgDe/h/taMJEOLRdtSswBAO9KmvltFSQWgM2LrwWu5SIuEUbdm3JsyQ==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -1515,9 +1464,9 @@ } }, "node_modules/concurrently": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz", - "integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -1624,9 +1573,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1439962", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1439962.tgz", - "integrity": "sha512-jJF48UdryzKiWhJ1bLKr7BFWUQCEIT5uCNbDLqkQJBtkFxYzILJH44WN0PDKMIlGDN7Utb8vyUY85C3w4R/t2g==", + "version": "0.0.1464554", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz", + "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==", "license": "BSD-3-Clause" }, "node_modules/dunder-proto": { @@ -1644,9 +1593,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.161", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", - "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==", + "version": "1.5.190", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", + "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -1656,18 +1605,18 @@ "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -1741,9 +1690,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1753,31 +1702,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -1877,9 +1827,9 @@ } }, "node_modules/fdir": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" @@ -2024,9 +1974,9 @@ } }, "node_modules/get-uri": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz", - "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", @@ -2174,9 +2124,9 @@ } }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -2213,9 +2163,9 @@ "license": "MIT" }, "node_modules/laravel-vite-plugin": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz", - "integrity": "sha512-R0pJ+IcTVeqEMoKz/B2Ij57QVq3sFTABiFmb06gAwFdivbOgsUtuhX6N2MGLEArajrS3U5JbberzwOe7uXHMHQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz", + "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==", "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -2693,9 +2643,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -2705,9 +2655,9 @@ } }, "node_modules/postcss": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", - "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -2773,9 +2723,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -2783,17 +2733,17 @@ } }, "node_modules/puppeteer": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.9.0.tgz", - "integrity": "sha512-L0pOtALIx8rgDt24Y+COm8X52v78gNtBOW6EmUcEPci0TYD72SAuaXKqasRIx4JXxmg2Tkw5ySKcpPOwN8xXnQ==", + "version": "24.15.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.15.0.tgz", + "integrity": "sha512-HPSOTw+DFsU/5s2TUUWEum9WjFbyjmvFDuGHtj2X4YUz2AzOzvKMkT3+A3FR+E+ZefiX/h3kyLyXzWJWx/eMLQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.5", - "chromium-bidi": "5.1.0", + "@puppeteer/browsers": "2.10.6", + "chromium-bidi": "7.2.0", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1439962", - "puppeteer-core": "24.9.0", + "devtools-protocol": "0.0.1464554", + "puppeteer-core": "24.15.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -2804,17 +2754,17 @@ } }, "node_modules/puppeteer-core": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.9.0.tgz", - "integrity": "sha512-HFdCeH/wx6QPz8EncafbCqJBqaCG1ENW75xg3cLFMRUoqZDgByT6HSueiumetT2uClZxwqj0qS4qMVZwLHRHHw==", + "version": "24.15.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.15.0.tgz", + "integrity": "sha512-2iy0iBeWbNyhgiCGd/wvGrDSo73emNFjSxYOcyAqYiagkYt5q4cPfVXaVDKBsukgc2fIIfLAalBZlaxldxdDYg==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.5", - "chromium-bidi": "5.1.0", + "@puppeteer/browsers": "2.10.6", + "chromium-bidi": "7.2.0", "debug": "^4.4.1", - "devtools-protocol": "0.0.1439962", + "devtools-protocol": "0.0.1464554", "typed-query-selector": "^2.12.0", - "ws": "^8.18.2" + "ws": "^8.18.3" }, "engines": { "node": ">=18" @@ -2839,12 +2789,12 @@ } }, "node_modules/rollup": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", - "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -2854,33 +2804,33 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.41.1", - "@rollup/rollup-android-arm64": "4.41.1", - "@rollup/rollup-darwin-arm64": "4.41.1", - "@rollup/rollup-darwin-x64": "4.41.1", - "@rollup/rollup-freebsd-arm64": "4.41.1", - "@rollup/rollup-freebsd-x64": "4.41.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", - "@rollup/rollup-linux-arm-musleabihf": "4.41.1", - "@rollup/rollup-linux-arm64-gnu": "4.41.1", - "@rollup/rollup-linux-arm64-musl": "4.41.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", - "@rollup/rollup-linux-riscv64-gnu": "4.41.1", - "@rollup/rollup-linux-riscv64-musl": "4.41.1", - "@rollup/rollup-linux-s390x-gnu": "4.41.1", - "@rollup/rollup-linux-x64-gnu": "4.41.1", - "@rollup/rollup-linux-x64-musl": "4.41.1", - "@rollup/rollup-win32-arm64-msvc": "4.41.1", - "@rollup/rollup-win32-ia32-msvc": "4.41.1", - "@rollup/rollup-win32-x64-msvc": "4.41.1", + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" } }, "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", - "integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "cpu": [ "x64" ], @@ -2912,9 +2862,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", - "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2934,9 +2884,9 @@ } }, "node_modules/socks": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", - "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", + "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", "license": "MIT", "dependencies": { "ip-address": "^9.0.5", @@ -2987,9 +2937,9 @@ "license": "BSD-3-Clause" }, "node_modules/streamx": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", - "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", "license": "MIT", "dependencies": { "fast-fifo": "^1.3.2", @@ -3041,9 +2991,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz", - "integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", "license": "MIT" }, "node_modules/tapable": { @@ -3073,9 +3023,9 @@ } }, "node_modules/tar-fs": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", - "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -3144,9 +3094,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT", "optional": true }, @@ -3300,9 +3250,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3376,9 +3326,9 @@ } }, "node_modules/zod": { - "version": "3.25.46", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.46.tgz", - "integrity": "sha512-IqRxcHEIjqLd4LNS/zKffB3Jzg3NwqJxQQ0Ns7pdrvgGkwQsEBdEQcOHaBVqvvZArShRzI39+aMST3FBGmTrLQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" From eacb891cbab6d93f23b9285411e28c17f953adc0 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 24 Jul 2025 20:17:34 +0200 Subject: [PATCH 005/209] feat: add screens endpoint * according to https://docs.usetrmnl.com/go/diy/byos#screens --- routes/api.php | 28 ++++++++++++++++ tests/Feature/Api/DeviceEndpointsTest.php | 40 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/routes/api.php b/routes/api.php index 519d633..9b2ef70 100644 --- a/routes/api.php +++ b/routes/api.php @@ -276,6 +276,34 @@ Route::post('/display/update', function (Request $request) { ->name('display.update') ->middleware('auth:sanctum', 'ability:update-screen'); +Route::post('/screens', function (Request $request) { + $mac_address = $request->header('id'); + $access_token = $request->header('access-token'); + $device = Device::where('mac_address', $mac_address) + ->where('api_key', $access_token) + ->first(); + + if (! $device) { + return response()->json([ + 'message' => 'MAC Address not registered or invalid access token', + ], 404); + } + + $request->validate([ + 'image' => 'array|required', + 'image.content' => 'string|required', + 'image.file_name' => 'string', + ]); + $content = $request['image']['content']; + + $view = Blade::render($content); + GenerateScreenJob::dispatchSync($device->id, null, $view); + + return response()->json([ + 'message' => 'success', + ]); +})->name('screens.update'); + Route::get('/display/status', function (Request $request) { $request->validate([ 'device_id' => 'required|exists:devices,id', diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 6bf1c28..db1791c 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -1,5 +1,6 @@ toContain('sleep.png'); expect($json['refresh_rate'])->toBeLessThanOrEqual(3600); // ~60 min }); + +test('screens endpoint accepts nullable file_name', function () { + Queue::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + ])->post('/api/screens', [ + 'image' => [ + 'content' => '
Test content
', + ], + ]); + + $response->assertOk(); + + Queue::assertPushed(GenerateScreenJob::class); +}); + +test('screens endpoint returns 404 for invalid device credentials', function () { + $response = $this->withHeaders([ + 'id' => 'invalid-mac', + 'access-token' => 'invalid-key', + ])->post('/api/screens', [ + 'image' => [ + 'content' => '
Test content
', + 'file_name' => 'test.blade.php', + ], + ]); + + $response->assertNotFound() + ->assertJson([ + 'message' => 'MAC Address not registered or invalid access token', + ]); +}); From 393fa9598c2d8606f21455dd5e0176f54c2468a2 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 24 Jul 2025 22:22:22 +0200 Subject: [PATCH 006/209] fix: import highcharts.js from TRMNL CDN --- resources/views/recipes/pollen-forecast-eu.liquid | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/recipes/pollen-forecast-eu.liquid b/resources/views/recipes/pollen-forecast-eu.liquid index f34414a..e3c2ddc 100644 --- a/resources/views/recipes/pollen-forecast-eu.liquid +++ b/resources/views/recipes/pollen-forecast-eu.liquid @@ -1,5 +1,5 @@ - - + +
From 9f23a7a48e7947e0b291101727f102c17871457a Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 25 Jul 2025 11:03:31 +0200 Subject: [PATCH 007/209] feat: add support section in settings --- .../components/layouts/app/header.blade.php | 3 +- .../components/settings/layout.blade.php | 1 + .../views/livewire/settings/support.blade.php | 33 +++++++++++++++++++ routes/web.php | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 resources/views/livewire/settings/support.blade.php diff --git a/resources/views/components/layouts/app/header.blade.php b/resources/views/components/layouts/app/header.blade.php index 85eac00..58c7f17 100644 --- a/resources/views/components/layouts/app/header.blade.php +++ b/resources/views/components/layouts/app/header.blade.php @@ -66,7 +66,8 @@ - Settings + Settings + Support diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 4d4c9fd..d0ed4cf 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -5,6 +5,7 @@ Profile Password Appearance + Support
diff --git a/resources/views/livewire/settings/support.blade.php b/resources/views/livewire/settings/support.blade.php new file mode 100644 index 0000000..7241d72 --- /dev/null +++ b/resources/views/livewire/settings/support.blade.php @@ -0,0 +1,33 @@ +
+ @include('partials.settings-heading') + + + +
+
+ {{ __('GitHub Sponsors') }} + {{ __('Buy me a coffee') }} +
+
+ +
+ {{ __('Referral Code') }} + {{ __('Use the code to receive a $15 discount on your TRMNL device purchase.') }} + +
+ + {{ __('Referral link') }} +
+ +
+
+
diff --git a/routes/web.php b/routes/web.php index d2887e6..47bda95 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ Route::middleware(['auth'])->group(function () { Volt::route('settings/profile', 'settings.profile')->name('settings.profile'); Volt::route('settings/password', 'settings.password')->name('settings.password'); Volt::route('settings/appearance', 'settings.appearance')->name('settings.appearance'); + Volt::route('settings/support', 'settings.support')->name('settings.support'); Volt::route('/dashboard', 'device-dashboard')->name('dashboard'); From 55b188a7e8b19fc835bddfa0d32cc11513d3c184 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 25 Jul 2025 12:25:06 +0200 Subject: [PATCH 008/209] fix: active menu item style in sidebar --- .../components/layouts/app/header.blade.php | 16 ++- .../components/layouts/app/sidebar.blade.php | 132 ------------------ 2 files changed, 9 insertions(+), 139 deletions(-) delete mode 100644 resources/views/components/layouts/app/sidebar.blade.php diff --git a/resources/views/components/layouts/app/header.blade.php b/resources/views/components/layouts/app/header.blade.php index 58c7f17..7a9f2c0 100644 --- a/resources/views/components/layouts/app/header.blade.php +++ b/resources/views/components/layouts/app/header.blade.php @@ -4,6 +4,8 @@ @include('partials.head') + + @@ -92,23 +94,23 @@ - + Dashboard - Devices - - + Plugins & Recipes - - + Playlists - + diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php deleted file mode 100644 index d0e913e..0000000 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ /dev/null @@ -1,132 +0,0 @@ - - - - @include('partials.head') - - - - - - - - - - - - Dashboard - - - - - - - - Repository - - - - Documentation - - - - - - - - - -
-
- - - {{ auth()->user()->initials() }} - - - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - Settings - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - - - - - - - - - - - -
-
- - - {{ auth()->user()->initials() }} - - - -
- {{ auth()->user()->name }} - {{ auth()->user()->email }} -
-
-
-
- - - - - Settings - - - - -
- @csrf - - {{ __('Log Out') }} - -
-
-
-
- - {{ $slot }} - - @fluxScripts - - From 7288fd7c6bce6b41b65ecfbddad6294350913d66 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 28 Jul 2025 21:35:07 +0200 Subject: [PATCH 009/209] chore: update dependencies --- composer.json | 2 +- composer.lock | 48 ++++++++++++++++++++++++------------------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/composer.json b/composer.json index be63349..50a4fce 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": "^8.2", "ext-imagick": "*", - "bnussbau/laravel-trmnl-blade": "1.1.*", + "bnussbau/laravel-trmnl-blade": "1.2.*", "intervention/image": "^3.11", "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", diff --git a/composer.lock b/composer.lock index d1a1ddb..fe10e04 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "29d4db93339349a577b09a89a73769f5", + "content-hash": "7aa9855bcbad922d5174715ff7877cd6", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.351.5", + "version": "3.351.8", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2f00efa2544d158ea366c1e1174097ef330ec883" + "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2f00efa2544d158ea366c1e1174097ef330ec883", - "reference": "2f00efa2544d158ea366c1e1174097ef330ec883", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/61bab063c6e3567942dcc4a2aac56155af22b6d5", + "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "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.351.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.351.8" }, - "time": "2025-07-23T18:04:16+00:00" + "time": "2025-07-28T18:40:29+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "1.1.1", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "a27dbce9203223591b3bbad46188ca3bf6c0af02" + "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/a27dbce9203223591b3bbad46188ca3bf6c0af02", - "reference": "a27dbce9203223591b3bbad46188ca3bf6c0af02", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/be3ee30b86940eae32c3188f5a158b9334ceaf34", + "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34", "shasum": "" }, "require": { @@ -223,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.1.1" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.0" }, "funding": [ { @@ -239,7 +239,7 @@ "type": "github" } ], - "time": "2025-07-14T18:37:41+00:00" + "time": "2025-07-28T19:31:11+00:00" }, { "name": "brick/math", @@ -3356,16 +3356,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.5.0", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", "shasum": "" }, "require": { @@ -3408,9 +3408,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" }, - "time": "2025-05-31T08:24:38+00:00" + "time": "2025-07-27T20:03:57+00:00" }, { "name": "nunomaduro/termwind", @@ -8963,16 +8963,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.19", + "version": "2.1.20", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "473a8c30e450d87099f76313edcbb90852f9afdf" + "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/473a8c30e450d87099f76313edcbb90852f9afdf", - "reference": "473a8c30e450d87099f76313edcbb90852f9afdf", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9ccfef95210f92ba6feea6e8d1eef42b5605499", + "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499", "shasum": "" }, "require": { @@ -9017,7 +9017,7 @@ "type": "github" } ], - "time": "2025-07-21T19:58:24+00:00" + "time": "2025-07-26T20:45:26+00:00" }, { "name": "phpunit/php-code-coverage", From b4639b3ffb37599e529fed5c82e10e0a7fdfe4e4 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 28 Jul 2025 21:40:42 +0200 Subject: [PATCH 010/209] fix: reverts sharpening Issue with half-pixel values causing blurry fonts is fixed in Design Framework 1.2.0 --- app/Services/ImageGenerationService.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index ae2cfd4..7f58001 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -125,9 +125,6 @@ class ImageGenerationService $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); - // Sharpen the image to make blurry text more defined - $imagick->sharpenImage(0.7, 0.5); - if ($quantize) { $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); } From 6bc74b2c5c95ba9771704ff4c74e8696619872f7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 29 Jul 2025 07:02:39 +0200 Subject: [PATCH 011/209] fix(#70): support revised log format --- .../views/livewire/devices/logs.blade.php | 63 +++++++++++-------- routes/api.php | 12 +++- tests/Feature/Api/DeviceEndpointsTest.php | 28 +++++++++ 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/resources/views/livewire/devices/logs.blade.php b/resources/views/livewire/devices/logs.blade.php index 7bbaf7b..18d5bec 100644 --- a/resources/views/livewire/devices/logs.blade.php +++ b/resources/views/livewire/devices/logs.blade.php @@ -45,37 +45,48 @@ new class extends Component { @foreach ($logs as $log) - @if (!isset($log->log_entry['log_message'])) + @php + $message = $log->log_entry['message'] ?? $log->log_entry['log_message'] ?? null; + @endphp + @if (!$message) @continue @endif + @php + // Support both previous and revised log formats + $timestamp = $log->log_entry['created_at'] ?? $log->log_entry['creation_timestamp'] ?? null; + $wifiStatus = $log->log_entry['wifi_status'] ?? $log->log_entry['device_status_stamp']['wifi_status'] ?? 'Unknown'; + $wifiRssi = $log->log_entry['wifi_signal'] ?? $log->log_entry['device_status_stamp']['wifi_rssi_level'] ?? null; + $hasDeviceStatus = isset($log->log_entry['device_status_stamp']) || + (isset($log->log_entry['wifi_status']) && $log->log_entry['wifi_status'] !== 'Unknown'); + @endphp - @if (isset($log->log_entry['creation_timestamp'])) - {{ \Carbon\Carbon::createFromTimestamp($log->log_entry['creation_timestamp'])->setTimezone(config('app.timezone'))->format('Y-m-d H:i:s') }} + @if ($timestamp) + {{ \Carbon\Carbon::createFromTimestamp($timestamp)->setTimezone(config('app.timezone'))->format('Y-m-d H:i:s') }} @endif
- {{ str_contains(strtolower($log->log_entry['log_message']), 'error') ? 'Error' : - (str_contains(strtolower($log->log_entry['log_message']), 'warning') ? 'Warning' : 'Info') }} + {{ str_contains(strtolower($message), 'error') ? 'Error' : + (str_contains(strtolower($message), 'warning') ? 'Warning' : 'Info') }}
- {{ $log->log_entry['device_status_stamp']['wifi_status'] ?? 'Unknown' }} - @if(isset($log->log_entry['device_status_stamp']['wifi_rssi_level'])) - ({{ $log->log_entry['device_status_stamp']['wifi_rssi_level'] }}dBm) + {{ $wifiStatus }} + @if($wifiRssi) + ({{ $wifiRssi }}dBm) @endif
- @if(isset($log->log_entry['device_status_stamp'])) + @if($hasDeviceStatus) @@ -84,7 +95,7 @@ new class extends Component {
- {{ $log->log_entry['log_message'] }} + {{ $message }} @@ -92,7 +103,7 @@ new class extends Component { - @if(isset($log->log_entry['device_status_stamp'])) + @if($hasDeviceStatus)
@@ -102,44 +113,46 @@ new class extends Component {
WiFi Status:
-
{{ $log->log_entry['device_status_stamp']['wifi_status'] ?? 'Unknown' }}
+
{{ $wifiStatus }}
WiFi RSSI:
-
{{ $log->log_entry['device_status_stamp']['wifi_rssi_level'] ?? 'Unknown' }} dBm
+
{{ $wifiRssi ?? 'Unknown' }} dBm
Refresh Rate:
-
{{ $log->log_entry['device_status_stamp']['refresh_rate'] ?? 'Unknown' }}s
+
{{ $log->log_entry['refresh_rate'] ?? $log->log_entry['device_status_stamp']['refresh_rate'] ?? 'Unknown' }}s
Time Since Sleep:
-
{{ $log->log_entry['device_status_stamp']['time_since_last_sleep_start'] ?? 'Unknown' }}s
+
{{ $log->log_entry['sleep_duration'] ?? $log->log_entry['device_status_stamp']['time_since_last_sleep_start'] ?? 'Unknown' }}s
Firmware Version:
-
{{ $log->log_entry['device_status_stamp']['current_fw_version'] ?? 'Unknown' }}
+
{{ $log->log_entry['firmware_version'] ?? $log->log_entry['device_status_stamp']['current_fw_version'] ?? 'Unknown' }}
Special Function:
-
{{ $log->log_entry['device_status_stamp']['special_function'] ?? 'None' }}
+
{{ $log->log_entry['special_function'] ?? $log->log_entry['device_status_stamp']['special_function'] ?? 'None' }}
Battery Voltage:
-
{{ $log->log_entry['device_status_stamp']['battery_voltage'] ?? 'Unknown' }}V
+
{{ $log->log_entry['battery_voltage'] ?? $log->log_entry['device_status_stamp']['battery_voltage'] ?? 'Unknown' }}V
Wakeup Reason:
-
{{ $log->log_entry['device_status_stamp']['wakeup_reason'] ?? 'Unknown' }}
+
{{ $log->log_entry['wake_reason'] ?? $log->log_entry['device_status_stamp']['wakeup_reason'] ?? 'Unknown' }}
Free Heap:
-
{{ $log->log_entry['device_status_stamp']['free_heap_size'] ?? 'Unknown' }} bytes
+
{{ $log->log_entry['free_heap_size'] ?? $log->log_entry['device_status_stamp']['free_heap_size'] ?? 'Unknown' }} bytes
+ @if(isset($log->log_entry['device_status_stamp']['max_alloc_size']))
Max Alloc Size:
-
{{ $log->log_entry['device_status_stamp']['max_alloc_size'] ?? 'Unknown' }} bytes
+
{{ $log->log_entry['device_status_stamp']['max_alloc_size'] }} bytes
+ @endif
@@ -161,11 +174,11 @@ new class extends Component {
Source File:
-
{{ $log->log_entry['log_sourcefile'] ?? 'Unknown' }}
+
{{ $log->log_entry['source_path'] ?? $log->log_entry['log_sourcefile'] ?? 'Unknown' }}
Line Number:
-
{{ $log->log_entry['log_codeline'] ?? 'Unknown' }}
+
{{ $log->log_entry['source_line'] ?? $log->log_entry['log_codeline'] ?? 'Unknown' }}
@if(isset($log->log_entry['additional_info']))
diff --git a/routes/api.php b/routes/api.php index 9b2ef70..bbe274d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Services\ImageGenerationService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -222,7 +223,16 @@ Route::post('/log', function (Request $request) { 'last_log_request' => $request->json()->all(), ]); - $logs = $request->json('log.logs_array', []); + $logs = []; + // Revised format: {"logs": [...]} + if ($request->has('logs')) { + $logs = $request->json('logs', []); + } + // Fall back to old format: {"log": {"logs_array": [...]}} + elseif ($request->has('log.logs_array')) { + $logs = $request->json('log.logs_array', []); + } + foreach ($logs as $log) { Log::info('Device Log', $log); DeviceLog::create([ diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index db1791c..7e8fbdf 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -192,6 +192,34 @@ test('device can submit logs', function () { expect($device->fresh()->last_log_request) ->toBe($logData); + + expect($device->logs()->count())->toBe(1); +}); + +test('device can submit logs in revised format', function () { + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + ]); + + $logData = [ + 'logs' => [ + ['message' => 'Test log message', 'level' => 'info'], + ], + ]; + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + ])->postJson('/api/log', $logData); + + $response->assertOk() + ->assertJson(['status' => '200']); + + expect($device->fresh()->last_log_request) + ->toBe($logData); + + expect($device->logs()->count())->toBe(1); }); // test('authenticated user can update device display', function () { From caaf5f87551f82f14e6c5b3acda5e369cebc41d8 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 12 Aug 2025 14:34:32 +0200 Subject: [PATCH 012/209] fix(#74): switch out of sync when deleting / moving items in table --- resources/views/livewire/devices/configure.blade.php | 4 ++-- resources/views/livewire/playlists/index.blade.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index bbfa7d3..488e904 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -632,7 +632,7 @@ new class extends Component { @foreach($playlist->items->sortBy('order') as $item) - + @if($item->isMashup())
@@ -649,7 +649,7 @@ new class extends Component { @endif - diff --git a/resources/views/livewire/playlists/index.blade.php b/resources/views/livewire/playlists/index.blade.php index e31993f..3e786b4 100644 --- a/resources/views/livewire/playlists/index.blade.php +++ b/resources/views/livewire/playlists/index.blade.php @@ -155,7 +155,7 @@ new class extends Component {

{{ $playlist->name }}

-
@@ -199,7 +199,7 @@ new class extends Component { @foreach($playlist->items->sortBy('order') as $item) - + @if($item->isMashup())
@@ -216,7 +216,7 @@ new class extends Component { @endif - From d6dd1c5f3122081a947ae67a956b36e4fe5e0eb6 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 14 Aug 2025 21:07:33 +0200 Subject: [PATCH 013/209] chore: update dependencies --- .cursor/mcp.json | 11 + .cursor/rules/laravel-boost.mdc | 534 ++++++++++++++++++++ .github/copilot-instructions.md | 531 ++++++++++++++++++++ .junie/guidelines.md | 531 ++++++++++++++++++++ .mcp.json | 11 + composer.json | 1 + composer.lock | 853 ++++++++++++++++++++++++-------- 7 files changed, 2262 insertions(+), 210 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/laravel-boost.mdc create mode 100644 .github/copilot-instructions.md create mode 100644 .junie/guidelines.md create mode 100644 .mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..ea30195 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "./artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc new file mode 100644 index 0000000..0b60289 --- /dev/null +++ b/.cursor/rules/laravel-boost.mdc @@ -0,0 +1,534 @@ +--- +alwaysApply: true +--- + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..737877e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,531 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..737877e --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,531 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ea30195 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "./artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 50a4fce..43d88d4 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "require-dev": { "fakerphp/faker": "^1.23", "larastan/larastan": "^3.0", + "laravel/boost": "^1.0", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index fe10e04..eba4a54 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7aa9855bcbad922d5174715ff7877cd6", + "content-hash": "44fd2c8aec6f954930c2ba3378fdf6b2", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.351.8", + "version": "3.354.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5" + "reference": "014ce3465277cf78a05e60c04ce04c9893733bf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/61bab063c6e3567942dcc4a2aac56155af22b6d5", - "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/014ce3465277cf78a05e60c04ce04c9893733bf2", + "reference": "014ce3465277cf78a05e60c04ce04c9893733bf2", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "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.351.8" + "source": "https://github.com/aws/aws-sdk-php/tree/3.354.0" }, - "time": "2025-07-28T18:40:29+00:00" + "time": "2025-08-14T18:10:08+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "1.2.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34" + "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/be3ee30b86940eae32c3188f5a158b9334ceaf34", - "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", + "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", "shasum": "" }, "require": { @@ -223,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.0" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.1" }, "funding": [ { @@ -239,7 +239,7 @@ "type": "github" } ], - "time": "2025-07-28T19:31:11+00:00" + "time": "2025-08-11T16:14:12+00:00" }, { "name": "brick/math", @@ -447,33 +447,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -518,7 +517,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -534,7 +533,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/lexer", @@ -1417,16 +1416,16 @@ }, { "name": "intervention/image", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17" + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/d0f097b8a3fa8fb758efc9440b513aa3833cda17", - "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17", + "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", "shasum": "" }, "require": { @@ -1473,7 +1472,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.3" + "source": "https://github.com/Intervention/image/tree/3.11.4" }, "funding": [ { @@ -1489,7 +1488,7 @@ "type": "ko_fi" } ], - "time": "2025-05-22T17:26:23+00:00" + "time": "2025-07-30T13:13:19+00:00" }, { "name": "keepsuit/laravel-liquid", @@ -1629,16 +1628,16 @@ }, { "name": "laravel/framework", - "version": "v12.21.0", + "version": "v12.24.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" + "reference": "6dcf2c46da23d159f35d6246234953a74b740d83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", - "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "url": "https://api.github.com/repos/laravel/framework/zipball/6dcf2c46da23d159f35d6246234953a74b740d83", + "reference": "6dcf2c46da23d159f35d6246234953a74b740d83", "shasum": "" }, "require": { @@ -1679,6 +1678,8 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.31", + "symfony/polyfill-php84": "^1.31", + "symfony/polyfill-php85": "^1.31", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1746,7 +1747,7 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.0.0", + "orchestra/testbench-core": "^10.6.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1840,7 +1841,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-07-22T15:41:55+00:00" + "time": "2025-08-13T20:30:36+00:00" }, { "name": "laravel/prompts", @@ -2645,16 +2646,16 @@ }, { "name": "livewire/flux", - "version": "v2.2.3", + "version": "v2.2.4", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "0fb4c0b78eac393ad3a19a387af193573c310371" + "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/0fb4c0b78eac393ad3a19a387af193573c310371", - "reference": "0fb4c0b78eac393ad3a19a387af193573c310371", + "url": "https://api.github.com/repos/livewire/flux/zipball/af81b5fd34c6490d5b5e05ed0f8140c0250e5069", + "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069", "shasum": "" }, "require": { @@ -2702,9 +2703,9 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.2.3" + "source": "https://github.com/livewire/flux/tree/v2.2.4" }, - "time": "2025-07-11T00:25:51+00:00" + "time": "2025-08-09T01:46:51+00:00" }, { "name": "livewire/livewire", @@ -2784,16 +2785,16 @@ }, { "name": "livewire/volt", - "version": "v1.7.1", + "version": "v1.7.2", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "ba3e609fd4c71f8b5783f024baf51715e48e93a6" + "reference": "91ba934e72bbd162442840862959ade24dbe728a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/ba3e609fd4c71f8b5783f024baf51715e48e93a6", - "reference": "ba3e609fd4c71f8b5783f024baf51715e48e93a6", + "url": "https://api.github.com/repos/livewire/volt/zipball/91ba934e72bbd162442840862959ade24dbe728a", + "reference": "91ba934e72bbd162442840862959ade24dbe728a", "shasum": "" }, "require": { @@ -2852,7 +2853,7 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-04-08T15:13:36+00:00" + "time": "2025-08-06T15:40:50+00:00" }, { "name": "maennchen/zipstream-php", @@ -3103,16 +3104,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.1", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", "shasum": "" }, "require": { @@ -3204,7 +3205,7 @@ "type": "tidelift" } ], - "time": "2025-06-21T15:19:35+00:00" + "time": "2025-08-02T09:36:06+00:00" }, { "name": "nette/schema", @@ -3270,29 +3271,29 @@ }, { "name": "nette/utils", - "version": "v4.0.7", + "version": "v4.0.8", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", - "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", "shasum": "" }, "require": { - "php": "8.0 - 8.4" + "php": "8.0 - 8.5" }, "conflict": { "nette/finder": "<3", "nette/schema": "<1.2.2" }, "require-dev": { - "jetbrains/phpstorm-attributes": "dev-master", + "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -3310,6 +3311,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -3350,22 +3354,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.7" + "source": "https://github.com/nette/utils/tree/v4.0.8" }, - "time": "2025-06-03T04:55:08+00:00" + "time": "2025-08-06T21:43:34+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.0", + "version": "v5.6.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", "shasum": "" }, "require": { @@ -3384,7 +3388,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -3408,9 +3412,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" }, - "time": "2025-07-27T20:03:57+00:00" + "time": "2025-08-13T20:13:15+00:00" }, { "name": "nunomaduro/termwind", @@ -3988,16 +3992,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.9", + "version": "v0.12.10", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "1b801844becfe648985372cb4b12ad6840245ace" + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", - "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", "shasum": "" }, "require": { @@ -4047,12 +4051,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -4061,9 +4064,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" }, - "time": "2025-06-23T02:35:06+00:00" + "time": "2025-08-04T12:39:37+00:00" }, { "name": "ralouphie/getallheaders", @@ -4529,16 +4532,16 @@ }, { "name": "symfony/console", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", "shasum": "" }, "require": { @@ -4603,7 +4606,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.1" + "source": "https://github.com/symfony/console/tree/v7.3.2" }, "funding": [ { @@ -4614,12 +4617,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/css-selector", @@ -4755,16 +4762,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", "shasum": "" }, "require": { @@ -4812,7 +4819,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" }, "funding": [ { @@ -4823,12 +4830,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-07-07T08:17:57+00:00" }, { "name": "symfony/event-dispatcher", @@ -4988,16 +4999,16 @@ }, { "name": "symfony/finder", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", "shasum": "" }, "require": { @@ -5032,7 +5043,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.0" + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -5043,25 +5054,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", "shasum": "" }, "require": { @@ -5111,7 +5126,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" }, "funding": [ { @@ -5122,25 +5137,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T15:07:14+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", "shasum": "" }, "require": { @@ -5225,7 +5244,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" }, "funding": [ { @@ -5236,25 +5255,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T08:24:55+00:00" + "time": "2025-07-31T10:45:04+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", "shasum": "" }, "require": { @@ -5305,7 +5328,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.1" + "source": "https://github.com/symfony/mailer/tree/v7.3.2" }, "funding": [ { @@ -5316,25 +5339,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/mime", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", "shasum": "" }, "require": { @@ -5389,7 +5416,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.0" + "source": "https://github.com/symfony/mime/tree/v7.3.2" }, "funding": [ { @@ -5400,12 +5427,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5965,6 +5996,158 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php84", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "000df7860439609837bbe28670b0be15783b7fbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-20T12:04:08+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-02T08:40:52+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.32.0", @@ -6107,16 +6290,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8e213820c5fea844ecea29203d2a308019007c15" + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", - "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", "shasum": "" }, "require": { @@ -6168,7 +6351,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.0" + "source": "https://github.com/symfony/routing/tree/v7.3.2" }, "funding": [ { @@ -6179,12 +6362,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T20:43:28+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/service-contracts", @@ -6271,16 +6458,16 @@ }, { "name": "symfony/string", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { @@ -6338,7 +6525,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.0" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -6349,25 +6536,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-20T20:19:01+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/translation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", "shasum": "" }, "require": { @@ -6434,7 +6625,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.1" + "source": "https://github.com/symfony/translation/tree/v7.3.2" }, "funding": [ { @@ -6445,12 +6636,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/translation-contracts", @@ -6606,16 +6801,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { @@ -6627,7 +6822,6 @@ "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -6670,7 +6864,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, "funding": [ { @@ -6681,25 +6875,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" + "reference": "05b3e90654c097817325d6abd284f7938b05f467" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", + "reference": "05b3e90654c097817325d6abd284f7938b05f467", "shasum": "" }, "require": { @@ -6747,7 +6945,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" }, "funding": [ { @@ -6758,12 +6956,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7330,16 +7532,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { @@ -7349,10 +7551,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -7379,7 +7581,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { @@ -7387,20 +7589,20 @@ "type": "github" } ], - "time": "2024-08-06T10:04:20+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "filp/whoops", - "version": "2.18.3", + "version": "2.18.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "59a123a3d459c5a23055802237cb317f609867e5" + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", - "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { @@ -7450,7 +7652,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.3" + "source": "https://github.com/filp/whoops/tree/2.18.4" }, "funding": [ { @@ -7458,7 +7660,7 @@ "type": "github" } ], - "time": "2025-06-16T00:02:10+00:00" + "time": "2025-08-08T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -7701,6 +7903,135 @@ ], "time": "2025-07-11T06:52:52+00:00" }, + { + "name": "laravel/boost", + "version": "v1.0.17", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "3d1121561f793d027b76cb02b4ef9e654f8870fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/3d1121561f793d027b76cb02b4ef9e654f8870fb", + "reference": "3d1121561f793d027b76cb02b4ef9e654f8870fb", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.0", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14|^1.23", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-08-14T17:31:57+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "417890c0d8032af9a46a86d16651bbe13946cddf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/417890c0d8032af9a46a86d16651bbe13946cddf", + "reference": "417890c0d8032af9a46a86d16651bbe13946cddf", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-12T07:09:39+00:00" + }, { "name": "laravel/pail", "version": "v1.2.3", @@ -7849,6 +8180,67 @@ }, "time": "2025-07-10T18:09:32+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/caeed7609b02c00c3f1efec52812d8d87c5d4096", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-08-13T15:00:25+00:00" + }, { "name": "laravel/sail", "version": "v1.44.0", @@ -7997,16 +8389,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.3", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -8045,7 +8437,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -8053,7 +8445,7 @@ "type": "tidelift" } ], - "time": "2025-07-05T12:25:42+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nunomaduro/collision", @@ -8963,16 +9355,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.20", + "version": "2.1.22", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499" + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9ccfef95210f92ba6feea6e8d1eef42b5605499", - "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", "shasum": "" }, "require": { @@ -9017,7 +9409,7 @@ "type": "github" } ], - "time": "2025-07-26T20:45:26+00:00" + "time": "2025-08-04T19:17:37+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9627,16 +10019,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.1", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", - "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", "shasum": "" }, "require": { @@ -9695,15 +10087,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2025-03-07T06:57:01+00:00" + "time": "2025-08-10T08:07:46+00:00" }, { "name": "sebastian/complexity", @@ -10220,23 +10624,23 @@ }, { "name": "sebastian/recursion-context", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { @@ -10272,28 +10676,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-07-03T05:10:34+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "5.1.2", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", - "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { @@ -10329,15 +10745,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2025-03-18T13:35:50+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", @@ -10395,16 +10823,16 @@ }, { "name": "spatie/pest-expectations", - "version": "1.11.0", + "version": "1.13.2", "source": { "type": "git", "url": "https://github.com/spatie/pest-expectations.git", - "reference": "6ec6e5184c70fe7113e4237d40ad4f8387ce1be2" + "reference": "d78d74cef4b563e669e4e07ae5f88cbeb4373600" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/6ec6e5184c70fe7113e4237d40ad4f8387ce1be2", - "reference": "6ec6e5184c70fe7113e4237d40ad4f8387ce1be2", + "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/d78d74cef4b563e669e4e07ae5f88cbeb4373600", + "reference": "d78d74cef4b563e669e4e07ae5f88cbeb4373600", "shasum": "" }, "require": { @@ -10412,6 +10840,7 @@ "php": "^8.2" }, "require-dev": { + "ext-sockets": "*", "illuminate/contracts": "^10.0|^11.0|^12.0", "laravel/pint": "^1.2", "orchestra/testbench": "^8.3|^9.0|^10.0", @@ -10448,7 +10877,7 @@ ], "support": { "issues": "https://github.com/spatie/pest-expectations/issues", - "source": "https://github.com/spatie/pest-expectations/tree/1.11.0" + "source": "https://github.com/spatie/pest-expectations/tree/1.13.2" }, "funding": [ { @@ -10456,7 +10885,7 @@ "type": "github" } ], - "time": "2025-04-13T12:30:30+00:00" + "time": "2025-08-12T17:04:55+00:00" }, { "name": "staabm/side-effects-detector", @@ -10512,16 +10941,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", "shasum": "" }, "require": { @@ -10564,7 +10993,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.1" + "source": "https://github.com/symfony/yaml/tree/v7.3.2" }, "funding": [ { @@ -10575,12 +11004,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:57:57+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", From 65b9162ef3e0ec7fc7291bf9074d2611c9504811 Mon Sep 17 00:00:00 2001 From: Carlos Quintana Date: Sun, 3 Aug 2025 11:58:00 +0200 Subject: [PATCH 014/209] feat: add OIDC support --- app/Console/Commands/OidcTestCommand.php | 90 ++ app/Http/Controllers/Auth/OidcController.php | 116 ++ app/Models/User.php | 1 + app/Providers/AppServiceProvider.php | 16 +- app/Services/OidcProvider.php | 156 ++ composer.json | 1 + composer.lock | 1291 +++++++++-------- config/services.php | 12 + ..._04_064514_add_oidc_sub_to_users_table.php | 28 + resources/views/livewire/auth/login.blade.php | 23 + routes/auth.php | 8 + tests/Feature/Auth/OidcAuthenticationTest.php | 158 ++ 12 files changed, 1256 insertions(+), 644 deletions(-) create mode 100644 app/Console/Commands/OidcTestCommand.php create mode 100644 app/Http/Controllers/Auth/OidcController.php create mode 100644 app/Services/OidcProvider.php create mode 100644 database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php create mode 100644 tests/Feature/Auth/OidcAuthenticationTest.php diff --git a/app/Console/Commands/OidcTestCommand.php b/app/Console/Commands/OidcTestCommand.php new file mode 100644 index 0000000..2ecfef2 --- /dev/null +++ b/app/Console/Commands/OidcTestCommand.php @@ -0,0 +1,90 @@ +info('Testing OIDC Configuration...'); + $this->newLine(); + + // Check if OIDC is enabled + $enabled = config('services.oidc.enabled'); + $this->line("OIDC Enabled: " . ($enabled ? '✅ Yes' : '❌ No')); + + // Check configuration values + $endpoint = config('services.oidc.endpoint'); + $clientId = config('services.oidc.client_id'); + $clientSecret = config('services.oidc.client_secret'); + $redirect = config('services.oidc.redirect'); + $scopes = config('services.oidc.scopes', []); + + $this->line("OIDC Endpoint: " . ($endpoint ? "✅ {$endpoint}" : '❌ Not set')); + $this->line("Client ID: " . ($clientId ? "✅ {$clientId}" : '❌ Not set')); + $this->line("Client Secret: " . ($clientSecret ? '✅ Set' : '❌ Not set')); + $this->line("Redirect URL: " . ($redirect ? "✅ {$redirect}" : '❌ Not set')); + $this->line("Scopes: " . (empty($scopes) ? '❌ Not set' : '✅ ' . implode(', ', $scopes))); + + $this->newLine(); + + // Test driver registration + try { + // Only test driver if we have basic configuration + if ($endpoint && $clientId && $clientSecret) { + $driver = Socialite::driver('oidc'); + $this->line("OIDC Driver: ✅ Successfully registered and accessible"); + + if ($enabled) { + $this->info("✅ OIDC is fully configured and ready to use!"); + $this->line("You can test the login flow at: /auth/oidc/redirect"); + } else { + $this->warn("⚠️ OIDC driver is working but OIDC_ENABLED is false."); + } + } else { + $this->line("OIDC Driver: ✅ Registered (configuration test skipped due to missing values)"); + $this->warn("⚠️ OIDC driver is registered but missing required configuration."); + $this->line("Please set the following environment variables:"); + if (!$enabled) $this->line(" - OIDC_ENABLED=true"); + if (!$endpoint) { + $this->line(" - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)"); + $this->line(" OR"); + $this->line(" - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)"); + } + if (!$clientId) $this->line(" - OIDC_CLIENT_ID=your-client-id"); + if (!$clientSecret) $this->line(" - OIDC_CLIENT_SECRET=your-client-secret"); + } + } catch (\InvalidArgumentException $e) { + if (str_contains($e->getMessage(), 'Driver [oidc] not supported')) { + $this->error("❌ OIDC Driver registration failed: Driver not supported"); + } else { + $this->error("❌ OIDC Driver error: " . $e->getMessage()); + } + } catch (\Exception $e) { + $this->warn("⚠️ OIDC Driver registered but configuration error: " . $e->getMessage()); + } + + $this->newLine(); + return Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php new file mode 100644 index 0000000..305dd49 --- /dev/null +++ b/app/Http/Controllers/Auth/OidcController.php @@ -0,0 +1,116 @@ +route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']); + } + + // Check if all required OIDC configuration is present + $requiredConfig = ['endpoint', 'client_id', 'client_secret']; + foreach ($requiredConfig as $key) { + if (!config("services.oidc.{$key}")) { + Log::error("OIDC configuration missing: {$key}"); + return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']); + } + } + + try { + return Socialite::driver('oidc')->redirect(); + } catch (\Exception $e) { + Log::error('OIDC redirect error: ' . $e->getMessage()); + return redirect()->route('login')->withErrors(['oidc' => 'Failed to initiate OIDC authentication.']); + } + } + + /** + * Obtain the user information from the OIDC provider. + */ + public function callback(Request $request) + { + if (!config('services.oidc.enabled')) { + return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']); + } + + // Check if all required OIDC configuration is present + $requiredConfig = ['endpoint', 'client_id', 'client_secret']; + foreach ($requiredConfig as $key) { + if (!config("services.oidc.{$key}")) { + Log::error("OIDC configuration missing: {$key}"); + return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']); + } + } + + try { + $oidcUser = Socialite::driver('oidc')->user(); + + // Find or create the user + $user = $this->findOrCreateUser($oidcUser); + + // Log the user in + Auth::login($user, true); + + return redirect()->intended(route('dashboard', absolute: false)); + + } catch (\Exception $e) { + Log::error('OIDC callback error: ' . $e->getMessage()); + return redirect()->route('login')->withErrors(['oidc' => 'Failed to authenticate with OIDC provider.']); + } + } + + /** + * Find or create a user based on OIDC information. + */ + protected function findOrCreateUser($oidcUser) + { + // First, try to find user by OIDC subject ID + $user = User::where('oidc_sub', $oidcUser->getId())->first(); + + if ($user) { + // Update user information from OIDC + $user->update([ + 'name' => $oidcUser->getName() ?: $user->name, + 'email' => $oidcUser->getEmail() ?: $user->email, + ]); + return $user; + } + + // If not found by OIDC sub, try to find by email + if ($oidcUser->getEmail()) { + $user = User::where('email', $oidcUser->getEmail())->first(); + + if ($user) { + // Link the existing user with OIDC + $user->update([ + 'oidc_sub' => $oidcUser->getId(), + 'name' => $oidcUser->getName() ?: $user->name, + ]); + return $user; + } + } + + // Create new user + return User::create([ + 'oidc_sub' => $oidcUser->getId(), + 'name' => $oidcUser->getName() ?: 'OIDC User', + 'email' => $oidcUser->getEmail() ?: $oidcUser->getId() . '@oidc.local', + 'password' => bcrypt(Str::random(32)), // Random password since we're using OIDC + 'email_verified_at' => now(), // OIDC users are considered verified + ]); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 1f524a7..a1c83ab 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,6 +26,7 @@ class User extends Authenticatable // implements MustVerifyEmail 'password', 'assign_new_devices', 'assign_new_device_id', + 'oidc_sub', ]; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9e5761f..6ac75bf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,10 @@ namespace App\Providers; +use App\Services\OidcProvider; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; -use URL; +use Laravel\Socialite\Facades\Socialite; class AppServiceProvider extends ServiceProvider { @@ -23,5 +25,17 @@ class AppServiceProvider extends ServiceProvider if (app()->isProduction() && config('app.force_https')) { URL::forceScheme('https'); } + + // Register OIDC provider with Socialite + Socialite::extend('oidc', function ($app) { + $config = $app['config']['services.oidc'] ?? []; + return new OidcProvider( + $app['request'], + $config['client_id'] ?? null, + $config['client_secret'] ?? null, + $config['redirect'] ?? null, + $config['scopes'] ?? ['openid', 'profile', 'email'] + ); + }); } } diff --git a/app/Services/OidcProvider.php b/app/Services/OidcProvider.php new file mode 100644 index 0000000..ad9799d --- /dev/null +++ b/app/Services/OidcProvider.php @@ -0,0 +1,156 @@ +baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint); + } else { + $this->baseUrl = rtrim($endpoint, '/'); + } + + $this->scopes = $scopes ?: ['openid', 'profile', 'email']; + $this->loadOidcConfiguration(); + } + + /** + * Load OIDC configuration from the well-known endpoint. + */ + protected function loadOidcConfiguration() + { + try { + $url = $this->baseUrl . '/.well-known/openid-configuration'; + $client = new Client(); + $response = $client->get($url); + $this->oidcConfig = json_decode($response->getBody()->getContents(), true); + + if (!$this->oidcConfig) { + throw new \Exception('OIDC configuration is empty or invalid JSON'); + } + + if (!isset($this->oidcConfig['authorization_endpoint'])) { + throw new \Exception('authorization_endpoint not found in OIDC configuration'); + } + + } catch (\Exception $e) { + throw new \Exception('Failed to load OIDC configuration: ' . $e->getMessage()); + } + } + + /** + * Get the authentication URL for the provider. + */ + protected function getAuthUrl($state) + { + if (!$this->oidcConfig || !isset($this->oidcConfig['authorization_endpoint'])) { + throw new \Exception('OIDC configuration not loaded or authorization_endpoint not found.'); + } + return $this->buildAuthUrlFromBase($this->oidcConfig['authorization_endpoint'], $state); + } + + /** + * Get the token URL for the provider. + */ + protected function getTokenUrl() + { + if (!$this->oidcConfig || !isset($this->oidcConfig['token_endpoint'])) { + throw new \Exception('OIDC configuration not loaded or token_endpoint not found.'); + } + return $this->oidcConfig['token_endpoint']; + } + + /** + * Get the raw user for the given access token. + */ + protected function getUserByToken($token) + { + if (!$this->oidcConfig || !isset($this->oidcConfig['userinfo_endpoint'])) { + throw new \Exception('OIDC configuration not loaded or userinfo_endpoint not found.'); + } + + $response = $this->getHttpClient()->get($this->oidcConfig['userinfo_endpoint'], [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + ], + ]); + + return json_decode($response->getBody(), true); + } + + /** + * Map the raw user array to a Socialite User instance. + */ + protected function mapUserToObject(array $user) + { + return (new User)->setRaw($user)->map([ + 'id' => $user['sub'], + 'nickname' => $user['preferred_username'] ?? null, + 'name' => $user['name'] ?? null, + 'email' => $user['email'] ?? null, + 'avatar' => $user['picture'] ?? null, + ]); + } + + /** + * Get the access token response for the given code. + */ + public function getAccessTokenResponse($code) + { + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + 'headers' => ['Accept' => 'application/json'], + 'form_params' => $this->getTokenFields($code), + ]); + + return json_decode($response->getBody(), true); + } + + /** + * Get the POST fields for the token request. + */ + protected function getTokenFields($code) + { + return array_merge(parent::getTokenFields($code), [ + 'grant_type' => 'authorization_code', + ]); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 43d88d4..a2c72e2 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", "laravel/sanctum": "^4.0", + "laravel/socialite": "^5.23", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.0", "livewire/volt": "^1.7", diff --git a/composer.lock b/composer.lock index eba4a54..6c2583d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "44fd2c8aec6f954930c2ba3378fdf6b2", + "content-hash": "7342818e54c036836a947a3fe0b67044", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.354.0", + "version": "3.351.8", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "014ce3465277cf78a05e60c04ce04c9893733bf2" + "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/014ce3465277cf78a05e60c04ce04c9893733bf2", - "reference": "014ce3465277cf78a05e60c04ce04c9893733bf2", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/61bab063c6e3567942dcc4a2aac56155af22b6d5", + "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "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.354.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.351.8" }, - "time": "2025-08-14T18:10:08+00:00" + "time": "2025-07-28T18:40:29+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "1.2.1", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36" + "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", - "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/be3ee30b86940eae32c3188f5a158b9334ceaf34", + "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34", "shasum": "" }, "require": { @@ -223,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.1" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.0" }, "funding": [ { @@ -239,7 +239,7 @@ "type": "github" } ], - "time": "2025-08-11T16:14:12+00:00" + "time": "2025-07-28T19:31:11+00:00" }, { "name": "brick/math", @@ -447,32 +447,33 @@ }, { "name": "doctrine/inflector", - "version": "2.1.0", + "version": "2.0.10", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", - "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^12.0 || ^13.0", - "phpstan/phpstan": "^1.12 || ^2.0", - "phpstan/phpstan-phpunit": "^1.4 || ^2.0", - "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", - "phpunit/phpunit": "^8.5 || ^12.2" + "doctrine/coding-standard": "^11.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.5", + "vimeo/psalm": "^4.25 || ^5.4" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "src" + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" } }, "notification-url": "https://packagist.org/downloads/", @@ -517,7 +518,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.1.0" + "source": "https://github.com/doctrine/inflector/tree/2.0.10" }, "funding": [ { @@ -533,7 +534,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T19:31:58+00:00" + "time": "2024-02-18T20:23:39+00:00" }, { "name": "doctrine/lexer", @@ -744,6 +745,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v6.11.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" + }, + "time": "2025-04-09T20:32:01+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.3.0", @@ -1416,16 +1480,16 @@ }, { "name": "intervention/image", - "version": "3.11.4", + "version": "3.11.3", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" + "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", - "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", + "url": "https://api.github.com/repos/Intervention/image/zipball/d0f097b8a3fa8fb758efc9440b513aa3833cda17", + "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17", "shasum": "" }, "require": { @@ -1472,7 +1536,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.4" + "source": "https://github.com/Intervention/image/tree/3.11.3" }, "funding": [ { @@ -1488,7 +1552,7 @@ "type": "ko_fi" } ], - "time": "2025-07-30T13:13:19+00:00" + "time": "2025-05-22T17:26:23+00:00" }, { "name": "keepsuit/laravel-liquid", @@ -1628,16 +1692,16 @@ }, { "name": "laravel/framework", - "version": "v12.24.0", + "version": "v12.21.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "6dcf2c46da23d159f35d6246234953a74b740d83" + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/6dcf2c46da23d159f35d6246234953a74b740d83", - "reference": "6dcf2c46da23d159f35d6246234953a74b740d83", + "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", "shasum": "" }, "require": { @@ -1678,8 +1742,6 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.31", - "symfony/polyfill-php84": "^1.31", - "symfony/polyfill-php85": "^1.31", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1747,7 +1809,7 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.6.0", + "orchestra/testbench-core": "^10.0.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1841,7 +1903,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-08-13T20:30:36+00:00" + "time": "2025-07-22T15:41:55+00:00" }, { "name": "laravel/prompts", @@ -2027,6 +2089,78 @@ }, "time": "2025-03-19T13:51:03+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.23.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "reference": "e9e0fc83b9d8d71c8385a5da20e5b95ca6234cf5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2025-07-23T14:16:08+00:00" + }, { "name": "laravel/tinker", "version": "v2.10.1", @@ -2470,6 +2604,82 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.5.1", @@ -2646,16 +2856,16 @@ }, { "name": "livewire/flux", - "version": "v2.2.4", + "version": "v2.2.3", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069" + "reference": "0fb4c0b78eac393ad3a19a387af193573c310371" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/af81b5fd34c6490d5b5e05ed0f8140c0250e5069", - "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069", + "url": "https://api.github.com/repos/livewire/flux/zipball/0fb4c0b78eac393ad3a19a387af193573c310371", + "reference": "0fb4c0b78eac393ad3a19a387af193573c310371", "shasum": "" }, "require": { @@ -2703,9 +2913,9 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.2.4" + "source": "https://github.com/livewire/flux/tree/v2.2.3" }, - "time": "2025-08-09T01:46:51+00:00" + "time": "2025-07-11T00:25:51+00:00" }, { "name": "livewire/livewire", @@ -2785,16 +2995,16 @@ }, { "name": "livewire/volt", - "version": "v1.7.2", + "version": "v1.7.1", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "91ba934e72bbd162442840862959ade24dbe728a" + "reference": "ba3e609fd4c71f8b5783f024baf51715e48e93a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/91ba934e72bbd162442840862959ade24dbe728a", - "reference": "91ba934e72bbd162442840862959ade24dbe728a", + "url": "https://api.github.com/repos/livewire/volt/zipball/ba3e609fd4c71f8b5783f024baf51715e48e93a6", + "reference": "ba3e609fd4c71f8b5783f024baf51715e48e93a6", "shasum": "" }, "require": { @@ -2853,7 +3063,7 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-08-06T15:40:50+00:00" + "time": "2025-04-08T15:13:36+00:00" }, { "name": "maennchen/zipstream-php", @@ -3104,16 +3314,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.2", + "version": "3.10.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", - "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", "shasum": "" }, "require": { @@ -3205,7 +3415,7 @@ "type": "tidelift" } ], - "time": "2025-08-02T09:36:06+00:00" + "time": "2025-06-21T15:19:35+00:00" }, { "name": "nette/schema", @@ -3271,29 +3481,29 @@ }, { "name": "nette/utils", - "version": "v4.0.8", + "version": "v4.0.7", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", - "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.0 - 8.4" }, "conflict": { "nette/finder": "<3", "nette/schema": "<1.2.2" }, "require-dev": { - "jetbrains/phpstorm-attributes": "^1.2", + "jetbrains/phpstorm-attributes": "dev-master", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "phpstan/phpstan": "^1.0", "tracy/tracy": "^2.9" }, "suggest": { @@ -3311,9 +3521,6 @@ } }, "autoload": { - "psr-4": { - "Nette\\": "src" - }, "classmap": [ "src/" ] @@ -3354,22 +3561,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.8" + "source": "https://github.com/nette/utils/tree/v4.0.7" }, - "time": "2025-08-06T21:43:34+00:00" + "time": "2025-06-03T04:55:08+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.6.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", "shasum": "" }, "require": { @@ -3388,7 +3595,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.x-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -3412,9 +3619,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-07-27T20:03:57+00:00" }, { "name": "nunomaduro/termwind", @@ -3503,6 +3710,123 @@ ], "time": "2025-05-08T08:14:37+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2024-05-08T12:36:18+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.3", @@ -3578,6 +3902,116 @@ ], "time": "2024-07-20T21:41:07+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.46", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "reference": "56483a7de62a6c2a6635e42e93b8a9e25d4f0ec6", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.46" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-06-26T16:29:55+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3992,16 +4426,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.10", + "version": "v0.12.9", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" + "reference": "1b801844becfe648985372cb4b12ad6840245ace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", - "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", "shasum": "" }, "require": { @@ -4051,11 +4485,12 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info" + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" } ], "description": "An interactive shell for modern PHP.", - "homepage": "https://psysh.org", + "homepage": "http://psysh.org", "keywords": [ "REPL", "console", @@ -4064,9 +4499,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" }, - "time": "2025-08-04T12:39:37+00:00" + "time": "2025-06-23T02:35:06+00:00" }, { "name": "ralouphie/getallheaders", @@ -4532,16 +4967,16 @@ }, { "name": "symfony/console", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", - "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", + "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", "shasum": "" }, "require": { @@ -4606,7 +5041,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.2" + "source": "https://github.com/symfony/console/tree/v7.3.1" }, "funding": [ { @@ -4617,16 +5052,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-30T17:13:41+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/css-selector", @@ -4762,16 +5193,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", "shasum": "" }, "require": { @@ -4819,7 +5250,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + "source": "https://github.com/symfony/error-handler/tree/v7.3.1" }, "funding": [ { @@ -4830,16 +5261,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-07T08:17:57+00:00" + "time": "2025-06-13T07:48:40+00:00" }, { "name": "symfony/event-dispatcher", @@ -4999,16 +5426,16 @@ }, { "name": "symfony/finder", - "version": "v7.3.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", "shasum": "" }, "require": { @@ -5043,7 +5470,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.3.0" }, "funding": [ { @@ -5054,29 +5481,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2024-12-30T19:00:26+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", "shasum": "" }, "require": { @@ -5126,7 +5549,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" }, "funding": [ { @@ -5137,29 +5560,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-06-23T15:07:14+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", - "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", "shasum": "" }, "require": { @@ -5244,7 +5663,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" }, "funding": [ { @@ -5255,29 +5674,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-31T10:45:04+00:00" + "time": "2025-06-28T08:24:55+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", - "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", "shasum": "" }, "require": { @@ -5328,7 +5743,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.2" + "source": "https://github.com/symfony/mailer/tree/v7.3.1" }, "funding": [ { @@ -5339,29 +5754,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/mime", - "version": "v7.3.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", "shasum": "" }, "require": { @@ -5416,7 +5827,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.2" + "source": "https://github.com/symfony/mime/tree/v7.3.0" }, "funding": [ { @@ -5427,16 +5838,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-02-19T08:51:26+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5996,158 +6403,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php84", - "version": "v1.32.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "000df7860439609837bbe28670b0be15783b7fbf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", - "reference": "000df7860439609837bbe28670b0be15783b7fbf", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-02-20T12:04:08+00:00" - }, - { - "name": "symfony/polyfill-php85", - "version": "v1.32.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", - "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.32.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-05-02T08:40:52+00:00" - }, { "name": "symfony/polyfill-uuid", "version": "v1.32.0", @@ -6290,16 +6545,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" + "reference": "8e213820c5fea844ecea29203d2a308019007c15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", - "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", + "reference": "8e213820c5fea844ecea29203d2a308019007c15", "shasum": "" }, "require": { @@ -6351,7 +6606,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.2" + "source": "https://github.com/symfony/routing/tree/v7.3.0" }, "funding": [ { @@ -6362,16 +6617,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-05-24T20:43:28+00:00" }, { "name": "symfony/service-contracts", @@ -6458,16 +6709,16 @@ }, { "name": "symfony/string", - "version": "v7.3.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", "shasum": "" }, "require": { @@ -6525,7 +6776,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.2" + "source": "https://github.com/symfony/string/tree/v7.3.0" }, "funding": [ { @@ -6536,29 +6787,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-04-20T20:19:01+00:00" }, { "name": "symfony/translation", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" + "reference": "241d5ac4910d256660238a7ecf250deba4c73063" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", - "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063", "shasum": "" }, "require": { @@ -6625,7 +6872,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.2" + "source": "https://github.com/symfony/translation/tree/v7.3.1" }, "funding": [ { @@ -6636,16 +6883,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-30T17:31:46+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/translation-contracts", @@ -6801,16 +7044,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "53205bea27450dc5c65377518b3275e126d45e75" + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", - "reference": "53205bea27450dc5c65377518b3275e126d45e75", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", "shasum": "" }, "require": { @@ -6822,6 +7065,7 @@ "symfony/console": "<6.4" }, "require-dev": { + "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -6864,7 +7108,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" }, "funding": [ { @@ -6875,29 +7119,25 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-29T20:02:46+00:00" + "time": "2025-06-27T19:55:54+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.2", + "version": "v7.3.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "05b3e90654c097817325d6abd284f7938b05f467" + "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", - "reference": "05b3e90654c097817325d6abd284f7938b05f467", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", + "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", "shasum": "" }, "require": { @@ -6945,7 +7185,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" }, "funding": [ { @@ -6956,16 +7196,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-05-15T09:04:05+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7532,16 +7768,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.3.0", + "version": "1.2.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + "reference": "8520451a140d3f46ac33042715115e290cf5785f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", - "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", + "reference": "8520451a140d3f46ac33042715115e290cf5785f", "shasum": "" }, "require": { @@ -7551,10 +7787,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-deprecation-rules": "^2.0.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan": "^1.9.2", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.2.2", + "phpstan/phpstan-strict-rules": "^1.4.4", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -7581,7 +7817,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" }, "funding": [ { @@ -7589,20 +7825,20 @@ "type": "github" } ], - "time": "2025-08-14T07:29:31+00:00" + "time": "2024-08-06T10:04:20+00:00" }, { "name": "filp/whoops", - "version": "2.18.4", + "version": "2.18.3", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "reference": "59a123a3d459c5a23055802237cb317f609867e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", "shasum": "" }, "require": { @@ -7652,7 +7888,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.4" + "source": "https://github.com/filp/whoops/tree/2.18.3" }, "funding": [ { @@ -7660,7 +7896,7 @@ "type": "github" } ], - "time": "2025-08-08T12:00:00+00:00" + "time": "2025-06-16T00:02:10+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -7903,135 +8139,6 @@ ], "time": "2025-07-11T06:52:52+00:00" }, - { - "name": "laravel/boost", - "version": "v1.0.17", - "source": { - "type": "git", - "url": "https://github.com/laravel/boost.git", - "reference": "3d1121561f793d027b76cb02b4ef9e654f8870fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/3d1121561f793d027b76cb02b4ef9e654f8870fb", - "reference": "3d1121561f793d027b76cb02b4ef9e654f8870fb", - "shasum": "" - }, - "require": { - "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" - }, - "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Boost\\BoostServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Boost\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", - "homepage": "https://github.com/laravel/boost", - "keywords": [ - "ai", - "dev", - "laravel" - ], - "support": { - "issues": "https://github.com/laravel/boost/issues", - "source": "https://github.com/laravel/boost" - }, - "time": "2025-08-14T17:31:57+00:00" - }, - { - "name": "laravel/mcp", - "version": "v0.1.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/mcp.git", - "reference": "417890c0d8032af9a46a86d16651bbe13946cddf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/417890c0d8032af9a46a86d16651bbe13946cddf", - "reference": "417890c0d8032af9a46a86d16651bbe13946cddf", - "shasum": "" - }, - "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" - }, - "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" - }, - "providers": [ - "Laravel\\Mcp\\Server\\McpServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", - "Laravel\\Mcp\\Server\\": "src/Server/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The easiest way to add MCP servers to your Laravel app.", - "homepage": "https://github.com/laravel/mcp", - "keywords": [ - "dev", - "laravel", - "mcp" - ], - "support": { - "issues": "https://github.com/laravel/mcp/issues", - "source": "https://github.com/laravel/mcp" - }, - "time": "2025-08-12T07:09:39+00:00" - }, { "name": "laravel/pail", "version": "v1.2.3", @@ -8180,67 +8287,6 @@ }, "time": "2025-07-10T18:09:32+00:00" }, - { - "name": "laravel/roster", - "version": "v0.2.3", - "source": { - "type": "git", - "url": "https://github.com/laravel/roster.git", - "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/caeed7609b02c00c3f1efec52812d8d87c5d4096", - "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096", - "shasum": "" - }, - "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2", - "symfony/yaml": "^6.4|^7.2" - }, - "require-dev": { - "laravel/pint": "^1.14", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Roster\\RosterServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Roster\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Detect packages & approaches in use within a Laravel project", - "homepage": "https://github.com/laravel/roster", - "keywords": [ - "dev", - "laravel" - ], - "support": { - "issues": "https://github.com/laravel/roster/issues", - "source": "https://github.com/laravel/roster" - }, - "time": "2025-08-13T15:00:25+00:00" - }, { "name": "laravel/sail", "version": "v1.44.0", @@ -8389,16 +8435,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.13.3", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", + "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", "shasum": "" }, "require": { @@ -8437,7 +8483,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" }, "funding": [ { @@ -8445,7 +8491,7 @@ "type": "tidelift" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2025-07-05T12:25:42+00:00" }, { "name": "nunomaduro/collision", @@ -9355,16 +9401,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.22", + "version": "2.1.20", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" + "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9ccfef95210f92ba6feea6e8d1eef42b5605499", + "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499", "shasum": "" }, "require": { @@ -9409,7 +9455,7 @@ "type": "github" } ], - "time": "2025-08-04T19:17:37+00:00" + "time": "2025-07-26T20:45:26+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10019,16 +10065,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", "shasum": "" }, "require": { @@ -10087,27 +10133,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", - "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2025-03-07T06:57:01+00:00" }, { "name": "sebastian/complexity", @@ -10624,23 +10658,23 @@ }, { "name": "sebastian/recursion-context", - "version": "6.0.3", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", - "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { @@ -10676,40 +10710,28 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", - "type": "tidelift" } ], - "time": "2025-08-13T04:42:22+00:00" + "time": "2024-07-03T05:10:34+00:00" }, { "name": "sebastian/type", - "version": "5.1.3", + "version": "5.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", - "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", "shasum": "" }, "require": { @@ -10745,27 +10767,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + "source": "https://github.com/sebastianbergmann/type/tree/5.1.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/type", - "type": "tidelift" } ], - "time": "2025-08-09T06:55:48+00:00" + "time": "2025-03-18T13:35:50+00:00" }, { "name": "sebastian/version", @@ -10823,16 +10833,16 @@ }, { "name": "spatie/pest-expectations", - "version": "1.13.2", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/spatie/pest-expectations.git", - "reference": "d78d74cef4b563e669e4e07ae5f88cbeb4373600" + "reference": "6ec6e5184c70fe7113e4237d40ad4f8387ce1be2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/d78d74cef4b563e669e4e07ae5f88cbeb4373600", - "reference": "d78d74cef4b563e669e4e07ae5f88cbeb4373600", + "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/6ec6e5184c70fe7113e4237d40ad4f8387ce1be2", + "reference": "6ec6e5184c70fe7113e4237d40ad4f8387ce1be2", "shasum": "" }, "require": { @@ -10840,7 +10850,6 @@ "php": "^8.2" }, "require-dev": { - "ext-sockets": "*", "illuminate/contracts": "^10.0|^11.0|^12.0", "laravel/pint": "^1.2", "orchestra/testbench": "^8.3|^9.0|^10.0", @@ -10877,7 +10886,7 @@ ], "support": { "issues": "https://github.com/spatie/pest-expectations/issues", - "source": "https://github.com/spatie/pest-expectations/tree/1.13.2" + "source": "https://github.com/spatie/pest-expectations/tree/1.11.0" }, "funding": [ { @@ -10885,7 +10894,7 @@ "type": "github" } ], - "time": "2025-08-12T17:04:55+00:00" + "time": "2025-04-13T12:30:30+00:00" }, { "name": "staabm/side-effects-detector", @@ -10941,16 +10950,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.2", + "version": "v7.3.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", "shasum": "" }, "require": { @@ -10993,7 +11002,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.2" + "source": "https://github.com/symfony/yaml/tree/v7.3.1" }, "funding": [ { @@ -11004,16 +11013,12 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-06-03T06:57:57+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/config/services.php b/config/services.php index 14b9dd1..7fe0344 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,16 @@ return [ ], ], + 'oidc' => [ + 'enabled' => env('OIDC_ENABLED', false), + // OIDC_ENDPOINT can be either: + // - Base URL: https://your-provider.com (will append /.well-known/openid-configuration) + // - Full well-known URL: https://your-provider.com/.well-known/openid-configuration + 'endpoint' => env('OIDC_ENDPOINT'), + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('APP_URL', 'http://localhost:8000') . '/auth/oidc/callback', + 'scopes' => explode(',', env('OIDC_SCOPES', 'openid,profile,email')), + ], + ]; diff --git a/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php new file mode 100644 index 0000000..d8dba38 --- /dev/null +++ b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php @@ -0,0 +1,28 @@ +string('oidc_sub')->nullable()->unique()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('oidc_sub'); + }); + } +}; diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index d1ea315..6f8488a 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -118,6 +118,29 @@ new #[Layout('components.layouts.auth')] class extends Component {
+ @if (config('services.oidc.enabled')) +
+
+ +
+
+ + {{ __('Or') }} + +
+
+ +
+ + {{ __('Continue with OIDC') }} + +
+ @endif @if (Route::has('register'))
diff --git a/routes/auth.php b/routes/auth.php index 5647405..49b2173 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,5 +1,6 @@ group(function () { Volt::route('reset-password/{token}', 'auth.reset-password') ->name('password.reset'); + // OIDC authentication routes + Route::get('auth/oidc/redirect', [OidcController::class, 'redirect']) + ->name('auth.oidc.redirect'); + + Route::get('auth/oidc/callback', [OidcController::class, 'callback']) + ->name('auth.oidc.callback'); + }); Route::middleware('auth')->group(function () { diff --git a/tests/Feature/Auth/OidcAuthenticationTest.php b/tests/Feature/Auth/OidcAuthenticationTest.php new file mode 100644 index 0000000..30d1bc2 --- /dev/null +++ b/tests/Feature/Auth/OidcAuthenticationTest.php @@ -0,0 +1,158 @@ +get(route('auth.oidc.redirect')); + + // Since we're using a mock OIDC provider, this will likely fail + // but we can check that the route exists and is accessible + $this->assertNotEquals(404, $response->getStatusCode()); + } + + public function test_oidc_redirect_fails_when_disabled() + { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('auth.oidc.redirect')); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); + } + + public function test_oidc_callback_creates_new_user() + { + $mockUser = $this->mockSocialiteUser(); + + $response = $this->get(route('auth.oidc.callback')); + + // We expect to be redirected to dashboard after successful authentication + // In a real test, this would be mocked properly + $this->assertTrue(true); // Placeholder assertion + } + + public function test_oidc_callback_updates_existing_user_by_oidc_sub() + { + // Create a user with OIDC sub + $user = User::factory()->create([ + 'oidc_sub' => 'test-sub-123', + 'name' => 'Old Name', + 'email' => 'old@example.com', + ]); + + $mockUser = $this->mockSocialiteUser([ + 'id' => 'test-sub-123', + 'name' => 'Updated Name', + 'email' => 'updated@example.com', + ]); + + // This would need proper mocking of Socialite in a real test + $this->assertTrue(true); // Placeholder assertion + } + + public function test_oidc_callback_links_existing_user_by_email() + { + // Create a user without OIDC sub but with matching email + $user = User::factory()->create([ + 'oidc_sub' => null, + 'email' => 'test@example.com', + ]); + + $mockUser = $this->mockSocialiteUser([ + 'id' => 'test-sub-456', + 'email' => 'test@example.com', + ]); + + // This would need proper mocking of Socialite in a real test + $this->assertTrue(true); // Placeholder assertion + } + + public function test_oidc_callback_fails_when_disabled() + { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('auth.oidc.callback')); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); + } + + public function test_login_view_shows_oidc_button_when_enabled() + { + $response = $this->get(route('login')); + + $response->assertStatus(200); + $response->assertSee('Continue with OIDC'); + $response->assertSee('Or'); + } + + public function test_login_view_hides_oidc_button_when_disabled() + { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('login')); + + $response->assertStatus(200); + $response->assertDontSee('Continue with OIDC'); + } + + public function test_user_model_has_oidc_sub_fillable() + { + $user = new User(); + + $this->assertContains('oidc_sub', $user->getFillable()); + } + + /** + * Mock a Socialite user for testing. + */ + protected function mockSocialiteUser(array $userData = []) + { + $defaultData = [ + 'id' => 'test-sub-123', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'avatar' => null, + ]; + + $userData = array_merge($defaultData, $userData); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn($userData['id']); + $socialiteUser->shouldReceive('getName')->andReturn($userData['name']); + $socialiteUser->shouldReceive('getEmail')->andReturn($userData['email']); + $socialiteUser->shouldReceive('getAvatar')->andReturn($userData['avatar']); + + return $socialiteUser; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file From 032c82e4aa4da502f7ed6b108c264131490e16ce Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 15 Aug 2025 22:47:58 +0200 Subject: [PATCH 015/209] chore: update dependencies --- composer.lock | 192 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/composer.lock b/composer.lock index 6c2583d..768e2a9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7342818e54c036836a947a3fe0b67044", + "content-hash": "9143c36674f3ae13a9e9bad15014d508", "packages": [ { "name": "aws/aws-crt-php", @@ -8139,6 +8139,135 @@ ], "time": "2025-07-11T06:52:52+00:00" }, + { + "name": "laravel/boost", + "version": "v1.0.17", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "3d1121561f793d027b76cb02b4ef9e654f8870fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/3d1121561f793d027b76cb02b4ef9e654f8870fb", + "reference": "3d1121561f793d027b76cb02b4ef9e654f8870fb", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.0", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14|^1.23", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-08-14T17:31:57+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "417890c0d8032af9a46a86d16651bbe13946cddf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/417890c0d8032af9a46a86d16651bbe13946cddf", + "reference": "417890c0d8032af9a46a86d16651bbe13946cddf", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-12T07:09:39+00:00" + }, { "name": "laravel/pail", "version": "v1.2.3", @@ -8287,6 +8416,67 @@ }, "time": "2025-07-10T18:09:32+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/caeed7609b02c00c3f1efec52812d8d87c5d4096", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-08-13T15:00:25+00:00" + }, { "name": "laravel/sail", "version": "v1.44.0", From e49de8da5fac95f56a644957ca584186be563087 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 15 Aug 2025 22:54:16 +0200 Subject: [PATCH 016/209] chore: update dependencies --- composer.lock | 661 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 452 insertions(+), 209 deletions(-) diff --git a/composer.lock b/composer.lock index 768e2a9..0c56e9b 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.351.8", + "version": "3.354.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5" + "reference": "6aa524596cd83416085777a3bd037d06a70b5c65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/61bab063c6e3567942dcc4a2aac56155af22b6d5", - "reference": "61bab063c6e3567942dcc4a2aac56155af22b6d5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/6aa524596cd83416085777a3bd037d06a70b5c65", + "reference": "6aa524596cd83416085777a3bd037d06a70b5c65", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "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.351.8" + "source": "https://github.com/aws/aws-sdk-php/tree/3.354.1" }, - "time": "2025-07-28T18:40:29+00:00" + "time": "2025-08-15T18:05:41+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "1.2.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34" + "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/be3ee30b86940eae32c3188f5a158b9334ceaf34", - "reference": "be3ee30b86940eae32c3188f5a158b9334ceaf34", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", + "reference": "fe11d1d7d896d6f0ea44664c1c6b5f00f1bdab36", "shasum": "" }, "require": { @@ -223,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.0" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/1.2.1" }, "funding": [ { @@ -239,7 +239,7 @@ "type": "github" } ], - "time": "2025-07-28T19:31:11+00:00" + "time": "2025-08-11T16:14:12+00:00" }, { "name": "brick/math", @@ -447,33 +447,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -518,7 +517,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -534,7 +533,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/lexer", @@ -1480,16 +1479,16 @@ }, { "name": "intervention/image", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17" + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/d0f097b8a3fa8fb758efc9440b513aa3833cda17", - "reference": "d0f097b8a3fa8fb758efc9440b513aa3833cda17", + "url": "https://api.github.com/repos/Intervention/image/zipball/8c49eb21a6d2572532d1bc425964264f3e496846", + "reference": "8c49eb21a6d2572532d1bc425964264f3e496846", "shasum": "" }, "require": { @@ -1536,7 +1535,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.3" + "source": "https://github.com/Intervention/image/tree/3.11.4" }, "funding": [ { @@ -1552,7 +1551,7 @@ "type": "ko_fi" } ], - "time": "2025-05-22T17:26:23+00:00" + "time": "2025-07-30T13:13:19+00:00" }, { "name": "keepsuit/laravel-liquid", @@ -1692,16 +1691,16 @@ }, { "name": "laravel/framework", - "version": "v12.21.0", + "version": "v12.24.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b" + "reference": "6dcf2c46da23d159f35d6246234953a74b740d83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b", - "reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b", + "url": "https://api.github.com/repos/laravel/framework/zipball/6dcf2c46da23d159f35d6246234953a74b740d83", + "reference": "6dcf2c46da23d159f35d6246234953a74b740d83", "shasum": "" }, "require": { @@ -1742,6 +1741,8 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.31", + "symfony/polyfill-php84": "^1.31", + "symfony/polyfill-php85": "^1.31", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1809,7 +1810,7 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^10.0.0", + "orchestra/testbench-core": "^10.6.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1903,7 +1904,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-07-22T15:41:55+00:00" + "time": "2025-08-13T20:30:36+00:00" }, { "name": "laravel/prompts", @@ -2856,16 +2857,16 @@ }, { "name": "livewire/flux", - "version": "v2.2.3", + "version": "v2.2.4", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "0fb4c0b78eac393ad3a19a387af193573c310371" + "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/0fb4c0b78eac393ad3a19a387af193573c310371", - "reference": "0fb4c0b78eac393ad3a19a387af193573c310371", + "url": "https://api.github.com/repos/livewire/flux/zipball/af81b5fd34c6490d5b5e05ed0f8140c0250e5069", + "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069", "shasum": "" }, "require": { @@ -2913,9 +2914,9 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.2.3" + "source": "https://github.com/livewire/flux/tree/v2.2.4" }, - "time": "2025-07-11T00:25:51+00:00" + "time": "2025-08-09T01:46:51+00:00" }, { "name": "livewire/livewire", @@ -2995,16 +2996,16 @@ }, { "name": "livewire/volt", - "version": "v1.7.1", + "version": "v1.7.2", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "ba3e609fd4c71f8b5783f024baf51715e48e93a6" + "reference": "91ba934e72bbd162442840862959ade24dbe728a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/ba3e609fd4c71f8b5783f024baf51715e48e93a6", - "reference": "ba3e609fd4c71f8b5783f024baf51715e48e93a6", + "url": "https://api.github.com/repos/livewire/volt/zipball/91ba934e72bbd162442840862959ade24dbe728a", + "reference": "91ba934e72bbd162442840862959ade24dbe728a", "shasum": "" }, "require": { @@ -3063,7 +3064,7 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-04-08T15:13:36+00:00" + "time": "2025-08-06T15:40:50+00:00" }, { "name": "maennchen/zipstream-php", @@ -3314,16 +3315,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.1", + "version": "3.10.2", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", - "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", + "reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24", "shasum": "" }, "require": { @@ -3415,7 +3416,7 @@ "type": "tidelift" } ], - "time": "2025-06-21T15:19:35+00:00" + "time": "2025-08-02T09:36:06+00:00" }, { "name": "nette/schema", @@ -3481,29 +3482,29 @@ }, { "name": "nette/utils", - "version": "v4.0.7", + "version": "v4.0.8", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", - "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede", + "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede", "shasum": "" }, "require": { - "php": "8.0 - 8.4" + "php": "8.0 - 8.5" }, "conflict": { "nette/finder": "<3", "nette/schema": "<1.2.2" }, "require-dev": { - "jetbrains/phpstorm-attributes": "dev-master", + "jetbrains/phpstorm-attributes": "^1.2", "nette/tester": "^2.5", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -3521,6 +3522,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -3561,22 +3565,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.7" + "source": "https://github.com/nette/utils/tree/v4.0.8" }, - "time": "2025-06-03T04:55:08+00:00" + "time": "2025-08-06T21:43:34+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.0", + "version": "v5.6.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56" + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/221b0d0fdf1369c71047ad1d18bb5880017bbc56", - "reference": "221b0d0fdf1369c71047ad1d18bb5880017bbc56", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", "shasum": "" }, "require": { @@ -3595,7 +3599,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -3619,9 +3623,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" }, - "time": "2025-07-27T20:03:57+00:00" + "time": "2025-08-13T20:13:15+00:00" }, { "name": "nunomaduro/termwind", @@ -4426,16 +4430,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.9", + "version": "v0.12.10", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "1b801844becfe648985372cb4b12ad6840245ace" + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", - "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/6e80abe6f2257121f1eb9a4c55bf29d921025b22", + "reference": "6e80abe6f2257121f1eb9a4c55bf29d921025b22", "shasum": "" }, "require": { @@ -4485,12 +4489,11 @@ "authors": [ { "name": "Justin Hileman", - "email": "justin@justinhileman.info", - "homepage": "http://justinhileman.com" + "email": "justin@justinhileman.info" } ], "description": "An interactive shell for modern PHP.", - "homepage": "http://psysh.org", + "homepage": "https://psysh.org", "keywords": [ "REPL", "console", @@ -4499,9 +4502,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.10" }, - "time": "2025-06-23T02:35:06+00:00" + "time": "2025-08-04T12:39:37+00:00" }, { "name": "ralouphie/getallheaders", @@ -4967,16 +4970,16 @@ }, { "name": "symfony/console", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "url": "https://api.github.com/repos/symfony/console/zipball/5f360ebc65c55265a74d23d7fe27f957870158a1", + "reference": "5f360ebc65c55265a74d23d7fe27f957870158a1", "shasum": "" }, "require": { @@ -5041,7 +5044,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.1" + "source": "https://github.com/symfony/console/tree/v7.3.2" }, "funding": [ { @@ -5052,12 +5055,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:13:41+00:00" }, { "name": "symfony/css-selector", @@ -5193,16 +5200,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", "shasum": "" }, "require": { @@ -5250,7 +5257,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + "source": "https://github.com/symfony/error-handler/tree/v7.3.2" }, "funding": [ { @@ -5261,12 +5268,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-07-07T08:17:57+00:00" }, { "name": "symfony/event-dispatcher", @@ -5426,16 +5437,16 @@ }, { "name": "symfony/finder", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", + "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", "shasum": "" }, "require": { @@ -5470,7 +5481,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.0" + "source": "https://github.com/symfony/finder/tree/v7.3.2" }, "funding": [ { @@ -5481,25 +5492,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", + "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", "shasum": "" }, "require": { @@ -5549,7 +5564,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" }, "funding": [ { @@ -5560,25 +5575,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T15:07:14+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6ecc895559ec0097e221ed2fd5eb44d5fede083c", + "reference": "6ecc895559ec0097e221ed2fd5eb44d5fede083c", "shasum": "" }, "require": { @@ -5663,7 +5682,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.2" }, "funding": [ { @@ -5674,25 +5693,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T08:24:55+00:00" + "time": "2025-07-31T10:45:04+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d43e84d9522345f96ad6283d5dfccc8c1cfc299b", + "reference": "d43e84d9522345f96ad6283d5dfccc8c1cfc299b", "shasum": "" }, "require": { @@ -5743,7 +5766,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.1" + "source": "https://github.com/symfony/mailer/tree/v7.3.2" }, "funding": [ { @@ -5754,25 +5777,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/mime", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", "shasum": "" }, "require": { @@ -5827,7 +5854,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.0" + "source": "https://github.com/symfony/mime/tree/v7.3.2" }, "funding": [ { @@ -5838,12 +5865,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6403,6 +6434,158 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php84", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "000df7860439609837bbe28670b0be15783b7fbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", + "reference": "000df7860439609837bbe28670b0be15783b7fbf", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-20T12:04:08+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "reference": "6fedf31ce4e3648f4ff5ca58bfd53127d38f05fd", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-02T08:40:52+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.32.0", @@ -6545,16 +6728,16 @@ }, { "name": "symfony/routing", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8e213820c5fea844ecea29203d2a308019007c15" + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", - "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "url": "https://api.github.com/repos/symfony/routing/zipball/7614b8ca5fa89b9cd233e21b627bfc5774f586e4", + "reference": "7614b8ca5fa89b9cd233e21b627bfc5774f586e4", "shasum": "" }, "require": { @@ -6606,7 +6789,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.0" + "source": "https://github.com/symfony/routing/tree/v7.3.2" }, "funding": [ { @@ -6617,12 +6800,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T20:43:28+00:00" + "time": "2025-07-15T11:36:08+00:00" }, { "name": "symfony/service-contracts", @@ -6709,16 +6896,16 @@ }, { "name": "symfony/string", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", + "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", "shasum": "" }, "require": { @@ -6776,7 +6963,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.0" + "source": "https://github.com/symfony/string/tree/v7.3.2" }, "funding": [ { @@ -6787,25 +6974,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-20T20:19:01+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/translation", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "url": "https://api.github.com/repos/symfony/translation/zipball/81b48f4daa96272efcce9c7a6c4b58e629df3c90", + "reference": "81b48f4daa96272efcce9c7a6c4b58e629df3c90", "shasum": "" }, "require": { @@ -6872,7 +7063,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.1" + "source": "https://github.com/symfony/translation/tree/v7.3.2" }, "funding": [ { @@ -6883,12 +7074,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-30T17:31:46+00:00" }, { "name": "symfony/translation-contracts", @@ -7044,16 +7239,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + "reference": "53205bea27450dc5c65377518b3275e126d45e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", + "reference": "53205bea27450dc5c65377518b3275e126d45e75", "shasum": "" }, "require": { @@ -7065,7 +7260,6 @@ "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -7108,7 +7302,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" }, "funding": [ { @@ -7119,25 +7313,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-07-29T20:02:46+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" + "reference": "05b3e90654c097817325d6abd284f7938b05f467" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", + "reference": "05b3e90654c097817325d6abd284f7938b05f467", "shasum": "" }, "require": { @@ -7185,7 +7383,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" }, "funding": [ { @@ -7196,12 +7394,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7768,16 +7970,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { @@ -7787,10 +7989,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -7817,7 +8019,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { @@ -7825,20 +8027,20 @@ "type": "github" } ], - "time": "2024-08-06T10:04:20+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "filp/whoops", - "version": "2.18.3", + "version": "2.18.4", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "59a123a3d459c5a23055802237cb317f609867e5" + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", - "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", "shasum": "" }, "require": { @@ -7888,7 +8090,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.3" + "source": "https://github.com/filp/whoops/tree/2.18.4" }, "funding": [ { @@ -7896,7 +8098,7 @@ "type": "github" } ], - "time": "2025-06-16T00:02:10+00:00" + "time": "2025-08-08T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -8625,16 +8827,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.3", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -8673,7 +8875,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -8681,7 +8883,7 @@ "type": "tidelift" } ], - "time": "2025-07-05T12:25:42+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nunomaduro/collision", @@ -9591,16 +9793,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.20", + "version": "2.1.22", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499" + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9ccfef95210f92ba6feea6e8d1eef42b5605499", - "reference": "a9ccfef95210f92ba6feea6e8d1eef42b5605499", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", + "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", "shasum": "" }, "require": { @@ -9645,7 +9847,7 @@ "type": "github" } ], - "time": "2025-07-26T20:45:26+00:00" + "time": "2025-08-04T19:17:37+00:00" }, { "name": "phpunit/php-code-coverage", @@ -10255,16 +10457,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.1", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", - "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", "shasum": "" }, "require": { @@ -10323,15 +10525,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2025-03-07T06:57:01+00:00" + "time": "2025-08-10T08:07:46+00:00" }, { "name": "sebastian/complexity", @@ -10848,23 +11062,23 @@ }, { "name": "sebastian/recursion-context", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { @@ -10900,28 +11114,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2024-07-03T05:10:34+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "5.1.2", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", - "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { @@ -10957,15 +11183,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2025-03-18T13:35:50+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", @@ -11023,16 +11261,16 @@ }, { "name": "spatie/pest-expectations", - "version": "1.11.0", + "version": "1.13.2", "source": { "type": "git", "url": "https://github.com/spatie/pest-expectations.git", - "reference": "6ec6e5184c70fe7113e4237d40ad4f8387ce1be2" + "reference": "d78d74cef4b563e669e4e07ae5f88cbeb4373600" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/6ec6e5184c70fe7113e4237d40ad4f8387ce1be2", - "reference": "6ec6e5184c70fe7113e4237d40ad4f8387ce1be2", + "url": "https://api.github.com/repos/spatie/pest-expectations/zipball/d78d74cef4b563e669e4e07ae5f88cbeb4373600", + "reference": "d78d74cef4b563e669e4e07ae5f88cbeb4373600", "shasum": "" }, "require": { @@ -11040,6 +11278,7 @@ "php": "^8.2" }, "require-dev": { + "ext-sockets": "*", "illuminate/contracts": "^10.0|^11.0|^12.0", "laravel/pint": "^1.2", "orchestra/testbench": "^8.3|^9.0|^10.0", @@ -11076,7 +11315,7 @@ ], "support": { "issues": "https://github.com/spatie/pest-expectations/issues", - "source": "https://github.com/spatie/pest-expectations/tree/1.11.0" + "source": "https://github.com/spatie/pest-expectations/tree/1.13.2" }, "funding": [ { @@ -11084,7 +11323,7 @@ "type": "github" } ], - "time": "2025-04-13T12:30:30+00:00" + "time": "2025-08-12T17:04:55+00:00" }, { "name": "staabm/side-effects-detector", @@ -11140,16 +11379,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.1", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", "shasum": "" }, "require": { @@ -11192,7 +11431,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.1" + "source": "https://github.com/symfony/yaml/tree/v7.3.2" }, "funding": [ { @@ -11203,12 +11442,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:57:57+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", From 93dc4a14929dd942546e1dc06417c77663f09b20 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 15 Aug 2025 23:15:24 +0200 Subject: [PATCH 017/209] chore: update laravel/boost instructions --- .cursor/rules/laravel-boost.mdc | 2 +- .github/copilot-instructions.md | 2 +- .gitignore | 1 + .junie/guidelines.md | 2 +- CLAUDE.md | 531 ++++++++++++++++++++++++++++++++ 5 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc index 0b60289..9464f06 100644 --- a/.cursor/rules/laravel-boost.mdc +++ b/.cursor/rules/laravel-boost.mdc @@ -210,7 +210,7 @@ avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, h ## Livewire Core - Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components - State should live on the server, with the UI reflecting it. - All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 737877e..a331541 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -207,7 +207,7 @@ avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, h ## Livewire Core - Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components - State should live on the server, with the UI reflecting it. - All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. diff --git a/.gitignore b/.gitignore index 33806df..3a2ae5a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ yarn-error.log /.vscode /.zed /database/seeders/PersonalDeviceSeeder.php +/.junie/mcp/mcp.json diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 737877e..a331541 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -207,7 +207,7 @@ avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, h ## Livewire Core - Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. -- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components - State should live on the server, with the UI reflecting it. - All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..737877e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,531 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.24 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- larastan/larastan (LARASTAN) - v3 +- laravel/pint (PINT) - v1 +- pestphp/pest (PEST) - v3 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== pest/core rules === + +## Pest + +### Testing +- If you need to verify a feature is working, write or update a Unit / Feature test. + +### Pest Tests +- All tests must be written using Pest. Use `php artisan make:test --pest `. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application. +- Tests should test all of the happy paths, failure paths, and weird paths. +- Tests live in the `tests/Feature` and `tests/Unit` directories. +- Pest tests look and behave like this: + +it('is true', function () { + expect(true)->toBeTrue(); +}); + + +### Running Tests +- Run the minimal number of tests using an appropriate filter before finalizing code edits. +- To run all tests: `php artisan test`. +- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). +- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing. + +### Pest Assertions +- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.: + +it('returns all', function () { + $response = $this->postJson('/api/docs', []); + + $response->assertSuccessful(); +}); + + +### Mocking +- Mocking can be very helpful when appropriate. +- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do. +- You can also create partial mocks using the same import or self method. + +### Datasets +- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules. + + +it('has emails', function (string $email) { + expect($email)->not->toBeEmpty(); +})->with([ + 'james' => 'james@laravel.com', + 'taylor' => 'taylor@laravel.com', +]); + + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file From 0503be65c250fa42f7822c41dafdfac284cd05f2 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 16 Aug 2025 00:12:00 +0200 Subject: [PATCH 018/209] chore: add pest arch exemption --- tests/Pest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Pest.php b/tests/Pest.php index 627fd57..624dd1c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -17,7 +17,10 @@ pest()->extend(Tests\TestCase::class) registerSpatiePestHelpers(); -arch()->preset()->laravel(); +arch() + ->preset() + ->laravel() + ->ignoring(App\Http\Controllers\Auth\OidcController::class); arch() ->expect('App') From a88e72b75efc876a4ad58abc36a6dddc80f35d54 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 15 Aug 2025 23:40:26 +0200 Subject: [PATCH 019/209] fix(#76): rotate back preview image --- .../views/livewire/device-dashboard.blade.php | 6 +- .../livewire/devices/configure.blade.php | 6 +- .../{ => Devices}/DeviceConfigureTest.php | 0 tests/Feature/Devices/DeviceRotationTest.php | 86 +++++++++++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) rename tests/Feature/{ => Devices}/DeviceConfigureTest.php (100%) create mode 100644 tests/Feature/Devices/DeviceRotationTest.php diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 76ce414..5db65d1 100644 --- a/resources/views/livewire/device-dashboard.blade.php +++ b/resources/views/livewire/device-dashboard.blade.php @@ -95,7 +95,11 @@ new class extends Component { @elseif($current_image_path) - Current Image +
+
+ Current Image +
+
@endif
diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 488e904..32a16f6 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -455,7 +455,11 @@ new class extends Component { @if(!$device->mirror_device_id) @if($current_image_path) - Next Image +
+
+ Next Image +
+
@endif diff --git a/tests/Feature/DeviceConfigureTest.php b/tests/Feature/Devices/DeviceConfigureTest.php similarity index 100% rename from tests/Feature/DeviceConfigureTest.php rename to tests/Feature/Devices/DeviceConfigureTest.php diff --git a/tests/Feature/Devices/DeviceRotationTest.php b/tests/Feature/Devices/DeviceRotationTest.php new file mode 100644 index 0000000..2f2374f --- /dev/null +++ b/tests/Feature/Devices/DeviceRotationTest.php @@ -0,0 +1,86 @@ +create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => 90, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + \Illuminate\Support\Facades\Storage::fake('public'); + \Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[90deg]'); + $response->assertSee('origin-center'); +}); + +test('device configure page shows device image with correct rotation', function () { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => 90, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + \Illuminate\Support\Facades\Storage::fake('public'); + \Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('devices.configure', $device)); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[90deg]'); + $response->assertSee('origin-center'); +}); + +test('device with no rotation shows no transform style', function () { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => 0, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + \Illuminate\Support\Facades\Storage::fake('public'); + \Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[0deg]'); +}); + +test('device with null rotation defaults to 0', function () { + $user = User::factory()->create(); + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'rotate' => null, + 'current_screen_image' => 'test-image-uuid', + ]); + + // Mock the file existence check + \Illuminate\Support\Facades\Storage::fake('public'); + \Illuminate\Support\Facades\Storage::disk('public')->put('images/generated/test-image-uuid.png', 'fake-image-content'); + + $response = $this->actingAs($user) + ->get(route('dashboard')); + + $response->assertSuccessful(); + $response->assertSee('-rotate-[0deg]'); +}); From ba3bf31bb7c396270603b348b6a1d731dd9e47ea Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 16 Aug 2025 09:41:00 +0200 Subject: [PATCH 020/209] feat: adapt device models api --- .../Commands/FetchDeviceModelsCommand.php | 46 ++ app/Enums/ImageFormat.php | 2 + app/Jobs/FetchDeviceModelsJob.php | 125 ++++++ app/Jobs/FetchProxyCloudResponses.php | 38 +- app/Models/Device.php | 5 +- app/Models/DeviceModel.php | 27 ++ app/Services/ImageGenerationService.php | 262 +++++++++-- config/app.php | 1 + database/factories/DeviceModelFactory.php | 38 ++ ...8_07_111635_create_device_models_table.php | 41 ++ ...3_add_device_model_id_to_devices_table.php | 29 ++ .../2025_08_16_135740_seed_device_models.php | 285 ++++++++++++ .../livewire/device-models/index.blade.php | 389 ++++++++++++++++ .../livewire/devices/configure.blade.php | 69 ++- .../views/livewire/devices/manage.blade.php | 34 ++ .../vendor/trmnl/components/screen.blade.php | 35 ++ routes/api.php | 16 + routes/console.php | 2 + routes/web.php | 2 + .../Feature/Api/DeviceModelsEndpointTest.php | 35 ++ tests/Feature/Auth/OidcAuthenticationTest.php | 285 ++++++------ tests/Feature/DeviceModelsTest.php | 89 ++++ .../Feature/FetchDeviceModelsCommandTest.php | 20 + .../Feature/FetchProxyCloudResponsesTest.php | 6 + tests/Feature/ImageGenerationServiceTest.php | 425 ++++++++++++++++++ .../Feature/Jobs/FirmwareDownloadJobTest.php | 4 + tests/Feature/Jobs/FirmwarePollJobTest.php | 14 +- tests/Pest.php | 8 +- .../Services/ImageGenerationServiceTest.php | 262 +++++++++++ 29 files changed, 2379 insertions(+), 215 deletions(-) create mode 100644 app/Console/Commands/FetchDeviceModelsCommand.php create mode 100644 app/Jobs/FetchDeviceModelsJob.php create mode 100644 app/Models/DeviceModel.php create mode 100644 database/factories/DeviceModelFactory.php create mode 100644 database/migrations/2025_08_07_111635_create_device_models_table.php create mode 100644 database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php create mode 100644 database/migrations/2025_08_16_135740_seed_device_models.php create mode 100644 resources/views/livewire/device-models/index.blade.php create mode 100644 resources/views/vendor/trmnl/components/screen.blade.php create mode 100644 tests/Feature/Api/DeviceModelsEndpointTest.php create mode 100644 tests/Feature/DeviceModelsTest.php create mode 100644 tests/Feature/FetchDeviceModelsCommandTest.php create mode 100644 tests/Feature/ImageGenerationServiceTest.php create mode 100644 tests/Unit/Services/ImageGenerationServiceTest.php diff --git a/app/Console/Commands/FetchDeviceModelsCommand.php b/app/Console/Commands/FetchDeviceModelsCommand.php new file mode 100644 index 0000000..78dd02a --- /dev/null +++ b/app/Console/Commands/FetchDeviceModelsCommand.php @@ -0,0 +1,46 @@ +info('Dispatching FetchDeviceModelsJob...'); + + try { + FetchDeviceModelsJob::dispatchSync(); + + $this->info('FetchDeviceModelsJob has been dispatched successfully.'); + + return self::SUCCESS; + } catch (Exception $e) { + $this->error('Failed to dispatch FetchDeviceModelsJob: '.$e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Enums/ImageFormat.php b/app/Enums/ImageFormat.php index 75a7307..67e9b79 100644 --- a/app/Enums/ImageFormat.php +++ b/app/Enums/ImageFormat.php @@ -8,6 +8,7 @@ enum ImageFormat: string case PNG_8BIT_GRAYSCALE = 'png_8bit_grayscale'; case BMP3_1BIT_SRGB = 'bmp3_1bit_srgb'; case PNG_8BIT_256C = 'png_8bit_256c'; + case PNG_2BIT_4C = 'png_2bit_4c'; public function label(): string { @@ -16,6 +17,7 @@ enum ImageFormat: string self::PNG_8BIT_GRAYSCALE => 'PNG 8-bit Grayscale Gray 2c', self::BMP3_1BIT_SRGB => 'BMP3 1-bit sRGB 2c', self::PNG_8BIT_256C => 'PNG 8-bit Grayscale Gray 256c', + self::PNG_2BIT_4C => 'PNG 2-bit Grayscale 4c', }; } } diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php new file mode 100644 index 0000000..695041f --- /dev/null +++ b/app/Jobs/FetchDeviceModelsJob.php @@ -0,0 +1,125 @@ +get(self::API_URL); + + if (! $response->successful()) { + Log::error('Failed to fetch device models from API', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return; + } + + $data = $response->json('data', []); + + if (! is_array($data)) { + Log::error('Invalid response format from device models API', [ + 'response' => $response->json(), + ]); + + return; + } + + $this->processDeviceModels($data); + + Log::info('Successfully fetched and updated device models', [ + 'count' => count($data), + ]); + + } catch (Exception $e) { + Log::error('Exception occurred while fetching device models', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * Process the device models data and update/create records. + */ + private function processDeviceModels(array $deviceModels): void + { + foreach ($deviceModels as $modelData) { + try { + $this->updateOrCreateDeviceModel($modelData); + } catch (Exception $e) { + Log::error('Failed to process device model', [ + 'model_data' => $modelData, + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Update or create a device model record. + */ + private function updateOrCreateDeviceModel(array $modelData): void + { + $name = $modelData['name'] ?? null; + + if (! $name) { + Log::warning('Device model data missing name field', [ + 'model_data' => $modelData, + ]); + + return; + } + + $attributes = [ + 'label' => $modelData['label'] ?? '', + 'description' => $modelData['description'] ?? '', + 'width' => $modelData['width'] ?? 0, + 'height' => $modelData['height'] ?? 0, + 'colors' => $modelData['colors'] ?? 0, + 'bit_depth' => $modelData['bit_depth'] ?? 0, + 'scale_factor' => $modelData['scale_factor'] ?? 1, + 'rotation' => $modelData['rotation'] ?? 0, + 'mime_type' => $modelData['mime_type'] ?? '', + 'offset_x' => $modelData['offset_x'] ?? 0, + 'offset_y' => $modelData['offset_y'] ?? 0, + 'published_at' => $modelData['published_at'] ?? null, + 'source' => 'api', + ]; + + DeviceModel::updateOrCreate( + ['name' => $name], + $attributes + ); + } +} diff --git a/app/Jobs/FetchProxyCloudResponses.php b/app/Jobs/FetchProxyCloudResponses.php index ece2808..b560085 100644 --- a/app/Jobs/FetchProxyCloudResponses.php +++ b/app/Jobs/FetchProxyCloudResponses.php @@ -78,22 +78,30 @@ class FetchProxyCloudResponses implements ShouldQueue Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}"); if ($device->last_log_request) { - Http::withHeaders([ - 'id' => $device->mac_address, - 'access-token' => $device->api_key, - 'width' => 800, - 'height' => 480, - 'rssi' => $device->last_rssi_level, - 'battery_voltage' => $device->last_battery_voltage, - 'refresh-rate' => $device->default_refresh_interval, - 'fw-version' => $device->last_firmware_version, - 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0', - 'user-agent' => 'ESP32HTTPClient', - ])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request); + try { + Http::withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'width' => 800, + 'height' => 480, + 'rssi' => $device->last_rssi_level, + 'battery_voltage' => $device->last_battery_voltage, + 'refresh-rate' => $device->default_refresh_interval, + 'fw-version' => $device->last_firmware_version, + 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0', + 'user-agent' => 'ESP32HTTPClient', + ])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request); - $device->update([ - 'last_log_request' => null, - ]); + // Only clear the pending log request if the POST succeeded + $device->update([ + 'last_log_request' => null, + ]); + } catch (Exception $e) { + // Do not fail the entire proxy fetch if the log upload fails + Log::error("Failed to upload device log for device: {$device->mac_address}", [ + 'error' => $e->getMessage(), + ]); + } } } catch (Exception $e) { diff --git a/app/Models/Device.php b/app/Models/Device.php index d786d2e..420975a 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -182,7 +182,10 @@ class Device extends Model { return $this->belongsTo(Firmware::class, 'update_firmware_id'); } - + public function deviceModel(): BelongsTo + { + return $this->belongsTo(DeviceModel::class); + } public function logs(): HasMany { return $this->hasMany(DeviceLog::class); diff --git a/app/Models/DeviceModel.php b/app/Models/DeviceModel.php new file mode 100644 index 0000000..c9de2af --- /dev/null +++ b/app/Models/DeviceModel.php @@ -0,0 +1,27 @@ + 'integer', + 'height' => 'integer', + 'colors' => 'integer', + 'bit_depth' => 'integer', + 'scale_factor' => 'float', + 'rotation' => 'integer', + 'offset_x' => 'integer', + 'offset_y' => 'integer', + 'published_at' => 'datetime', + ]; +} diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index 7f58001..a7bd3c6 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Enums\ImageFormat; use App\Models\Device; +use App\Models\DeviceModel; use App\Models\Plugin; use Exception; use Illuminate\Support\Facades\Storage; @@ -20,11 +21,14 @@ class ImageGenerationService { public static function generateImage(string $markup, $deviceId): string { - $device = Device::find($deviceId); + $device = Device::with('deviceModel')->find($deviceId); $uuid = Uuid::uuid4()->toString(); $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png'); $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp'); + // Get image generation settings from DeviceModel if available, otherwise use device settings + $imageSettings = self::getImageSettings($device); + // Generate PNG if (config('app.puppeteer_mode') === 'sidecar-aws') { try { @@ -43,19 +47,219 @@ class ImageGenerationService } else { try { $browsershot = Browsershot::html($markup) - ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []) - ->windowSize(800, 480); - + ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []); if (config('app.puppeteer_wait_for_network_idle')) { $browsershot->waitUntilNetworkIdle(); } + if (config('app.puppeteer_window_size_strategy') == 'v2') { + $browsershot->windowSize($imageSettings['width'], $imageSettings['height']); + } else { + $browsershot->windowSize(800, 480); + } $browsershot->save($pngPath); } catch (Exception $e) { Log::error('Failed to generate PNG: '.$e->getMessage()); throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e); } } - switch ($device->image_format) { + + // Convert image based on DeviceModel settings or fallback to device settings + self::convertImage($pngPath, $bmpPath, $imageSettings); + + $device->update(['current_screen_image' => $uuid]); + Log::info("Device $device->id: updated with new image: $uuid"); + + return $uuid; + } + + /** + * Get image generation settings from DeviceModel if available, otherwise use device settings + */ + private static function getImageSettings(Device $device): array + { + // If device has a DeviceModel, use its settings + if ($device->deviceModel) { + /** @var DeviceModel $model */ + $model = $device->deviceModel; + + return [ + 'width' => $model->width, + 'height' => $model->height, + 'colors' => $model->colors, + 'bit_depth' => $model->bit_depth, + 'scale_factor' => $model->scale_factor, + 'rotation' => $model->rotation, + 'mime_type' => $model->mime_type, + 'offset_x' => $model->offset_x, + 'offset_y' => $model->offset_y, + 'image_format' => self::determineImageFormatFromModel($model), + 'use_model_settings' => true, + ]; + } + + // Fallback to device settings + return [ + 'width' => $device->width ?? 800, + 'height' => $device->height ?? 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1.0, + 'rotation' => $device->rotate ?? 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'image_format' => $device->image_format, + 'use_model_settings' => false, + ]; + } + + /** + * Determine the appropriate ImageFormat based on DeviceModel settings + */ + private static function determineImageFormatFromModel(DeviceModel $model): string + { + // Map DeviceModel settings to ImageFormat + if ($model->mime_type === 'image/bmp' && $model->bit_depth === 1) { + return ImageFormat::BMP3_1BIT_SRGB->value; + } + if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 2) { + return ImageFormat::PNG_8BIT_GRAYSCALE->value; + } + if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 256) { + return ImageFormat::PNG_8BIT_256C->value; + } + if ($model->mime_type === 'image/png' && $model->bit_depth === 2 && $model->colors === 4) { + return ImageFormat::PNG_2BIT_4C->value; + } + + // Default to AUTO for unknown combinations + return ImageFormat::AUTO->value; + } + + /** + * Convert image based on the provided settings + */ + private static function convertImage(string $pngPath, string $bmpPath, array $settings): void + { + $imageFormat = $settings['image_format']; + $useModelSettings = $settings['use_model_settings'] ?? false; + + if ($useModelSettings) { + // Use DeviceModel-specific conversion + self::convertUsingModelSettings($pngPath, $bmpPath, $settings); + } else { + // Use legacy device-specific conversion + self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings); + } + } + + /** + * Convert image using DeviceModel settings + */ + private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void + { + try { + $imagick = new Imagick($pngPath); + + // Apply scale factor if needed + if ($settings['scale_factor'] !== 1.0) { + $newWidth = (int) ($settings['width'] * $settings['scale_factor']); + $newHeight = (int) ($settings['height'] * $settings['scale_factor']); + $imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1, true); + } else { + // Resize to model dimensions if different from generated size + if ($imagick->getImageWidth() !== $settings['width'] || $imagick->getImageHeight() !== $settings['height']) { + $imagick->resizeImage($settings['width'], $settings['height'], Imagick::FILTER_LANCZOS, 1, true); + } + } + + // Apply rotation + if ($settings['rotation'] !== 0) { + $imagick->rotateImage(new ImagickPixel('black'), $settings['rotation']); + } + + // Apply offset if specified + if ($settings['offset_x'] !== 0 || $settings['offset_y'] !== 0) { + $imagick->rollImage($settings['offset_x'], $settings['offset_y']); + } + + // Handle special case for 4-color, 2-bit PNG + if ($settings['colors'] === 4 && $settings['bit_depth'] === 2 && $settings['mime_type'] === 'image/png') { + self::convertTo4Color2BitPng($imagick, $settings['width'], $settings['height']); + } else { + // Set image type and color depth based on model settings + $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); + + if ($settings['bit_depth'] === 1) { + $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); + $imagick->setImageDepth(1); + } else { + $imagick->quantizeImage($settings['colors'], Imagick::COLORSPACE_GRAY, 0, true, false); + $imagick->setImageDepth($settings['bit_depth']); + } + } + + $imagick->stripImage(); + + // Save in the appropriate format + if ($settings['mime_type'] === 'image/bmp') { + $imagick->setFormat('BMP3'); + $imagick->writeImage($bmpPath); + } else { + $imagick->setFormat('png'); + $imagick->writeImage($pngPath); + } + + $imagick->clear(); + } catch (ImagickException $e) { + throw new RuntimeException('Failed to convert image using model settings: '.$e->getMessage(), 0, $e); + } + } + + /** + * Convert image to 4-color, 2-bit PNG using custom colormap and dithering + */ + private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void + { + // Step 1: Create 4-color grayscale colormap in memory + $colors = ['#000000', '#555555', '#aaaaaa', '#ffffff']; + $colormap = new Imagick(); + + foreach ($colors as $color) { + $swatch = new Imagick(); + $swatch->newImage(1, 1, new ImagickPixel($color)); + $swatch->setImageFormat('png'); + $colormap->addImage($swatch); + } + + $colormap = $colormap->appendImages(true); // horizontal + $colormap->setType(Imagick::IMGTYPE_PALETTE); + $colormap->setImageFormat('png'); + + // Step 2: Resize to target dimensions without keeping aspect ratio + $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, false); + + // Step 3: Apply Floyd–Steinberg dithering + $imagick->setOption('dither', 'FloydSteinberg'); + + // Step 4: Remap to our 4-color colormap + // $imagick->remapImage($colormap, Imagick::DITHERMETHOD_FLOYDSTEINBERG); + + // Step 5: Force 2-bit grayscale PNG + $imagick->setImageFormat('png'); + $imagick->setImageDepth(2); + $imagick->setType(Imagick::IMGTYPE_GRAYSCALE); + + // Cleanup colormap + $colormap->clear(); + } + + /** + * Convert image using legacy device settings + */ + private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void + { + switch ($imageFormat) { case ImageFormat::BMP3_1BIT_SRGB->value: try { self::convertToBmpImageMagick($pngPath, $bmpPath); @@ -66,33 +270,22 @@ class ImageGenerationService case ImageFormat::PNG_8BIT_GRAYSCALE->value: case ImageFormat::PNG_8BIT_256C->value: try { - self::convertToPngImageMagick($pngPath, $device->width, $device->height, $device->rotate, quantize: $device->image_format === ImageFormat::PNG_8BIT_GRAYSCALE->value); + self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation'], quantize: $imageFormat === ImageFormat::PNG_8BIT_GRAYSCALE->value); } catch (ImagickException $e) { throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); } break; case ImageFormat::AUTO->value: default: - if (isset($device->last_firmware_version) - && version_compare($device->last_firmware_version, '1.5.2', '<')) { - try { - self::convertToBmpImageMagick($pngPath, $bmpPath); - } catch (ImagickException $e) { - throw new RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e); - } - } else { - try { - self::convertToPngImageMagick($pngPath, $device->width, $device->height, $device->rotate); - } catch (ImagickException $e) { - throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); - } + // For AUTO format, we need to check if this is a legacy device + // This would require checking if the device has a firmware version + // For now, we'll use the device's current logic + try { + self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation']); + } catch (ImagickException $e) { + throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e); } } - - $device->update(['current_screen_image' => $uuid]); - Log::info("Device $device->id: updated with new image: $uuid"); - - return $uuid; } /** @@ -124,6 +317,7 @@ class ImageGenerationService } $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE); + $imagick->setOption('dither', 'FloydSteinberg'); if ($quantize) { $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false); @@ -159,16 +353,20 @@ class ImageGenerationService public static function resetIfNotCacheable(?Plugin $plugin): void { if ($plugin?->id) { - if ( - Device::query() - ->where('width', '!=', 800) - ->orWhere('height', '!=', 480) - ->orWhere('rotate', '!=', 0) - ->exists() - ) { + // Check if any devices have custom dimensions or use DeviceModels + $hasCustomDimensions = Device::query() + ->where(function ($query) { + $query->where('width', '!=', 800) + ->orWhere('height', '!=', 480) + ->orWhere('rotate', '!=', 0); + }) + ->orWhereNotNull('device_model_id') + ->exists(); + + if ($hasCustomDimensions) { // TODO cache image per device $plugin->update(['current_image' => null]); - Log::debug('Skip cache as devices with other dimensions exist'); + Log::debug('Skip cache as devices with custom dimensions or DeviceModels exist'); } } } diff --git a/config/app.php b/config/app.php index 8282215..98eaee9 100644 --- a/config/app.php +++ b/config/app.php @@ -131,6 +131,7 @@ return [ 'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false), + 'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), 'notifications' => [ 'battery_low' => [ diff --git a/database/factories/DeviceModelFactory.php b/database/factories/DeviceModelFactory.php new file mode 100644 index 0000000..ec3f77d --- /dev/null +++ b/database/factories/DeviceModelFactory.php @@ -0,0 +1,38 @@ + + */ +class DeviceModelFactory extends Factory +{ + protected $model = DeviceModel::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->unique()->slug(), + 'label' => $this->faker->words(2, true), + 'description' => $this->faker->sentence(), + 'width' => $this->faker->randomElement([800, 1024, 1280, 1920]), + 'height' => $this->faker->randomElement([480, 600, 720, 1080]), + 'colors' => $this->faker->randomElement([2, 16, 256, 65536]), + 'bit_depth' => $this->faker->randomElement([1, 4, 8, 16]), + 'scale_factor' => $this->faker->randomElement([1, 2, 4]), + 'rotation' => $this->faker->randomElement([0, 90, 180, 270]), + 'mime_type' => $this->faker->randomElement(['image/png', 'image/jpeg', 'image/gif']), + 'offset_x' => $this->faker->numberBetween(-100, 100), + 'offset_y' => $this->faker->numberBetween(-100, 100), + 'published_at' => $this->faker->optional()->dateTimeBetween('-1 year', 'now'), + ]; + } +} diff --git a/database/migrations/2025_08_07_111635_create_device_models_table.php b/database/migrations/2025_08_07_111635_create_device_models_table.php new file mode 100644 index 0000000..338ca98 --- /dev/null +++ b/database/migrations/2025_08_07_111635_create_device_models_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('name')->unique(); + $table->string('label'); + $table->text('description'); + $table->unsignedInteger('width'); + $table->unsignedInteger('height'); + $table->unsignedInteger('colors'); + $table->unsignedInteger('bit_depth'); + $table->float('scale_factor'); + $table->integer('rotation'); + $table->string('mime_type'); + $table->integer('offset_x')->default(0); + $table->integer('offset_y')->default(0); + $table->timestamp('published_at')->nullable(); + $table->string('source')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('device_models'); + } +}; diff --git a/database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php b/database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php new file mode 100644 index 0000000..727c545 --- /dev/null +++ b/database/migrations/2025_08_07_131843_add_device_model_id_to_devices_table.php @@ -0,0 +1,29 @@ +foreignId('device_model_id')->nullable()->constrained('device_models')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['device_model_id']); + $table->dropColumn('device_model_id'); + }); + } +}; diff --git a/database/migrations/2025_08_16_135740_seed_device_models.php b/database/migrations/2025_08_16_135740_seed_device_models.php new file mode 100644 index 0000000..355227f --- /dev/null +++ b/database/migrations/2025_08_16_135740_seed_device_models.php @@ -0,0 +1,285 @@ + 'og_png', + 'label' => 'TRMNL OG (1-bit)', + 'description' => 'TRMNL OG (1-bit)', + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'og_plus', + 'label' => 'TRMNL OG (2-bit)', + 'description' => 'TRMNL OG (2-bit)', + 'width' => 800, + 'height' => 480, + 'colors' => 4, + 'bit_depth' => 2, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_2024', + 'label' => 'Amazon Kindle 2024', + 'description' => 'Amazon Kindle 2024', + 'width' => 1400, + 'height' => 840, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 2.414, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 75, + 'offset_y' => 25, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_paperwhite_6th_gen', + 'label' => 'Amazon Kindle PW 6th Gen', + 'description' => 'Amazon Kindle PW 6th Gen', + 'width' => 1024, + 'height' => 768, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_paperwhite_7th_gen', + 'label' => 'Amazon Kindle PW 7th Gen', + 'description' => 'Amazon Kindle PW 7th Gen', + 'width' => 1448, + 'height' => 1072, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'inkplate_10', + 'label' => 'Inkplate 10', + 'description' => 'Inkplate 10', + 'width' => 1200, + 'height' => 820, + 'colors' => 8, + 'bit_depth' => 3, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_7', + 'label' => 'Amazon Kindle 7', + 'description' => 'Amazon Kindle 7', + 'width' => 800, + 'height' => 600, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'inky_impression_7_3', + 'label' => 'Inky Impression 7.3', + 'description' => 'Inky Impression 7.3', + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'kobo_libra_2', + 'label' => 'Kobo Libra 2', + 'description' => 'Kobo Libra 2', + 'width' => 1680, + 'height' => 1264, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'amazon_kindle_oasis_2', + 'label' => 'Amazon Kindle Oasis 2', + 'description' => 'Amazon Kindle Oasis 2', + 'width' => 1680, + 'height' => 1264, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'kobo_aura_one', + 'label' => 'Kobo Aura One', + 'description' => 'Kobo Aura One', + 'width' => 1872, + 'height' => 1404, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'kobo_aura_hd', + 'label' => 'Kobo Aura HD', + 'description' => 'Kobo Aura HD', + 'width' => 1440, + 'height' => 1080, + 'colors' => 16, + 'bit_depth' => 4, + 'scale_factor' => 1, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'inky_impression_13_3', + 'label' => 'Inky Impression 13.3', + 'description' => 'Inky Impression 13.3', + 'width' => 1600, + 'height' => 1200, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + 'published_at' => '2024-01-01 00:00:00', + 'source' => 'api', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]; + + // Upsert by unique 'name' to avoid duplicates and keep data fresh + DeviceModel::query()->upsert( + $deviceModels, + ['name'], + [ + 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', + 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'source', + 'created_at', 'updated_at', + ] + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $names = [ + 'og_png', + 'amazon_kindle_2024', + 'amazon_kindle_paperwhite_6th_gen', + 'amazon_kindle_paperwhite_7th_gen', + 'inkplate_10', + 'amazon_kindle_7', + 'inky_impression_7_3', + 'kobo_libra_2', + 'amazon_kindle_oasis_2', + 'og_plus', + 'kobo_aura_one', + 'kobo_aura_hd', + 'inky_impression_13_3', + ]; + + DeviceModel::query()->whereIn('name', $names)->delete(); + } +}; diff --git a/resources/views/livewire/device-models/index.blade.php b/resources/views/livewire/device-models/index.blade.php new file mode 100644 index 0000000..a78f2a2 --- /dev/null +++ b/resources/views/livewire/device-models/index.blade.php @@ -0,0 +1,389 @@ + 'required|string|max:255|unique:device_models,name', + 'label' => 'required|string|max:255', + 'description' => 'required|string', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'colors' => 'required|integer|min:1', + 'bit_depth' => 'required|integer|min:1', + 'scale_factor' => 'required|numeric|min:0.1', + 'rotation' => 'required|integer', + 'mime_type' => 'required|string|max:255', + 'offset_x' => 'required|integer', + 'offset_y' => 'required|integer', + 'published_at' => 'nullable|date', + ]; + + public function mount() + { + $this->deviceModels = DeviceModel::all(); + return view('livewire.device-models.index'); + } + + public function createDeviceModel(): void + { + $this->validate(); + + DeviceModel::create([ + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'width' => $this->width, + 'height' => $this->height, + 'colors' => $this->colors, + 'bit_depth' => $this->bit_depth, + 'scale_factor' => $this->scale_factor, + 'rotation' => $this->rotation, + 'mime_type' => $this->mime_type, + 'offset_x' => $this->offset_x, + 'offset_y' => $this->offset_y, + 'published_at' => $this->published_at, + ]); + + $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at']); + \Flux::modal('create-device-model')->close(); + + $this->deviceModels = DeviceModel::all(); + session()->flash('message', 'Device model created successfully.'); + } + + public $editingDeviceModelId; + + public function editDeviceModel(DeviceModel $deviceModel): void + { + $this->editingDeviceModelId = $deviceModel->id; + $this->name = $deviceModel->name; + $this->label = $deviceModel->label; + $this->description = $deviceModel->description; + $this->width = $deviceModel->width; + $this->height = $deviceModel->height; + $this->colors = $deviceModel->colors; + $this->bit_depth = $deviceModel->bit_depth; + $this->scale_factor = $deviceModel->scale_factor; + $this->rotation = $deviceModel->rotation; + $this->mime_type = $deviceModel->mime_type; + $this->offset_x = $deviceModel->offset_x; + $this->offset_y = $deviceModel->offset_y; + $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); + } + + public function updateDeviceModel(): void + { + $deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId); + + $this->validate([ + 'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id, + 'label' => 'required|string|max:255', + 'description' => 'required|string', + 'width' => 'required|integer|min:1', + 'height' => 'required|integer|min:1', + 'colors' => 'required|integer|min:1', + 'bit_depth' => 'required|integer|min:1', + 'scale_factor' => 'required|numeric|min:0.1', + 'rotation' => 'required|integer', + 'mime_type' => 'required|string|max:255', + 'offset_x' => 'required|integer', + 'offset_y' => 'required|integer', + 'published_at' => 'nullable|date', + ]); + + $deviceModel->update([ + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'width' => $this->width, + 'height' => $this->height, + 'colors' => $this->colors, + 'bit_depth' => $this->bit_depth, + 'scale_factor' => $this->scale_factor, + 'rotation' => $this->rotation, + 'mime_type' => $this->mime_type, + 'offset_x' => $this->offset_x, + 'offset_y' => $this->offset_y, + 'published_at' => $this->published_at, + ]); + + $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'editingDeviceModelId']); + \Flux::modal('edit-device-model-' . $deviceModel->id)->close(); + + $this->deviceModels = DeviceModel::all(); + session()->flash('message', 'Device model updated successfully.'); + } + + public function deleteDeviceModel(DeviceModel $deviceModel): void + { + $deviceModel->delete(); + + $this->deviceModels = DeviceModel::all(); + session()->flash('message', 'Device model deleted successfully.'); + } +} + +?> + +
+
+
+
+

Device Models

+ {{-- --}} + {{-- Add Device Model--}} + {{-- --}} +
+ @if (session()->has('message')) +
+ + + + + +
+ @endif + + +
+
+ Add Device Model +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + Create Device Model +
+
+
+
+ + @foreach ($deviceModels as $deviceModel) + +
+
+ Edit Device Model +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + image/png + image/bmp + + +
+ +
+ + +
+ +
+ + Update Device Model +
+
+
+
+ @endforeach + + + + + + + + + + + + + + @foreach ($deviceModels as $deviceModel) + + + + + + + + @endforeach + +
+
Description
+
+
Width
+
+
Height
+
+
Bit Depth
+
+
Actions
+
+
+
{{ $deviceModel->label }}
+
{{ Str::limit($deviceModel->name, 50) }}
+
+
+ {{ $deviceModel->width }} + + {{ $deviceModel->height }} + + {{ $deviceModel->bit_depth }} + +
+ + + + + + + + +
+
+
+
+
diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 32a16f6..44e424c 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -1,6 +1,7 @@ height = $device->height; $this->rotate = $device->rotate; $this->image_format = $device->image_format; + $this->device_model_id = $device->device_model_id; + $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { + // Put TRMNL models at the top, then sort alphabetically within each group + $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); + return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label; + }); $this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get(); $this->firmwares = \App\Models\Firmware::orderBy('latest', 'desc')->orderBy('created_at', 'desc')->get(); $this->selected_firmware_id = $this->firmwares->where('latest', true)->first()?->id; @@ -77,6 +88,24 @@ new class extends Component { redirect()->route('devices'); } + public function updatedDeviceModelId() + { + // Convert empty string to null for custom selection + if (empty($this->device_model_id)) { + $this->device_model_id = null; + return; + } + + if ($this->device_model_id) { + $deviceModel = DeviceModel::find($this->device_model_id); + if ($deviceModel) { + $this->width = $deviceModel->width; + $this->height = $deviceModel->height; + $this->rotate = $deviceModel->rotation; + } + } + } + public function updateDevice() { abort_unless(auth()->user()->devices->contains($this->device), 403); @@ -90,12 +119,16 @@ new class extends Component { 'height' => 'required|integer|min:1', 'rotate' => 'required|integer|min:0|max:359', 'image_format' => 'required|string', + 'device_model_id' => 'nullable|exists:device_models,id', 'sleep_mode_enabled' => 'boolean', 'sleep_mode_from' => 'nullable|date_format:H:i', 'sleep_mode_to' => 'nullable|date_format:H:i', 'special_function' => 'nullable|string', ]); + // Convert empty string to null for custom selection + $deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id; + $this->device->update([ 'name' => $this->name, 'friendly_id' => $this->friendly_id, @@ -105,6 +138,7 @@ new class extends Component { 'height' => $this->height, 'rotate' => $this->rotate, 'image_format' => $this->image_format, + 'device_model_id' => $deviceModelId, 'sleep_mode_enabled' => $this->sleep_mode_enabled, 'sleep_mode_from' => $this->sleep_mode_from, 'sleep_mode_to' => $this->sleep_mode_to, @@ -357,20 +391,33 @@ new class extends Component { - -
- - - -
- - @foreach(\App\Enums\ImageFormat::cases() as $format) - {{$format->label()}} - @endforeach - + + + Custom (Manual Dimensions) + @foreach($deviceModels as $deviceModel) + + {{ $deviceModel->label }} ({{ $deviceModel->width }}x{{ $deviceModel->height }}) + + @endforeach + + + @if(empty($device_model_id)) + +
+ + + +
+ + @foreach(\App\Enums\ImageFormat::cases() as $format) + {{$format->label()}} + @endforeach + + @endif + Sleep diff --git a/resources/views/livewire/devices/manage.blade.php b/resources/views/livewire/devices/manage.blade.php index 2ff699d..d87bd1c 100644 --- a/resources/views/livewire/devices/manage.blade.php +++ b/resources/views/livewire/devices/manage.blade.php @@ -1,6 +1,7 @@ 'required', 'api_key' => 'required', 'default_refresh_interval' => 'required|integer', + 'device_model_id' => 'nullable|exists:device_models,id', 'mirror_device_id' => 'required_if:is_mirror,true', ]; public function mount() { $this->devices = auth()->user()->devices; + $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { + // Put TRMNL models at the top, then sort alphabetically within each group + $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); + return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label; + }); return view('livewire.devices.manage'); } + public function updatedDeviceModelId(): void + { + // Convert empty string to null for custom selection + if (empty($this->device_model_id)) { + $this->device_model_id = null; + } + } + public function createDevice(): void { $this->validate(); @@ -49,6 +66,9 @@ new class extends Component { abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device'); } + // Convert empty string to null for custom selection + $deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id; + Device::create([ 'name' => $this->name, 'mac_address' => $this->mac_address, @@ -56,6 +76,7 @@ new class extends Component { 'default_refresh_interval' => $this->default_refresh_interval, 'friendly_id' => $this->friendly_id, 'user_id' => auth()->id(), + 'device_model_id' => $deviceModelId, 'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null, ]); @@ -154,6 +175,19 @@ new class extends Component { autofocus/>
+
+ + Custom (Manual Dimensions) + @if ($deviceModels && $deviceModels->count() > 0) + @foreach($deviceModels as $deviceModel) + + {{ $deviceModel->label }} ({{ $deviceModel->width }}x{{ $deviceModel->height }}) + + @endforeach + @endif + +
+
diff --git a/resources/views/vendor/trmnl/components/screen.blade.php b/resources/views/vendor/trmnl/components/screen.blade.php new file mode 100644 index 0000000..99aa147 --- /dev/null +++ b/resources/views/vendor/trmnl/components/screen.blade.php @@ -0,0 +1,35 @@ +@props([ + 'noBleed' => false, + 'darkMode' => false, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '2bit', + 'scaleLevel' => null, +]) + + + + + + + + @if (config('trmnl-blade.framework_css_url')) + + @else + + @endif + @if (config('trmnl-blade.framework_js_url')) + + @else + + @endif + {{ $title ?? config('app.name') }} + + +
+ {{ $slot }} +
+ + diff --git a/routes/api.php b/routes/api.php index bbe274d..c59b539 100644 --- a/routes/api.php +++ b/routes/api.php @@ -266,6 +266,22 @@ Route::get('/devices', function (Request $request) { ]); })->middleware('auth:sanctum'); +Route::get('/device-models', function (Request $request) { + $deviceModels = App\Models\DeviceModel::get([ + 'id', + 'name', + 'label', + 'description', + 'width', + 'height', + 'bit_depth', + ]); + + return response()->json([ + 'data' => $deviceModels, + ]); +})->middleware('auth:sanctum'); + Route::post('/display/update', function (Request $request) { $request->validate([ 'device_id' => 'required|exists:devices,id', diff --git a/routes/console.php b/routes/console.php index 7dce7de..24ea529 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ cron( Schedule::job(FirmwarePollJob::class)->daily(); Schedule::job(CleanupDeviceLogsJob::class)->daily(); +Schedule::job(FetchDeviceModelsJob::class)->weekly(); Schedule::job(NotifyDeviceBatteryLowJob::class)->dailyAt('10:00'); diff --git a/routes/web.php b/routes/web.php index 47bda95..3be1c66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,8 @@ Route::middleware(['auth'])->group(function () { Volt::route('/devices/{device}/configure', 'devices.configure')->name('devices.configure'); Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs'); + Volt::route('/device-models', 'device-models.index')->name('device-models.index'); + Volt::route('plugins', 'plugins.index')->name('plugins.index'); Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe'); diff --git a/tests/Feature/Api/DeviceModelsEndpointTest.php b/tests/Feature/Api/DeviceModelsEndpointTest.php new file mode 100644 index 0000000..b37ec4f --- /dev/null +++ b/tests/Feature/Api/DeviceModelsEndpointTest.php @@ -0,0 +1,35 @@ +create(); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/device-models'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'label', + 'description', + 'width', + 'height', + 'bit_depth', + ], + ], + ]); +}); + +it('blocks unauthenticated users from accessing device models', function (): void { + $response = $this->getJson('/api/device-models'); + + $response->assertUnauthorized(); +}); diff --git a/tests/Feature/Auth/OidcAuthenticationTest.php b/tests/Feature/Auth/OidcAuthenticationTest.php index 30d1bc2..4a832b9 100644 --- a/tests/Feature/Auth/OidcAuthenticationTest.php +++ b/tests/Feature/Auth/OidcAuthenticationTest.php @@ -1,158 +1,149 @@ shouldReceive('redirect')->andReturn(redirect('/fake-oidc-redirect')); + + // Default Socialite user returned by callback + $socialiteUser = mockSocialiteUser(); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('oidc') + ->andReturn($provider); +}); + +afterEach(function (): void { + Mockery::close(); +}); + +it('oidc redirect works when enabled', function (): void { + $response = $this->get(route('auth.oidc.redirect')); + + // Since we're using a mock OIDC provider, this will likely fail + // but we can check that the route exists and is accessible + expect($response->getStatusCode())->not->toBe(404); +}); + +it('oidc redirect fails when disabled', function (): void { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('auth.oidc.redirect')); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); +}); + +it('oidc callback creates new user (placeholder)', function (): void { + mockSocialiteUser(); + + $this->get(route('auth.oidc.callback')); + + // We expect to be redirected to dashboard after successful authentication + // In a real test, this would be mocked properly + expect(true)->toBeTrue(); // Placeholder assertion +}); + +it('oidc callback updates existing user by oidc_sub (placeholder)', function (): void { + // Create a user with OIDC sub + User::factory()->create([ + 'oidc_sub' => 'test-sub-123', + 'name' => 'Old Name', + 'email' => 'old@example.com', + ]); + + mockSocialiteUser([ + 'id' => 'test-sub-123', + 'name' => 'Updated Name', + 'email' => 'updated@example.com', + ]); + + // This would need proper mocking of Socialite in a real test + expect(true)->toBeTrue(); // Placeholder assertion +}); + +it('oidc callback links existing user by email (placeholder)', function (): void { + // Create a user without OIDC sub but with matching email + User::factory()->create([ + 'oidc_sub' => null, + 'email' => 'test@example.com', + ]); + + mockSocialiteUser([ + 'id' => 'test-sub-456', + 'email' => 'test@example.com', + ]); + + // This would need proper mocking of Socialite in a real test + expect(true)->toBeTrue(); // Placeholder assertion +}); + +it('oidc callback fails when disabled', function (): void { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('auth.oidc.callback')); + + $response->assertRedirect(route('login')); + $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); +}); + +it('login view shows oidc button when enabled', function (): void { + $response = $this->get(route('login')); + + $response->assertStatus(200); + $response->assertSee('Continue with OIDC'); + $response->assertSee('Or'); +}); + +it('login view hides oidc button when disabled', function (): void { + Config::set('services.oidc.enabled', false); + + $response = $this->get(route('login')); + + $response->assertStatus(200); + $response->assertDontSee('Continue with OIDC'); +}); + +it('user model has oidc_sub fillable', function (): void { + $user = new User(); + + expect($user->getFillable())->toContain('oidc_sub'); +}); + +/** + * Mock a Socialite user for testing. + * + * @param array $userData + */ +function mockSocialiteUser(array $userData = []): SocialiteUser { - use RefreshDatabase; + $defaultData = [ + 'id' => 'test-sub-123', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'avatar' => null, + ]; - protected function setUp(): void - { - parent::setUp(); - - // Enable OIDC for testing - Config::set('services.oidc.enabled', true); - Config::set('services.oidc.endpoint', 'https://example.com/oidc'); - Config::set('services.oidc.client_id', 'test-client-id'); - Config::set('services.oidc.client_secret', 'test-client-secret'); - } + $userData = array_merge($defaultData, $userData); - public function test_oidc_redirect_works_when_enabled() - { - $response = $this->get(route('auth.oidc.redirect')); + /** @var SocialiteUser $socialiteUser */ + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn($userData['id']); + $socialiteUser->shouldReceive('getName')->andReturn($userData['name']); + $socialiteUser->shouldReceive('getEmail')->andReturn($userData['email']); + $socialiteUser->shouldReceive('getAvatar')->andReturn($userData['avatar']); - // Since we're using a mock OIDC provider, this will likely fail - // but we can check that the route exists and is accessible - $this->assertNotEquals(404, $response->getStatusCode()); - } - - public function test_oidc_redirect_fails_when_disabled() - { - Config::set('services.oidc.enabled', false); - - $response = $this->get(route('auth.oidc.redirect')); - - $response->assertRedirect(route('login')); - $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); - } - - public function test_oidc_callback_creates_new_user() - { - $mockUser = $this->mockSocialiteUser(); - - $response = $this->get(route('auth.oidc.callback')); - - // We expect to be redirected to dashboard after successful authentication - // In a real test, this would be mocked properly - $this->assertTrue(true); // Placeholder assertion - } - - public function test_oidc_callback_updates_existing_user_by_oidc_sub() - { - // Create a user with OIDC sub - $user = User::factory()->create([ - 'oidc_sub' => 'test-sub-123', - 'name' => 'Old Name', - 'email' => 'old@example.com', - ]); - - $mockUser = $this->mockSocialiteUser([ - 'id' => 'test-sub-123', - 'name' => 'Updated Name', - 'email' => 'updated@example.com', - ]); - - // This would need proper mocking of Socialite in a real test - $this->assertTrue(true); // Placeholder assertion - } - - public function test_oidc_callback_links_existing_user_by_email() - { - // Create a user without OIDC sub but with matching email - $user = User::factory()->create([ - 'oidc_sub' => null, - 'email' => 'test@example.com', - ]); - - $mockUser = $this->mockSocialiteUser([ - 'id' => 'test-sub-456', - 'email' => 'test@example.com', - ]); - - // This would need proper mocking of Socialite in a real test - $this->assertTrue(true); // Placeholder assertion - } - - public function test_oidc_callback_fails_when_disabled() - { - Config::set('services.oidc.enabled', false); - - $response = $this->get(route('auth.oidc.callback')); - - $response->assertRedirect(route('login')); - $response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']); - } - - public function test_login_view_shows_oidc_button_when_enabled() - { - $response = $this->get(route('login')); - - $response->assertStatus(200); - $response->assertSee('Continue with OIDC'); - $response->assertSee('Or'); - } - - public function test_login_view_hides_oidc_button_when_disabled() - { - Config::set('services.oidc.enabled', false); - - $response = $this->get(route('login')); - - $response->assertStatus(200); - $response->assertDontSee('Continue with OIDC'); - } - - public function test_user_model_has_oidc_sub_fillable() - { - $user = new User(); - - $this->assertContains('oidc_sub', $user->getFillable()); - } - - /** - * Mock a Socialite user for testing. - */ - protected function mockSocialiteUser(array $userData = []) - { - $defaultData = [ - 'id' => 'test-sub-123', - 'name' => 'Test User', - 'email' => 'test@example.com', - 'avatar' => null, - ]; - - $userData = array_merge($defaultData, $userData); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn($userData['id']); - $socialiteUser->shouldReceive('getName')->andReturn($userData['name']); - $socialiteUser->shouldReceive('getEmail')->andReturn($userData['email']); - $socialiteUser->shouldReceive('getAvatar')->andReturn($userData['avatar']); - - return $socialiteUser; - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file + return $socialiteUser; +} diff --git a/tests/Feature/DeviceModelsTest.php b/tests/Feature/DeviceModelsTest.php new file mode 100644 index 0000000..14a374d --- /dev/null +++ b/tests/Feature/DeviceModelsTest.php @@ -0,0 +1,89 @@ +create(); + $deviceModels = DeviceModel::factory()->count(3)->create(); + + $response = $this->actingAs($user)->get('/device-models'); + + $response->assertSuccessful(); + $response->assertSee('Device Models'); + $response->assertSee('Add Device Model'); + + foreach ($deviceModels as $deviceModel) { + $response->assertSee($deviceModel->label); + $response->assertSee((string) $deviceModel->width); + $response->assertSee((string) $deviceModel->height); + $response->assertSee((string) $deviceModel->bit_depth); + } +}); + +it('allows creating a device model', function (): void { + $user = User::factory()->create(); + + $deviceModelData = [ + 'name' => 'test-model', + 'label' => 'Test Model', + 'description' => 'A test device model', + 'width' => 800, + 'height' => 600, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]; + + $deviceModel = DeviceModel::create($deviceModelData); + + $this->assertDatabaseHas('device_models', $deviceModelData); + expect($deviceModel->name)->toBe($deviceModelData['name']); +}); + +it('allows updating a device model', function (): void { + $user = User::factory()->create(); + $deviceModel = DeviceModel::factory()->create(); + + $updatedData = [ + 'name' => 'updated-model', + 'label' => 'Updated Model', + 'description' => 'An updated device model', + 'width' => 1024, + 'height' => 768, + 'colors' => 65536, + 'bit_depth' => 16, + 'scale_factor' => 1.5, + 'rotation' => 90, + 'mime_type' => 'image/jpeg', + 'offset_x' => 10, + 'offset_y' => 20, + ]; + + $deviceModel->update($updatedData); + + $this->assertDatabaseHas('device_models', $updatedData); + expect($deviceModel->fresh()->name)->toBe($updatedData['name']); +}); + +it('allows deleting a device model', function (): void { + $user = User::factory()->create(); + $deviceModel = DeviceModel::factory()->create(); + + $deviceModelId = $deviceModel->id; + $deviceModel->delete(); + + $this->assertDatabaseMissing('device_models', ['id' => $deviceModelId]); +}); + +it('redirects unauthenticated users from the device models page', function (): void { + $response = $this->get('/device-models'); + + $response->assertRedirect('/login'); +}); diff --git a/tests/Feature/FetchDeviceModelsCommandTest.php b/tests/Feature/FetchDeviceModelsCommandTest.php new file mode 100644 index 0000000..2836330 --- /dev/null +++ b/tests/Feature/FetchDeviceModelsCommandTest.php @@ -0,0 +1,20 @@ +artisan('device-models:fetch') + ->expectsOutput('Dispatching FetchDeviceModelsJob...') + ->expectsOutput('FetchDeviceModelsJob has been dispatched successfully.') + ->assertExitCode(0); + + Queue::assertPushed(FetchDeviceModelsJob::class); +}); diff --git a/tests/Feature/FetchProxyCloudResponsesTest.php b/tests/Feature/FetchProxyCloudResponsesTest.php index 5f5dc65..bd58002 100644 --- a/tests/Feature/FetchProxyCloudResponsesTest.php +++ b/tests/Feature/FetchProxyCloudResponsesTest.php @@ -10,6 +10,12 @@ uses(Illuminate\Foundation\Testing\RefreshDatabase::class); beforeEach(function () { Storage::fake('public'); Storage::disk('public')->makeDirectory('/images/generated'); + Http::preventStrayRequests(); + Http::fake([ + 'https://example.com/test-image.bmp*' => Http::response([], 200), + 'https://trmnl.app/api/log' => Http::response([], 200), + 'https://example.com/api/log' => Http::response([], 200), + ]); }); test('it fetches and processes proxy cloud responses for devices', function () { diff --git a/tests/Feature/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php new file mode 100644 index 0000000..22699c5 --- /dev/null +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -0,0 +1,425 @@ +makeDirectory('/images/generated'); +}); + +it('generates image for device without device model', function (): void { + // Create a device without a DeviceModel (legacy behavior) + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + 'image_format' => ImageFormat::PNG_8BIT_GRAYSCALE->value, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('generates image for device with device model', function (): void { + // Create a DeviceModel + $deviceModel = DeviceModel::factory()->create([ + 'width' => 1024, + 'height' => 768, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('generates 4-color 2-bit PNG with device model', function (): void { + // Create a DeviceModel for 4-color, 2-bit PNG + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 4, + 'bit_depth' => 2, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); + + // Verify the image file has content and isn't blank + $imagePath = Storage::disk('public')->path("/images/generated/{$uuid}.png"); + $imageSize = filesize($imagePath); + expect($imageSize)->toBeGreaterThan(200); // Should be at least 200 bytes for a 2-bit PNG + + // Verify it's a valid PNG file + $imageInfo = getimagesize($imagePath); + expect($imageInfo[0])->toBe(800); // Width + expect($imageInfo[1])->toBe(480); // Height + expect($imageInfo[2])->toBe(IMAGETYPE_PNG); // PNG type + + // Debug: Check if the image has any non-transparent pixels + $image = imagecreatefrompng($imagePath); + $width = imagesx($image); + $height = imagesy($image); + $hasContent = false; + + // Check a few sample pixels to see if there's content + for ($x = 0; $x < min(10, $width); $x += 2) { + for ($y = 0; $y < min(10, $height); $y += 2) { + $color = imagecolorat($image, $x, $y); + if ($color !== 0) { // Not black/transparent + $hasContent = true; + break 2; + } + } + } + + imagedestroy($image); + expect($hasContent)->toBe(true, 'Image should contain visible content'); +})->skipOnGitHubActions(); + +it('generates BMP with device model', function (): void { + // Create a DeviceModel for BMP format + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 2, + 'bit_depth' => 1, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/bmp', + 'offset_x' => 0, + 'offset_y' => 0, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert BMP file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); +})->skipOnGitHubActions(); + +it('applies scale factor from device model', function (): void { + // Create a DeviceModel with scale factor + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 2.0, // Scale up by 2x + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('applies rotation from device model', function (): void { + // Create a DeviceModel with rotation + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 90, // Rotate 90 degrees + 'mime_type' => 'image/png', + 'offset_x' => 0, + 'offset_y' => 0, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('applies offset from device model', function (): void { + // Create a DeviceModel with offset + $deviceModel = DeviceModel::factory()->create([ + 'width' => 800, + 'height' => 480, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.0, + 'rotation' => 0, + 'mime_type' => 'image/png', + 'offset_x' => 10, // Offset by 10 pixels + 'offset_y' => 20, // Offset by 20 pixels + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('falls back to device settings when no device model', function (): void { + // Create a device with custom settings but no DeviceModel + $device = Device::factory()->create([ + 'width' => 1024, + 'height' => 768, + 'rotate' => 180, + 'image_format' => ImageFormat::PNG_8BIT_256C->value, + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('handles auto image format for legacy devices', function (): void { + // Create a device with AUTO format (legacy behavior) + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + 'image_format' => ImageFormat::AUTO->value, + 'last_firmware_version' => '1.6.0', // Modern firmware + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert PNG file was created (modern firmware defaults to PNG) + Storage::disk('public')->assertExists("/images/generated/{$uuid}.png"); +})->skipOnGitHubActions(); + +it('cleanupFolder removes unused images', function (): void { + // Create active devices with images + Device::factory()->create(['current_screen_image' => 'active-uuid-1']); + Device::factory()->create(['current_screen_image' => 'active-uuid-2']); + + // Create some test files + Storage::disk('public')->put('/images/generated/active-uuid-1.png', 'test'); + Storage::disk('public')->put('/images/generated/active-uuid-2.png', 'test'); + Storage::disk('public')->put('/images/generated/inactive-uuid.png', 'test'); + Storage::disk('public')->put('/images/generated/another-inactive.png', 'test'); + + // Run cleanup + ImageGenerationService::cleanupFolder(); + + // Assert active files are preserved + Storage::disk('public')->assertExists('/images/generated/active-uuid-1.png'); + Storage::disk('public')->assertExists('/images/generated/active-uuid-2.png'); + + // Assert inactive files are removed + Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.png'); + Storage::disk('public')->assertMissing('/images/generated/another-inactive.png'); +})->skipOnGitHubActions(); + +it('cleanupFolder preserves .gitignore', function (): void { + // Create gitignore file + Storage::disk('public')->put('/images/generated/.gitignore', '*'); + + // Create some test files + Storage::disk('public')->put('/images/generated/test.png', 'test'); + + // Run cleanup + ImageGenerationService::cleanupFolder(); + + // Assert gitignore is preserved + Storage::disk('public')->assertExists('/images/generated/.gitignore'); +})->skipOnGitHubActions(); + +it('resetIfNotCacheable resets when device models exist', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a device with DeviceModel (should trigger cache reset) + Device::factory()->create([ + 'device_model_id' => DeviceModel::factory()->create()->id, + ]); + + // Run reset check + ImageGenerationService::resetIfNotCacheable($plugin); + + // Assert plugin image was reset + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +})->skipOnGitHubActions(); + +it('resetIfNotCacheable resets when custom dimensions exist', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a device with custom dimensions (should trigger cache reset) + Device::factory()->create([ + 'width' => 1024, // Different from default 800 + 'height' => 768, // Different from default 480 + ]); + + // Run reset check + ImageGenerationService::resetIfNotCacheable($plugin); + + // Assert plugin image was reset + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +})->skipOnGitHubActions(); + +it('resetIfNotCacheable preserves image for standard devices', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create devices with standard dimensions (should not trigger cache reset) + Device::factory()->count(3)->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + ]); + + // Run reset check + ImageGenerationService::resetIfNotCacheable($plugin); + + // Assert plugin image was preserved + $plugin->refresh(); + expect($plugin->current_image)->toBe('test-uuid'); +})->skipOnGitHubActions(); + +it('determines correct image format from device model', function (): void { + // Test BMP format detection + $bmpModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/bmp', + 'bit_depth' => 1, + 'colors' => 2, + ]); + + $device = Device::factory()->create(['device_model_id' => $bmpModel->id]); + $markup = '
Test
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); + + // Test PNG 8-bit grayscale format detection + $pngGrayscaleModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 8, + 'colors' => 2, + ]); + + $device2 = Device::factory()->create(['device_model_id' => $pngGrayscaleModel->id]); + $uuid2 = ImageGenerationService::generateImage($markup, $device2->id); + + $device2->refresh(); + expect($device2->current_screen_image)->toBe($uuid2); + Storage::disk('public')->assertExists("/images/generated/{$uuid2}.png"); + + // Test PNG 8-bit 256 color format detection + $png256Model = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 8, + 'colors' => 256, + ]); + + $device3 = Device::factory()->create(['device_model_id' => $png256Model->id]); + $uuid3 = ImageGenerationService::generateImage($markup, $device3->id); + + $device3->refresh(); + expect($device3->current_screen_image)->toBe($uuid3); + Storage::disk('public')->assertExists("/images/generated/{$uuid3}.png"); +})->skipOnGitHubActions(); diff --git a/tests/Feature/Jobs/FirmwareDownloadJobTest.php b/tests/Feature/Jobs/FirmwareDownloadJobTest.php index 4f5fd79..8d09866 100644 --- a/tests/Feature/Jobs/FirmwareDownloadJobTest.php +++ b/tests/Feature/Jobs/FirmwareDownloadJobTest.php @@ -16,6 +16,10 @@ test('it creates firmwares directory if it does not exist', function () { 'version_tag' => '1.0.0', ]); + Http::fake([ + 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), + ]); + (new FirmwareDownloadJob($firmware))->handle(); expect(Storage::disk('public')->exists('firmwares'))->toBeTrue(); diff --git a/tests/Feature/Jobs/FirmwarePollJobTest.php b/tests/Feature/Jobs/FirmwarePollJobTest.php index 27e91b5..751bc8c 100644 --- a/tests/Feature/Jobs/FirmwarePollJobTest.php +++ b/tests/Feature/Jobs/FirmwarePollJobTest.php @@ -11,7 +11,7 @@ beforeEach(function () { test('it creates new firmware record when polling', function () { Http::fake([ - 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'version' => '1.0.0', 'url' => 'https://example.com/firmware.bin', ], 200), @@ -33,7 +33,7 @@ test('it updates existing firmware record when polling', function () { ]); Http::fake([ - 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'version' => '1.0.0', 'url' => 'https://new-url.com/firmware.bin', ], 200), @@ -53,7 +53,7 @@ test('it marks previous firmware as not latest when new version is found', funct ]); Http::fake([ - 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'version' => '1.1.0', 'url' => 'https://example.com/firmware.bin', ], 200), @@ -67,7 +67,7 @@ test('it marks previous firmware as not latest when new version is found', funct test('it handles connection exception gracefully', function () { Http::fake([ - 'usetrmnl.com/api/firmware/latest' => function () { + 'https://usetrmnl.com/api/firmware/latest' => function () { throw new ConnectionException('Connection failed'); }, ]); @@ -80,7 +80,7 @@ test('it handles connection exception gracefully', function () { test('it handles invalid response gracefully', function () { Http::fake([ - 'usetrmnl.com/api/firmware/latest' => Http::response(null, 200), + 'https://usetrmnl.com/api/firmware/latest' => Http::response(null, 200), ]); (new FirmwarePollJob)->handle(); @@ -91,7 +91,7 @@ test('it handles invalid response gracefully', function () { test('it handles missing version in response gracefully', function () { Http::fake([ - 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'url' => 'https://example.com/firmware.bin', ], 200), ]); @@ -104,7 +104,7 @@ test('it handles missing version in response gracefully', function () { test('it handles missing url in response gracefully', function () { Http::fake([ - 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'https://usetrmnl.com/api/firmware/latest' => Http::response([ 'version' => '1.0.0', ], 200), ]); diff --git a/tests/Pest.php b/tests/Pest.php index 624dd1c..bd6d6fe 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -20,11 +20,15 @@ registerSpatiePestHelpers(); arch() ->preset() ->laravel() - ->ignoring(App\Http\Controllers\Auth\OidcController::class); + ->ignoring([ + App\Http\Controllers\Auth\OidcController::class, + App\Models\DeviceModel::class, + ]); arch() ->expect('App') - ->not->toUse(['die', 'dd', 'dump']); + ->not->toUse(['die', 'dd', 'dump', 'ray']); + /* |-------------------------------------------------------------------------- | Expectations diff --git a/tests/Unit/Services/ImageGenerationServiceTest.php b/tests/Unit/Services/ImageGenerationServiceTest.php new file mode 100644 index 0000000..4941c3c --- /dev/null +++ b/tests/Unit/Services/ImageGenerationServiceTest.php @@ -0,0 +1,262 @@ +create([ + 'width' => 1024, + 'height' => 768, + 'colors' => 256, + 'bit_depth' => 8, + 'scale_factor' => 1.5, + 'rotation' => 90, + 'mime_type' => 'image/png', + 'offset_x' => 10, + 'offset_y' => 20, + ]); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + // Use reflection to access private method + $reflection = new ReflectionClass(ImageGenerationService::class); + $method = $reflection->getMethod('getImageSettings'); + $method->setAccessible(true); + + $settings = $method->invoke(null, $device); + + // Assert DeviceModel settings are used + expect($settings['width'])->toBe(1024); + expect($settings['height'])->toBe(768); + expect($settings['colors'])->toBe(256); + expect($settings['bit_depth'])->toBe(8); + expect($settings['scale_factor'])->toBe(1.5); + expect($settings['rotation'])->toBe(90); + expect($settings['mime_type'])->toBe('image/png'); + expect($settings['offset_x'])->toBe(10); + expect($settings['offset_y'])->toBe(20); + expect($settings['use_model_settings'])->toBe(true); +})->skipOnGitHubActions(); + +it('get_image_settings falls back to device settings when no device model', function (): void { + // Create a device without DeviceModel + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 180, + 'image_format' => ImageFormat::PNG_8BIT_GRAYSCALE->value, + ]); + + // Use reflection to access private method + $reflection = new ReflectionClass(ImageGenerationService::class); + $method = $reflection->getMethod('getImageSettings'); + $method->setAccessible(true); + + $settings = $method->invoke(null, $device); + + // Assert device settings are used + expect($settings['width'])->toBe(800); + expect($settings['height'])->toBe(480); + expect($settings['rotation'])->toBe(180); + expect($settings['image_format'])->toBe(ImageFormat::PNG_8BIT_GRAYSCALE->value); + expect($settings['use_model_settings'])->toBe(false); +})->skipOnGitHubActions(); + +it('get_image_settings uses defaults for missing device properties', function (): void { + // Create a device without DeviceModel and missing properties + $device = Device::factory()->create([ + 'width' => null, + 'height' => null, + 'rotate' => null, + // image_format has a default value of 'auto', so we can't set it to null + ]); + + // Use reflection to access private method + $reflection = new ReflectionClass(ImageGenerationService::class); + $method = $reflection->getMethod('getImageSettings'); + $method->setAccessible(true); + + $settings = $method->invoke(null, $device); + + // Assert default values are used + expect($settings['width'])->toBe(800); + expect($settings['height'])->toBe(480); + expect($settings['rotation'])->toBe(0); + expect($settings['colors'])->toBe(2); + expect($settings['bit_depth'])->toBe(1); + expect($settings['scale_factor'])->toBe(1.0); + expect($settings['mime_type'])->toBe('image/png'); + expect($settings['offset_x'])->toBe(0); + expect($settings['offset_y'])->toBe(0); + // image_format will be null if the device doesn't have it set, which is the expected behavior + expect($settings['image_format'])->toBeNull(); +})->skipOnGitHubActions(); + +it('determine_image_format_from_model returns correct formats', function (): void { + // Use reflection to access private method + $reflection = new ReflectionClass(ImageGenerationService::class); + $method = $reflection->getMethod('determineImageFormatFromModel'); + $method->setAccessible(true); + + // Test BMP format + $bmpModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/bmp', + 'bit_depth' => 1, + 'colors' => 2, + ]); + $format = $method->invoke(null, $bmpModel); + expect($format)->toBe(ImageFormat::BMP3_1BIT_SRGB->value); + + // Test PNG 8-bit grayscale format + $pngGrayscaleModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 8, + 'colors' => 2, + ]); + $format = $method->invoke(null, $pngGrayscaleModel); + expect($format)->toBe(ImageFormat::PNG_8BIT_GRAYSCALE->value); + + // Test PNG 8-bit 256 color format + $png256Model = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 8, + 'colors' => 256, + ]); + $format = $method->invoke(null, $png256Model); + expect($format)->toBe(ImageFormat::PNG_8BIT_256C->value); + + // Test PNG 2-bit 4 color format + $png4ColorModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/png', + 'bit_depth' => 2, + 'colors' => 4, + ]); + $format = $method->invoke(null, $png4ColorModel); + expect($format)->toBe(ImageFormat::PNG_2BIT_4C->value); + + // Test unknown format returns AUTO + $unknownModel = DeviceModel::factory()->create([ + 'mime_type' => 'image/jpeg', + 'bit_depth' => 16, + 'colors' => 65536, + ]); + $format = $method->invoke(null, $unknownModel); + expect($format)->toBe(ImageFormat::AUTO->value); +})->skipOnGitHubActions(); + +it('cleanup_folder identifies active images correctly', function (): void { + // Create devices with images + $device1 = Device::factory()->create(['current_screen_image' => 'active-uuid-1']); + $device2 = Device::factory()->create(['current_screen_image' => 'active-uuid-2']); + $device3 = Device::factory()->create(['current_screen_image' => null]); + + // Create a plugin with image + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'plugin-uuid']); + + // For unit testing, we could test the logic that determines active UUIDs + $activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray(); + $activePluginImageUuids = App\Models\Plugin::pluck('current_image')->filter()->toArray(); + $activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids); + + expect($activeImageUuids)->toContain('active-uuid-1'); + expect($activeImageUuids)->toContain('active-uuid-2'); + expect($activeImageUuids)->toContain('plugin-uuid'); + expect($activeImageUuids)->not->toContain(null); +}); + +it('reset_if_not_cacheable detects device models', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a device with DeviceModel + Device::factory()->create([ + 'device_model_id' => DeviceModel::factory()->create()->id, + ]); + + // Test that the method detects DeviceModels and resets cache + ImageGenerationService::resetIfNotCacheable($plugin); + + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +})->skipOnGitHubActions(); + +it('reset_if_not_cacheable detects custom dimensions', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a device with custom dimensions + Device::factory()->create([ + 'width' => 1024, // Different from default 800 + 'height' => 768, // Different from default 480 + ]); + + // Test that the method detects custom dimensions and resets cache + ImageGenerationService::resetIfNotCacheable($plugin); + + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +})->skipOnGitHubActions(); + +it('reset_if_not_cacheable preserves cache for standard devices', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create devices with standard dimensions + Device::factory()->count(3)->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + ]); + + // Test that the method preserves cache for standard devices + ImageGenerationService::resetIfNotCacheable($plugin); + + $plugin->refresh(); + expect($plugin->current_image)->toBe('test-uuid'); +})->skipOnGitHubActions(); + +it('reset_if_not_cacheable handles null plugin', function (): void { + // Test that the method handles null plugin gracefully + expect(fn () => ImageGenerationService::resetIfNotCacheable(null))->not->toThrow(Exception::class); +})->skipOnGitHubActions(); + +it('image_format enum includes new 2bit 4c format', function (): void { + // Test that the new format is properly defined in the enum + expect(ImageFormat::PNG_2BIT_4C->value)->toBe('png_2bit_4c'); + expect(ImageFormat::PNG_2BIT_4C->label())->toBe('PNG 2-bit Grayscale 4c'); +}); + +it('device model relationship works correctly', function (): void { + // Create a DeviceModel + $deviceModel = DeviceModel::factory()->create(); + + // Create a device with the DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => $deviceModel->id, + ]); + + // Test the relationship + expect($device->deviceModel)->toBeInstanceOf(DeviceModel::class); + expect($device->deviceModel->id)->toBe($deviceModel->id); +}); + +it('device without device model returns null relationship', function (): void { + // Create a device without DeviceModel + $device = Device::factory()->create([ + 'device_model_id' => null, + ]); + + // Test the relationship returns null + expect($device->deviceModel)->toBeNull(); +}); From fdf8031d089d2566506029736be5ab37182c7b1f Mon Sep 17 00:00:00 2001 From: Angel J <78835633+Iamanaws@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:45:32 -0700 Subject: [PATCH 021/209] docs: clarify local development setup and add missing steps --- docs/DEVELOPMENT.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c7bca71..07ec847 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,4 +1,4 @@ -### Local Development +## Local Development #### Requirements @@ -19,11 +19,12 @@ cp .env.example .env php artisan key:generate ``` -#### Install dependencies +#### Install dependencies and Build frontend ```bash composer install npm i +npm run build ``` #### Run migrations @@ -32,6 +33,12 @@ npm i php artisan migrate --seed ``` +#### Link storage to expose public assets + +```bash +php artisan storage:link +``` + #### Run the server To expose the built-in server to the local network, you can run the following command: @@ -40,7 +47,9 @@ To expose the built-in server to the local network, you can run the following co php artisan serve --host=0.0.0.0 --port 4567 ``` -### Docker +--- + +## Docker Use the provided Dockerfile, or docker-compose file to run the server in a container. #### .devcontainer From 888b61a5756bd53cf66da9b3432832a0c89ac559 Mon Sep 17 00:00:00 2001 From: Kyle Carter Date: Sat, 16 Aug 2025 21:25:18 -0600 Subject: [PATCH 022/209] Add mention of APP_TIMEZONE to README I lost more time than I care to admit trying to track this down. Putting it as part of the documentation should hopefully help others in the future. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 95ed5c8..4b4ef26 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ php artisan db:seed --class=ExampleRecipesSeeder | `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 | | `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 | | `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 | +| `APP_TIMEZONE` | The timezone that the system should run under (Affects "Sleep Mode" | UTC | #### Login From 4e3b47e4eb39763ef96db08fcb4b31b9c27bc701 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 17 Aug 2025 11:06:29 +0200 Subject: [PATCH 023/209] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b4ef26..73b9a6b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ php artisan db:seed --class=ExampleRecipesSeeder | `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 | | `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 | | `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 | -| `APP_TIMEZONE` | The timezone that the system should run under (Affects "Sleep Mode" | UTC | +| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC | #### Login From 2ed3fd5ca923746fdc76918a54d7abe3faeff5a6 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 18 Aug 2025 17:53:05 +0200 Subject: [PATCH 024/209] fix(#80): display endpoint respects preferred bmp image format --- routes/api.php | 46 +++++++++--- tests/Feature/Api/DeviceImageFormatTest.php | 74 ++++++++++++++++++++ tests/Feature/ImageGenerationServiceTest.php | 32 +++++++++ 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/routes/api.php b/routes/api.php index c59b539..5f2a9f3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -126,10 +126,25 @@ Route::get('/display', function (Request $request) { $image_path = 'images/setup-logo.bmp'; $filename = 'setup-logo.bmp'; } else { - if (isset($device->last_firmware_version) - && version_compare($device->last_firmware_version, '1.5.2', '<') - && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { - $image_path = 'images/generated/'.$image_uuid.'.bmp'; + // Determine image format based on device settings + $preferred_format = 'png'; // Default to PNG for newer firmware + + if (! $device->device_model_id) { + // No device model, use device's image_format setting + if (str_contains($device->image_format, 'bmp')) { + $preferred_format = 'bmp'; + } + // For 'auto' or unknown formats, fall back to firmware version logic + if (isset($device->last_firmware_version) + && version_compare($device->last_firmware_version, '1.5.2', '<') + && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { + $preferred_format = 'bmp'; + } + } + + // Check if a preferred format exists, otherwise fall back + if (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.'.$preferred_format)) { + $image_path = 'images/generated/'.$image_uuid.'.'.$preferred_format; } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) { $image_path = 'images/generated/'.$image_uuid.'.png'; } else { @@ -422,10 +437,25 @@ Route::get('/current_screen', function (Request $request) { $image_path = 'images/setup-logo.bmp'; $filename = 'setup-logo.bmp'; } else { - if (isset($device->last_firmware_version) - && version_compare($device->last_firmware_version, '1.5.2', '<') - && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { - $image_path = 'images/generated/'.$image_uuid.'.bmp'; + // Determine image format based on device settings + $preferred_format = 'png'; // Default to PNG for newer firmware + + if (! $device->device_model_id) { + // No device model, use device's image_format setting + if (str_contains($device->image_format, 'bmp')) { + $preferred_format = 'bmp'; + } + // For 'auto' or unknown formats, fall back to firmware version logic + if (isset($device->last_firmware_version) + && version_compare($device->last_firmware_version, '1.5.2', '<') + && Storage::disk('public')->exists('images/generated/'.$image_uuid.'.bmp')) { + $preferred_format = 'bmp'; + } + } + + // Check if preferred format exists, otherwise fall back + if (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.'.$preferred_format)) { + $image_path = 'images/generated/'.$image_uuid.'.'.$preferred_format; } elseif (Storage::disk('public')->exists('images/generated/'.$image_uuid.'.png')) { $image_path = 'images/generated/'.$image_uuid.'.png'; } else { diff --git a/tests/Feature/Api/DeviceImageFormatTest.php b/tests/Feature/Api/DeviceImageFormatTest.php index ff24744..fcb7555 100644 --- a/tests/Feature/Api/DeviceImageFormatTest.php +++ b/tests/Feature/Api/DeviceImageFormatTest.php @@ -1,6 +1,11 @@ 'test-image.bmp', ]); }); + +test('device without device_model_id and image_format bmp3_1bit_srgb returns bmp when plugin is rendered', function () { + // Create a user with auto-assign enabled + $user = User::factory()->create([ + 'assign_new_devices' => true, + ]); + + // Create a device without device_model_id and with bmp3_1bit_srgb format + $device = Device::factory()->create([ + 'user_id' => $user->id, + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'device_model_id' => null, // Explicitly set to null + 'image_format' => ImageFormat::BMP3_1BIT_SRGB->value, + 'last_firmware_version' => '1.5.2', + ]); + + // Create a plugin + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'render_markup' => '
Test Content
', + 'data_strategy' => 'static', + 'markup_language' => 'blade', + 'current_image' => 'test-generated-image', // Set current image directly + ]); + + // Create a playlist for the device + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'is_active' => true, + 'refresh_time' => 900, + ]); + + // Create a playlist item with the plugin + $playlistItem = PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $plugin->id, + 'is_active' => true, + 'order' => 1, + ]); + + // Mock the image generation to create both bmp and png files + $imageUuid = 'test-generated-image'; + Storage::disk('public')->put('images/generated/'.$imageUuid.'.bmp', 'fake bmp content'); + Storage::disk('public')->put('images/generated/'.$imageUuid.'.png', 'fake png content'); + + // Set the device's current screen image to the plugin's image + $device->update(['current_screen_image' => $imageUuid]); + + // Test /api/display endpoint + $displayResponse = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.5.2', + ])->get('/api/current_screen'); + + $displayResponse->assertOk(); + $displayResponse->assertJson([ + 'filename' => $imageUuid.'.bmp', + ]); + + // Verify that the device's image_format is correctly set + $device->refresh(); + expect($device->image_format)->toBe(ImageFormat::BMP3_1BIT_SRGB->value) + ->and($device->device_model_id)->toBeNull(); +}); diff --git a/tests/Feature/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php index 22699c5..fc06a5b 100644 --- a/tests/Feature/ImageGenerationServiceTest.php +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -423,3 +423,35 @@ it('determines correct image format from device model', function (): void { expect($device3->current_screen_image)->toBe($uuid3); Storage::disk('public')->assertExists("/images/generated/{$uuid3}.png"); })->skipOnGitHubActions(); + +it('generates BMP for legacy device with bmp3_1bit_srgb format', function (): void { + // Create a device with BMP format but no DeviceModel (legacy behavior) + $device = Device::factory()->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + 'image_format' => ImageFormat::BMP3_1BIT_SRGB->value, + 'device_model_id' => null, // Explicitly no DeviceModel + ]); + + $markup = '
Test Content
'; + $uuid = ImageGenerationService::generateImage($markup, $device->id); + + // Assert the device was updated with a new image UUID + $device->refresh(); + expect($device->current_screen_image)->toBe($uuid); + + // Assert BMP file was created + Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp"); + + // Verify the BMP file has content and isn't blank + $imagePath = Storage::disk('public')->path("/images/generated/{$uuid}.bmp"); + $imageSize = filesize($imagePath); + expect($imageSize)->toBeGreaterThan(100); // Should be at least 100 bytes for a BMP + + // Verify it's a valid BMP file + $imageInfo = getimagesize($imagePath); + expect($imageInfo[0])->toBe(800); // Width + expect($imageInfo[1])->toBe(480); // Height + expect($imageInfo[2])->toBe(IMAGETYPE_BMP); // BMP type +})->skipOnGitHubActions(); From 51af95da2c88c8916415d71af485b240cdec3a4f Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 18 Aug 2025 19:09:57 +0200 Subject: [PATCH 025/209] fix: restore plugin image cache for OG device model --- app/Services/ImageGenerationService.php | 13 +++-- .../Services/ImageGenerationServiceTest.php | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index a7bd3c6..d0ecddc 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -353,20 +353,27 @@ class ImageGenerationService public static function resetIfNotCacheable(?Plugin $plugin): void { if ($plugin?->id) { - // Check if any devices have custom dimensions or use DeviceModels + // Check if any devices have custom dimensions or use non-standard DeviceModels $hasCustomDimensions = Device::query() ->where(function ($query) { $query->where('width', '!=', 800) ->orWhere('height', '!=', 480) ->orWhere('rotate', '!=', 0); }) - ->orWhereNotNull('device_model_id') + ->orWhereHas('deviceModel', function ($query) { + // Only allow caching if all device models have standard dimensions (800x480, rotation=0) + $query->where(function ($subQuery) { + $subQuery->where('width', '!=', 800) + ->orWhere('height', '!=', 480) + ->orWhere('rotation', '!=', 0); + }); + }) ->exists(); if ($hasCustomDimensions) { // TODO cache image per device $plugin->update(['current_image' => null]); - Log::debug('Skip cache as devices with custom dimensions or DeviceModels exist'); + Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist'); } } } diff --git a/tests/Unit/Services/ImageGenerationServiceTest.php b/tests/Unit/Services/ImageGenerationServiceTest.php index 4941c3c..b254243 100644 --- a/tests/Unit/Services/ImageGenerationServiceTest.php +++ b/tests/Unit/Services/ImageGenerationServiceTest.php @@ -226,6 +226,59 @@ it('reset_if_not_cacheable preserves cache for standard devices', function (): v expect($plugin->current_image)->toBe('test-uuid'); })->skipOnGitHubActions(); +it('reset_if_not_cacheable preserves cache for og_png and og_plus device models', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create og_png device model + $ogPngModel = DeviceModel::factory()->create([ + 'name' => 'test_og_png', + 'width' => 800, + 'height' => 480, + 'rotation' => 0, + ]); + + // Create og_plus device model + $ogPlusModel = DeviceModel::factory()->create([ + 'name' => 'test_og_plus', + 'width' => 800, + 'height' => 480, + 'rotation' => 0, + ]); + + // Create devices with og_png and og_plus device models + Device::factory()->create(['device_model_id' => $ogPngModel->id]); + Device::factory()->create(['device_model_id' => $ogPlusModel->id]); + + // Test that the method preserves cache for standard device models + ImageGenerationService::resetIfNotCacheable($plugin); + + $plugin->refresh(); + expect($plugin->current_image)->toBe('test-uuid'); +})->skipOnGitHubActions(); + +it('reset_if_not_cacheable resets cache for non-standard device models', function (): void { + // Create a plugin + $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); + + // Create a non-standard device model (e.g., kindle) + $kindleModel = DeviceModel::factory()->create([ + 'name' => 'test_amazon_kindle_2024', + 'width' => 1400, + 'height' => 840, + 'rotation' => 90, + ]); + + // Create a device with the non-standard device model + Device::factory()->create(['device_model_id' => $kindleModel->id]); + + // Test that the method resets cache for non-standard device models + ImageGenerationService::resetIfNotCacheable($plugin); + + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +})->skipOnGitHubActions(); + it('reset_if_not_cacheable handles null plugin', function (): void { // Test that the method handles null plugin gracefully expect(fn () => ImageGenerationService::resetIfNotCacheable(null))->not->toThrow(Exception::class); From 2427436b31de936882bc59515305875733fad958 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 19 Aug 2025 15:36:18 +0200 Subject: [PATCH 026/209] feat(#55): auto assign device model when provided at setup --- routes/api.php | 11 +++++- tests/Feature/Api/DeviceEndpointsTest.php | 44 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/routes/api.php b/routes/api.php index 5f2a9f3..2881796 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; use App\Models\DeviceLog; +use App\Models\DeviceModel; use App\Models\Plugin; use App\Models\User; use App\Services\ImageGenerationService; @@ -178,6 +179,7 @@ Route::get('/display', function (Request $request) { Route::get('/setup', function (Request $request) { $mac_address = $request->header('id'); + $model_name = $request->header('model-id'); if (! $mac_address) { return response()->json([ @@ -193,6 +195,12 @@ Route::get('/setup', function (Request $request) { $auto_assign_user = User::where('assign_new_devices', true)->first(); if ($auto_assign_user) { + // Check if device model exists by name + $device_model = null; + if ($model_name) { + $device_model = DeviceModel::where('name', $model_name)->first(); + } + // Create a new device and assign it to this user $device = Device::create([ 'mac_address' => $mac_address, @@ -202,6 +210,7 @@ Route::get('/setup', function (Request $request) { 'friendly_id' => Str::random(6), 'default_refresh_interval' => 900, 'mirror_device_id' => $auto_assign_user->assign_new_device_id, + 'device_model_id' => $device_model?->id, ]); } else { return response()->json([ @@ -282,7 +291,7 @@ Route::get('/devices', function (Request $request) { })->middleware('auth:sanctum'); Route::get('/device-models', function (Request $request) { - $deviceModels = App\Models\DeviceModel::get([ + $deviceModels = DeviceModel::get([ 'id', 'name', 'label', diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 7e8fbdf..2ee5dcf 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -2,10 +2,12 @@ use App\Jobs\GenerateScreenJob; use App\Models\Device; +use App\Models\DeviceModel; use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; use App\Models\User; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; @@ -906,3 +908,45 @@ test('screens endpoint returns 404 for invalid device credentials', function () 'message' => 'MAC Address not registered or invalid access token', ]); }); + +test('setup endpoint assigns device model when model-id header is provided', function () { + $user = User::factory()->create(['assign_new_devices' => true]); + $deviceModel = DeviceModel::factory()->create([ + 'name' => 'test-model', + 'label' => 'Test Model', + ]); + + $response = $this->withHeaders([ + 'id' => '00:11:22:33:44:55', + 'model-id' => 'test-model', + ])->get('/api/setup'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'message' => 'Welcome to TRMNL BYOS', + ]); + + $device = Device::where('mac_address', '00:11:22:33:44:55')->first(); + expect($device)->not->toBeNull() + ->and($device->device_model_id)->toBe($deviceModel->id); +}); + +test('setup endpoint handles non-existent device model gracefully', function () { + $user = User::factory()->create(['assign_new_devices' => true]); + + $response = $this->withHeaders([ + 'id' => '00:11:22:33:44:55', + 'model-id' => 'non-existent-model', + ])->get('/api/setup'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'message' => 'Welcome to TRMNL BYOS', + ]); + + $device = Device::where('mac_address', '00:11:22:33:44:55')->first(); + expect($device)->not->toBeNull() + ->and($device->device_model_id)->toBeNull(); +}); From 2318c8d2aed4d35bdd0960a4e353c30afca48ede Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 19 Aug 2025 23:22:48 +0200 Subject: [PATCH 027/209] fix: update list after Seeding Example Plugins --- resources/views/livewire/plugins/index.blade.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 241ff57..7ab674b 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -69,9 +69,8 @@ new class extends Component { public function seedExamplePlugins(): void { -// \Artisan::call('db:seed', ['--class' => 'ExampleRecipesSeeder']); \Artisan::call(\App\Console\Commands\ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); - + $this->refreshPlugins(); } }; From a927c0fb97f923d53281ab7249aecad767248d9a Mon Sep 17 00:00:00 2001 From: Sunny Date: Thu, 21 Aug 2025 13:27:05 +0200 Subject: [PATCH 028/209] chore: minor fixes for documentation --- docs/DEVELOPMENT.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 07ec847..40bcbd3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -16,10 +16,9 @@ git clone git@github.com:usetrmnl/byos_laravel.git ```bash cp .env.example .env -php artisan key:generate ``` -#### Install dependencies and Build frontend +#### Install dependencies and build frontend ```bash composer install @@ -27,6 +26,12 @@ npm i npm run build ``` +#### Generate application key + +```bash +php artisan key:generate +``` + #### Run migrations ```bash From 414ca47cbfe6e01a0857ea738a5a0ea8a24e6164 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 13 Jun 2025 12:23:52 +0200 Subject: [PATCH 029/209] feat: recipes zip import support, add trmnlp compatible recipe configuration * recipes zip import support * add trmnlp compatible recipe configuration * support for multiple polling urls --- .../FileSystems/InlineTemplatesFileSystem.php | 61 ++++ app/Liquid/Tags/TemplateTag.php | 99 ++++++ app/Models/Plugin.php | 243 ++++++++++++- app/Providers/AppServiceProvider.php | 12 + app/Services/PluginImportService.php | 206 ++++++++++++ composer.json | 2 + composer.lock | 157 ++++----- ...932_add_configuration_to_plugins_table.php | 30 ++ resources/views/flux/icon/github.blade.php | 42 +++ .../views/livewire/plugins/index.blade.php | 101 +++++- .../views/livewire/plugins/recipe.blade.php | 318 ++++++++++++++++-- tests/Feature/PluginDefaultValuesTest.php | 126 +++++++ tests/Feature/PluginImportTest.php | 179 ++++++++++ tests/Feature/PluginInlineTemplatesTest.php | 175 ++++++++++ .../PluginRequiredConfigurationTest.php | 218 ++++++++++++ tests/Unit/Liquid/InlineTemplatesTest.php | 297 ++++++++++++++++ tests/Unit/Models/PluginTest.php | 268 +++++++++++++++ 17 files changed, 2409 insertions(+), 125 deletions(-) create mode 100644 app/Liquid/FileSystems/InlineTemplatesFileSystem.php create mode 100644 app/Liquid/Tags/TemplateTag.php create mode 100644 app/Services/PluginImportService.php create mode 100644 database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php create mode 100644 resources/views/flux/icon/github.blade.php create mode 100644 tests/Feature/PluginDefaultValuesTest.php create mode 100644 tests/Feature/PluginImportTest.php create mode 100644 tests/Feature/PluginInlineTemplatesTest.php create mode 100644 tests/Feature/PluginRequiredConfigurationTest.php create mode 100644 tests/Unit/Liquid/InlineTemplatesTest.php diff --git a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php new file mode 100644 index 0000000..01adf1b --- /dev/null +++ b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php @@ -0,0 +1,61 @@ + + */ + protected array $templates = []; + + /** + * Register a template with the given name and content + */ + public function register(string $name, string $content): void + { + $this->templates[$name] = $content; + } + + /** + * Check if a template exists + */ + public function hasTemplate(string $templateName): bool + { + return isset($this->templates[$templateName]); + } + + /** + * Get all registered template names + * + * @return array + */ + public function getTemplateNames(): array + { + return array_keys($this->templates); + } + + /** + * Clear all registered templates + */ + public function clear(): void + { + $this->templates = []; + } + + public function readTemplateFile(string $templateName): string + { + if (!isset($this->templates[$templateName])) { + throw new \InvalidArgumentException("Template '{$templateName}' not found in inline templates"); + } + + return $this->templates[$templateName]; + } +} \ No newline at end of file diff --git a/app/Liquid/Tags/TemplateTag.php b/app/Liquid/Tags/TemplateTag.php new file mode 100644 index 0000000..19f664e --- /dev/null +++ b/app/Liquid/Tags/TemplateTag.php @@ -0,0 +1,99 @@ +params->expression(); + + $this->templateName = match (true) { + is_string($templateNameExpression) => trim($templateNameExpression), + is_numeric($templateNameExpression) => (string) $templateNameExpression, + $templateNameExpression instanceof VariableLookup => (string) $templateNameExpression, + default => throw new SyntaxException("Template name must be a string, number, or variable"), + }; + + // Validate template name (letters, numbers, underscores, and slashes only) + if (!preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) { + throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes"); + } + + $context->params->assertEnd(); + + assert($context->body instanceof BodyNode); + + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('template tag must have a single raw body'), + }; + + // Register the template with the file system during parsing + $fileSystem = $context->getParseContext()->environment->fileSystem; + if ($fileSystem instanceof InlineTemplatesFileSystem) { + // Store the raw content for later rendering + $fileSystem->register($this->templateName, $this->body->value); + } + + return $this; + } + + public function render(RenderContext $context): string + { + // Get the file system from the environment + $fileSystem = $context->environment->fileSystem; + + if (!$fileSystem instanceof InlineTemplatesFileSystem) { + // If no inline file system is available, just return empty string + // This allows the template to be used in contexts where inline templates aren't supported + return ''; + } + + // Register the template with the file system + $fileSystem->register($this->templateName, $this->body->render($context)); + + // Return empty string as template tags don't output anything + return ''; + } + + public function getTemplateName(): string + { + return $this->templateName; + } + + public function getBody(): Raw + { + return $this->body; + } +} \ No newline at end of file diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 6c17101..e2b3260 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -2,18 +2,23 @@ namespace App\Models; +use App\Liquid\FileSystems\InlineTemplatesFileSystem; 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 App\Liquid\Tags\TemplateTag; +use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Keepsuit\Liquid\Exceptions\LiquidException; +use Keepsuit\Liquid\Extensions\StandardExtension; class Plugin extends Model { @@ -26,6 +31,8 @@ class Plugin extends Model 'data_payload_updated_at' => 'datetime', 'is_native' => 'boolean', 'markup_language' => 'string', + 'configuration' => 'json', + 'configuration_template' => 'json', ]; protected static function boot() @@ -39,6 +46,49 @@ class Plugin extends Model }); } + 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 (($currentValue === null || $currentValue === '' || (is_array($currentValue) && empty($currentValue))) && ! 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') { @@ -59,7 +109,9 @@ class Plugin extends Model $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; if ($this->polling_header) { - $headerLines = explode("\n", trim($this->polling_header)); + // Resolve Liquid variables in the polling header + $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); + $headerLines = explode("\n", trim($resolvedHeader)); foreach ($headerLines as $line) { $parts = explode(':', $line, 2); if (count($parts) === 2) { @@ -68,26 +120,138 @@ class Plugin extends Model } } - $httpRequest = Http::withHeaders($headers); + // Split URLs by newline and filter out empty lines + $urls = array_filter( + array_map('trim', explode("\n", $this->polling_url)), + fn ($url) => ! empty($url) + ); - if ($this->polling_verb === 'post' && $this->polling_body) { - $httpRequest = $httpRequest->withBody($this->polling_body); + // 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); + } + + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); + + try { + // Make the request based on the verb + if ($this->polling_verb === 'post') { + $response = $httpRequest->post($resolvedUrl)->json(); + } else { + $response = $httpRequest->get($resolvedUrl)->json(); + } + + $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; } - // Make the request based on the verb - if ($this->polling_verb === 'post') { - $response = $httpRequest->post($this->polling_url)->json(); - } else { - $response = $httpRequest->get($this->polling_url)->json(); + // 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); + } + + // Resolve Liquid variables in the polling URL + $resolvedUrl = $this->resolveLiquidVariables($url); + + try { + // Make the request based on the verb + if ($this->polling_verb === 'post') { + $response = $httpRequest->post($resolvedUrl)->json(); + } else { + $response = $httpRequest->get($resolvedUrl)->json(); + } + + // Check if response is an array at root level + if (is_array($response) && 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' => $response, + 'data_payload' => $combinedResponse, 'data_payload_updated_at' => now(), ]); } } + /** + * Apply Liquid template replacements (converts 'with' syntax to comma syntax) + */ + private function applyLiquidReplacements(string $template): string + { + $replacements = [ + 'date: "%N"' => 'date: "u"', + '%-m/%-d/%Y' => 'm/d/Y', + ]; + + // Apply basic replacements + $template = str_replace(array_keys($replacements), array_values($replacements), $template); + + // Convert {% render "template" with %} syntax to {% render "template", %} syntax + $template = preg_replace( + '/{%\s*render\s+([^}]+?)\s+with\s+/i', + '{% render $1, ', + $template + ); + + return $template; + } + + /** + * Resolve Liquid variables in a template string using the Liquid template engine + * + * @param string $template The template string containing Liquid variables + * @return string The resolved template with variables replaced with their values + * + * @throws LiquidException + */ + public function resolveLiquidVariables(string $template): string + { + // Get configuration variables - make them available at root level + $variables = $this->configuration ?? []; + + // Use the Liquid template engine to resolve variables + $environment = App::make('liquid.environment'); + $liquidTemplate = $environment->parseString($template); + $context = $environment->newRenderContext(data: $variables); + + return $liquidTemplate->render($context); + } + /** * Render the plugin's markup * @@ -99,7 +263,12 @@ class Plugin extends Model $renderedContent = ''; if ($this->markup_language === 'liquid') { - $environment = App::make('liquid.environment'); + // Create a custom environment with inline templates support + $inlineFileSystem = new InlineTemplatesFileSystem(); + $environment = new \Keepsuit\Liquid\Environment( + fileSystem: $inlineFileSystem, + extensions: [new StandardExtension()] + ); // Register all custom filters $environment->filterRegistry->register(Numbers::class); @@ -108,11 +277,47 @@ class Plugin extends Model $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]); + // 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' => [ + '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' => 'no', + 'no_screen_padding' => 'no', + 'polling_headers' => $this->polling_header, + 'polling_url' => $this->polling_url, + 'custom_fields_values' => [ + ...(is_array($this->configuration) ? $this->configuration : []), + ], + ], + ], + ] + ); $renderedContent = $template->render($context); } else { - $renderedContent = Blade::render($this->render_markup, ['size' => $size, 'data' => $this->data_payload]); + $renderedContent = Blade::render($this->render_markup, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ]); } if ($standalone) { @@ -130,6 +335,7 @@ class Plugin extends Model 'slot' => view($this->render_markup_view, [ 'size' => $size, 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], ])->render(), ])->render(); } @@ -137,10 +343,19 @@ class Plugin extends Model 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; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6ac75bf..8433d76 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Services\OidcProvider; +use Illuminate\Http\Request; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Socialite\Facades\Socialite; @@ -26,6 +27,17 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) { + $https = clone $this; + $https->server->set('HTTPS', 'on'); + + $http = clone $this; + $http->server->set('HTTPS', 'off'); + + return URL::hasValidSignature($https, $absolute, $ignoreQuery) + || URL::hasValidSignature($http, $absolute, $ignoreQuery); + }); + // Register OIDC provider with Socialite Socialite::extend('oidc', function ($app) { $config = $app['config']['services.oidc'] ?? []; diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php new file mode 100644 index 0000000..9cf3d76 --- /dev/null +++ b/app/Services/PluginImportService.php @@ -0,0 +1,206 @@ +getRealPath(); + + // Extract the ZIP file using ZipArchive + $zip = new ZipArchive(); + if ($zip->open($zipFullPath) !== true) { + throw new Exception('Could not open the ZIP file.'); + } + + $zip->extractTo($tempDir); + $zip->close(); + + // Find the required files (settings.yml and full.liquid/full.blade.php) + $filePaths = $this->findRequiredFiles($tempDir); + + // Validate that we found the required files + if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { + throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); + } + + // Parse settings.yml + $settingsYaml = File::get($filePaths['settingsYamlPath']); + $settings = Yaml::parse($settingsYaml); + + // Read full.liquid content + $fullLiquid = File::get($filePaths['fullLiquidPath']); + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + + // Prepend shared.liquid content if available + if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { + $sharedLiquid = File::get($filePaths['sharedLiquidPath']); + $fullLiquid = $sharedLiquid."\n".$fullLiquid; + } + + // Check if the file ends with .liquid to set markup language + $markupLanguage = 'blade'; + if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { + $markupLanguage = 'liquid'; + } + + // Ensure custom_fields is properly formatted + if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) { + $settings['custom_fields'] = []; + } + + // Create configuration template with the custom fields + $configurationTemplate = [ + 'custom_fields' => $settings['custom_fields'], + ]; + + // Extract default values from custom_fields and populate configuration + $configuration = []; + if (isset($settings['custom_fields']) && is_array($settings['custom_fields'])) { + foreach ($settings['custom_fields'] as $field) { + if (isset($field['keyname']) && isset($field['default'])) { + $configuration[$field['keyname']] = $field['default']; + } + } + } + + // Create a new plugin + $plugin = Plugin::create([ + 'user_id' => $user->id, + 'name' => $settings['name'] ?? 'Imported Plugin', + 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, + 'data_strategy' => $settings['strategy'] ?? 'static', + 'polling_url' => $settings['polling_url'] ?? null, + 'polling_verb' => $settings['polling_verb'] ?? 'get', + 'polling_header' => isset($settings['polling_headers']) + ? str_replace('=', ':', $settings['polling_headers']) + : null, + 'polling_body' => $settings['polling_body'] ?? null, + 'markup_language' => $markupLanguage, + 'render_markup' => $fullLiquid, + 'configuration_template' => $configurationTemplate, + 'configuration' => $configuration, + 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + ]); + + return $plugin; + + } finally { + // Clean up temporary directory + Storage::deleteDirectory($tempDirName); + } + } + + /** + * Find required files in the extracted ZIP directory + * + * @param string $tempDir The temporary directory path + * @return array Array containing paths to required files + */ + private function findRequiredFiles(string $tempDir): array + { + $settingsYamlPath = null; + $fullLiquidPath = null; + $sharedLiquidPath = null; + + // First, check if files are directly in the src folder + if (File::exists($tempDir.'/src/settings.yml')) { + $settingsYamlPath = $tempDir.'/src/settings.yml'; + + // Check for full.liquid or full.blade.php + if (File::exists($tempDir.'/src/full.liquid')) { + $fullLiquidPath = $tempDir.'/src/full.liquid'; + } elseif (File::exists($tempDir.'/src/full.blade.php')) { + $fullLiquidPath = $tempDir.'/src/full.blade.php'; + } + + // Check for shared.liquid in the same directory + if (File::exists($tempDir.'/src/shared.liquid')) { + $sharedLiquidPath = $tempDir.'/src/shared.liquid'; + } + } else { + // Search for the files in the extracted directory structure + $directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($directories); + + foreach ($files as $file) { + $filename = $file->getFilename(); + $filepath = $file->getPathname(); + + if ($filename === 'settings.yml') { + $settingsYamlPath = $filepath; + } elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') { + $fullLiquidPath = $filepath; + } elseif ($filename === 'shared.liquid') { + $sharedLiquidPath = $filepath; + } + + // If we found both required files, break the loop + if ($settingsYamlPath && $fullLiquidPath) { + break; + } + } + + // If we found the files but they're not in the src folder, + // check if they're in the root of the ZIP or in a subfolder + if ($settingsYamlPath && $fullLiquidPath) { + // If the files are in the root of the ZIP, create a src folder and move them there + $srcDir = dirname($settingsYamlPath); + + // If the parent directory is not named 'src', create a src directory + if (basename($srcDir) !== 'src') { + $newSrcDir = $tempDir.'/src'; + File::makeDirectory($newSrcDir, 0755, true); + + // Copy the files to the src directory + File::copy($settingsYamlPath, $newSrcDir.'/settings.yml'); + File::copy($fullLiquidPath, $newSrcDir.'/full.liquid'); + + // Copy shared.liquid if it exists + if ($sharedLiquidPath) { + File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid'); + $sharedLiquidPath = $newSrcDir.'/shared.liquid'; + } + + // Update the paths + $settingsYamlPath = $newSrcDir.'/settings.yml'; + $fullLiquidPath = $newSrcDir.'/full.liquid'; + } + } + } + + return [ + 'settingsYamlPath' => $settingsYamlPath, + 'fullLiquidPath' => $fullLiquidPath, + 'sharedLiquidPath' => $sharedLiquidPath, + ]; + } +} diff --git a/composer.json b/composer.json index a2c72e2..6804500 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": "^8.2", "ext-imagick": "*", + "ext-zip": "*", "bnussbau/laravel-trmnl-blade": "1.2.*", "intervention/image": "^3.11", "keepsuit/laravel-liquid": "^0.5.2", @@ -22,6 +23,7 @@ "livewire/flux": "^2.0", "livewire/volt": "^1.7", "spatie/browsershot": "^5.0", + "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 0c56e9b..3f04004 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9143c36674f3ae13a9e9bad15014d508", + "content-hash": "fea763810f7e3a912c2221d2fe0a751e", "packages": [ { "name": "aws/aws-crt-php", @@ -7405,6 +7405,82 @@ ], "time": "2025-07-10T08:47:49+00:00" }, + { + "name": "symfony/yaml", + "version": "v7.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", + "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:47:49+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.3.0", @@ -11377,82 +11453,6 @@ ], "time": "2024-10-20T05:08:20+00:00" }, - { - "name": "symfony/yaml", - "version": "v7.3.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30", - "reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-10T08:47:49+00:00" - }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.5", @@ -11570,7 +11570,8 @@ "prefer-lowest": false, "platform": { "php": "^8.2", - "ext-imagick": "*" + "ext-imagick": "*", + "ext-zip": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php b/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php new file mode 100644 index 0000000..2ed9123 --- /dev/null +++ b/database/migrations/2025_06_13_102932_add_configuration_to_plugins_table.php @@ -0,0 +1,30 @@ +json('configuration_template')->nullable(); + $table->json('configuration')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('configuration_template'); + $table->dropColumn('configuration'); + }); + } +}; diff --git a/resources/views/flux/icon/github.blade.php b/resources/views/flux/icon/github.blade.php new file mode 100644 index 0000000..1463734 --- /dev/null +++ b/resources/views/flux/icon/github.blade.php @@ -0,0 +1,42 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 7ab674b..9a5dd69 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -1,8 +1,13 @@ @@ -50,7 +56,7 @@ new class extends Component { $this->validate(); \App\Models\Plugin::create([ - 'uuid' => \Illuminate\Support\Str::uuid(), + 'uuid' => Str::uuid(), 'user_id' => auth()->id(), 'name' => $this->name, 'data_stale_minutes' => $this->data_stale_minutes, @@ -69,8 +75,32 @@ new class extends Component { public function seedExamplePlugins(): void { - \Artisan::call(\App\Console\Commands\ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); + Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]); $this->refreshPlugins(); + + } + + + public function importZip(PluginImportService $pluginImportService): void + { + abort_unless(auth()->user() !== null, 403); + + $this->validate([ + 'zipFile' => 'required|file|mimes:zip|max:10240', // 10MB max + ]); + + try { + $plugin = $pluginImportService->importFromZip($this->zipFile, auth()->user()); + + $this->refreshPlugins(); + $this->reset(['zipFile']); + + Flux::modal('import-zip')->close(); + $this->dispatch('notify', ['type' => 'success', 'message' => 'Plugin imported successfully!']); + + } catch (\Exception $e) { + $this->dispatch('notify', ['type' => 'error', 'message' => 'Error importing plugin: ' . $e->getMessage()]); + } } }; @@ -89,15 +119,10 @@ new class extends Component { + + Import Recipe + Seed Example Recipes - {{-- --}} - {{-- --}} - {{-- Import Recipe ZIP File--}} - {{-- --}} - {{-- --}} - {{-- --}} - {{-- New Native Plugin--}} - {{-- --}} @@ -105,6 +130,62 @@ new class extends Component {
+ +
+
+ Import Recipe + Alpha + + Upload a ZIP archive containing a TRMNL recipe — either exported from the cloud service or structured using the trmnlp project structure. +
+ +
+ The archive must at least contain settings.yml and full.liquid files. +{{--

The ZIP file should contain the following structure:

--}} +{{--
--}}
+{{--.--}}
+{{--├── src--}}
+{{--│   ├── full.liquid (required)--}}
+{{--│   ├── settings.yml (required)--}}
+{{--│   └── ...--}}
+{{--└── ...--}}
+{{--                    
--}} +
+ +
+ Limitations +
    +
  • Only full view will be imported; shared markup will be prepended
  • +
  • Some Liquid filters may be not supported or behave differently
  • +
  • API responses in formats other than JSON are not yet supported
  • +{{--
      --}} +{{--
    • date: "%N" is unsupported. Use date: "u" instead
    • --}} +{{--
    --}} +
+ Please report issues on GitHub. Include your example zip file. +
+ +
+
+ + + @error('zipFile') {{ $message }} @enderror +
+ +
+ + Import +
+
+
+
+
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 32f6e18..0edee8a 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -2,8 +2,10 @@ use App\Models\Plugin; use Illuminate\Support\Carbon; +use Keepsuit\Liquid\Exceptions\LiquidException; use Livewire\Volt\Component; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Arr; new class extends Component { public Plugin $plugin; @@ -28,10 +30,15 @@ new class extends Component { public string $selected_playlist = ''; public string $mashup_layout = 'full'; public array $mashup_plugins = []; + public array $configuration_template = []; + public array $configuration = []; public function mount(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + $this->blade_code = $this->plugin->render_markup; + $this->configuration_template = $this->plugin->configuration_template ?? []; + $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; if ($this->plugin->render_markup_view) { try { @@ -86,7 +93,7 @@ new class extends Component { 'name' => 'required|string|max:255', 'data_stale_minutes' => 'required|integer|min:1', 'data_strategy' => 'required|string|in:polling,webhook,static', - 'polling_url' => 'required_if:data_strategy,polling|nullable|url', + 'polling_url' => 'required_if:data_strategy,polling|nullable', 'polling_verb' => 'required|string|in:get,post', 'polling_header' => 'nullable|string|max:255', 'polling_body' => 'nullable|string', @@ -104,11 +111,30 @@ new class extends Component { public function editSettings() { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // Custom validation for polling_url with Liquid variable resolution + $this->validatePollingUrl(); + $validated = $this->validate(); $validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true); $this->plugin->update($validated); } + protected function validatePollingUrl(): void + { + if ($this->data_strategy === 'polling' && !empty($this->polling_url)) { + try { + $resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url); + + if (!filter_var($resolvedUrl, FILTER_VALIDATE_URL)) { + $this->addError('polling_url', 'The polling URL must be a valid URL after resolving configuration variables.'); + } + } catch (\Exception $e) { + $this->addError('polling_url', 'Error resolving Liquid variables: ' . $e->getMessage()); + } + } + } + public function updateData(): void { if ($this->plugin->data_strategy === 'polling') { @@ -197,11 +223,39 @@ new class extends Component { Flux::modal('add-to-playlist')->close(); } + public function saveConfiguration() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + $configurationValues = []; + if (isset($this->configuration_template['custom_fields'])) { + foreach ($this->configuration_template['custom_fields'] as $field) { + $fieldKey = $field['keyname']; + if (isset($this->configuration[$fieldKey])) { + $configurationValues[$fieldKey] = $this->configuration[$fieldKey]; + } + } + } + + $this->plugin->update([ + 'configuration' => $configurationValues + ]); + + Flux::modal('configuration-modal')->close(); + } + public function getDevicePlaylists($deviceId) { return \App\Models\Playlist::where('device_id', $deviceId)->get(); } + public function getConfigurationValue($key, $default = null) + { + return $this->configuration[$key] ?? $default; + } + + + public function renderExample(string $example) { switch ($example) { @@ -270,9 +324,16 @@ HTML; { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + // If data strategy is polling and data_payload is null, fetch the data first + if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) { + $this->updateData(); + } + try { $previewMarkup = $this->plugin->render($size); $this->dispatch('preview-updated', preview: $previewMarkup); + } catch (LiquidException $e) { + $this->dispatch('preview-error', message: $e->toLiquidErrorMessage()); } catch (\Exception $e) { $this->dispatch('preview-error', message: $e->getMessage()); } @@ -297,23 +358,23 @@ HTML; - Preview + Preview - + - Half-Horizontal + Half-Horizontal - Half-Vertical + Half-Vertical - Quadrant + Quadrant @@ -321,7 +382,7 @@ HTML; - Add to Playlist + Add to Playlist @@ -429,7 +490,7 @@ HTML;
- Add to Playlist + Add to Playlist
@@ -461,6 +522,143 @@ HTML;
+ +
+
+ Configuration + Configure your plugin settings +
+ +
+ @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) + @foreach($configuration_template['custom_fields'] as $field) + @php + $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; + $currentValue = $configuration[$fieldKey] ?? ''; + @endphp +
+ @if($field['field_type'] === 'author_bio') + @continue + @endif + + @if($field['field_type'] === 'copyable_webhook_url') + @continue + @endif + + @if($field['field_type'] === 'string' || $field['field_type'] === 'url') + + @elseif($field['field_type'] === 'password') + + @elseif($field['field_type'] === 'copyable') + + @elseif($field['field_type'] === 'time_zone') + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + @elseif($field['field_type'] === 'number') + + @elseif($field['field_type'] === 'boolean') + + @elseif($field['field_type'] === 'date') + + @elseif($field['field_type'] === 'select') + @if(isset($field['multiple']) && $field['multiple'] === true) + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + + @endif + @endforeach + @endif + + @else + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + @endif + @else +

{{ $field['name'] }}: Field type "{{ $field['field_type'] }}" not yet supported

+ @endif +
+ @endforeach + @endif + +
+ + Save Configuration +
+
+
+
+

Settings

@@ -472,6 +670,85 @@ HTML; name="name" autofocus/>
+ @php + $authorField = null; + if (isset($configuration_template['custom_fields'])) { + foreach ($configuration_template['custom_fields'] as $field) { + if ($field['field_type'] === 'author_bio') { + $authorField = $field; + break; + } + } + } + @endphp + + @if($authorField) +
+
+ {{ $authorField['description'] }} +
+ + @if(isset($authorField['github_url']) || isset($authorField['learn_more_url']) || isset($authorField['email_address'])) +
+ @if(isset($authorField['github_url'])) + @php + $githubUrl = $authorField['github_url']; + $githubUsername = null; + + // Extract username from various GitHub URL formats + if (preg_match('/github\.com\/([^\/\?]+)/', $githubUrl, $matches)) { + $githubUsername = $matches[1]; + } + @endphp + @if($githubUsername)@endif + @endif + @if(isset($authorField['learn_more_url'])) + + Learn More + + @endif + + @if(isset($authorField['github_url'])) + + + @endif + + @if(isset($authorField['email_address'])) + + + @endif +
+ @endif +
+ @endif + + @if(isset($configuration_template['custom_fields']) && !empty($configuration_template['custom_fields'])) + @if($plugin->hasMissingRequiredConfigurationFields()) + + @endif +
+ + Configuration + +
+ @endif
@@ -482,15 +759,13 @@ HTML; @if($data_strategy === 'polling')
- - - - + class="block w-full" type="text" name="polling_url" autofocus> + + Fetch data now +
@@ -533,6 +808,7 @@ HTML;
-
-

Send JSON payload with key merge_variables to the webhook URL. The payload - will be merged with the plugin data.

-
@elseif($data_strategy === 'static') -
-

Enter static JSON data in the Data Payload field.

-
+ Enter static JSON data in the Data Payload field. @endif
- Save + Save
@@ -627,6 +897,8 @@ HTML;
+ + @script +@endscript diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8e9c7af..8a2d72c 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -14,6 +14,8 @@ class extends Component { public array $recipes = []; public string $search = ''; public bool $isSearching = false; + public string $previewingRecipe = ''; + public array $previewData = []; public function mount(): void { @@ -125,6 +127,31 @@ class extends Component { } } + public function previewRecipe(string $recipeId): void + { + $recipe = collect($this->recipes)->firstWhere('id', $recipeId); + + if (!$recipe) { + $this->addError('preview', 'Recipe not found.'); + return; + } + + $this->previewingRecipe = $recipeId; + $this->previewData = $recipe; + + // Store scroll position for restoration later + $this->dispatch('store-scroll-position'); + } + + public function closePreview(): void + { + $this->previewingRecipe = ''; + $this->previewData = []; + + // Restore scroll position when returning to catalog + $this->dispatch('restore-scroll-position'); + } + /** * @param array> $items * @return array> @@ -218,6 +245,19 @@ class extends Component { @endif + @if($recipe['id']) + + + Preview + + + @endif + + + @if($recipe['detail_url']) @endif + + + + @if($previewingRecipe && !empty($previewData)) +
+ Preview {{ $previewData['name'] ?? 'Recipe' }} +
+ +
+ @if($previewData['screenshot_url']) +
+ Preview of {{ $previewData['name'] }} +
+ @elseif($previewData['icon_url']) +
+ {{ $previewData['name'] }} icon +

No preview image available

+
+ @else +
+ +

No preview available

+
+ @endif + + @if($previewData['author_bio']) +
+

Description

+

{{ $previewData['author_bio'] }}

+
+ @endif + + @if(data_get($previewData, 'stats.installs')) +
+

Statistics

+

+ Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} +

+
+ @endif + +
+ @if($previewData['detail_url']) + + View on TRMNL + + @endif + + + Install Recipe + + +
+
+ @endif +
+ +@script + +@endscript From a7963947f8111cfc476bf898f065f4815fbc5174 Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Sun, 30 Nov 2025 07:12:16 +0100 Subject: [PATCH 159/209] use flux design --- .../views/livewire/catalog/trmnl.blade.php | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8a2d72c..0460337 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,7 +204,7 @@ class extends Component { @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) @@ -218,9 +218,9 @@ class extends Component {
-

{{ $recipe['name'] }}

+ {{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) -

Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}

+ Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@@ -233,7 +233,7 @@ class extends Component {
@if($recipe['author_bio']) -

{{ $recipe['author_bio'] }}

+ {{ $recipe['author_bio'] }} @endif
@@ -269,7 +269,7 @@ class extends Component {
-
+
@endforeach
@endif @@ -293,30 +293,30 @@ class extends Component { {{ $previewData['name'] }} icon -

No preview image available

+ No preview image available
@else
-

No preview available

+ No preview available
@endif @if($previewData['author_bio']) -
-

Description

-

{{ $previewData['author_bio'] }}

-
+ + Description + {{ $previewData['author_bio'] }} + @endif @if(data_get($previewData, 'stats.installs')) -
-

Statistics

-

+ + Statistics + Installs: {{ data_get($previewData, 'stats.installs') }} · Forks: {{ data_get($previewData, 'stats.forks') }} -

-
+ + @endif
From f3538048d4071957deb0761ac84173f9cb79f4bc Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Sun, 30 Nov 2025 08:08:06 +0100 Subject: [PATCH 160/209] use flux design --- .../views/livewire/catalog/trmnl.blade.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 0460337..96a92c0 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,7 +204,7 @@ class extends Component { @else
@foreach($recipes as $recipe) - +
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) @@ -220,7 +220,7 @@ class extends Component {
{{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} + Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@@ -269,7 +269,7 @@ class extends Component {
-
+
@endforeach
@endif @@ -293,30 +293,30 @@ class extends Component { {{ $previewData['name'] }} icon - No preview image available + No preview image available
@else
- No preview available + No preview available
@endif @if($previewData['author_bio']) - +
Description {{ $previewData['author_bio'] }} - +
@endif @if(data_get($previewData, 'stats.installs')) - +
Statistics Installs: {{ data_get($previewData, 'stats.installs') }} · Forks: {{ data_get($previewData, 'stats.forks') }} - +
@endif
From be2bb637c9a4d728206df11d1eb78924d4c8da95 Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Sun, 30 Nov 2025 08:46:51 +0100 Subject: [PATCH 161/209] styling in line with project standards --- .../views/livewire/catalog/trmnl.blade.php | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 96a92c0..e32ae58 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,8 +204,9 @@ class extends Component { @else
@foreach($recipes as $recipe) -
-
+
+
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) {{ $recipe['name'] }} @@ -269,6 +270,7 @@ class extends Component {
+
@endforeach
@@ -303,19 +305,23 @@ class extends Component { @endif @if($previewData['author_bio']) -
- Description - {{ $previewData['author_bio'] }} +
+
+ Description + {{ $previewData['author_bio'] }} +
@endif @if(data_get($previewData, 'stats.installs')) -
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - +
+
+ Statistics + + Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} + +
@endif From d49a2d4f6c78c0fdfa8fa65e0dea95e78f7c998f Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 13 Dec 2025 13:05:35 +0100 Subject: [PATCH 162/209] fix: styling in line with project standards --- .../views/livewire/catalog/trmnl.blade.php | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index e32ae58..1b5dd50 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,7 +204,7 @@ class extends Component { @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @@ -256,17 +256,6 @@ class extends Component { @endif - - - - @if($recipe['detail_url']) - - View on TRMNL - - @endif
@@ -305,7 +294,7 @@ class extends Component { @endif @if($previewData['author_bio']) -
+
Description {{ $previewData['author_bio'] }} @@ -314,7 +303,7 @@ class extends Component { @endif @if(data_get($previewData, 'stats.installs')) -
+
Statistics From f1a9103f0dccee996e213e8a5d05f09d1837410d Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 17 Dec 2025 22:53:29 +0100 Subject: [PATCH 163/209] chore: update dependencies --- composer.lock | 290 +++++++++++++++++++++++++------------------------- 1 file changed, 147 insertions(+), 143 deletions(-) diff --git a/composer.lock b/composer.lock index d13a3e8..a2e83af 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.366.4", + "version": "3.369.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "1861cc8eede21cdaab0732fd44f43f19ddf1effd" + "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1861cc8eede21cdaab0732fd44f43f19ddf1effd", - "reference": "1861cc8eede21cdaab0732fd44f43f19ddf1effd", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", + "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", "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.366.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.1" }, - "time": "2025-12-09T19:21:22+00:00" + "time": "2025-12-22T19:13:21+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -1617,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.42.0", + "version": "v12.43.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75" + "reference": "195b893593a9298edee177c0844132ebaa02102f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75", + "url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f", + "reference": "195b893593a9298edee177c0844132ebaa02102f", "shasum": "" }, "require": { @@ -1835,7 +1835,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-09T15:51:23+00:00" + "time": "2025-12-16T18:53:08+00:00" }, { "name": "laravel/prompts", @@ -2851,16 +2851,16 @@ }, { "name": "livewire/flux", - "version": "v2.9.2", + "version": "v2.10.2", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "6572847f70a18e7cf136bb31201d4064f5c8ade1" + "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/6572847f70a18e7cf136bb31201d4064f5c8ade1", - "reference": "6572847f70a18e7cf136bb31201d4064f5c8ade1", + "url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975", + "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975", "shasum": "" }, "require": { @@ -2868,12 +2868,12 @@ "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/view": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.5.19|^4.0", + "livewire/livewire": "^3.7.3|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, "conflict": { - "livewire/blaze": "<0.1.0" + "livewire/blaze": "<1.0.0" }, "type": "library", "extra": { @@ -2911,22 +2911,22 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.9.2" + "source": "https://github.com/livewire/flux/tree/v2.10.2" }, - "time": "2025-12-04T17:09:39+00:00" + "time": "2025-12-19T02:11:45+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "214da8f3a1199a88b56ab2fe901d4a607f784805" + "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/214da8f3a1199a88b56ab2fe901d4a607f784805", - "reference": "214da8f3a1199a88b56ab2fe901d4a607f784805", + "url": "https://api.github.com/repos/livewire/livewire/zipball/a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", + "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", "shasum": "" }, "require": { @@ -2981,7 +2981,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.1" + "source": "https://github.com/livewire/livewire/tree/v3.7.3" }, "funding": [ { @@ -2989,7 +2989,7 @@ "type": "github" } ], - "time": "2025-12-03T22:41:13+00:00" + "time": "2025-12-19T02:00:29+00:00" }, { "name": "livewire/volt", @@ -3481,16 +3481,16 @@ }, { "name": "nette/utils", - "version": "v4.1.0", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", - "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { @@ -3564,9 +3564,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.0" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2025-12-01T17:49:23+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikic/php-parser", @@ -3715,23 +3715,23 @@ }, { "name": "om/icalparser", - "version": "v3.2.0", + "version": "v3.2.1", "source": { "type": "git", "url": "https://github.com/OzzyCzech/icalparser.git", - "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b" + "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/3aa0716aa9e729f08fba20390773d6dcd685169b", - "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b", + "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", + "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", "shasum": "" }, "require": { "php": ">=8.1.0" }, "require-dev": { - "nette/tester": "^2.5.6" + "nette/tester": "^2.5.7" }, "suggest": { "ext-dom": "for timezone tool" @@ -3760,9 +3760,9 @@ ], "support": { "issues": "https://github.com/OzzyCzech/icalparser/issues", - "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.0" + "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.1" }, - "time": "2025-09-08T07:04:53+00:00" + "time": "2025-12-15T06:25:09+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -3960,16 +3960,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.47", + "version": "3.0.48", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d" + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d", - "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", "shasum": "" }, "require": { @@ -4050,7 +4050,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" }, "funding": [ { @@ -4066,7 +4066,7 @@ "type": "tidelift" } ], - "time": "2025-10-06T01:07:24+00:00" + "time": "2025-12-15T11:51:42+00:00" }, { "name": "psr/clock", @@ -4482,16 +4482,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.16", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -4555,9 +4555,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2025-12-07T03:39:01+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -4681,20 +4681,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4753,22 +4753,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "spatie/browsershot", - "version": "5.1.1", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/spatie/browsershot.git", - "reference": "127c20da43d0d711ebbc64f85053f50bc147c515" + "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/browsershot/zipball/127c20da43d0d711ebbc64f85053f50bc147c515", - "reference": "127c20da43d0d711ebbc64f85053f50bc147c515", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/9bc6b8d67175810d7a399b2588c3401efe2d02a8", + "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8", "shasum": "" }, "require": { @@ -4815,7 +4815,7 @@ "webpage" ], "support": { - "source": "https://github.com/spatie/browsershot/tree/5.1.1" + "source": "https://github.com/spatie/browsershot/tree/5.2.0" }, "funding": [ { @@ -4823,7 +4823,7 @@ "type": "github" } ], - "time": "2025-11-26T09:49:20+00:00" + "time": "2025-12-22T10:02:16+00:00" }, { "name": "spatie/laravel-package-tools", @@ -5124,20 +5124,20 @@ }, { "name": "symfony/css-selector", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -5169,7 +5169,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" }, "funding": [ { @@ -5189,7 +5189,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:39:42+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -7668,23 +7668,23 @@ }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -7717,9 +7717,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", @@ -7969,16 +7969,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.15.0", + "version": "v7.16.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "272ff9d59b2ed0bd97c86c3cfe97c9784dabf786" + "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/272ff9d59b2ed0bd97c86c3cfe97c9784dabf786", - "reference": "272ff9d59b2ed0bd97c86c3cfe97c9784dabf786", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/a10878ed0fe0bbc2f57c980f7a08065338b970b6", + "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6", "shasum": "" }, "require": { @@ -7989,10 +7989,10 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.0", + "phpunit/php-code-coverage": "^12.5.1", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.4.4", + "phpunit/phpunit": "^12.5.2", "sebastian/environment": "^8.0.3", "symfony/console": "^7.3.4 || ^8.0.0", "symfony/process": "^7.3.4 || ^8.0.0" @@ -8002,9 +8002,9 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.32", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-phpunit": "^2.0.10", "phpstan/phpstan-strict-rules": "^2.0.7", "symfony/filesystem": "^7.3.2 || ^8.0.0" }, @@ -8046,7 +8046,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.15.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.16.0" }, "funding": [ { @@ -8058,7 +8058,7 @@ "type": "paypal" } ], - "time": "2025-11-30T08:08:11+00:00" + "time": "2025-12-09T20:03:26+00:00" }, { "name": "doctrine/deprecations", @@ -8457,16 +8457,16 @@ }, { "name": "larastan/larastan", - "version": "v3.8.0", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e" + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", - "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", + "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", "shasum": "" }, "require": { @@ -8480,7 +8480,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1", "php": "^8.2", - "phpstan/phpstan": "^2.1.29" + "phpstan/phpstan": "^2.1.32" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8535,7 +8535,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.8.0" + "source": "https://github.com/larastan/larastan/tree/v3.8.1" }, "funding": [ { @@ -8543,29 +8543,29 @@ "type": "github" } ], - "time": "2025-10-27T23:09:14+00:00" + "time": "2025-12-11T16:37:35+00:00" }, { "name": "laravel/boost", - "version": "v1.8.5", + "version": "v1.8.7", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e" + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", - "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", + "url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c", + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "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.4.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", "laravel/prompts": "0.1.25|^0.3.6", "laravel/roster": "^0.2.9", "php": "^8.1" @@ -8609,20 +8609,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-12-08T21:54:49+00:00" + "time": "2025-12-19T15:04:12+00:00" }, { "name": "laravel/mcp", - "version": "v0.4.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef" + "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/1c7878be3931a19768f791ddf141af29f43fb4ef", - "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef", + "url": "https://api.github.com/repos/laravel/mcp/zipball/10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", + "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", "shasum": "" }, "require": { @@ -8682,7 +8682,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-12-07T15:49:15+00:00" + "time": "2025-12-17T06:14:23+00:00" }, { "name": "laravel/pail", @@ -9198,33 +9198,33 @@ }, { "name": "pestphp/pest", - "version": "v4.1.6", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "ae419afd363299c29ad5b17e8b70d118b1068bb4" + "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/ae419afd363299c29ad5b17e8b70d118b1068bb4", - "reference": "ae419afd363299c29ad5b17e8b70d118b1068bb4", + "url": "https://api.github.com/repos/pestphp/pest/zipball/7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", + "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", "shasum": "" }, "require": { - "brianium/paratest": "^7.14.2", + "brianium/paratest": "^7.16.0", "nunomaduro/collision": "^8.8.3", "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", - "pestphp/pest-plugin-profanity": "^4.2.0", + "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.4.4", + "phpunit/phpunit": "^12.5.3", "symfony/process": "^7.4.0|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.4.4", + "phpunit/phpunit": ">12.5.3", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9232,7 +9232,7 @@ "pestphp/pest-dev-tools": "^4.0.0", "pestphp/pest-plugin-browser": "^4.1.1", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.15" + "psy/psysh": "^0.12.17" }, "bin": [ "bin/pest" @@ -9298,7 +9298,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.1.6" + "source": "https://github.com/pestphp/pest/tree/v4.2.0" }, "funding": [ { @@ -9310,7 +9310,7 @@ "type": "github" } ], - "time": "2025-11-28T12:04:48+00:00" + "time": "2025-12-15T11:49:28+00:00" }, { "name": "pestphp/pest-plugin", @@ -9900,16 +9900,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.5", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -9919,7 +9919,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -9958,9 +9958,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2025-11-27T19:50:05+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -10456,16 +10456,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.4.4", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9253ec75a672e39fcc9d85bdb61448215b8162c7" + "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9253ec75a672e39fcc9d85bdb61448215b8162c7", - "reference": "9253ec75a672e39fcc9d85bdb61448215b8162c7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6dc2e076d09960efbb0c1272aa9bc156fc80955e", + "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e", "shasum": "" }, "require": { @@ -10479,7 +10479,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.4.0", + "phpunit/php-code-coverage": "^12.5.1", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -10501,7 +10501,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.4-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -10533,7 +10533,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.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.3" }, "funding": [ { @@ -10557,7 +10557,7 @@ "type": "tidelift" } ], - "time": "2025-11-21T07:39:11+00:00" + "time": "2025-12-11T08:52:59+00:00" }, { "name": "rector/rector", @@ -11679,23 +11679,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54", + "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -11705,7 +11705,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -11721,6 +11721,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -11731,9 +11735,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.0.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2025-12-16T21:36:00+00:00" } ], "aliases": [], From 0b2b5bf25fdb578ed40c2e5dbeb0c81f87b4d631 Mon Sep 17 00:00:00 2001 From: dowjames Date: Sat, 27 Dec 2025 16:24:18 -0500 Subject: [PATCH 164/209] Update holidays-ical.blade.php *Past events are removed. *Events that started earlier but are still ongoing today remain visible. *Anything from today onward displays. --- .../views/recipes/holidays-ical.blade.php | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/resources/views/recipes/holidays-ical.blade.php b/resources/views/recipes/holidays-ical.blade.php index f5f5403..454709d 100644 --- a/resources/views/recipes/holidays-ical.blade.php +++ b/resources/views/recipes/holidays-ical.blade.php @@ -2,36 +2,46 @@ @php use Carbon\Carbon; + $today = Carbon::today(config('app.timezone')); + $events = collect($data['ical'] ?? []) ->map(function (array $event): array { - $start = null; - $end = null; - try { - $start = isset($event['DTSTART']) ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) : null; + $start = isset($event['DTSTART']) + ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) + : null; } catch (Exception $e) { $start = null; } try { - $end = isset($event['DTEND']) ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) : null; + $end = isset($event['DTEND']) + ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) + : null; } catch (Exception $e) { $end = null; } return [ - 'summary' => $event['SUMMARY'] ?? 'Untitled event', - 'location' => $event['LOCATION'] ?? null, - 'start' => $start, - 'end' => $end, + 'summary' => $event['SUMMARY'] ?? 'Untitled event', + 'location' => $event['LOCATION'] ?? '—', + 'start' => $start, + 'end' => $end, ]; }) - ->filter(fn ($event) => $event['start']) + ->filter(fn ($event) => + $event['start'] && + ( + $event['start']->greaterThanOrEqualTo($today) || + ($event['end'] && $event['end']->greaterThanOrEqualTo($today)) + ) + ) ->sortBy('start') ->take($size === 'quadrant' ? 5 : 8) ->values(); @endphp + From d81c1b99f1611ac05ab4be63fd36acec8c56ff5e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 11:39:21 +0100 Subject: [PATCH 165/209] Update download and star counts in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20bae5d..34f5c3d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](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** (Screens API, Markup), **recipes** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 30k downloads and 130+ stars, it’s the most popular community-driven BYOS. +It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 35k downloads and 150+ stars, it’s the most popular community-driven BYOS. ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) From d4b5cf99d578421c1459c90a68fccd52902599b8 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 22:05:20 +0100 Subject: [PATCH 166/209] chore: update dependencies --- composer.lock | 100 +++++++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/composer.lock b/composer.lock index a2e83af..1b578bf 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.1", + "version": "3.369.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3" + "reference": "2aa1ef195e90140d733382e4341732ce113024f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", - "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2aa1ef195e90140d733382e4341732ce113024f5", + "reference": "2aa1ef195e90140d733382e4341732ce113024f5", "shasum": "" }, "require": { @@ -85,7 +85,7 @@ "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", - "symfony/filesystem": "^v6.4.3 || ^v7.1.0 || ^v8.0.0" + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -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.369.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.4" }, - "time": "2025-12-22T19:13:21+00:00" + "time": "2025-12-29T19:07:47+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -950,24 +950,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -996,7 +996,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -1008,7 +1008,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1617,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.43.1", + "version": "v12.44.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "195b893593a9298edee177c0844132ebaa02102f" + "reference": "592bbf1c036042958332eb98e3e8131b29102f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f", - "reference": "195b893593a9298edee177c0844132ebaa02102f", + "url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33", + "reference": "592bbf1c036042958332eb98e3e8131b29102f33", "shasum": "" }, "require": { @@ -1835,7 +1835,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-16T18:53:08+00:00" + "time": "2025-12-23T15:29:43+00:00" }, { "name": "laravel/prompts", @@ -3885,16 +3885,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3944,7 +3944,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3956,7 +3956,7 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "phpseclib/phpseclib", @@ -7723,26 +7723,26 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -7791,7 +7791,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -7803,7 +7803,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -10122,16 +10122,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.1", + "version": "12.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c467c59a4f6e04b942be422844e7a6352fa01b57" + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c467c59a4f6e04b942be422844e7a6352fa01b57", - "reference": "c467c59a4f6e04b942be422844e7a6352fa01b57", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", "shasum": "" }, "require": { @@ -10146,7 +10146,7 @@ "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^2.0" + "theseer/tokenizer": "^2.0.1" }, "require-dev": { "phpunit/phpunit": "^12.5.1" @@ -10187,7 +10187,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.1" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" }, "funding": [ { @@ -10207,7 +10207,7 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:17:58+00:00" + "time": "2025-12-24T07:03:04+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10561,16 +10561,16 @@ }, { "name": "rector/rector", - "version": "2.2.14", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d" + "reference": "f7166355dcf47482f27be59169b0825995f51c7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/6d56bb0e94d4df4f57a78610550ac76ab403657d", - "reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f7166355dcf47482f27be59169b0825995f51c7d", + "reference": "f7166355dcf47482f27be59169b0825995f51c7d", "shasum": "" }, "require": { @@ -10609,7 +10609,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.14" + "source": "https://github.com/rectorphp/rector/tree/2.3.0" }, "funding": [ { @@ -10617,7 +10617,7 @@ "type": "github" } ], - "time": "2025-12-09T10:57:55+00:00" + "time": "2025-12-25T22:00:18+00:00" }, { "name": "sebastian/cli-parser", From e6d66af2984c0750a7f76e62c2529fb9bb091f20 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 22:16:29 +0100 Subject: [PATCH 167/209] fix(#135): use user configured timezone in Playlists --- app/Models/Playlist.php | 24 +++++++++---- tests/Feature/PlaylistSchedulingTest.php | 45 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 7b55a73..68fbddb 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; class Playlist extends Model { @@ -37,21 +38,32 @@ class Playlist extends Model return false; } - // Check weekday - if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) { + // Get user's timezone or fall back to app timezone + $timezone = $this->device->user->timezone ?? config('app.timezone'); + $now = now($timezone); + + // Check weekday (using timezone-aware time) + if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) { return false; } if ($this->active_from !== null && $this->active_until !== null) { - $now = now(); + // Create timezone-aware datetime objects for active_from and active_until + $activeFrom = $now->copy() + ->setTimeFrom($this->active_from) + ->timezone($timezone); + + $activeUntil = $now->copy() + ->setTimeFrom($this->active_until) + ->timezone($timezone); // Handle time ranges that span across midnight - if ($this->active_from > $this->active_until) { + if ($activeFrom > $activeUntil) { // Time range spans midnight (e.g., 09:01 to 03:58) - if ($now >= $this->active_from || $now <= $this->active_until) { + if ($now >= $activeFrom || $now <= $activeUntil) { return true; } - } elseif ($now >= $this->active_from && $now <= $this->active_until) { + } elseif ($now >= $activeFrom && $now <= $activeUntil) { return true; } diff --git a/tests/Feature/PlaylistSchedulingTest.php b/tests/Feature/PlaylistSchedulingTest.php index aea4923..18d0032 100644 --- a/tests/Feature/PlaylistSchedulingTest.php +++ b/tests/Feature/PlaylistSchedulingTest.php @@ -130,3 +130,48 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0)); expect($playlist->isActiveNow())->toBeFalse(); }); + +test('playlist scheduling respects user timezone preference', function (): void { + // Create a user with a timezone that's UTC+1 (e.g., Europe/Berlin) + // This simulates the bug where setting 00:15 doesn't work until one hour later + $user = User::factory()->create([ + 'timezone' => 'Europe/Berlin', // UTC+1 in winter, UTC+2 in summer + ]); + + $device = Device::factory()->create(['user_id' => $user->id]); + + // Create a playlist that should be active from 00:15 to 01:00 in the user's timezone + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'is_active' => true, + 'active_from' => '00:15', + 'active_until' => '01:00', + 'weekdays' => null, + ]); + + // Set test time to 00:15 in the user's timezone (Europe/Berlin) + // In January, Europe/Berlin is UTC+1, so 00:15 Berlin time = 23:15 UTC the previous day + // But Carbon::setTestNow uses UTC by default, so we need to set it to the UTC equivalent + // For January 1, 2024 at 00:15 Berlin time (UTC+1), that's December 31, 2023 at 23:15 UTC + $berlinTime = Carbon::create(2024, 1, 1, 0, 15, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + + // The playlist should be active at 00:15 in the user's timezone + // This test should pass after the fix, but will fail with the current bug + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 00:30 in user's timezone - should still be active + $berlinTime = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 01:15 in user's timezone - should NOT be active (past the end time) + $berlinTime = Carbon::create(2024, 1, 1, 1, 15, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + expect($playlist->isActiveNow())->toBeFalse(); + + // Test at 00:10 in user's timezone - should NOT be active (before start time) + $berlinTime = Carbon::create(2024, 1, 1, 0, 10, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + expect($playlist->isActiveNow())->toBeFalse(); +}); From a5cb38421ebafa4e4c34916a84102fe3c9fa3c52 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 22:24:32 +0100 Subject: [PATCH 168/209] fix(#131): invalidate cache when updating recipe markup --- app/Models/Plugin.php | 7 ++++++ tests/Feature/ImageGenerationServiceTest.php | 24 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 2915247..9132d6c 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -55,6 +55,13 @@ class Plugin extends Model $model->uuid = Str::uuid(); } }); + + static::updating(function ($model): void { + // Reset image cache when markup changes + if ($model->isDirty('render_markup')) { + $model->current_image = null; + } + }); } public function user() diff --git a/tests/Feature/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php index 603205e..07bb6a6 100644 --- a/tests/Feature/ImageGenerationServiceTest.php +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -324,6 +324,30 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void expect($plugin->current_image)->toBe('test-uuid'); }); +it('cache is reset when plugin markup changes', function (): void { + // Create a plugin with cached image + $plugin = App\Models\Plugin::factory()->create([ + 'current_image' => 'cached-uuid', + 'render_markup' => '
Original markup
', + ]); + + // Create devices with standard dimensions (cacheable) + Device::factory()->count(2)->create([ + 'width' => 800, + 'height' => 480, + 'rotate' => 0, + ]); + + // Update the plugin markup + $plugin->update([ + 'render_markup' => '
Updated markup
', + ]); + + // Assert cache was reset when markup changed + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +}); + it('determines correct image format from device model', function (): void { // Test BMP format detection $bmpModel = DeviceModel::factory()->create([ From 1298814521b1ee27e955867364d9d9fdd597e9a2 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 23:07:21 +0100 Subject: [PATCH 169/209] fix(#136): mac address matching is case senstive --- app/Models/Device.php | 8 +++ routes/api.php | 10 ++-- tests/Feature/Api/DeviceEndpointsTest.php | 69 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/app/Models/Device.php b/app/Models/Device.php index 2eeb25b..3583f48 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -20,6 +20,14 @@ class Device extends Model protected $guarded = ['id']; + /** + * Set the MAC address attribute, normalizing to uppercase. + */ + public function setMacAddressAttribute(?string $value): void + { + $this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null; + } + protected $casts = [ 'battery_notification_sent' => 'boolean', 'proxy_cloud' => 'boolean', diff --git a/routes/api.php b/routes/api.php index 9721a0f..d1dbcac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,7 +18,7 @@ use Illuminate\Support\Str; Route::get('/display', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', $mac_address) + $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) ->where('api_key', $access_token) ->first(); @@ -29,7 +29,7 @@ Route::get('/display', function (Request $request) { if ($auto_assign_user) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => $mac_address, + 'mac_address' => mb_strtoupper($mac_address ?? ''), 'api_key' => $access_token, 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -204,7 +204,7 @@ Route::get('/setup', function (Request $request) { ], 404); } - $device = Device::where('mac_address', $mac_address)->first(); + $device = Device::where('mac_address', mb_strtoupper($mac_address))->first(); if (! $device) { // Check if there's a user with assign_new_devices enabled @@ -219,7 +219,7 @@ Route::get('/setup', function (Request $request) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => $mac_address, + 'mac_address' => mb_strtoupper($mac_address), 'api_key' => Str::random(22), 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -345,7 +345,7 @@ Route::post('/display/update', function (Request $request) { Route::post('/screens', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', $mac_address) + $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) ->where('api_key', $access_token) ->first(); diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 726f313..aff6758 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -954,3 +954,72 @@ test('setup endpoint handles non-existent device model gracefully', function (): expect($device)->not->toBeNull() ->and($device->device_model_id)->toBeNull(); }); + +test('setup endpoint matches MAC address case-insensitively', function (): void { + // Create device with lowercase MAC address + $device = Device::factory()->create([ + 'mac_address' => 'a1:b2:c3:d4:e5:f6', + 'api_key' => 'test-api-key', + 'friendly_id' => 'test-device', + ]); + + // Request with uppercase MAC address should still match + $response = $this->withHeaders([ + 'id' => 'A1:B2:C3:D4:E5:F6', + ])->get('/api/setup'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'api_key' => 'test-api-key', + 'friendly_id' => 'test-device', + 'message' => 'Welcome to TRMNL BYOS', + ]); +}); + +test('display endpoint matches MAC address case-insensitively', function (): void { + // Create device with lowercase MAC address + $device = Device::factory()->create([ + 'mac_address' => 'a1:b2:c3:d4:e5:f6', + 'api_key' => 'test-api-key', + 'current_screen_image' => 'test-image', + ]); + + // Request with uppercase MAC address should still match + $response = $this->withHeaders([ + 'id' => 'A1:B2:C3:D4:E5:F6', + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk() + ->assertJson([ + 'status' => '0', + 'filename' => 'test-image.bmp', + ]); +}); + +test('screens endpoint matches MAC address case-insensitively', function (): void { + Queue::fake(); + + // Create device with uppercase MAC address + $device = Device::factory()->create([ + 'mac_address' => 'A1:B2:C3:D4:E5:F6', + 'api_key' => 'test-api-key', + ]); + + // Request with lowercase MAC address should still match + $response = $this->withHeaders([ + 'id' => 'a1:b2:c3:d4:e5:f6', + 'access-token' => $device->api_key, + ])->post('/api/screens', [ + 'image' => [ + 'content' => '
Test content
', + ], + ]); + + $response->assertOk(); + Queue::assertPushed(GenerateScreenJob::class); +}); From 3cdc2678093b6efb260ae434260dbb699396b99e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 23:08:52 +0100 Subject: [PATCH 170/209] chore: pint --- app/Models/Playlist.php | 1 - app/Services/Plugin/Parsers/IcalResponseParser.php | 2 +- database/factories/DevicePaletteFactory.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 68fbddb..b4daf5e 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Support\Carbon; class Playlist extends Model { diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php index f87e71c..c8f2b58 100644 --- a/app/Services/Plugin/Parsers/IcalResponseParser.php +++ b/app/Services/Plugin/Parsers/IcalResponseParser.php @@ -34,7 +34,7 @@ class IcalResponseParser implements ResponseParser $filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool { $startDate = $this->asCarbon($event['DTSTART'] ?? null); - if (!$startDate instanceof \Carbon\Carbon) { + if (! $startDate instanceof Carbon) { return false; } diff --git a/database/factories/DevicePaletteFactory.php b/database/factories/DevicePaletteFactory.php index a672873..1d7ed2d 100644 --- a/database/factories/DevicePaletteFactory.php +++ b/database/factories/DevicePaletteFactory.php @@ -20,7 +20,7 @@ class DevicePaletteFactory extends Factory public function definition(): array { return [ - 'id' => 'test-' . $this->faker->unique()->slug(), + 'id' => 'test-'.$this->faker->unique()->slug(), 'name' => $this->faker->words(3, true), 'grays' => $this->faker->randomElement([2, 4, 16, 256]), 'colors' => $this->faker->optional()->passthrough([ From 50853728bcb785244bdf1748647e63c66141e6d4 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 09:43:05 +0100 Subject: [PATCH 171/209] refactor(#120): remove unnecessary js, improve cache handling --- .../views/livewire/catalog/index.blade.php | 150 +++------ .../views/livewire/catalog/trmnl.blade.php | 303 ++++++++---------- tests/Feature/Livewire/Catalog/IndexTest.php | 83 +++++ tests/Feature/Volt/CatalogTrmnlTest.php | 62 ++++ 4 files changed, 324 insertions(+), 274 deletions(-) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 83a34fc..3a24b7e 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -1,20 +1,24 @@ filter(function ($plugin) use ($currentVersion) { // Check if Laravel compatibility is true - if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { + if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { return false; } @@ -81,8 +85,9 @@ class extends Component { }) ->sortBy('name') ->toArray(); - } catch (\Exception $e) { - Log::error('Failed to load catalog from URL: ' . $e->getMessage()); + } catch (Exception $e) { + Log::error('Failed to load catalog from URL: '.$e->getMessage()); + return []; } }); @@ -94,8 +99,9 @@ class extends Component { $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (!$plugin || !$plugin['zip_url']) { + if (! $plugin || ! $plugin['zip_url']) { $this->addError('installation', 'Plugin not found or no download URL available.'); + return; } @@ -113,8 +119,8 @@ class extends Component { $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); - } catch (\Exception $e) { - $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } catch (Exception $e) { + $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); } finally { $this->installingPlugin = ''; } @@ -124,32 +130,27 @@ class extends Component { { $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (!$plugin) { + if (! $plugin) { $this->addError('preview', 'Plugin not found.'); + return; } $this->previewingPlugin = $pluginId; $this->previewData = $plugin; - - // Store scroll position for restoration later - $this->dispatch('store-scroll-position'); } public function closePreview(): void { $this->previewingPlugin = ''; $this->previewData = []; - - // Restore scroll position when returning to catalog - $this->dispatch('restore-scroll-position'); } }; ?>
@if(empty($catalogPlugins))
- + No plugins available Catalog is empty
@@ -165,25 +166,25 @@ class extends Component { @if($plugin['logo_url']) {{ $plugin['name'] }} @else -
- +
+
@endif
-

{{ $plugin['name'] }}

+ {{ $plugin['name'] }} @if ($plugin['github']) -

by {{ $plugin['github'] }}

+ by {{ $plugin['github'] }} @endif
@if($plugin['license']) - {{ $plugin['license'] }} + {{ $plugin['license'] }} @endif @if($plugin['repo_url']) - + @endif @@ -191,7 +192,7 @@ class extends Component {
@if($plugin['description']) -

{{ $plugin['description'] }}

+ {{ $plugin['description'] }} @endif
@@ -201,14 +202,16 @@ class extends Component { Install - - - Preview - - + @if($plugin['screenshot_url']) + + + Preview + + + @endif @@ -236,34 +239,20 @@ class extends Component {
- @if($previewData['screenshot_url']) -
- Preview of {{ $previewData['name'] }} -
- @elseif($previewData['logo_url']) -
- {{ $previewData['name'] }} logo -

No preview image available

-
- @else -
- -

No preview available

-
- @endif +
+ Preview of {{ $previewData['name'] }} +
@if($previewData['description']) -
-

Description

-

{{ $previewData['description'] }}

+
+ Description + {{ $previewData['description'] }}
@endif -
+
- -@script - -@endscript diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 1b5dd50..dd97e0e 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -1,20 +1,24 @@ 'newest', ]); - if (!$response->successful()) { - throw new \RuntimeException('Failed to fetch TRMNL recipes'); + 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()); + } catch (Throwable $e) { + Log::error('TRMNL catalog load error: '.$e->getMessage()); $this->recipes = []; } } @@ -62,23 +67,24 @@ class extends Component { { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_' . md5($term); + $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'); + 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()); + } catch (Throwable $e) { + Log::error('TRMNL catalog search error: '.$e->getMessage()); $this->recipes = []; } finally { $this->isSearching = false; @@ -87,13 +93,14 @@ class extends Component { public function updatedSearch(): void { - $term = trim($this->search); + $term = mb_trim($this->search); if ($term === '') { $this->loadNewest(); + return; } - if (strlen($term) < 2) { + if (mb_strlen($term) < 2) { // Require at least 2 chars to avoid noisy calls return; } @@ -121,62 +128,78 @@ class extends Component { $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()); + } catch (Exception $e) { + Log::error('Plugin installation failed: '.$e->getMessage()); + $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); } } public function previewRecipe(string $recipeId): void { - $recipe = collect($this->recipes)->firstWhere('id', $recipeId); - - if (!$recipe) { - $this->addError('preview', 'Recipe not found.'); - return; - } - $this->previewingRecipe = $recipeId; - $this->previewData = $recipe; - - // Store scroll position for restoration later - $this->dispatch('store-scroll-position'); - } - - public function closePreview(): void - { - $this->previewingRecipe = ''; $this->previewData = []; - // Restore scroll position when returning to catalog - $this->dispatch('restore-scroll-position'); + try { + $response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json"); + + if ($response->successful()) { + $item = $response->json()['data'] ?? []; + $this->previewData = $this->mapRecipe($item); + } else { + // Fallback to searching for the specific recipe if single endpoint doesn't exist + $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ + 'search' => $recipeId, + ]); + + if ($response->successful()) { + $data = $response->json()['data'] ?? []; + $item = collect($data)->firstWhere('id', $recipeId); + if ($item) { + $this->previewData = $this->mapRecipe($item); + } + } + } + } catch (Throwable $e) { + Log::error('TRMNL catalog preview fetch error: '.$e->getMessage()); + } + + if (empty($this->previewData)) { + $this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? []; + } } /** - * @param array> $items + * @param array> $items * @return array> */ 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, - ]; - }) + ->map(fn (array $item) => $this->mapRecipe($item)) ->toArray(); } + + /** + * @param array $item + * @return array + */ + private function mapRecipe(array $item): array + { + 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, + ]; + } }; ?>
@@ -188,7 +211,7 @@ class extends Component { icon="magnifying-glass" />
- Newest + Newest
@error('installation') @@ -197,7 +220,7 @@ class extends Component { @if(empty($recipes))
- + No recipes found Try a different search term
@@ -211,8 +234,8 @@ class extends Component { @if($thumb) {{ $recipe['name'] }} @else -
- +
+
@endif @@ -221,12 +244,12 @@ class extends Component {
{{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} + Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@if($recipe['detail_url']) - + @endif @@ -246,7 +269,7 @@ class extends Component { @endif - @if($recipe['id']) + @if($recipe['id'] && ($recipe['screenshot_url'] ?? null)) - @if($previewingRecipe && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Recipe' }} +
+
+ + Fetching recipe details...
+
-
- @if($previewData['screenshot_url']) +
+ @if($previewingRecipe && !empty($previewData)) +
+ Preview {{ $previewData['name'] ?? 'Recipe' }} +
+ +
Preview of {{ $previewData['name'] }}
- @elseif($previewData['icon_url']) -
- {{ $previewData['name'] }} icon - No preview image available -
- @else -
- - No preview available -
- @endif - @if($previewData['author_bio']) -
-
- Description - {{ $previewData['author_bio'] }} + @if($previewData['author_bio']) +
+
+ Description + {{ $previewData['author_bio'] }} +
-
- @endif - - @if(data_get($previewData, 'stats.installs')) -
-
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - -
-
- @endif - -
- @if($previewData['detail_url']) - - View on TRMNL - @endif - - - Install Recipe - - + + @if(data_get($previewData, 'stats.installs')) +
+
+ Statistics + + Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} + +
+
+ @endif + +
+ @if($previewData['detail_url']) + + View on TRMNL + + @endif + + + Install Recipe + + +
-
- @endif + @endif +
- -@script - -@endscript diff --git a/tests/Feature/Livewire/Catalog/IndexTest.php b/tests/Feature/Livewire/Catalog/IndexTest.php index 22ab4b6..1b2efba 100644 --- a/tests/Feature/Livewire/Catalog/IndexTest.php +++ b/tests/Feature/Livewire/Catalog/IndexTest.php @@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void { $component->assertSee('testuser'); $component->assertSee('A test plugin'); $component->assertSee('MIT'); + $component->assertSee('Preview'); +}); + +it('hides preview button when screenshot_url is missing', function (): void { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL without screenshot_url + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin Without Screenshot', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'byos' => [ + 'byos_laravel' => [ + 'compatibility' => true, + ], + ], + 'logo_url' => 'https://example.com/logo.png', + 'screenshot_url' => null, + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.index') + ->assertSee('Test Plugin Without Screenshot') + ->assertDontSeeHtml('variant="subtle" icon="eye"'); }); it('shows error when plugin not found', function (): void { @@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void { $component->assertHasErrors(); }); + +it('can preview a plugin', function (): void { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin description', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'byos' => [ + 'byos_laravel' => [ + 'compatibility' => true, + ], + ], + 'logo_url' => 'https://example.com/logo.png', + 'screenshot_url' => 'https://example.com/screenshot.png', + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.index') + ->assertSee('Test Plugin') + ->call('previewPlugin', 'test-plugin') + ->assertSet('previewingPlugin', 'test-plugin') + ->assertSet('previewData.name', 'Test Plugin') + ->assertSee('Preview Test Plugin') + ->assertSee('A test plugin description'); +}); diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index ba1b722..4c338df 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -28,9 +28,33 @@ it('loads newest TRMNL recipes on mount', function (): void { Volt::test('catalog.trmnl') ->assertSee('Weather Chum') ->assertSee('Install') + ->assertDontSeeHtml('variant="subtle" icon="eye"') ->assertSee('Installs: 10'); }); +it('shows preview button when screenshot_url is provided', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/screenshot.png', + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->assertSee('Preview'); +}); + it('searches TRMNL recipes when search term is provided', function (): void { Http::fake([ // First call (mount -> newest) @@ -152,3 +176,41 @@ it('shows error when plugin installation fails', function (): void { ->call('installPlugin', '123') ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid }); + +it('previews a recipe with async fetch', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/old.png', + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + 'usetrmnl.com/recipes/123.json' => Http::response([ + 'data' => [ + 'id' => 123, + 'name' => 'Weather Chum Updated', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/new.png', + 'author_bio' => ['description' => 'New bio'], + 'stats' => ['installs' => 11, 'forks' => 3], + ], + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->call('previewRecipe', '123') + ->assertSet('previewingRecipe', '123') + ->assertSet('previewData.name', 'Weather Chum Updated') + ->assertSet('previewData.screenshot_url', 'https://example.com/new.png') + ->assertSee('Preview Weather Chum Updated') + ->assertSee('New bio'); +}); From 3250bb0402730e3195d4415b6904b7371199651c Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 10:28:41 +0100 Subject: [PATCH 172/209] fix: install loading spinner not shown after catalog search --- resources/views/livewire/catalog/index.blade.php | 2 +- resources/views/livewire/catalog/trmnl.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 3a24b7e..7257ab0 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -161,7 +161,7 @@ class extends Component @enderror @foreach($catalogPlugins as $plugin) -
+
@if($plugin['logo_url']) {{ $plugin['name'] }} diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index dd97e0e..8efd6b5 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -227,7 +227,7 @@ class extends Component @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) From 7f97114f6e521355bafce523dd867fc761a2b644 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 10:52:54 +0100 Subject: [PATCH 173/209] feat: add trmnl catalog paginator --- .../views/livewire/catalog/trmnl.blade.php | 77 +++++++++++++++---- tests/Feature/Volt/CatalogTrmnlTest.php | 70 +++++++++++++++++ 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8efd6b5..9ecad1a 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -13,6 +13,10 @@ class extends Component { public array $recipes = []; + public int $page = 1; + + public bool $hasMore = false; + public string $search = ''; public bool $isSearching = false; @@ -43,23 +47,36 @@ class extends Component private function loadNewest(): void { try { - $this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { + $cacheKey = 'trmnl_recipes_newest_page_'.$this->page; + $response = Cache::remember($cacheKey, 43200, function () { $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ 'sort-by' => 'newest', + 'page' => $this->page, ]); if (! $response->successful()) { throw new RuntimeException('Failed to fetch TRMNL recipes'); } - $json = $response->json(); - $data = $json['data'] ?? []; - - return $this->mapRecipes($data); + return $response->json(); }); + + $data = $response['data'] ?? []; + $mapped = $this->mapRecipes($data); + + if ($this->page === 1) { + $this->recipes = $mapped; + } else { + $this->recipes = array_merge($this->recipes, $mapped); + } + + $this->hasMore = ! empty($response['next_page_url']); } catch (Throwable $e) { Log::error('TRMNL catalog load error: '.$e->getMessage()); - $this->recipes = []; + if ($this->page === 1) { + $this->recipes = []; + } + $this->hasMore = false; } } @@ -67,32 +84,57 @@ class extends Component { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_'.md5($term); - $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { + $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page; + $response = Cache::remember($cacheKey, 300, function () use ($term) { $response = Http::get('https://usetrmnl.com/recipes.json', [ 'search' => $term, 'sort-by' => 'newest', + 'page' => $this->page, ]); if (! $response->successful()) { throw new RuntimeException('Failed to search TRMNL recipes'); } - $json = $response->json(); - $data = $json['data'] ?? []; - - return $this->mapRecipes($data); + return $response->json(); }); + + $data = $response['data'] ?? []; + $mapped = $this->mapRecipes($data); + + if ($this->page === 1) { + $this->recipes = $mapped; + } else { + $this->recipes = array_merge($this->recipes, $mapped); + } + + $this->hasMore = ! empty($response['next_page_url']); } catch (Throwable $e) { Log::error('TRMNL catalog search error: '.$e->getMessage()); - $this->recipes = []; + if ($this->page === 1) { + $this->recipes = []; + } + $this->hasMore = false; } finally { $this->isSearching = false; } } + public function loadMore(): void + { + $this->page++; + + $term = mb_trim($this->search); + if ($term === '' || mb_strlen($term) < 2) { + $this->loadNewest(); + } else { + $this->searchRecipes($term); + } + } + public function updatedSearch(): void { + $this->page = 1; $term = mb_trim($this->search); if ($term === '') { $this->loadNewest(); @@ -286,6 +328,15 @@ class extends Component
@endforeach
+ + @if($hasMore) +
+ + Load next page + Loading... + +
+ @endif @endif diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index 4c338df..a80c63a 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -214,3 +214,73 @@ it('previews a recipe with async fetch', function (): void { ->assertSee('Preview Weather Chum Updated') ->assertSee('New bio'); }); + +it('supports pagination and loading more recipes', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'name' => 'Recipe Page 1', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 1, 'forks' => 0], + ], + ], + 'next_page_url' => '/recipes.json?page=2', + ], 200), + 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([ + 'data' => [ + [ + 'id' => 2, + 'name' => 'Recipe Page 2', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 2, 'forks' => 0], + ], + ], + 'next_page_url' => null, + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Recipe Page 1') + ->assertDontSee('Recipe Page 2') + ->assertSee('Load next page') + ->call('loadMore') + ->assertSee('Recipe Page 1') + ->assertSee('Recipe Page 2') + ->assertDontSee('Load next page'); +}); + +it('resets pagination when search term changes', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence() + ->push([ + 'data' => [['id' => 1, 'name' => 'Initial 1']], + 'next_page_url' => '/recipes.json?page=2', + ]) + ->push([ + 'data' => [['id' => 3, 'name' => 'Initial 1 Again']], + 'next_page_url' => null, + ]), + 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([ + 'data' => [['id' => 2, 'name' => 'Weather Result']], + 'next_page_url' => null, + ]), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Initial 1') + ->call('loadMore') + ->set('search', 'weather') + ->assertSee('Weather Result') + ->assertDontSee('Initial 1') + ->assertSet('page', 1); +}); From 265972ac24930cfaebb05d1a85344d5e2437ade7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 14:09:31 +0100 Subject: [PATCH 174/209] fix(#130): server error on faulty recipes --- app/Services/ImageGenerationService.php | 25 ++- .../views/default-screens/error.blade.php | 23 +++ routes/api.php | 24 ++- tests/Feature/Api/DeviceEndpointsTest.php | 161 ++++++++++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 resources/views/default-screens/error.blade.php diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index cdfc9d2..fcd5f12 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -311,7 +311,7 @@ class ImageGenerationService public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep'])) { + if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { return null; } @@ -345,10 +345,10 @@ class ImageGenerationService /** * Generate a default screen image from Blade template */ - public static function generateDefaultScreenImage(Device $device, string $imageType): string + public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep'])) { + if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { throw new InvalidArgumentException("Invalid image type: {$imageType}"); } @@ -365,7 +365,7 @@ class ImageGenerationService $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); // Generate HTML from Blade template - $html = self::generateDefaultScreenHtml($device, $imageType); + $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName); // Create custom Browsershot instance if using AWS Lambda $browsershotInstance = null; @@ -445,12 +445,13 @@ class ImageGenerationService /** * Generate HTML from Blade template for default screens */ - private static function generateDefaultScreenHtml(Device $device, string $imageType): string + private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string { // Map image type to template name $templateName = match ($imageType) { 'setup-logo' => 'default-screens.setup', 'sleep' => 'default-screens.sleep', + 'error' => 'default-screens.error', default => throw new InvalidArgumentException("Invalid image type: {$imageType}") }; @@ -461,14 +462,22 @@ class ImageGenerationService $scaleLevel = $device->scaleLevel(); $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode - // Render the Blade template - return view($templateName, [ + // Build view data + $viewData = [ 'noBleed' => false, 'darkMode' => $darkMode, 'deviceVariant' => $deviceVariant, 'deviceOrientation' => $deviceOrientation, 'colorDepth' => $colorDepth, 'scaleLevel' => $scaleLevel, - ])->render(); + ]; + + // Add plugin name for error screens + if ($imageType === 'error' && $pluginName !== null) { + $viewData['pluginName'] = $pluginName; + } + + // Render the Blade template + return view($templateName, $viewData)->render(); } } diff --git a/resources/views/default-screens/error.blade.php b/resources/views/default-screens/error.blade.php new file mode 100644 index 0000000..be8063a --- /dev/null +++ b/resources/views/default-screens/error.blade.php @@ -0,0 +1,23 @@ +@props([ + 'noBleed' => false, + 'darkMode' => false, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '1bit', + 'scaleLevel' => null, + 'pluginName' => 'Recipe', +]) + + + + + + Error on {{ $pluginName }} + Unable to render content. Please check server logs. + + + + + diff --git a/routes/api.php b/routes/api.php index d1dbcac..b1d08b4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) { // Check and update stale data if needed if ($plugin->isDataStale() || $plugin->current_image === null) { $plugin->updateDataPayload(); - $markup = $plugin->render(device: $device); + try { + $markup = $plugin->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + } catch (Exception $e) { + Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); + // Generate error display + $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name); + $device->update(['current_screen_image' => $errorImageUuid]); + } } $plugin->refresh(); @@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) { } } - $markup = $playlistItem->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, null, $markup); + try { + $markup = $playlistItem->render(device: $device); + GenerateScreenJob::dispatchSync($device->id, null, $markup); + } catch (Exception $e) { + Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage()); + // For mashups, show error for the first plugin or a generic error + $firstPlugin = $plugins->first(); + $pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe'; + $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName); + $device->update(['current_screen_image' => $errorImageUuid]); + } $device->refresh(); diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index aff6758..2925a5e 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -7,6 +7,7 @@ use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; use App\Models\User; +use App\Services\ImageGenerationService; use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -1023,3 +1024,163 @@ test('screens endpoint matches MAC address case-insensitively', function (): voi $response->assertOk(); Queue::assertPushed(GenerateScreenJob::class); }); + +test('display endpoint handles plugin rendering errors gracefully', function (): void { + TrmnlPipeline::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create a plugin with Blade markup that will cause an exception when accessing data[0] + // when data is not an array or doesn't have index 0 + $plugin = Plugin::factory()->create([ + 'name' => 'Broken Recipe', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access + 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail if data[0] doesn't exist + 'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail + 'data_payload_updated_at' => now()->subMinutes(2), // Make it stale + 'current_image' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'test_playlist', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $plugin->id, + 'order' => 1, + 'is_active' => true, + 'last_displayed_at' => null, + ]); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify error screen was generated and set on device + $device->refresh(); + expect($device->current_screen_image)->not->toBeNull(); + + // Verify the error image exists + $errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png"); + // The TrmnlPipeline is faked, so we just verify the UUID was set + expect($device->current_screen_image)->toBeString(); +}); + +test('display endpoint handles mashup rendering errors gracefully', function (): void { + TrmnlPipeline::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create plugins for mashup, one with invalid markup + $plugin1 = Plugin::factory()->create([ + 'name' => 'Working Plugin', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + 'data_payload_updated_at' => now()->subMinutes(2), + 'current_image' => null, + ]); + + $plugin2 = Plugin::factory()->create([ + 'name' => 'Broken Plugin', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access + 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail + 'data_payload' => ['error' => 'Failed to fetch data'], + 'data_payload_updated_at' => now()->subMinutes(2), + 'current_image' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'test_playlist', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + // Create mashup playlist item + $playlistItem = PlaylistItem::createMashup( + $playlist, + '1Lx1R', + [$plugin1->id, $plugin2->id], + 'Test Mashup', + 1 + ); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify error screen was generated and set on device + $device->refresh(); + expect($device->current_screen_image)->not->toBeNull(); + + // Verify the error image UUID was set + expect($device->current_screen_image)->toBeString(); +}); + +test('generateDefaultScreenImage creates error screen with plugin name', function (): void { + TrmnlPipeline::fake(); + Storage::fake('public'); + Storage::disk('public')->makeDirectory('/images/generated'); + + $device = Device::factory()->create(); + + $errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name'); + + expect($errorUuid)->not->toBeEmpty(); + + // Verify the error image path would be created + $errorPath = "images/generated/{$errorUuid}.png"; + // Since TrmnlPipeline is faked, we just verify the UUID was generated + expect($errorUuid)->toBeString(); +}); + +test('generateDefaultScreenImage throws exception for invalid error image type', function (): void { + $device = Device::factory()->create(); + + expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type')) + ->toThrow(InvalidArgumentException::class); +}); + +test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void { + $device = new Device(); + $device->deviceModel = null; + + $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error'); + expect($result)->toBeNull(); +}); From 4451361f1547e577e2265323ffb238f338afc43b Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 31 Dec 2025 10:20:03 +0100 Subject: [PATCH 175/209] chore: update dependencies --- composer.lock | 164 +++++++++++++++++++++++++------------------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/composer.lock b/composer.lock index 1b578bf..199fa86 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.4", + "version": "3.369.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2aa1ef195e90140d733382e4341732ce113024f5" + "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2aa1ef195e90140d733382e4341732ce113024f5", - "reference": "2aa1ef195e90140d733382e4341732ce113024f5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", + "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", "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.369.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.5" }, - "time": "2025-12-29T19:07:47+00:00" + "time": "2025-12-30T19:07:16+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -3142,16 +3142,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -3169,7 +3169,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -3229,7 +3229,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -3241,7 +3241,7 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "mtdowling/jmespath.php", @@ -5026,16 +5026,16 @@ }, { "name": "symfony/console", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "shasum": "" }, "require": { @@ -5100,7 +5100,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.1" + "source": "https://github.com/symfony/console/tree/v7.4.3" }, "funding": [ { @@ -5120,7 +5120,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:23:39+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/css-selector", @@ -5573,16 +5573,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", "shasum": "" }, "require": { @@ -5617,7 +5617,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v7.4.3" }, "funding": [ { @@ -5637,20 +5637,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", "shasum": "" }, "require": { @@ -5699,7 +5699,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" }, "funding": [ { @@ -5719,20 +5719,20 @@ "type": "tidelift" } ], - "time": "2025-12-07T11:13:10+00:00" + "time": "2025-12-23T14:23:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.2", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" + "reference": "885211d4bed3f857b8c964011923528a55702aa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", + "reference": "885211d4bed3f857b8c964011923528a55702aa5", "shasum": "" }, "require": { @@ -5818,7 +5818,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" }, "funding": [ { @@ -5838,20 +5838,20 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:43:37+00:00" + "time": "2025-12-31T08:43:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", "shasum": "" }, "require": { @@ -5902,7 +5902,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.0" + "source": "https://github.com/symfony/mailer/tree/v7.4.3" }, "funding": [ { @@ -5922,7 +5922,7 @@ "type": "tidelift" } ], - "time": "2025-11-21T15:26:00+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/mime", @@ -6844,16 +6844,16 @@ }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "shasum": "" }, "require": { @@ -6885,7 +6885,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v7.4.3" }, "funding": [ { @@ -6905,20 +6905,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/routing", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "4720254cb2644a0b876233d258a32bf017330db7" + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", - "reference": "4720254cb2644a0b876233d258a32bf017330db7", + "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", "shasum": "" }, "require": { @@ -6970,7 +6970,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.0" + "source": "https://github.com/symfony/routing/tree/v7.4.3" }, "funding": [ { @@ -6990,7 +6990,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/service-contracts", @@ -7171,16 +7171,16 @@ }, { "name": "symfony/translation", - "version": "v8.0.1", + "version": "v8.0.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca" + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca", + "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", "shasum": "" }, "require": { @@ -7240,7 +7240,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.1" + "source": "https://github.com/symfony/translation/tree/v8.0.3" }, "funding": [ { @@ -7260,7 +7260,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2025-12-21T10:59:45+00:00" }, { "name": "symfony/translation-contracts", @@ -7424,16 +7424,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { @@ -7487,7 +7487,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -7507,7 +7507,7 @@ "type": "tidelift" } ], - "time": "2025-10-27T20:36:44+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { "name": "symfony/var-exporter", @@ -9198,16 +9198,16 @@ }, { "name": "pestphp/pest", - "version": "v4.2.0", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd" + "reference": "e86bec3e68f1874c112ca782fb9db1333f3fe7ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", - "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", + "url": "https://api.github.com/repos/pestphp/pest/zipball/e86bec3e68f1874c112ca782fb9db1333f3fe7ab", + "reference": "e86bec3e68f1874c112ca782fb9db1333f3fe7ab", "shasum": "" }, "require": { @@ -9219,12 +9219,12 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.3", + "phpunit/phpunit": "^12.5.4", "symfony/process": "^7.4.0|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.3", + "phpunit/phpunit": ">12.5.4", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9232,7 +9232,7 @@ "pestphp/pest-dev-tools": "^4.0.0", "pestphp/pest-plugin-browser": "^4.1.1", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.17" + "psy/psysh": "^0.12.18" }, "bin": [ "bin/pest" @@ -9298,7 +9298,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.2.0" + "source": "https://github.com/pestphp/pest/tree/v4.3.0" }, "funding": [ { @@ -9310,7 +9310,7 @@ "type": "github" } ], - "time": "2025-12-15T11:49:28+00:00" + "time": "2025-12-30T19:48:33+00:00" }, { "name": "pestphp/pest-plugin", @@ -10456,16 +10456,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.3", + "version": "12.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e" + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6dc2e076d09960efbb0c1272aa9bc156fc80955e", - "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", "shasum": "" }, "require": { @@ -10533,7 +10533,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.5.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" }, "funding": [ { @@ -10557,7 +10557,7 @@ "type": "tidelift" } ], - "time": "2025-12-11T08:52:59+00:00" + "time": "2025-12-15T06:05:34+00:00" }, { "name": "rector/rector", From 838b4fd33b223be1e6991f934a1b5e36dfbd38eb Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 2 Jan 2026 22:20:42 +0100 Subject: [PATCH 176/209] feat: bump to Design Framework 2.1 --- composer.json | 3 ++- composer.lock | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 2281415..f801679 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "keywords": [ "trmnl", "trmnl-server", + "trmnl-byos", "laravel" ], "license": "MIT", @@ -14,7 +15,7 @@ "ext-imagick": "*", "ext-simplexml": "*", "ext-zip": "*", - "bnussbau/laravel-trmnl-blade": "2.0.*", + "bnussbau/laravel-trmnl-blade": "2.1.*", "bnussbau/trmnl-pipeline-php": "^0.6.0", "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", diff --git a/composer.lock b/composer.lock index 199fa86..b9e0495 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3e4c22c016c04e49512b5fcd20983baa", + "content-hash": "4d958d48655a5ad9e3de6b4a9fb52b0a", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.5", + "version": "3.369.6", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe" + "reference": "b1e1846a4b6593b6916764d86fc0890a31727370" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", - "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b1e1846a4b6593b6916764d86fc0890a31727370", + "reference": "b1e1846a4b6593b6916764d86fc0890a31727370", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "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.369.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.6" }, - "time": "2025-12-30T19:07:16+00:00" + "time": "2026-01-02T19:09:23+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "2.0.1", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e" + "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/59343cfa9c41c7c7f9285b366584cde92bf1294e", - "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/1e1cabfead00118d7a80c86ac6108aece2989bc7", + "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7", "shasum": "" }, "require": { @@ -223,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.0.1" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.0" }, "funding": [ { @@ -239,7 +239,7 @@ "type": "github" } ], - "time": "2025-09-22T12:12:00+00:00" + "time": "2026-01-02T20:38:51+00:00" }, { "name": "bnussbau/trmnl-pipeline-php", From 9019561bb3b7057e8bbffd4979255e7e8db4ead7 Mon Sep 17 00:00:00 2001 From: jerremyng Date: Sat, 3 Jan 2026 17:25:37 +0000 Subject: [PATCH 177/209] add zip dependencies to dev-container dockerfiles --- .devcontainer/cli/Dockerfile | 5 +++-- .devcontainer/fpm/Dockerfile | 5 +++-- package-lock.json | 13 ++----------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile index 0317097..ab13330 100644 --- a/.devcontainer/cli/Dockerfile +++ b/.devcontainer/cli/Dockerfile @@ -9,7 +9,8 @@ RUN apk add --no-cache composer # Add Chromium and Image Magick for puppeteer. RUN apk add --no-cache \ imagemagick-dev \ - chromium + chromium \ + libzip-dev ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 # Install PHP extensions -RUN docker-php-ext-install imagick +RUN docker-php-ext-install imagick zip # Composer uses its php binary, but we want it to use the container's one RUN rm -f /usr/bin/php84 diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile index 8c585c8..3e658b6 100644 --- a/.devcontainer/fpm/Dockerfile +++ b/.devcontainer/fpm/Dockerfile @@ -14,7 +14,8 @@ RUN apk add --no-cache \ nodejs \ npm \ imagemagick-dev \ - chromium + chromium \ + libzip-dev ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 # Install PHP extensions -RUN docker-php-ext-install imagick +RUN docker-php-ext-install imagick zip RUN rm -f /usr/bin/php84 RUN ln -s /usr/local/bin/php /usr/bin/php84 diff --git a/package-lock.json b/package-lock.json index 8411d6a..e722432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "laravel-trmnl-server", + "name": "laravel", "lockfileVersion": 3, "requires": true, "packages": { @@ -156,7 +156,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -193,7 +192,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -215,7 +213,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -718,7 +715,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -1614,7 +1610,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1898,8 +1893,7 @@ "version": "0.0.1521046", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2951,7 +2945,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2978,7 +2971,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3429,7 +3421,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", From 46e792bc6d79d3acc2bb62471aef5615153fe5ca Mon Sep 17 00:00:00 2001 From: jerremyng Date: Sun, 4 Jan 2026 08:15:09 +0000 Subject: [PATCH 178/209] add HTML rendering on config modal with tests Models/Plugin will now sanitize "description" and "help text" before loading. This allows HTML from these fields to be rendered safely. Sanitization is done using Purify library for completeness (new dependency). A test suite of simple xss attacks is also added. --- app/Models/Plugin.php | 24 ++ composer.json | 1 + composer.lock | 129 ++++++- resources/css/app.css | 4 + .../views/livewire/plugins/recipe.blade.php | 356 ++++++++++-------- tests/Unit/Models/PluginTest.php | 114 +++++- 6 files changed, 470 insertions(+), 158 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 9132d6c..6f5d88b 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -62,6 +62,11 @@ class Plugin extends Model $model->current_image = null; } }); + + // Sanitize configuration template on save + static::saving(function ($model): void { + $model->sanitizeTemplate(); + }); } public function user() @@ -69,6 +74,25 @@ class Plugin extends Model return $this->belongsTo(User::class); } + // sanitize configuration template descriptions and help texts (since they allow HTML rendering) + protected function sanitizeTemplate(): void + { + $template = $this->configuration_template; + + if (isset($template['custom_fields']) && is_array($template['custom_fields'])) { + foreach ($template['custom_fields'] as &$field) { + if (isset($field['description'])) { + $field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']); + } + if (isset($field['help_text'])) { + $field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']); + } + } + + $this->configuration_template = $template; + } + } + public function hasMissingRequiredConfigurationFields(): bool { if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) { diff --git a/composer.json b/composer.json index f801679..0ced4da 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "livewire/volt": "^1.7", "om/icalparser": "^3.2", "spatie/browsershot": "^5.0", + "stevebauman/purify": "^6.3", "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" }, diff --git a/composer.lock b/composer.lock index b9e0495..9767a0d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4d958d48655a5ad9e3de6b4a9fb52b0a", + "content-hash": "25c2a1a4a2f2594adefe25ddb6a072fb", "packages": [ { "name": "aws/aws-crt-php", @@ -814,6 +814,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -4947,6 +5008,72 @@ ], "time": "2025-01-13T13:04:43+00:00" }, + { + "name": "stevebauman/purify", + "version": "v6.3.1", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.3.1" + }, + "time": "2025-05-21T16:53:09+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/resources/css/app.css b/resources/css/app.css index 46b9ca1..30cb7a1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -59,6 +59,10 @@ @apply !mb-0 !leading-tight; } +[data-flux-description] a { + @apply text-accent underline hover:opacity-80; +} + input:focus[data-flux-control], textarea:focus[data-flux-control], select:focus[data-flux-control] { diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 4be96cc..e8ab799 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -264,7 +264,7 @@ new class extends Component { $fieldKey = $field['keyname']; if (isset($this->configuration[$fieldKey])) { $value = $this->configuration[$fieldKey]; - + // For code fields, if the value is a JSON string and the original was an array, decode it if ($field['field_type'] === 'code' && is_string($value)) { $decoded = json_decode($value, true); @@ -274,7 +274,7 @@ new class extends Component { $value = $decoded; } } - + $configurationValues[$fieldKey] = $value; } } @@ -639,7 +639,14 @@ HTML; @php $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - + # These are sanitized at PluginImportService when imported, safe to render HTML + $safeDescription = $field['description'] ?? ''; + $safeHelp = $field['help_text'] ?? ''; + + //Important: Sanitize with Purify to prevent XSS attacks + // $safeDescription = Stevebauman\Purify\Facades\Purify::clean($field['description'] ?? ''); + // $safeHelp = Stevebauman\Purify\Facades\Purify::clean($field['help_text'] ?? ''); + // For code fields, if the value is an array, JSON encode it if ($field['field_type'] === 'code' && is_array($rawValue)) { $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -657,176 +664,211 @@ HTML; @endif @if($field['field_type'] === 'string' || $field['field_type'] === 'url') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'text') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'code') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'password') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'copyable') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'time_zone') - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'number') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'boolean') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'date') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'time') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'select') @if(isset($field['multiple']) && $field['multiple'] === true) - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - + + {{ $field['name'] }} + {!! $safeDescription !!} + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + @else + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @endif + + @elseif($field['field_type'] === 'xhrSelect') + + {{ $field['name'] }} + {!! $safeDescription !!} - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - + @endif @endforeach @endif - @endif - @elseif($field['field_type'] === 'xhrSelect') - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'xhrSelectSearch')
{{ $field['name'] }} - {{ $field['description'] ?? '' }} + {!! $safeDescription !!} - {{ $field['help_text'] ?? '' }} + {!! $safeHelp !!} @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) @elseif($field['field_type'] === 'multi_string') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + @else Field type "{{ $field['field_type'] }}" not yet supported @endif diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index cf8ea97..49d3f2e 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -4,8 +4,12 @@ use App\Models\Plugin; use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Facades\Http; +use Livewire\Volt\Volt; +use Illuminate\Foundation\Testing\RefreshDatabase; -uses(Illuminate\Foundation\Testing\RefreshDatabase::class); +use Tests\TestCase; + +uses(TestCase::class,RefreshDatabase::class); test('plugin has required attributes', function (): void { $plugin = Plugin::factory()->create([ @@ -679,3 +683,111 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context ->toContain('America/Chicago') ->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds) }); + + +/** + * Plugin security: XSS Payload Dataset + * [Input, Expected to See, Dangerous parts that must be Missing] + */ +dataset('xss_vectors', [ + 'standard_script' => [ + 'Safe ', + 'Safe', + ['', + 'Unclosed tag', + ['', - 'Safe', - ['', - 'Unclosed tag', - ['', 'Safe ', '', 'Safe ', '', 'Safe ', ' + + + + + + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json new file mode 100644 index 0000000..4d44e44 --- /dev/null +++ b/public/mirror/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "TRMNL BYOS Laravel Mirror", + "short_name": "TRMNL BYOS", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff" +} From ddce3947c61a703c77a695f98835662778a8932a Mon Sep 17 00:00:00 2001 From: Gabriele Lauricella Date: Thu, 8 Jan 2026 19:04:21 +0100 Subject: [PATCH 192/209] feat: enhanced web mirror trmnl client --- public/mirror/index.html | 116 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/public/mirror/index.html b/public/mirror/index.html index 2c5fcf6..64746fe 100644 --- a/public/mirror/index.html +++ b/public/mirror/index.html @@ -18,6 +18,7 @@