├── 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 |
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 | [](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 |
4 |
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 |
--------------------------------------------------------------------------------
/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 |
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 | ]]>
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 |
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