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 +<img alt="@seo('title')" src="@seo('flipp', 'blog')"> +``` + +## 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 +<meta name="facebook-title" content="@seo('facebook.foo')"> +``` + +Once your view is created, register the extension: + +```php +seo()->extension('facebook', view: 'my-component') +// The extension will use <x-my-component> +``` + +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 @@ +<meta name="twitter:card" content="summary_large_image"> +@if(seo('twitter.user')) <meta name="twitter:site" content="@seo('twitter.user')"> @endif +@if(seo('twitter.title')) <meta name="twitter:title" content="@seo('twitter.title')"> @endif +@if(seo('twitter.description')) <meta name="twitter:description" content="@seo('twitter.description')"> @endif +@if(seo('twitter.image')) <meta name="twitter:image" content="@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 @@ +<title>@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) }}" />