├── tests ├── Feature │ ├── LaravelCacheAdapterTest.php │ ├── SymfonyCacheAdapterTest.php │ ├── SymfonyConfigAdapterTest.php │ ├── SymfonyViewAdapterTest.php │ ├── SymfonyResponseAdapterTest.php │ ├── FeedLinkTest.php │ ├── FeedGetShorteningTest.php │ ├── FeedShorteningLimitTest.php │ ├── FeedCacheKeyTest.php │ ├── FeedDateFormatTest.php │ ├── FeedCustomViewTest.php │ ├── FeedNamespacesTest.php │ ├── FeedFormatTest.php │ ├── FeedClearCacheTest.php │ ├── FeedIsCachedTest.php │ ├── FeedShorteningTest.php │ ├── FeedRenderTest.php │ ├── FeedAddItemMultiBranchTest.php │ ├── FeedCacheTest.php │ ├── FeedTest.php │ ├── FeedSetCacheEdgeTest.php │ ├── FeedAddItemMultiTest.php │ ├── FeedRenderCachePutTest.php │ ├── FeedFormatDateBranchTest.php │ ├── FeedRenderBranchesTest.php │ ├── FeedUncoveredMethodsTest.php │ ├── FeedSettersTest.php │ └── ViewTemplatesTest.php └── Unit │ ├── SymfonyConfigAdapterTest.php │ ├── SymfonyCacheAdapterTest.php │ ├── LaravelConfigAdapterTest.php │ ├── SymfonyResponseAdapterTest.php │ ├── LaravelViewAdapterTest.php │ ├── SymfonyViewAdapterTest.php │ ├── SimpleResponseAdapterTest.php │ ├── FeedFactoryTest.php │ ├── SimpleConfigAdapterTest.php │ ├── SimpleCacheAdapterTest.php │ ├── LaravelCacheAdapterTest.php │ ├── LaravelResponseAdapterTest.php │ └── SimpleViewAdapterTest.php ├── config └── config.php ├── .gitignore ├── .editorconfig ├── src ├── Rumenx │ └── Feed │ │ ├── Symfony │ │ ├── SymfonyConfigAdapter.php │ │ ├── SymfonyCacheAdapterImpl.php │ │ ├── SymfonyViewAdapter.php │ │ └── SymfonyResponseAdapter.php │ │ ├── FeedConfigInterface.php │ │ ├── FeedViewInterface.php │ │ ├── Laravel │ │ ├── LaravelConfigAdapter.php │ │ ├── LaravelResponseAdapter.php │ │ ├── LaravelCacheAdapter.php │ │ └── LaravelViewAdapter.php │ │ ├── FeedResponseInterface.php │ │ ├── SimpleResponseAdapter.php │ │ ├── FeedFactory.php │ │ ├── SimpleConfigAdapter.php │ │ ├── FeedCacheInterface.php │ │ ├── SimpleCacheAdapter.php │ │ ├── views │ │ ├── atom.php │ │ └── rss.php │ │ ├── SimpleViewAdapter.php │ │ └── Feed.php ├── config │ └── config.php └── views │ ├── atom.blade.php │ └── rss.blade.php ├── phpunit.xml ├── phpstan.neon ├── .github ├── workflows │ └── ci.yml └── dependabot.yml ├── LICENSE.md ├── CHANGELOG.md ├── FUNDING.md ├── SECURITY.md ├── composer.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md └── README.md /tests/Feature/LaravelCacheAdapterTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Feature/SymfonyCacheAdapterTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Feature/SymfonyConfigAdapterTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Feature/SymfonyViewAdapterTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Feature/SymfonyResponseAdapterTest.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | false, 4 | 'cache_key' => 'php-feed', 5 | 'cache_duration' => 3600, 6 | 'testing' => true, 7 | ]; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .directory 5 | .DS_Store 6 | Thumbs.db 7 | *.sh 8 | /tests/tmp 9 | .php_cs 10 | /.php_cs.cache 11 | /.phpunit.result.cache 12 | .idea/ 13 | /build 14 | /html 15 | --cache-directory/ -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [{package.json,.travis.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/Symfony/SymfonyConfigAdapter.php: -------------------------------------------------------------------------------- 1 | false, 6 | 'cache_key' => 'php-feed.' . config('app.url'), 7 | 'cache_duration' => 3600, 8 | 'escaping' => true, 9 | 'use_limit_size' => false, 10 | 'max_size' => null, 11 | 'use_styles' => true, 12 | 'styles_location' => null, 13 | ]; 14 | -------------------------------------------------------------------------------- /tests/Unit/SymfonyConfigAdapterTest.php: -------------------------------------------------------------------------------- 1 | engine->render($view, $data); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/FeedConfigInterface.php: -------------------------------------------------------------------------------- 1 | $data 17 | * @return mixed 18 | */ 19 | public function make(string $view, array $data = []): mixed; 20 | } 21 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/Laravel/LaravelConfigAdapter.php: -------------------------------------------------------------------------------- 1 | config->get($key, $default); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/FeedResponseInterface.php: -------------------------------------------------------------------------------- 1 | $headers 18 | * @return mixed 19 | */ 20 | public function make(mixed $content, int $status = 200, array $headers = []): mixed; 21 | } 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | ./tests/ 9 | 10 | 11 | 12 | 13 | ./src/Rumenx/Feed/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/Laravel/LaravelResponseAdapter.php: -------------------------------------------------------------------------------- 1 | $headers 17 | * @return mixed 18 | */ 19 | public function make(mixed $content, int $status = 200, array $headers = []): mixed 20 | { 21 | return $this->response->make($content, $status, $headers); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/SimpleResponseAdapter.php: -------------------------------------------------------------------------------- 1 | $headers 17 | * @return mixed 18 | */ 19 | public function make(mixed $content, int $status = 200, array $headers = []): mixed 20 | { 21 | return $content; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src 5 | excludePaths: 6 | - tests 7 | - src/Rumenx/Feed/Symfony 8 | - views (?) 9 | treatPhpDocTypesAsCertain: false 10 | ignoreErrors: 11 | # Allow mixed return types for adapter interfaces to maintain framework compatibility 12 | - '#Method .+::(make|get) has no return type specified\.#' 13 | - '#Method .+ should return .+ but returns mixed\.#' 14 | # Template variables are provided by calling context (include/require) 15 | - '#Variable \$namespaces might not be defined\.#' 16 | - '#Variable \$channel might not be defined\.#' 17 | - '#Variable \$items might not be defined\.#' 18 | reportUnmatchedIgnoredErrors: false 19 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/FeedFactory.php: -------------------------------------------------------------------------------- 1 | $config Optional configuration 15 | * @return Feed 16 | */ 17 | public static function create(array $config = []): Feed 18 | { 19 | return new Feed([ 20 | 'cache' => new SimpleCacheAdapter(), 21 | 'config' => new SimpleConfigAdapter($config), 22 | 'response' => new SimpleResponseAdapter(), 23 | 'view' => new SimpleViewAdapter(), 24 | ]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/Laravel/LaravelCacheAdapter.php: -------------------------------------------------------------------------------- 1 | cache->has($key); 16 | } 17 | public function get(string $key, mixed $default = null): mixed 18 | { 19 | return $this->cache->get($key, $default); 20 | } 21 | public function put(string $key, mixed $value, int $ttl): void 22 | { 23 | $this->cache->put($key, $value, $ttl); 24 | } 25 | public function forget(string $key): void 26 | { 27 | $this->cache->forget($key); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Feature/FeedLinkTest.php: -------------------------------------------------------------------------------- 1 | toBe(''); 7 | expect(Feed::link($url, 'rss'))->toBe(''); 8 | expect(Feed::link($url, 'text/xml'))->toBe(''); 9 | expect(Feed::link($url, 'rss', 'Feed: RSS'))->toBe(''); 10 | expect(Feed::link($url, 'atom', 'Feed: Atom', 'en'))->toBe(''); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/Unit/LaravelConfigAdapterTest.php: -------------------------------------------------------------------------------- 1 | 'bar']); 17 | $adapter = new LaravelConfigAdapter($repo); 18 | expect($adapter->get('foo'))->toBe('bar'); 19 | }); 20 | it('returns default if missing', function () { 21 | $repo = new Repository([]); 22 | $adapter = new LaravelConfigAdapter($repo); 23 | expect($adapter->get('missing', 'default'))->toBe('default'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | push: 8 | branches: [ master, develop ] 9 | pull_request: 10 | branches: [ master, develop ] 11 | 12 | jobs: 13 | pest: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | php-version: ['8.2', '8.3', '8.4'] 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-version }} 24 | coverage: xdebug 25 | - name: Install dependencies 26 | run: composer install --prefer-dist --no-interaction --no-progress 27 | - name: Run Pest with coverage 28 | run: ./vendor/bin/pest --coverage-clover=coverage.xml 29 | - name: Upload coverage to Codecov 30 | uses: codecov/codecov-action@v5 31 | with: 32 | files: ./coverage.xml 33 | fail_ci_if_error: true 34 | verbose: true 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/Laravel/LaravelViewAdapter.php: -------------------------------------------------------------------------------- 1 | $data Data to pass to the view 30 | * @return mixed 31 | */ 32 | public function make(string $view, array $data = []): mixed 33 | { 34 | return $this->view->make($view, $data); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/SimpleConfigAdapter.php: -------------------------------------------------------------------------------- 1 | */ 12 | private array $config; 13 | 14 | /** 15 | * @param array $config 16 | */ 17 | public function __construct(array $config = []) 18 | { 19 | $this->config = array_merge([ 20 | 'application.language' => 'en', 21 | 'application.url' => 'http://localhost', 22 | ], $config); 23 | } 24 | 25 | /** 26 | * Get configuration value with optional default. 27 | * 28 | * @param string $key 29 | * @param mixed $default 30 | * @return mixed 31 | */ 32 | public function get(string $key, mixed $default = null): mixed 33 | { 34 | return $this->config[$key] ?? $default; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "composer" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "wednesday" 8 | time: "07:00" 9 | timezone: "UTC" 10 | open-pull-requests-limit: 10 11 | reviewers: 12 | - "RumenDamyanov" 13 | assignees: 14 | - "RumenDamyanov" 15 | commit-message: 16 | prefix: "chore" 17 | prefix-development: "chore" 18 | include: "scope" 19 | 20 | # Enable version updates for GitHub Actions 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "weekly" 25 | day: "wednesday" 26 | time: "07:00" 27 | timezone: "UTC" 28 | open-pull-requests-limit: 5 29 | reviewers: 30 | - "RumenDamyanov" 31 | assignees: 32 | - "RumenDamyanov" 33 | commit-message: 34 | prefix: "ci" 35 | include: "scope" 36 | # Group GitHub Actions updates 37 | groups: 38 | github-actions: 39 | patterns: 40 | - "actions/*" 41 | - "github/*" 42 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/FeedCacheInterface.php: -------------------------------------------------------------------------------- 1 | response = $response; 15 | } 16 | 17 | public function getStatusCode() 18 | { 19 | return $this->response->getStatusCode(); 20 | } 21 | 22 | public function setStatusCode($status) 23 | { 24 | $this->response->setStatusCode($status); 25 | } 26 | 27 | public function getContent() 28 | { 29 | return $this->response->getContent(); 30 | } 31 | 32 | public function setContent($content) 33 | { 34 | $this->response->setContent($content); 35 | } 36 | 37 | public function make(mixed $content, int $status = 200, array $headers = []): mixed 38 | { 39 | return new Response($content, $status, $headers); 40 | } 41 | 42 | // ...other methods from SymfonyResponseAdapter.php... 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Rumen Damyanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/Feature/FeedGetShorteningTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | expect($feed->getShortening())->toBeFalse(); 23 | $feed->setShortening(true); 24 | expect($feed->getShortening())->toBeTrue(); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Feature/FeedShorteningLimitTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | expect($feed->getTextLimit())->toBe(150); 23 | $feed->setTextLimit(10); 24 | expect($feed->getTextLimit())->toBe(10); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Feature/FeedCacheKeyTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | $feed->setCache(42, 'my-key'); 23 | expect($feed->getCacheKey())->toBe('my-key'); 24 | expect($feed->getCacheDuration())->toBe(42); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Feature/FeedDateFormatTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | expect($feed->getDateFormat())->toBe('datetime'); 23 | $feed->setDateFormat('carbon'); 24 | expect($feed->getDateFormat())->toBe('carbon'); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/Feature/FeedCustomViewTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | $feed->setCustomView('custom.view'); 23 | expect($feed->getCustomView())->toBe('custom.view'); 24 | $feed->setCustomView(null); 25 | expect($feed->getCustomView())->toBeNull(); 26 | }); 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | 12 | - Framework-agnostic architecture with dependency injection 13 | - Laravel adapter with service provider and auto-discovery 14 | - Symfony adapter for cache, config, response, and view 15 | - RSS 2.0 and Atom 1.0 feed generation 16 | - Comprehensive caching support 17 | - Custom view templates 18 | - 100% test coverage with Pest 19 | - PHPStan static analysis (level 6) 20 | - PSR-12 code style compliance 21 | - Comprehensive documentation 22 | 23 | ### Changed 24 | 25 | - Minimum PHP version requirement to 8.3+ 26 | - Strict typing throughout the codebase 27 | - Properties are now private with public getters/setters 28 | - Moved to modern PHP testing with Pest 29 | 30 | ### Removed 31 | 32 | - Direct property access (replaced with getter/setter methods) 33 | - Support for PHP versions below 8.3 34 | 35 | ## [1.0.0] - TBD 36 | 37 | ### Initial Release 38 | 39 | - Initial release of the framework-agnostic PHP Feed package 40 | - Support for Laravel, Symfony, and plain PHP 41 | - Modern PHP 8.3+ architecture 42 | - Comprehensive test suite 43 | - Full documentation 44 | -------------------------------------------------------------------------------- /tests/Unit/SymfonyResponseAdapterTest.php: -------------------------------------------------------------------------------- 1 | 'bar']); 17 | $adapter = new SymfonyResponseAdapter($resp); 18 | expect($adapter->getContent())->toBe('foo'); 19 | expect($adapter->getStatusCode())->toBe(201); 20 | $adapter->setContent('bar'); 21 | expect($adapter->getContent())->toBe('bar'); 22 | $adapter->setStatusCode(404); 23 | expect($adapter->getStatusCode())->toBe(404); 24 | }); 25 | it('make returns a new Response', function () { 26 | $adapter = new SymfonyResponseAdapter(new Response()); 27 | $resp = $adapter->make('baz', 202, ['X-Foo' => 'baz']); 28 | expect($resp)->toBeInstanceOf(Response::class); 29 | expect($resp->getContent())->toBe('baz'); 30 | expect($resp->getStatusCode())->toBe(202); 31 | expect($resp->headers->get('X-Foo'))->toBe('baz'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/Feature/FeedNamespacesTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | expect($feed->getNamespaces())->toBeArray(); 23 | $namespaces = $feed->getNamespaces(); 24 | $namespaces[] = 'testNamespace'; 25 | $feed->setNamespaces($namespaces); 26 | expect($feed->getNamespaces())->toContain('testNamespace'); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/Feature/FeedFormatTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | $date = '2025-06-07 12:00:00'; 23 | $feed->setDateFormat('datetime'); 24 | expect($feed->formatDate($date, 'atom'))->toBe(date('c', strtotime($date))); 25 | expect($feed->formatDate($date, 'rss'))->toBe(date('D, d M Y H:i:s O', strtotime($date))); 26 | }); 27 | -------------------------------------------------------------------------------- /src/views/atom.blade.php: -------------------------------------------------------------------------------- 1 | {!! '<'.'?'.'xml version="1.0" encoding="UTF-8" ?>' !!} 2 | > 5 | {!! $channel['title'] !!} 6 | 7 | 8 | {{ $channel['link'] }} 9 | 10 | 11 | @if (!empty($channel['logo'])) 12 | {{ $channel['logo'] }} 13 | @endif 14 | @if (!empty($channel['icon'])) 15 | {{ $channel['icon'] }} 16 | @endif 17 | {{ $channel['pubdate'] }} 18 | @foreach($items as $item) 19 | 20 | 21 | {{ $item['author'] }} 22 | 23 | <![CDATA[{!! $item['title'] !!}]]> 24 | 25 | {{ $item['link'] }} 26 | 27 | 28 | {{ $item['pubdate'] }} 29 | 30 | @endforeach 31 | 32 | -------------------------------------------------------------------------------- /tests/Unit/LaravelViewAdapterTest.php: -------------------------------------------------------------------------------- 1 | make('foo', ['bar' => 'baz']); 29 | expect($result)->toBe('foo{"bar":"baz"}[]'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/Feature/FeedClearCacheTest.php: -------------------------------------------------------------------------------- 1 | store[$key]); } 8 | public function get(string $key, $default = null): mixed { return $this->store[$key] ?? $default; } 9 | public function put(string $key, mixed $value, int $ttl): void { $this->store[$key] = $value; } 10 | public function forget(string $key): void { unset($this->store[$key]); } 11 | }; 12 | $feed = new Feed([ 13 | 'cache' => $cache, 14 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 15 | public function get(string $key, $default = null): mixed { return $default; } 16 | }, 17 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 18 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 19 | }, 20 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 21 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 22 | }, 23 | ]); 24 | $feed->setCache(10, 'clear-cache-key'); 25 | $cache->put('clear-cache-key', 'value', 10); 26 | $feed->clearCache(); 27 | expect($feed->isCached())->toBeFalse(); 28 | }); 29 | -------------------------------------------------------------------------------- /FUNDING.md: -------------------------------------------------------------------------------- 1 | # Funding 2 | 3 | If you find this project useful and would like to support its development, there are several ways you can contribute: 4 | 5 | ## GitHub Sponsors 6 | 7 | Support this project through GitHub Sponsors: 8 | 9 | [![GitHub Sponsors](https://img.shields.io/github/sponsors/RumenDamyanov?style=for-the-badge&logo=github-sponsors&logoColor=white)](https://github.com/sponsors/RumenDamyanov) 10 | 11 | ## Other Ways to Support 12 | 13 | - ⭐ **Star this repository** to show your appreciation 14 | - 🐛 **Report bugs** and suggest improvements 15 | - 📖 **Contribute to documentation** 16 | - 💻 **Submit pull requests** with new features or fixes 17 | - 🔗 **Share this project** with others who might find it useful 18 | - 📝 **Write about this project** in blog posts or social media 19 | 20 | ## Commercial Support 21 | 22 | For commercial support, consulting, or custom development services, please contact: 23 | 24 | - 📧 **Email**: [contact@rumenx.com](mailto:contact@rumenx.com) 25 | - 🐙 **GitHub**: [@RumenDamyanov](https://github.com/RumenDamyanov) 26 | 27 | ## What Your Support Helps With 28 | 29 | Your contributions help with: 30 | 31 | - 🔧 **Maintenance and bug fixes** 32 | - ✨ **New feature development** 33 | - 📚 **Documentation improvements** 34 | - 🧪 **Testing and quality assurance** 35 | - 🚀 **Performance optimizations** 36 | - 🔒 **Security updates** 37 | 38 | Thank you for your support! Every contribution, no matter how small, is greatly appreciated and helps keep this project alive and growing. 39 | -------------------------------------------------------------------------------- /tests/Feature/FeedIsCachedTest.php: -------------------------------------------------------------------------------- 1 | store[$key]); } 8 | public function get(string $key, $default = null): mixed { return $this->store[$key] ?? $default; } 9 | public function put(string $key, mixed $value, int $ttl): void { $this->store[$key] = $value; } 10 | public function forget(string $key): void { unset($this->store[$key]); } 11 | }; 12 | $feed = new Feed([ 13 | 'cache' => $cache, 14 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 15 | public function get(string $key, $default = null): mixed { return $default; } 16 | }, 17 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 18 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 19 | }, 20 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 21 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 22 | }, 23 | ]); 24 | $feed->setCache(10, 'is-cached-key'); 25 | $cache->put('is-cached-key', 'value', 10); 26 | expect($feed->isCached())->toBeTrue(); 27 | $feed->clearCache(); 28 | expect($feed->isCached())->toBeFalse(); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/Unit/SymfonyViewAdapterTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('symfony/templating is not available or deprecated.'); 20 | } 21 | $engine = new class implements \Symfony\Component\Templating\EngineInterface { 22 | public function render(string|\Symfony\Component\Templating\TemplateReferenceInterface $name, array $parameters = []): string { return $name . json_encode($parameters); } 23 | public function exists(string|\Symfony\Component\Templating\TemplateReferenceInterface $name): bool { return true; } 24 | public function supports(string|\Symfony\Component\Templating\TemplateReferenceInterface $name): bool { return true; } 25 | }; 26 | $adapter = new \Rumenx\Feed\Symfony\SymfonyViewAdapter($engine); 27 | $result = $adapter->make('foo', ['bar' => 'baz']); 28 | expect($result)->toBe('foo{"bar":"baz"}'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/SimpleCacheAdapter.php: -------------------------------------------------------------------------------- 1 | */ 12 | private array $cache = []; 13 | 14 | /** 15 | * Check if an item exists in the cache. 16 | * 17 | * @param string $key 18 | * @return bool 19 | */ 20 | public function has(string $key): bool 21 | { 22 | return isset($this->cache[$key]); 23 | } 24 | 25 | /** 26 | * Get an item from the cache. 27 | * 28 | * @param string $key 29 | * @param mixed $default 30 | * @return mixed 31 | */ 32 | public function get(string $key, mixed $default = null): mixed 33 | { 34 | return $this->cache[$key] ?? $default; 35 | } 36 | 37 | /** 38 | * Put an item in the cache. 39 | * 40 | * @param string $key 41 | * @param mixed $value 42 | * @param int $ttl Time to live in minutes (ignored in simple implementation) 43 | * @return void 44 | */ 45 | public function put(string $key, mixed $value, int $ttl): void 46 | { 47 | $this->cache[$key] = $value; 48 | } 49 | 50 | /** 51 | * Remove an item from the cache. 52 | * 53 | * @param string $key 54 | * @return void 55 | */ 56 | public function forget(string $key): void 57 | { 58 | if (isset($this->cache[$key])) { 59 | unset($this->cache[$key]); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Feature/FeedShorteningTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | $feed->setShortening(true); 23 | $feed->setTextLimit(5); 24 | $feed->addItem([ 25 | 'title' => 'ShortTest', 26 | 'author' => 'Tester', 27 | 'link' => 'https://example.com', 28 | 'pubdate' => date('c'), 29 | 'description' => '1234567890', 30 | ]); 31 | $items = (new \ReflectionClass($feed))->getProperty('items'); 32 | $items->setAccessible(true); 33 | $itemsArr = $items->getValue($feed); 34 | expect($itemsArr[0]['description'])->toBe('12345...'); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/Feature/FeedRenderTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return ['content' => $content, 'status' => $status, 'headers' => $headers]; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | $feed->setTitle('RenderTest'); 23 | $feed->addItem([ 24 | 'title' => 'RenderItem', 25 | 'author' => 'Tester', 26 | 'link' => 'https://example.com', 27 | 'pubdate' => date('c'), 28 | 'description' => 'desc', 29 | ]); 30 | $result = $feed->render('rss'); 31 | expect($result['content'])->toContain('view:feed::rss'); 32 | expect($result['status'])->toBe(200); 33 | expect($result['headers'])->toBeArray(); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/Feature/FeedAddItemMultiBranchTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 9 | public function has(string $key): bool { return false; } 10 | public function get(string $key, mixed $default = null): mixed { return null; } 11 | public function put(string $key, mixed $value, int $ttl): void {} 12 | public function forget(string $key): void {} 13 | }, 14 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 15 | public function get(string $key, mixed $default = null): mixed { return 'en'; } 16 | }, 17 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 18 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 19 | }, 20 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 21 | public function make(string $view, array $data = []): mixed { return ''; } 22 | }, 23 | ]); 24 | $feed->addItem([ 25 | ['title' => 'A'], 26 | ['title' => 'B'], 27 | ]); 28 | $items = (new \ReflectionClass($feed))->getProperty('items'); 29 | $items->setAccessible(true); 30 | $itemsArr = $items->getValue($feed); 31 | expect($itemsArr)->toHaveCount(2); 32 | expect($itemsArr[0]['title'])->toBe('A'); 33 | expect($itemsArr[1]['title'])->toBe('B'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/Feature/FeedCacheTest.php: -------------------------------------------------------------------------------- 1 | store[$key]); } 8 | public function get(string $key, $default = null): mixed { return $this->store[$key] ?? $default; } 9 | public function put(string $key, mixed $value, int $ttl): void { $this->store[$key] = $value; } 10 | public function forget(string $key): void { unset($this->store[$key]); } 11 | }; 12 | $feed = new Feed([ 13 | 'cache' => $cache, 14 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 15 | public function get(string $key, $default = null): mixed { return $default; } 16 | }, 17 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 18 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 19 | }, 20 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 21 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 22 | }, 23 | ]); 24 | $feed->setCache(10, 'test-key'); 25 | $feed->addItem([ 26 | 'title' => 'CacheTest', 27 | 'author' => 'Tester', 28 | 'link' => 'https://example.com', 29 | 'pubdate' => date('c'), 30 | 'description' => 'desc', 31 | ]); 32 | expect($feed->getCacheKey())->toBe('test-key'); 33 | expect($feed->getCacheDuration())->toBe(10); 34 | $feed->clearCache(); 35 | expect($feed->isCached())->toBeFalse(); 36 | }); 37 | -------------------------------------------------------------------------------- /tests/Feature/FeedTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | private array $store = []; 8 | public function has(string $key): bool { return isset($this->store[$key]); } 9 | public function get(string $key, $default = null): mixed { return $this->store[$key] ?? $default; } 10 | public function put(string $key, mixed $value, int $ttl): void { $this->store[$key] = $value; } 11 | public function forget(string $key): void { unset($this->store[$key]); } 12 | }, 13 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 14 | public function get(string $key, $default = null): mixed { return $default; } 15 | }, 16 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 17 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 18 | }, 19 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 20 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 21 | }, 22 | ]); 23 | $feed->addItem([ 24 | 'title' => 'Test', 25 | 'author' => 'Tester', 26 | 'link' => 'https://example.com', 27 | 'pubdate' => date('c'), 28 | 'description' => 'desc', 29 | ]); 30 | $items = (new \ReflectionClass($feed))->getProperty('items'); 31 | $items->setAccessible(true); 32 | $itemsArr = $items->getValue($feed); 33 | expect($itemsArr)->toHaveCount(1); 34 | expect($itemsArr[0]['title'])->toBe('Test'); 35 | }); 36 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/views/atom.php: -------------------------------------------------------------------------------- 1 | '; ?> 2 | > 3 | <?php echo htmlspecialchars($channel['title']); ?> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | <![CDATA[<?php echo $item['title']; ?>]]> 16 | 17 | 18 | 19 | ]]> 20 | ]]> 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Feature/FeedSetCacheEdgeTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 9 | private $cleared = false; 10 | public function has(string $key): bool { return true; } 11 | public function get(string $key, mixed $default = null): mixed { return null; } 12 | public function put(string $key, mixed $value, int $ttl): void {} 13 | public function forget(string $key): void { $this->cleared = true; } 14 | public function wasCleared(): bool { return $this->cleared; } 15 | }, 16 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 17 | public function get(string $key, mixed $default = null): mixed { return 'en'; } 18 | }, 19 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 20 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 21 | }, 22 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 23 | public function make(string $view, array $data = []): mixed { return ''; } 24 | }, 25 | ]); 26 | $feed->setCache(0, 'test-key'); 27 | expect($feed->getCacheKey())->toBe('test-key'); 28 | expect($feed->getCacheDuration())->toBe(0); 29 | // The cache->forget should have been called 30 | $cacheProp = (new \ReflectionClass($feed))->getProperty('cache'); 31 | $cacheProp->setAccessible(true); 32 | $cacheObj = $cacheProp->getValue($feed); 33 | expect($cacheObj->wasCleared())->toBeTrue(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/Feature/FeedAddItemMultiTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 7 | public function has(string $key): bool { return false; } 8 | public function get(string $key, $default = null): mixed { return $default; } 9 | public function put(string $key, mixed $value, int $ttl): void {} 10 | public function forget(string $key): void {} 11 | }, 12 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 13 | public function get(string $key, $default = null): mixed { return $default; } 14 | }, 15 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 16 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 17 | }, 18 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 19 | public function make(string $view, array $data = []): mixed { return 'view:'.$view; } 20 | }, 21 | ]); 22 | $feed->addItem([ 23 | [ 24 | 'title' => 'A', 25 | 'author' => 'Tester', 26 | 'link' => 'https://example.com/a', 27 | 'pubdate' => date('c'), 28 | 'description' => 'desc', 29 | ], 30 | [ 31 | 'title' => 'B', 32 | 'author' => 'Tester', 33 | 'link' => 'https://example.com/b', 34 | 'pubdate' => date('c'), 35 | 'description' => 'desc', 36 | ] 37 | ]); 38 | $items = (new \ReflectionClass($feed))->getProperty('items'); 39 | $items->setAccessible(true); 40 | $itemsArr = $items->getValue($feed); 41 | expect($itemsArr)->toHaveCount(2); 42 | expect($itemsArr[0]['title'])->toBe('A'); 43 | expect($itemsArr[1]['title'])->toBe('B'); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/Unit/SimpleResponseAdapterTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(\Rumenx\Feed\SimpleResponseAdapter::class); 9 | expect($adapter)->toBeInstanceOf(\Rumenx\Feed\FeedResponseInterface::class); 10 | }); 11 | 12 | test('SimpleResponseAdapter make returns the content as string', function () { 13 | $adapter = new \Rumenx\Feed\SimpleResponseAdapter(); 14 | $content = 'test content'; 15 | 16 | $result = $adapter->make($content); 17 | expect($result)->toBe($content); 18 | }); 19 | 20 | test('SimpleResponseAdapter make handles different content types', function () { 21 | $adapter = new \Rumenx\Feed\SimpleResponseAdapter(); 22 | 23 | // Test string content 24 | $stringContent = 'Hello World'; 25 | expect($adapter->make($stringContent))->toBe($stringContent); 26 | 27 | // Test XML content 28 | $xmlContent = 'test'; 29 | expect($adapter->make($xmlContent))->toBe($xmlContent); 30 | 31 | // Test empty content 32 | $emptyContent = ''; 33 | expect($adapter->make($emptyContent))->toBe($emptyContent); 34 | }); 35 | 36 | test('SimpleResponseAdapter preserves special characters', function () { 37 | $adapter = new \Rumenx\Feed\SimpleResponseAdapter(); 38 | $content = 'Content with special chars: & < > " \''; 39 | 40 | $result = $adapter->make($content); 41 | expect($result)->toBe($content); 42 | }); 43 | 44 | test('SimpleResponseAdapter handles large content', function () { 45 | $adapter = new \Rumenx\Feed\SimpleResponseAdapter(); 46 | $content = str_repeat('Large content block. ', 1000); 47 | 48 | $result = $adapter->make($content); 49 | expect($result)->toBe($content); 50 | expect(strlen($result))->toBe(strlen($content)); 51 | }); 52 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We actively support the following versions of PHP Feed with security updates: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 1.x | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | We take security seriously. If you discover a security vulnerability in PHP Feed, please report it responsibly. 14 | 15 | ### How to Report 16 | 17 | Please **DO NOT** open a public GitHub issue for security vulnerabilities. Instead, please: 18 | 19 | 1. **Email us directly** at: `contact@rumenx.com` 20 | 2. **Include the following information:** 21 | - A clear description of the vulnerability 22 | - Steps to reproduce the issue 23 | - Potential impact of the vulnerability 24 | - Any suggested fixes (if you have them) 25 | 26 | ### What to Expect 27 | 28 | - **Acknowledgment**: We will acknowledge receipt of your report within 48 hours 29 | - **Investigation**: We will investigate and assess the vulnerability within 5 business days 30 | - **Updates**: We will keep you informed of our progress throughout the process 31 | - **Resolution**: We aim to resolve security issues within 30 days 32 | - **Credit**: With your permission, we will credit you in our security advisory 33 | 34 | ### Security Best Practices 35 | 36 | When using PHP Feed in your applications: 37 | 38 | - Keep the package updated to the latest version 39 | - Validate and sanitize all user input before passing to feed methods 40 | - Use proper authentication and authorization for feed endpoints 41 | - Consider rate limiting for public feed endpoints 42 | - Regularly review your dependencies for security updates 43 | 44 | ### Disclosure Policy 45 | 46 | - We will coordinate with you on the timing of any public disclosure 47 | - We prefer to disclose vulnerabilities after a fix is available 48 | - We will publish security advisories for significant vulnerabilities 49 | 50 | Thank you for helping to keep PHP Feed secure! 51 | -------------------------------------------------------------------------------- /tests/Feature/FeedRenderCachePutTest.php: -------------------------------------------------------------------------------- 1 | calls = &$calls; } 10 | public function has(string $key): bool { $this->calls[] = "has:$key"; return false; } 11 | public function get(string $key, mixed $default = null): mixed { $this->calls[] = "get:$key"; return $this->store[$key] ?? ($key === 'key_ctype' ? 'application/rss+xml' : null); } 12 | public function put(string $key, mixed $value, int $ttl): void { $this->calls[] = "put:$key"; $this->store[$key] = $value; } 13 | public function forget(string $key): void { $this->calls[] = "forget:$key"; unset($this->store[$key]); } 14 | public function getCalls() { return $this->calls; } 15 | }; 16 | $view = new class implements \Rumenx\Feed\FeedViewInterface { 17 | public function make(string $view, array $data = []): mixed { 18 | return new class { 19 | public function render() { return 'RENDERED'; } 20 | }; 21 | } 22 | }; 23 | $response = new class implements \Rumenx\Feed\FeedResponseInterface { 24 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return [$content, $status, $headers]; } 25 | }; 26 | $feed = new Feed([ 27 | 'cache' => $cache, 28 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 29 | public function get(string $key, mixed $default = null): mixed { return 'en'; } 30 | }, 31 | 'response' => $response, 32 | 'view' => $view, 33 | ]); 34 | $feed->setCache(10, 'key'); 35 | $result = $feed->render('rss'); 36 | expect($result[0])->toBe('RENDERED'); 37 | expect($result[1])->toBe(200); 38 | expect($result[2]['Content-Type'])->toBe('application/rss+xml; charset=utf-8'); 39 | }); 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rumenx/php-feed", 3 | "description": "Framework-agnostic PHP Feed generator for Laravel, Symfony, and more.", 4 | "homepage": "https://github.com/RumenDamyanov/php-feed", 5 | "keywords": ["php", "feed", "rss", "atom", "laravel", "symfony", "generator"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Rumen Damyanov", 10 | "email": "contact@rumenx.com", 11 | "role": "Developer", 12 | "homepage": "https://rumenx.com" 13 | } 14 | ], 15 | "support": { 16 | "issues": "https://github.com/RumenDamyanov/php-feed/issues", 17 | "source": "https://github.com/RumenDamyanov/php-feed", 18 | "wiki": "https://github.com/RumenDamyanov/php-feed/wiki" 19 | }, 20 | "funding": [ 21 | { 22 | "type": "github", 23 | "url": "https://github.com/sponsors/RumenDamyanov" 24 | } 25 | ], 26 | "require": { 27 | "php": "^8.2" 28 | }, 29 | "require-dev": { 30 | "laravel/framework": "^11.0", 31 | "symfony/http-foundation": "^7.0", 32 | "phpunit/phpunit": "^11.0", 33 | "pestphp/pest": "^3.8", 34 | "orchestra/testbench": "^9.14", 35 | "symfony/cache": "^7.3", 36 | "symfony/dependency-injection": "^7.3", 37 | "symfony/templating": "^6.4", 38 | "phpstan/phpstan": "^2.1", 39 | "squizlabs/php_codesniffer": "^4.0" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Rumenx\\Feed\\": "src/Rumenx/Feed/" 44 | } 45 | }, 46 | "suggest": { 47 | "laravel/framework": "For Laravel integration", 48 | "symfony/http-foundation": "For Symfony integration" 49 | }, 50 | "minimum-stability": "stable", 51 | "prefer-stable": true, 52 | "config": { 53 | "allow-plugins": { 54 | "pestphp/pest-plugin": true 55 | } 56 | }, 57 | "scripts": { 58 | "test": "pest", 59 | "test:coverage": "pest --coverage", 60 | "test:coverage-html": "pest --coverage-html", 61 | "test:watch": "pest --watch", 62 | "analyse": "phpstan analyse --level=6 -c phpstan.neon", 63 | "format": "pint", 64 | "style": "phpcs --standard=PSR12 src/", 65 | "style:fix": "phpcbf --standard=PSR12 src/", 66 | "check": [ 67 | "@test", 68 | "@analyse", 69 | "@style" 70 | ], 71 | "ci": [ 72 | "@test:coverage", 73 | "@analyse", 74 | "@style" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/Unit/FeedFactoryTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(\Rumenx\Feed\Feed::class); 9 | }); 10 | 11 | test('FeedFactory creates Feed with simple adapters', function () { 12 | $feed = \Rumenx\Feed\FeedFactory::create(); 13 | 14 | // Set some basic properties to test the adapters work 15 | $feed->setTitle('Test Feed'); 16 | $feed->setDescription('Test Description'); 17 | $feed->setLink('https://example.com'); 18 | 19 | expect($feed->getTitle())->toBe('Test Feed'); 20 | expect($feed->getDescription())->toBe('Test Description'); 21 | expect($feed->getLink())->toBe('https://example.com'); 22 | }); 23 | 24 | test('FeedFactory accepts custom config', function () { 25 | $config = [ 26 | 'use_cache' => false, 27 | 'cache_key' => 'custom-key', 28 | 'cache_duration' => 7200 29 | ]; 30 | 31 | $feed = \Rumenx\Feed\FeedFactory::create($config); 32 | expect($feed)->toBeInstanceOf(\Rumenx\Feed\Feed::class); 33 | }); 34 | 35 | test('FeedFactory created Feed can add items and render', function () { 36 | $feed = \Rumenx\Feed\FeedFactory::create(); 37 | $feed->setTitle('Test Feed'); 38 | $feed->setDescription('Test Description'); 39 | $feed->setLink('https://example.com'); 40 | 41 | $feed->addItem([ 42 | 'title' => 'Test Item', 43 | 'description' => 'Test Description', 44 | 'link' => 'https://example.com/test', 45 | 'author' => 'Test Author', 46 | 'pubdate' => date('r') 47 | ]); 48 | 49 | $rss = $feed->render('rss'); 50 | expect($rss)->toBeObject(); 51 | 52 | $rssContent = (string) $rss; 53 | expect($rssContent)->toBeString(); 54 | expect($rssContent)->toContain('toContain('toContain('Test Item'); 57 | 58 | $atom = $feed->render('atom'); 59 | expect($atom)->toBeObject(); 60 | 61 | $atomContent = (string) $atom; 62 | expect($atomContent)->toBeString(); 63 | expect($atomContent)->toContain('toContain('toContain('Test Item'); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/Unit/SimpleConfigAdapterTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(\Rumenx\Feed\SimpleConfigAdapter::class); 9 | expect($adapter)->toBeInstanceOf(\Rumenx\Feed\FeedConfigInterface::class); 10 | }); 11 | 12 | test('SimpleConfigAdapter can be instantiated with config', function () { 13 | $config = ['custom_key' => 'custom_value']; 14 | $adapter = new \Rumenx\Feed\SimpleConfigAdapter($config); 15 | expect($adapter)->toBeInstanceOf(\Rumenx\Feed\SimpleConfigAdapter::class); 16 | }); 17 | 18 | test('SimpleConfigAdapter returns default values', function () { 19 | $adapter = new \Rumenx\Feed\SimpleConfigAdapter(); 20 | 21 | expect($adapter->get('application.language'))->toBe('en'); 22 | expect($adapter->get('application.url'))->toBe('http://localhost'); 23 | expect($adapter->get('non_existent_key'))->toBeNull(); 24 | expect($adapter->get('non_existent_key', 'default_value'))->toBe('default_value'); 25 | }); 26 | 27 | test('SimpleConfigAdapter returns custom config values', function () { 28 | $config = [ 29 | 'use_cache' => true, 30 | 'cache_key' => 'custom-key', 31 | 'cache_duration' => 7200, 32 | 'custom_setting' => 'custom_value' 33 | ]; 34 | $adapter = new \Rumenx\Feed\SimpleConfigAdapter($config); 35 | 36 | expect($adapter->get('use_cache'))->toBeTrue(); 37 | expect($adapter->get('cache_key'))->toBe('custom-key'); 38 | expect($adapter->get('cache_duration'))->toBe(7200); 39 | expect($adapter->get('custom_setting'))->toBe('custom_value'); 40 | }); 41 | 42 | test('SimpleConfigAdapter returns default for missing keys', function () { 43 | $adapter = new \Rumenx\Feed\SimpleConfigAdapter(); 44 | expect($adapter->get('non_existent_key', 'default_value'))->toBe('default_value'); 45 | }); 46 | 47 | test('SimpleConfigAdapter handles nested config access', function () { 48 | $config = [ 49 | 'nested' => [ 50 | 'deep' => [ 51 | 'value' => 'found' 52 | ] 53 | ] 54 | ]; 55 | $adapter = new \Rumenx\Feed\SimpleConfigAdapter($config); 56 | 57 | // Note: This adapter uses simple array access, not dot notation 58 | expect($adapter->get('nested'))->toBe(['deep' => ['value' => 'found']]); 59 | }); 60 | -------------------------------------------------------------------------------- /tests/Unit/SimpleCacheAdapterTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(\Rumenx\Feed\SimpleCacheAdapter::class); 9 | expect($adapter)->toBeInstanceOf(\Rumenx\Feed\FeedCacheInterface::class); 10 | }); 11 | 12 | test('SimpleCacheAdapter has method returns false initially', function () { 13 | $adapter = new \Rumenx\Feed\SimpleCacheAdapter(); 14 | expect($adapter->has('test-key'))->toBeFalse(); 15 | }); 16 | 17 | test('SimpleCacheAdapter can store and retrieve values', function () { 18 | $adapter = new \Rumenx\Feed\SimpleCacheAdapter(); 19 | 20 | $adapter->put('test-key', 'test-value', 3600); 21 | expect($adapter->has('test-key'))->toBeTrue(); 22 | expect($adapter->get('test-key'))->toBe('test-value'); 23 | }); 24 | 25 | test('SimpleCacheAdapter get returns default for missing keys', function () { 26 | $adapter = new \Rumenx\Feed\SimpleCacheAdapter(); 27 | expect($adapter->get('missing-key', 'default-value'))->toBe('default-value'); 28 | }); 29 | 30 | test('SimpleCacheAdapter can forget values', function () { 31 | $adapter = new \Rumenx\Feed\SimpleCacheAdapter(); 32 | 33 | $adapter->put('test-key', 'test-value', 3600); 34 | expect($adapter->has('test-key'))->toBeTrue(); 35 | 36 | $adapter->forget('test-key'); 37 | expect($adapter->has('test-key'))->toBeFalse(); 38 | }); 39 | 40 | test('SimpleCacheAdapter forget on non-existent key does not error', function () { 41 | $adapter = new \Rumenx\Feed\SimpleCacheAdapter(); 42 | $adapter->forget('non-existent-key'); 43 | expect(true)->toBeTrue(); // If we get here, no error was thrown 44 | }); 45 | 46 | test('SimpleCacheAdapter handles various value types', function () { 47 | $adapter = new \Rumenx\Feed\SimpleCacheAdapter(); 48 | 49 | // Test string 50 | $adapter->put('string', 'hello', 3600); 51 | expect($adapter->get('string'))->toBe('hello'); 52 | 53 | // Test array 54 | $adapter->put('array', ['a', 'b', 'c'], 3600); 55 | expect($adapter->get('array'))->toBe(['a', 'b', 'c']); 56 | 57 | // Test object 58 | $obj = (object)['prop' => 'value']; 59 | $adapter->put('object', $obj, 3600); 60 | expect($adapter->get('object'))->toEqual($obj); 61 | 62 | // Test null 63 | $adapter->put('null', null, 3600); 64 | expect($adapter->get('null'))->toBeNull(); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/Unit/LaravelCacheAdapterTest.php: -------------------------------------------------------------------------------- 1 | has($key))->toBeFalse(); 21 | $adapter->put($key, 'bar', 10); 22 | expect($adapter->has($key))->toBeTrue(); 23 | expect($adapter->get($key))->toBe('bar'); 24 | $adapter->forget($key); 25 | expect($adapter->has($key))->toBeFalse(); 26 | }); 27 | 28 | it('returns default if key missing', function () { 29 | $repo = new Repository(new ArrayStore()); 30 | $adapter = new LaravelCacheAdapter($repo); 31 | expect($adapter->get('missing', 'default'))->toBe('default'); 32 | }); 33 | 34 | it('put works with 0 and negative TTL', function () { 35 | $repo = new Repository(new ArrayStore()); 36 | $adapter = new LaravelCacheAdapter($repo); 37 | $adapter->put('zero', 'val', 0); 38 | expect($adapter->get('zero'))->toBeNull(); // ArrayStore does not persist with 0 TTL 39 | $adapter->put('neg', 'val2', -10); 40 | expect($adapter->get('neg'))->toBeNull(); // ArrayStore does not persist with negative TTL 41 | }); 42 | 43 | it('forget on non-existent key does not error', function () { 44 | $repo = new Repository(new ArrayStore()); 45 | $adapter = new LaravelCacheAdapter($repo); 46 | $adapter->forget('nope'); 47 | expect(true)->toBeTrue(); // No exception 48 | }); 49 | 50 | it('handles various value types', function () { 51 | $repo = new Repository(new ArrayStore()); 52 | $adapter = new LaravelCacheAdapter($repo); 53 | $adapter->put('int', 123, 10); 54 | $adapter->put('arr', [1,2,3], 10); 55 | $adapter->put('obj', (object)['a'=>1], 10); 56 | expect($adapter->get('int'))->toBe(123); 57 | expect($adapter->get('arr'))->toBe([1,2,3]); 58 | expect($adapter->get('obj'))->toEqual((object)['a'=>1]); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/Unit/LaravelResponseAdapterTest.php: -------------------------------------------------------------------------------- 1 | make('foo', 201, ['X-Test' => 'bar']); 37 | expect($resp)->toBeInstanceOf(\Illuminate\Http\Response::class); 38 | expect($resp->getContent())->toBe('foo'); 39 | expect($resp->getStatusCode())->toBe(201); 40 | expect($resp->headers->get('X-Test'))->toBe('bar'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/Rumenx/Feed/views/rss.php: -------------------------------------------------------------------------------- 1 | '; ?> 2 | > 3 | 4 | <?php echo htmlspecialchars($channel['title']); ?> 5 | 6 | ]]> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | <![CDATA[<?php echo $item['title']; ?>]]> 35 | 36 | ]]> 37 | 38 | 39 | 40 | 41 | length=""/> 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/views/rss.blade.php: -------------------------------------------------------------------------------- 1 | {!! '<'.'?'.'xml version="1.0" encoding="UTF-8" ?>' !!} 2 | > 5 | 6 | {!! $channel['title'] !!} 7 | {{ $channel['rssLink'] }} 8 | 9 | 10 | @if (!empty($channel['copyright'])) 11 | {{ $channel['copyright'] }} 12 | @endif 13 | @if (!empty($channel['color'])) 14 | {{ $channel['color'] }} 15 | @endif 16 | @if (!empty($channel['cover'])) 17 | 18 | @endif 19 | @if (!empty($channel['icon'])) 20 | {{ $channel['icon'] }} 21 | @endif 22 | @if (!empty($channel['logo'])) 23 | {{ $channel['logo'] }} 24 | 25 | {{ $channel['logo'] }} 26 | {{ $channel['title'] }} 27 | {{ $channel['rssLink'] }} 28 | 29 | @endif 30 | @if (!empty($channel['related'])) 31 | 32 | @endif 33 | @if (!empty($channel['ga'])) 34 | 35 | @endif 36 | {{ $channel['lang'] }} 37 | {{ $channel['pubdate'] }} 38 | @foreach($items as $item) 39 | 40 | <![CDATA[{!! $item['title'] !!}]]> 41 | @if (!empty($item['category'])) 42 | {{ $item['category'] }} 43 | @endif 44 | {{ $item['link'] }} 45 | {{ $item['link'] }} 46 | 47 | @if (!empty($item['content'])) 48 | 49 | @endif 50 | {!! $item['author'] !!} 51 | {{ $item['pubdate'] }} 52 | @if (!empty($item['enclosure'])) 53 | $v) 55 | {!! $k.'="'.$v.'" ' !!} 56 | @endforeach 57 | /> 58 | @endif 59 | @if (!empty($item['media:content'])) 60 | $v) 62 | {!! $k.'="'.$v.'" ' !!} 63 | @endforeach 64 | /> 65 | @endif 66 | @if (!empty($item['media:thumbnail'])) 67 | $v) 69 | {!! $k.'="'.$v.'" ' !!} 70 | @endforeach 71 | /> 72 | @endif 73 | @if (!empty($item['media:title'])) 74 | {{ $item['media:title'] }} 75 | @endif 76 | @if (!empty($item['media:description'])) 77 | {{ $item['media:description'] }} 78 | @endif 79 | @if (!empty($item['media:keywords'])) 80 | {{ $item['media:title'] }} 81 | @endif 82 | @if (!empty($item['media:rating'])) 83 | {{ $item['media:rating'] }} 84 | @endif 85 | @if (!empty($item['creativeCommons:license'])) 86 | {{ $item['creativeCommons:license'] }} 87 | @endif 88 | 89 | @endforeach 90 | 91 | 92 | -------------------------------------------------------------------------------- /tests/Feature/FeedFormatDateBranchTest.php: -------------------------------------------------------------------------------- 1 | new class implements \Rumenx\Feed\FeedCacheInterface { 10 | public function has(string $key): bool { return false; } 11 | public function get(string $key, mixed $default = null): mixed { return null; } 12 | public function put(string $key, mixed $value, int $ttl): void {} 13 | public function forget(string $key): void {} 14 | }, 15 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 16 | public function get(string $key, mixed $default = null): mixed { return 'en'; } 17 | }, 18 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 19 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 20 | }, 21 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 22 | public function make(string $view, array $data = []): mixed { return ''; } 23 | }, 24 | ]); 25 | $feed->setDateFormat('carbon'); 26 | $date = Carbon::create(2024, 1, 2, 3, 4, 5); 27 | expect($feed->formatDate($date, 'atom'))->toBe('2024-01-02T03:04:05+00:00'); 28 | expect($feed->formatDate($date, 'rss'))->toBe('Tue, 02 Jan 2024 03:04:05 +0000'); 29 | }); 30 | 31 | it('formats timestamp', function () { 32 | $feed = new Feed([ 33 | 'cache' => new class implements \Rumenx\Feed\FeedCacheInterface { 34 | public function has(string $key): bool { return false; } 35 | public function get(string $key, mixed $default = null): mixed { return null; } 36 | public function put(string $key, mixed $value, int $ttl): void {} 37 | public function forget(string $key): void {} 38 | }, 39 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 40 | public function get(string $key, mixed $default = null): mixed { return 'en'; } 41 | }, 42 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 43 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 44 | }, 45 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 46 | public function make(string $view, array $data = []): mixed { return ''; } 47 | }, 48 | ]); 49 | $feed->setDateFormat('timestamp'); 50 | $timestamp = strtotime('2024-01-02 03:04:05'); 51 | expect($feed->formatDate($timestamp, 'atom'))->toBe('2024-01-02T03:04:05+00:00'); 52 | expect($feed->formatDate($timestamp, 'rss'))->toBe('Tue, 02 Jan 2024 03:04:05 +0000'); 53 | }); 54 | 55 | it('formats datetime string', function () { 56 | $feed = new Feed([ 57 | 'cache' => new class implements \Rumenx\Feed\FeedCacheInterface { 58 | public function has(string $key): bool { return false; } 59 | public function get(string $key, mixed $default = null): mixed { return null; } 60 | public function put(string $key, mixed $value, int $ttl): void {} 61 | public function forget(string $key): void {} 62 | }, 63 | 'config' => new class implements \Rumenx\Feed\FeedConfigInterface { 64 | public function get(string $key, mixed $default = null): mixed { return 'en'; } 65 | }, 66 | 'response' => new class implements \Rumenx\Feed\FeedResponseInterface { 67 | public function make(mixed $content, int $status = 200, array $headers = []): mixed { return $content; } 68 | }, 69 | 'view' => new class implements \Rumenx\Feed\FeedViewInterface { 70 | public function make(string $view, array $data = []): mixed { return ''; } 71 | }, 72 | ]); 73 | $feed->setDateFormat('datetime'); 74 | $date = '2024-01-02 03:04:05'; 75 | expect($feed->formatDate($date, 'atom'))->toBe('2024-01-02T03:04:05+00:00'); 76 | expect($feed->formatDate($date, 'rss'))->toBe('Tue, 02 Jan 2024 03:04:05 +0000'); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /tests/Unit/SimpleViewAdapterTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(\Rumenx\Feed\SimpleViewAdapter::class); 9 | expect($adapter)->toBeInstanceOf(\Rumenx\Feed\FeedViewInterface::class); 10 | }); 11 | 12 | test('SimpleViewAdapter generates RSS feed', function () { 13 | $adapter = new \Rumenx\Feed\SimpleViewAdapter(); 14 | 15 | $data = [ 16 | 'items' => [ 17 | [ 18 | 'title' => 'Test Item', 19 | 'description' => 'Test Description', 20 | 'link' => 'https://example.com/test', 21 | 'author' => 'Test Author', 22 | 'pubdate' => date('r') 23 | ] 24 | ], 25 | 'channel' => [ 26 | 'title' => 'Test Feed', 27 | 'description' => 'Test Description', 28 | 'link' => 'https://example.com' 29 | ] 30 | ]; 31 | 32 | $result = $adapter->make('rss', $data); 33 | $content = $result->render(); 34 | 35 | expect($content)->toContain(''); 36 | expect($content)->toContain('toContain(''); 38 | expect($content)->toContain('Test Feed'); 39 | expect($content)->toContain(''); 40 | expect($content)->toContain('Test Item'); 41 | }); 42 | 43 | test('SimpleViewAdapter generates Atom feed', function () { 44 | $adapter = new \Rumenx\Feed\SimpleViewAdapter(); 45 | 46 | $data = [ 47 | 'items' => [ 48 | [ 49 | 'title' => 'Test Item', 50 | 'description' => 'Test Description', 51 | 'link' => 'https://example.com/test', 52 | 'author' => 'Test Author', 53 | 'pubdate' => date('r') 54 | ] 55 | ], 56 | 'channel' => [ 57 | 'title' => 'Test Feed', 58 | 'description' => 'Test Description', 59 | 'link' => 'https://example.com' 60 | ] 61 | ]; 62 | 63 | $result = $adapter->make('atom', $data); 64 | $content = $result->render(); 65 | 66 | expect($content)->toContain(''); 67 | expect($content)->toContain(''); 68 | expect($content)->toContain('Test Feed'); 69 | expect($content)->toContain(''); 70 | expect($content)->toContain('Test Item'); 71 | }); 72 | 73 | test('SimpleViewAdapter handles empty data gracefully', function () { 74 | $adapter = new \Rumenx\Feed\SimpleViewAdapter(); 75 | 76 | $result = $adapter->make('rss', []); 77 | $content = $result->render(); 78 | 79 | expect($content)->toContain(''); 80 | expect($content)->toContain('toContain(''); 82 | }); 83 | 84 | test('SimpleViewAdapter uses default values when data is missing', function () { 85 | $adapter = new \Rumenx\Feed\SimpleViewAdapter(); 86 | 87 | $result = $adapter->make('rss'); 88 | $content = $result->render(); 89 | 90 | expect($content)->toContain('Feed'); 91 | expect($content)->toContain(''); 92 | expect($content)->toContain('http://localhost'); 93 | }); 94 | 95 | test('SimpleViewAdapter result object implements __toString', function () { 96 | $adapter = new \Rumenx\Feed\SimpleViewAdapter(); 97 | 98 | $data = [ 99 | 'channel' => ['title' => 'Test'] 100 | ]; 101 | 102 | $result = $adapter->make('rss', $data); 103 | $stringContent = (string)$result; 104 | 105 | expect($stringContent)->toContain('toContain('Test'); 107 | }); 108 | 109 | test('SimpleViewAdapter escapes HTML entities correctly', function () { 110 | $adapter = new \Rumenx\Feed\SimpleViewAdapter(); 111 | 112 | $data = [ 113 | 'items' => [ 114 | [ 115 | 'title' => 'Title with & special chars', 116 | 'description' => 'Description with