diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7a66f64..3abe78b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,8 +18,6 @@ jobs:
steps:
- uses: actions/checkout@v2
- - name: Start docker containers
- run: docker-compose up -d
- name: Install composer dependencies
run: composer install
- name: Run tests
diff --git a/README.md b/README.md
index d7bf5b4..77bf8c6 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,195 @@
-# REPLACE
+# laravel-seo
-Simple and flexible package template.
+This is a simple package for improving SEO via OpenGraph and Twitter meta tags.
-# Usage
+It's semi-opinionated, as it's what we use for all of our websites. At the same time, it's not overengineered like many other SEO packages.
-- Replace all occurances of `REPLACE` (case sensitive) with the name of the package namespace. E.g. the `Foo` in `ArchTech\Foo`.
-- Replace all occurances of `replace2` with the name of the package on composer, e.g. the `bar` in `archtechx/bar`.
-- If MySQL is not needed, remove `docker-compose.yml`, remove the line that runs docker from `./check`, and set `DB_CONNECTION` in `phpunit.xml` to `sqlite`, and `DB_DATABASE` to `:memory:`.
+**Features**:
+- Setting SEO tags from PHP
+- Setting SEO tags from Blade
+- Integration with [Flipp](https://useflipp.com), to automatically generate cover images
+- Custom extension support
+- Expressive & simple API
----
+Example usage:
+```php
+seo()
+ ->title($post->title)
+ ->description($post->excerpt)
+ ->flipp('blog')
+
+// Adds OpenGraph tags
+// Adds Twitter card tags
+// Generates social image using Flipp and sets it as the cover photo
+```
## Installation
```sh
-composer require stancl/replace2
+composer require archtechx/laravel-seo
+```
+
+And add the following line to your layout file's `
` tag:
+
+```html
+
```
## Usage
+The package can be used from any PHP code, or specifically from Blade using the `@seo` directive.
+
+### PHP
+
+Use the `seo()` helper to retrieve the SeoManager instance, on which you can call the following methods:
+
+Available methods:
+```js
+site(string $site)
+title(string $title)
+description(string $description)
+image(string $url)
+
+twitterUser(string $username)
+twitterTitle(string $title)
+twitterDescription(string $description)
+twitterImage(string $url)
+```
+
+Example usage:
+
```php
-// ...
+seo()->title('foo')->description('bar')
+```
+
+### Blade views
+
+You can use the `@seo` directive to call the methods from Blade:
+
+```html
+@seo('title') // Echoes the title
+@seo('title', 'foo') // Sets the title & echoes it
+@seo(['title' => 'foo']) // Sets the title without echoing it
+```
+
+In general, you'll want to use `@seo(['title' => 'foo'])` at the start of a view — to set the values — and `@seo('title')` inside the view if you wish to fetch the value.
+
+That is, if you'll use the helpers in Blade at all. Some apps will only use the PHP helper.
+
+For Twitter, use the `twitter.author` format, e.g. `@seo('twitter.author')`.
+
+### Defaults
+
+To configure default values, call the methods with the `default` argument:
+
+```php
+seo()
+ ->title(default: 'ArchTech — Meticulously architected web applications')
+ ->description(default: 'We are a web development agency that ...');
+```
+
+### Modifiers
+
+You may want to modify certain values before they get inserted into the template. For example, you may want to suffix the meta `` with `| ArchTech` when it has a non-default value.
+
+To do that, simply add the `modify` argument to the method calls like this:
+
+```php
+seo()->title(modify: fn (string $title) => $title . ' | ArchTech');
+```
+
+You can, of course, combine these with the defaults:
+
+```php
+seo()->title(
+ default: 'ArchTech — Meticulously architected web applications',
+ modify: fn (string $title) => $title . ' | ArchTech'
+);
+```
+
+Which will make the package use the default if no title is provided, and if a title is provided using e.g. `seo()->title('Blog')`, it will be modified **right before being inserted into the template**.
+
+### Flipp integration
+
+First, you need to add your Flipp API keys:
+1. Add your API key to the `FLIPP_KEY` environment variable. You can get the key [here](https://useflipp.com/settings/profile/api).
+2. Go to `config/services.php` and add:
+ ```php
+ 'flipp' => [
+ 'key' => env('FLIPP_KEY'),
+ ],
+ ```
+
+Then, register your templates:
+```php
+seo()->flipp('blog', 'v8ywdwho3bso');
+seo()->flipp('page', 'egssigeabtm7');
+```
+
+After that, you can use the templates by calling `seo()->flipp()` like this:
+```php
+seo()->flipp('blog', ['title' => 'Foo', 'content' => 'bar'])`
+```
+
+The call will set the generated image as the OpenGraph and Twitter card images. The generated URLs are signed.
+
+If no data array is provided, the method will use the `title` and `description` from the current SEO config:
+
+```php
+seo()->title($post->title);
+seo()->description($post->excerpt);
+seo()->flipp('blog');
+```
+
+The `flipp()` method also returns a signed URL to the image, which lets you use it in other places, such as blog cover images.
+```php
+
+```
+
+## Customization
+
+### Views
+
+You can publish views running `php artisan vendor:publish --tag=seo-views`.
+
+### Extensions
+
+To use a custom extension, create a Blade *component* with the desired meta tags. The component should read data using `{{ seo()->get('foo') }}` or `@seo('foo')`.
+
+For example:
+
+```php
+
+```
+
+Once your view is created, register the extension:
+
+```php
+seo()->extension('facebook', view: 'my-component')
+// The extension will use
+```
+
+To set data for an extension (in our case `facebook`), simply prefix calls with the extension name in camelCase, or use the `->set()` method:
+
+```php
+seo()->facebookFoo('bar')
+seo()->facebookTitle('About us')
+seo()->set('facebook.description', 'We are a web development agency that ...')
+seo(['facebook.description' => 'We are a web development agency that ...'])
+```
+
+To disable an extension, set the second argument in the `extension()` call to false:
+
+```php
+seo()->extension('facebook', false);
```
## Development
-Running all checks locally:
+Run all checks locally:
```sh
./check
```
-Running tests:
-
-```sh
-MYSQL_PORT=3307 docker-compose up -d
-
-phpunit
-```
-
Code style will be automatically fixed by php-cs-fixer.
diff --git a/assets/views/components/extensions/twitter.blade.php b/assets/views/components/extensions/twitter.blade.php
new file mode 100644
index 0000000..af3cf2a
--- /dev/null
+++ b/assets/views/components/extensions/twitter.blade.php
@@ -0,0 +1,5 @@
+
+@if(seo('twitter.user')) @endif
+@if(seo('twitter.title')) @endif
+@if(seo('twitter.description')) @endif
+@if(seo('twitter.image')) @endif
diff --git a/assets/views/components/meta.blade.php b/assets/views/components/meta.blade.php
new file mode 100644
index 0000000..2e4a964
--- /dev/null
+++ b/assets/views/components/meta.blade.php
@@ -0,0 +1,10 @@
+@seo('title')
+
+@if(seo('site')) @endif
+@if(seo('title')) @endif
+@if(seo('description')) @endif
+@if(seo('image')) @endif
+
+@foreach(seo()->extensions() as $extension)
+
+@endforeach
diff --git a/check b/check
index 12ca616..c437764 100755
--- a/check
+++ b/check
@@ -43,8 +43,6 @@ else
offer_run './vendor/bin/phpstan analyse'
fi
-(MYSQL_PORT=3307 docker-compose up -d > /dev/null 2>/dev/null) || true
-
if (./vendor/bin/pest > /dev/null 2>/dev/null); then
echo '✅ PEST OK'
else
diff --git a/composer.json b/composer.json
index cf0d20f..eddce5d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,5 @@
{
- "name": "archtechx/replace",
+ "name": "archtechx/laravel-seo",
"description": "",
"type": "library",
"license": "MIT",
@@ -10,17 +10,22 @@
}
],
"autoload": {
+ "files": [
+ "src/helpers.php"
+ ],
"psr-4": {
- "ArchTech\\REPLACE\\": "src/"
+ "ArchTech\\SEO\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
- "ArchTech\\REPLACE\\Tests\\": "tests/"
+ "ArchTech\\SEO\\Tests\\": "tests/"
}
},
"require": {
- "illuminate/support": "^8.24"
+ "php": "^8.0",
+ "illuminate/support": "^8.24",
+ "imliam/laravel-blade-helper": "^1.0"
},
"require-dev": {
"orchestra/testbench": "^6.9",
@@ -32,7 +37,7 @@
"extra": {
"laravel": {
"providers": [
- "ArchTech\\REPLACE\\PackageServiceProvider"
+ "ArchTech\\SEO\\PackageServiceProvider"
]
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 45edba6..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-version: '3'
-services:
- mysql:
- image: mysql:5.7
- environment:
- MYSQL_ROOT_PASSWORD: password
- MYSQL_DATABASE: main
- MYSQL_USER: user
- MYSQL_PASSWORD: password
- MYSQL_TCP_PORT: ${MYSQL_PORT}
- ports:
- - "${MYSQL_PORT}:${MYSQL_PORT}"
diff --git a/src/REPLACEServiceProvider.php b/src/REPLACEServiceProvider.php
deleted file mode 100644
index f687b0d..0000000
--- a/src/REPLACEServiceProvider.php
+++ /dev/null
@@ -1,32 +0,0 @@
-loadViewsFrom(__DIR__ . '/../assets/views', 'package');
-
- // $this->publishes([
- // __DIR__ . '/../assets/views' => resource_path('views/vendor/package'),
- // ], 'package-views');
-
- // $this->mergeConfigFrom(
- // __DIR__ . '/../assets/package.php',
- // 'package'
- // );
-
- // $this->publishes([
- // __DIR__ . '/../assets/package.php' => config_path('package.php'),
- // ], 'package-config');
- }
-}
diff --git a/src/SEOManager.php b/src/SEOManager.php
new file mode 100644
index 0000000..f6e3e5b
--- /dev/null
+++ b/src/SEOManager.php
@@ -0,0 +1,222 @@
+ false,
+ ];
+
+ /** Metadata for additional features. */
+ protected array $meta = [];
+
+ /** Get all used values. */
+ public function all(): array
+ {
+ return collect($this->getKeys())
+ ->mapWithKeys(fn (string $key) => [$key => $this->get($key)])
+ ->toArray();
+ }
+
+ /** Get a list of used keys. */
+ protected function getKeys(): array
+ {
+ return collect(['site', 'title', 'image', 'description', 'twitter.user', 'twitter.title', 'twitter.image', 'twitter.description'])
+ ->merge(array_keys($this->defaults))
+ ->merge(array_keys($this->values))
+ ->unique()
+ ->filter(function (string $key) {
+ if (count($parts = explode('.', $key)) > 1) {
+ if (isset($this->extensions[$parts[0]])) {
+ // Is the extension allowed?
+ return $this->extensions[$parts[0]];
+ }
+
+ return false;
+ }
+
+ return true;
+ })
+ ->toArray();
+ }
+
+ /** Get a modified value. */
+ protected function modify(string $key): string|null
+ {
+ return isset($this->modifiers[$key])
+ ? $this->modifiers[$key]($this->values[$key])
+ : $this->values[$key];
+ }
+
+ /** Set one or more values. */
+ public function set(string|array $key, string|null $value = null): string|array
+ {
+ if (is_array($key)) {
+ foreach ($key as $k => $v) {
+ $this->set($k, $v);
+ }
+
+ return collect($key)
+ ->keys()
+ ->mapWithKeys(fn (string $key) => [$key => $this->get($key)])
+ ->toArray();
+ } else {
+ $this->values[$key] = $value;
+
+ if (Str::contains($key, '.')) {
+ $this->extension(Str::before($key, '.'), enabled: true);
+ }
+
+ return $this->get($key);
+ }
+ }
+
+ /** Resolve a value. */
+ public function get(string $key): string|null
+ {
+ return isset($this->values[$key])
+ ? $this->modify($key)
+ : $this->defaults[$key] ?? (
+ Str::contains($key, '.') ? $this->get(Str::after($key, '.')) : null
+ );
+ }
+
+ /** Configure an extension. */
+ public function extension(string $name, bool $enabled = true, string $view = null): static
+ {
+ $this->extensions[$name] = $enabled;
+
+ if ($view) {
+ $this->meta("extensions.$name.view", $view);
+ }
+
+ return $this;
+ }
+
+ /** Get a list of enabled extensions. */
+ public function extensions(): array
+ {
+ return collect($this->extensions)
+ ->filter(fn (bool $enabled) => $enabled)
+ ->keys()
+ ->mapWithKeys(fn (string $extension) => [
+ $extension => $this->meta("extensions.$extension.view") ?? ("seo::extensions." . $extension)
+ ])
+ ->toArray();
+ }
+
+ /** Configure or use Flipp. */
+ public function flipp(string $template, string|array $data = null): string|static
+ {
+ if (is_string($data)) {
+ $this->meta("flipp.templates.$template", $data);
+
+ return $this;
+ }
+
+ if ($data === null) {
+ $data = [
+ 'title' => $this->title,
+ 'description' => $this->description,
+ ];
+ }
+
+ $query = base64_encode(json_encode($data));
+
+ $signature = hash_hmac('sha256', $template . $query, config('services.flipp.key'));
+
+ return $this->set('image', "https://s.useflipp.com/{$template}.png?s={$signature}&v={$query}");
+ }
+
+ /**
+ * Get or set metadata.
+ * @param string|array $key The key or key-value pair being set.
+ * @param string|array|mixed $value The value (if a single key is provided).
+ * @return mixed
+ */
+ public function meta(string|array $key, mixed $value = null): mixed
+ {
+ if (is_array($key)) {
+ foreach ($key as $k => $v) {
+ $this->meta($k, $v);
+ }
+
+ return $this;
+ }
+
+ if ($value === null) {
+ return data_get($this->meta, $key);
+ }
+
+ data_set($this->meta, $key, $value);
+
+ return $this;
+ }
+
+ /** Handle magic method calls. */
+ public function __call($name, $arguments)
+ {
+ if (isset($this->extensions[$name])) {
+ return $this->extension($name, $arguments[0] ?? true);
+ }
+
+ $key = Str::snake($name, '.');
+
+ if (isset($arguments['default'])) {
+ $this->defaults[$key] = $arguments['default'];
+ }
+
+ if (isset($arguments['modifier'])) {
+ $this->modifiers[$key] = $arguments['modifier'];
+ }
+
+ // modify: ... is an alias for modifier: ...
+ if (isset($arguments['modify'])) {
+ $this->modifiers[$key] = $arguments['modify'];
+ }
+
+ if (isset($arguments[0])) {
+ $this->set($key, $arguments[0]);
+
+ return $this;
+ }
+
+ return $this->get($key);
+ }
+
+ /** Handle magic get. */
+ public function __get(string $key)
+ {
+ return $this->get(Str::snake($key, '.'));
+ }
+
+ /** Handle magic set. */
+ public function __set(string $key, mixed $value)
+ {
+ return $this->set(Str::snake($key, '.'), $value);
+ }
+}
diff --git a/src/SEOServiceProvider.php b/src/SEOServiceProvider.php
new file mode 100644
index 0000000..1a54a41
--- /dev/null
+++ b/src/SEOServiceProvider.php
@@ -0,0 +1,40 @@
+app->singleton('seo', SEOManager::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadViewsFrom(__DIR__ . '/../assets/views', 'seo');
+
+ $this->publishes([
+ __DIR__ . '/../assets/views' => resource_path('views/vendor/seo'),
+ ], 'seo-views');
+
+ BladeHelper::directive('seo', function (...$args) {
+ if (count($args) === 2) {
+ return seo()->set($args[0], $args[1]);
+ }
+
+ if (is_array($args[0])) {
+ seo($args[0]);
+
+ return null;
+ }
+
+ return seo()->get($args[0]);
+ });
+ }
+}
diff --git a/src/helpers.php b/src/helpers.php
new file mode 100644
index 0000000..865a534
--- /dev/null
+++ b/src/helpers.php
@@ -0,0 +1,16 @@
+set($key);
+ } else {
+ return app('seo')->get($key);
+ }
+ }
+}
diff --git a/tests/Pest.php b/tests/Pest.php
index 9103936..ef1d7ae 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -11,7 +11,7 @@
|
*/
-uses(ArchTech\REPLACE\Tests\TestCase::class)->in('Pest');
+uses(ArchTech\SEO\Tests\TestCase::class)->in('Pest');
/*
|--------------------------------------------------------------------------
@@ -39,7 +39,12 @@ expect()->extend('toBeOne', function () {
|
*/
-function something()
+function blade(string $blade): string
{
- // ..
+ return eval('ob_start(); ?>' . app('blade.compiler')->compileString($blade) . ' render();
}
diff --git a/tests/Pest/BladeTest.php b/tests/Pest/BladeTest.php
new file mode 100644
index 0000000..bef123b
--- /dev/null
+++ b/tests/Pest/BladeTest.php
@@ -0,0 +1,38 @@
+ 'foo']);
+
+ expect(blade('
'))
+ ->toBe('
');
+});
+
+test('the @seo helper can be used for setting & fetching values', function () {
+ expect(blade('
'))
+ ->toBe('
');
+});
+
+test('the @seo helper can be used for setting values with no output', function () {
+ expect(blade('
'))
+ ->toBe('
');
+
+ expect(seo('image'))->toBe('foo');
+});
+
+test("opengraph tags are rendered only if they're set", function () {
+ seo()->title('foo');
+
+ expect(meta())
+ ->toContain('og:title')
+ ->not()->toContain('og:description');
+});
+
+test('twitter tags are rendered only if the extension is enabled', function () {
+ seo()->title('foo');
+
+ expect(meta())->not()->toContain('twitter');
+
+ seo()->twitter()->twitterTitle('bar');
+
+ expect(meta())->toContain('twitter');
+});
diff --git a/tests/Pest/ExampleTest.php b/tests/Pest/ExampleTest.php
deleted file mode 100644
index 5d10dd3..0000000
--- a/tests/Pest/ExampleTest.php
+++ /dev/null
@@ -1,9 +0,0 @@
-toBeTrue();
-});
-
-it('fails', function () {
- expect(false)->toBeTrue();
-});
diff --git a/tests/Pest/ExtensionTest.php b/tests/Pest/ExtensionTest.php
new file mode 100644
index 0000000..d93d3de
--- /dev/null
+++ b/tests/Pest/ExtensionTest.php
@@ -0,0 +1,72 @@
+all())
+ ->not()->toBeEmpty()
+ ->not()->toHaveKey('twitter.title');
+});
+
+test('the twitter extension can be enabled by calling twitter', function () {
+ expect(seo()->twitter()->all())
+ ->not()->toBeEmpty()
+ ->toHaveKey('twitter.title');
+});
+
+test('the twitter extension can be disabled by calling twitter with false', function () {
+ expect(seo()->twitter()->twitter(false)->all())
+ ->not()->toBeEmpty()
+ ->not()->toHaveKey('twitter.title');
+});
+
+test('when an extension is enabled, all of its keys are included in the resolved values', function () {
+ expect(seo()->twitter()->all())
+ ->not()->toBeEmpty()
+ ->toHaveKeys(['twitter.title', 'twitter.description', 'twitter.user', 'twitter.image']);
+});
+
+test('extension keys can be set by prefixing the call with the extension name and using camelcase', function () {
+ seo()->extension('foo');
+
+ seo()->fooTitle('bar');
+
+ expect(seo()->all())
+ ->toHaveKey('foo.title', 'bar');
+});
+
+test('extensions can use custom blade paths', function () {
+ view()->addNamespace('test', __DIR__ . '/../views');
+
+ seo()->extension('facebook', view: 'test::facebook');
+
+ seo()->facebookTitle('abc');
+
+ expect(meta())->toContain('');
+});
+
+test('twitter falls back to the default values', function () {
+ seo()->twitter();
+
+ seo()->title('foo');
+
+ seo()->twitterDescription('bar');
+
+ seo()->description('baz');
+
+ expect(seo('twitter.title'))->toBe('foo');
+ expect(seo('twitter.description'))->toBe('bar');
+ expect(seo('description'))->toBe('baz');
+
+ expect(meta())->toContain('');
+});
+
+test('extensions are automatically enabled when values for them are set', function () {
+ expect(seo()->extensions())->not()->toHaveKey('twitter');
+
+ seo()->twitterTitle('foo');
+
+ expect(seo()->extensions())->toHaveKey('twitter');
+});
diff --git a/tests/Pest/FlippTest.php b/tests/Pest/FlippTest.php
new file mode 100644
index 0000000..3bf2c67
--- /dev/null
+++ b/tests/Pest/FlippTest.php
@@ -0,0 +1,42 @@
+flipp('blog', 'abcdefg');
+
+ expect(seo()->meta('flipp.templates'))
+ ->toHaveCount(1)
+ ->toHaveKey('blog', 'abcdefg');
+});
+
+test('flipp templates can be given data', function () {
+ seo()->flipp('blog', 'abcdefg');
+ expect(seo()->flipp('blog', ['title' => 'abc', 'excerpt' => 'def']))
+ ->toContain('s.useflipp.com/blog')
+ ->toContain(base64_encode(json_encode(['title' => 'abc', 'excerpt' => 'def'])));
+});
+
+test('the flipp method returns a link to a signed url', function () {
+ seo()->flipp('blog', 'abcdefg');
+
+ expect(seo()->flipp('blog', ['title' => 'abc']))
+ ->toContain('?s=' . hash_hmac('sha256', 'blog' . base64_encode(json_encode(['title' => 'abc'])), config('services.flipp.key')));
+});
+
+test("flipp templates use default data when they're not passed any data explicitly", function () {
+ seo()->flipp('blog', 'abcdefg');
+
+ seo()->title('foo')->description('bar');
+
+ expect(seo()->flipp('blog'))
+ ->toContain('s.useflipp.com/blog')
+ ->toContain(base64_encode(json_encode(['title' => 'foo', 'description' => 'bar'])));
+});
+
+test('flipp images are used as the cover images', function () {
+ seo()->flipp('blog', 'abcdefg');
+
+ seo()->title('foo')->description('bar');
+
+ expect(seo()->flipp('blog'))
+ ->toBe(seo('image'));
+});
diff --git a/tests/Pest/HelperTest.php b/tests/Pest/HelperTest.php
new file mode 100644
index 0000000..27409fe
--- /dev/null
+++ b/tests/Pest/HelperTest.php
@@ -0,0 +1,20 @@
+toBeInstanceOf(SEOManager::class);
+});
+
+test('the seo helper returns a value when an argument is passed', function () {
+ seo()->title('foo');
+
+ expect(seo('title'))->toBe('foo');
+});
+
+test('the seo helper accepts an array of key-value pairs', function () {
+ seo(['foo' => 'bar', 'abc' => 'xyz']);
+
+ expect(seo('foo'))->toBe('bar');
+ expect(seo('abc'))->toBe('xyz');
+});
diff --git a/tests/Pest/ManagerTest.php b/tests/Pest/ManagerTest.php
new file mode 100644
index 0000000..eb0f697
--- /dev/null
+++ b/tests/Pest/ManagerTest.php
@@ -0,0 +1,68 @@
+set('foo', 'bar'))->toBe('bar');
+});
+
+test('the __call proxy is chainable', function () {
+ expect(seo()->foo('bar'))->toBeInstanceOf(SEOManager::class);
+});
+
+test('default values can be set in the proxy call', function () {
+ seo()->title(default: 'foo');
+ expect(seo('title'))->toBe('foo');
+
+ seo()->title('bar');
+ expect(seo('title'))->toBe('bar');
+});
+
+test('default values can be set in the proxy call alongside the value', function () {
+ seo()->description('bar', default: 'foo');
+
+ expect(seo('description'))->toBe('bar');
+});
+
+test('metadata can be used as strings', function () {
+ seo()->meta('foo', 'bar');
+
+ expect(seo()->meta('foo'))->toBe('bar');
+});
+
+test('metadata can be used as arrays', function () {
+ seo()->meta('abc', ['def' => 'xyz']);
+ expect(seo()->meta('abc.def'))->toBe('xyz');
+
+ seo()->meta('abc.def', 'xxx');
+ expect(seo()->meta('abc.def'))->toBe('xxx');
+
+ seo()->meta(['abc.def' => 'yyy']);
+ expect(seo()->meta('abc.def'))->toBe('yyy');
+});
+
+test('values can be set magically', function () {
+ seo()->foo = 'bar';
+
+ expect(seo('foo'))->toBe('bar');
+ expect(seo()->foo)->toBe('bar');
+});
+
+test('magic access respects modifiers', function () {
+ seo()->foo(modify: 'strtoupper');
+
+ seo()->foo = 'bar';
+
+ expect(seo('foo'))->toBe('BAR');
+ expect(seo()->foo)->toBe('BAR');
+});
+
+test('magic access gets converted to dot syntax', function () {
+ seo()->fooBar('baz');
+ expect(seo('foo.bar'))->toBe('baz');
+ expect(seo()->fooBar)->toBe('baz');
+
+ seo()->abcDef = 'xyz';
+ expect(seo('abc.def'))->toBe('xyz');
+ expect(seo()->abcDef)->toBe('xyz');
+});
diff --git a/tests/Pest/ModifierTest.php b/tests/Pest/ModifierTest.php
new file mode 100644
index 0000000..9ae3477
--- /dev/null
+++ b/tests/Pest/ModifierTest.php
@@ -0,0 +1,21 @@
+title(modify: fn (string $title) => $title . ' | ArchTech');
+
+ seo()->title('About us');
+
+ expect(seo('title'))->toBe('About us | ArchTech');
+});
+
+test('modifiers are applied on values returned from set', function () {
+ seo()->title(modify: fn (string $title) => $title . ' | ArchTech');
+
+ expect(seo(['title' => 'Blog']))->toHaveKey('title', 'Blog | ArchTech');
+});
+
+test('modifiers are not applied on default values', function () {
+ seo()->title(modify: fn (string $title) => $title . ' | ArchTech', default: 'ArchTech — Web development agency');
+
+ expect(seo('title'))->toBe('ArchTech — Web development agency');
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
index ee3c5db..9b1e3f0 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -1,16 +1,18 @@
facebookTitle) }}" />