├── .editorconfig ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE.md ├── README.md ├── composer.json ├── config └── seo.php ├── database ├── factories │ └── SeoFactory.php └── migrations │ └── 2023_01_19_171805_create_seo_table.php ├── phpunit.xml.dist ├── src ├── Casts │ └── UrlCast.php ├── Commands │ └── MoonShineCommand.php ├── Models │ └── Seo.php ├── Providers │ └── SeoServiceProvider.php ├── Rules │ └── UrlRule.php ├── Seo.php ├── SeoManager.php ├── SeoMeta.php └── helpers.php ├── stubs ├── moonshine_seo_resource_v1.stub ├── moonshine_seo_resource_v2.stub └── moonshine_seo_resource_v3.stub └── tests ├── SeoFeaturesTest.php └── TestCase.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | composer.lock 4 | node_modules 5 | .phpunit.result.cache 6 | .php-cs-fixer.cache 7 | app 8 | reports 9 | tools 10 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'method_argument_space' => [ 30 | 'on_multiline' => 'ensure_fully_multiline', 31 | 'keep_multiple_spaces_after_comma' => true, 32 | ], 33 | 'single_trait_insert_per_statement' => true, 34 | ]) 35 | ->setFinder($finder); 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Danil Shutsky 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Laravel 9+ 3 | PHP 8+ 4 |

5 | 6 | ### Prolog 7 | 8 | Seo data is stored in the database in the `seo` table and is linked to pages based on the url, the url is unique for websites, therefore, the seo in this package is built from it 9 | 10 | - Easy to use 11 | - Not tied to entities 12 | - All data is cached relative to url and reset by events on the model 13 | 14 | ### Installation 15 | 16 | ```shell 17 | composer require lee-to/laravel-seo-by-url 18 | ``` 19 | Publish config 20 | 21 | ```shell 22 | php artisan vendor:publish --provider="Leeto\Seo\Providers\SeoServiceProvider" 23 | ``` 24 | 25 | ```shell 26 | php artisan migrate 27 | ``` 28 | 29 | ### Are you a visual learner? 30 | 31 | We've recorded a [video](https://youtu.be/QjTsC1QF0co) on how to use this package. It's the best way to get started using media library 32 | 33 | ### MoonShine 34 | 35 | if you use the [MoonShine](https://moonshine-laravel.com), then publish the resource with this command 36 | 37 | ```shell 38 | php artisan seo:moonshine 39 | ``` 40 | 41 | ### Get started 42 | 43 | For starters, you can choose the best usage approach for you: 44 | 45 | - Facade 46 | ```php 47 | use Leeto\Seo\Seo; 48 | 49 | // ... 50 | 51 | Seo::title('Hello world') 52 | ``` 53 | 54 | - Helper 55 | ```php 56 | seo()->title('Hello world') 57 | ``` 58 | 59 | - DI 60 | ```php 61 | use Leeto\Seo\SeoManager; 62 | 63 | // ... 64 | 65 | public function __invoke(SeoManager $seo) 66 | { 67 | // 68 | } 69 | ``` 70 | 71 | * Ok I prefer to use the helper 72 | 73 | ### Blade directives 74 | 75 | #### Render meta tags 76 | title, descriptions, keywords, og 77 | 78 | ```html 79 | 80 | 81 | 82 | 83 | @seo 84 | 85 | 86 | 87 | ``` 88 | 89 | #### Render seo text 90 | 91 | ```html 92 |
93 | @seoText('Default text') 94 |
95 | ``` 96 | 97 | ### Set and save seo data 98 | 99 | - set 100 | 101 | ```php 102 | seo()->title('Im page title') 103 | ``` 104 | 105 | - set and save in database 106 | 107 | ```php 108 | seo()->title('Im page title', true) 109 | ``` 110 | 111 | - other tags 112 | 113 | ```php 114 | seo()->description('Im page description') 115 | seo()->keywords('Im page description') 116 | seo()->text('Im page description') 117 | seo()->og(['image' => 'link to image']) 118 | ``` 119 | 120 | - get value 121 | 122 | ```php 123 | seo()->meta()->title() 124 | seo()->meta()->description() 125 | seo()->meta()->keywords() 126 | seo()->meta()->text() 127 | seo()->meta()->og() 128 | ``` 129 | 130 | - get html tags 131 | 132 | ```php 133 | seo()->meta()->html() 134 | ``` 135 | 136 | - save by model 137 | 138 | ```php 139 | use Leeto\Seo\Models\Seo; 140 | 141 | Seo::create([ 142 | 'url' => '/', 143 | 'title' => 'Im title' 144 | ]); 145 | ``` 146 | 147 | ### Default values 148 | 149 | Set in seo config `config/seo.php` 150 | 151 | ```php 152 | return [ 153 | 'default' => [ 154 | 'title' => 'Im title' 155 | ] 156 | ]); 157 | ``` 158 | 159 | ### Inertia 160 | 161 | Use Shared Data 162 | 163 | ```php 164 | class HandleInertiaRequests extends Middleware 165 | { 166 | // 167 | public function share(Request $request) 168 | { 169 | return array_merge(parent::share($request), [ 170 | // ... 171 | 172 | 'seo' => [ 173 | 'title' => seo()->meta()->title(), 174 | 'description' => seo()->meta()->description(), 175 | 'keywords' => seo()->meta()->keywords(), 176 | 'og' => seo()->meta()->og(), 177 | 'text' => seo()->meta()->text(), 178 | ] 179 | ]); 180 | } 181 | // 182 | } 183 | ``` 184 | 185 | ```js 186 | import { Head } from '@inertiajs/vue3' 187 | 188 | 189 | {{ $page.props.seo.title }} 190 | 191 | 192 | ``` 193 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lee-to/laravel-seo-by-url", 3 | "description": "Easy seo for Laravel and MoonShine", 4 | "minimum-stability": "stable", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Danil Shutsky", 9 | "email": "info@cutcode.ru", 10 | "homepage": "https://github.com/lee-to" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.0|^8.1|^8.2" 15 | }, 16 | "require-dev": { 17 | "fakerphp/faker": "^1.9.2", 18 | "phpunit/phpunit": "^9.5.8", 19 | "mockery/mockery": "^1.4.4", 20 | "orchestra/testbench": "^7.5" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Leeto\\Seo\\": "src/" 25 | }, 26 | "files": [ 27 | "src/helpers.php" 28 | ] 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Leeto\\Seo\\Tests\\": "tests/", 33 | "Leeto\\Seo\\Database\\Factories\\": "database/factories/" 34 | } 35 | }, 36 | "scripts": { 37 | "test": "vendor/bin/phpunit", 38 | "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" 39 | }, 40 | "extra": { 41 | "laravel": { 42 | "providers": [ 43 | "Leeto\\Seo\\Providers\\SeoServiceProvider" 44 | ], 45 | "aliases": { 46 | "Seo": "Leeto\\Seo\\Seo" 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /config/seo.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'title' => env('APP_NAME', '') 6 | ] 7 | ]; 8 | -------------------------------------------------------------------------------- /database/factories/SeoFactory.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class SeoFactory extends Factory 14 | { 15 | public function definition(): array 16 | { 17 | return [ 18 | 'title' => ucfirst($this->faker->word()), 19 | 'description' => $this->faker->text(100), 20 | 'keywords' => $this->faker->text(100), 21 | 'text' => $this->faker->text(100), 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/migrations/2023_01_19_171805_create_seo_table.php: -------------------------------------------------------------------------------- 1 | id(); 12 | $table->string('url')->unique(); 13 | $table->string('title'); 14 | $table->text('description')->nullable(); 15 | $table->text('keywords')->nullable(); 16 | $table->text('text')->nullable(); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | public function down(): void 22 | { 23 | Schema::dropIfExists('seo'); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./tests 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | src 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/Casts/UrlCast.php: -------------------------------------------------------------------------------- 1 | choice( 16 | 'Choose MoonShine version (default v3)', 17 | [ 18 | 1 => 'v1', 19 | 2 => 'v2', 20 | 3 => 'v3', 21 | ], 22 | 'v3' 23 | ); 24 | 25 | $stub = "moonshine_seo_resource_{$version}.stub"; 26 | 27 | if ($version === 'v3') { 28 | /** @var \MoonShine\Contracts\Core\DependencyInjection\ConfiguratorContract $config */ 29 | $config = app(\MoonShine\Contracts\Core\DependencyInjection\ConfiguratorContract::class); 30 | 31 | $resource = $config->getDir() . '/Resources/SeoResource.php'; 32 | $namespace = $config->getNamespace('\Resources'); 33 | } else { 34 | $resource = \MoonShine\MoonShine::dir() . '/Resources/SeoResource.php'; 35 | $namespace = \MoonShine\MoonShine::namespace('\Resources'); 36 | } 37 | 38 | $contents = $this->laravel['files']->get(__DIR__ . "/../../stubs/{$stub}"); 39 | $contents = str_replace('{namespace}', $namespace, $contents); 40 | 41 | $this->laravel['files']->put( 42 | $resource, 43 | $contents 44 | ); 45 | 46 | $this->components->info('Now register resource in menu'); 47 | 48 | return self::SUCCESS; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Models/Seo.php: -------------------------------------------------------------------------------- 1 | UrlCast::class, 34 | ]; 35 | 36 | protected static function boot() 37 | { 38 | parent::boot(); 39 | 40 | static::created(static fn (Seo $model) => $model->flushCache()); 41 | static::updated(static fn (Seo $model) => $model->flushCache()); 42 | static::deleting(static fn (Seo $model) => $model->flushCache()); 43 | } 44 | 45 | public function flushCache(): void 46 | { 47 | seo()->flushCache( 48 | seo()->getCacheKey($this->url) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Providers/SeoServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 17 | __DIR__.'/../../config/seo.php', 'seo' 18 | ); 19 | } 20 | 21 | public function boot(): void 22 | { 23 | $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); 24 | 25 | $this->publishes([ 26 | __DIR__.'/../../config/seo.php' => config_path('seo.php'), 27 | ]); 28 | 29 | $this->app->singleton(SeoManager::class); 30 | 31 | Blade::directive('seo', static function ($expression) { 32 | return '{!! seo() !!}'; 33 | }); 34 | 35 | Blade::directive('seoText', static function ($expression) { 36 | return '{!! seo()->meta()->text('.$expression.') !!}'; 37 | }); 38 | 39 | if ($this->app->runningInConsole()) { 40 | $this->commands([ 41 | MoonShineCommand::class, 42 | ]); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Rules/UrlRule.php: -------------------------------------------------------------------------------- 1 | data = $this->cachedByUrl(); 31 | } 32 | 33 | private function data(): ?Seo 34 | { 35 | return $this->data; 36 | } 37 | 38 | private function custom(): array 39 | { 40 | return $this->custom; 41 | } 42 | 43 | public function url(string $url = null): Stringable 44 | { 45 | return str($url ?? request()->getRequestUri()) 46 | ->trim('/') 47 | ->prepend('/'); 48 | } 49 | 50 | public function getCacheKey(string $url = null): string 51 | { 52 | $url = (string) $this->url($url); 53 | $parsedUrl = parse_url($url); 54 | $path = $parsedUrl["path"] ?? '/'; 55 | $query = isset($parsedUrl["query"]) ? $this->normalizeQuery($parsedUrl["query"]) : ''; 56 | 57 | return md5($path . '?' . $query); 58 | } 59 | 60 | protected function normalizeQuery(string $query): string 61 | { 62 | parse_str($query, $params); 63 | ksort($params); 64 | return http_build_query($params); 65 | } 66 | 67 | public function meta(): SeoMeta 68 | { 69 | if ($this->custom()) { 70 | $data = $this->data() 71 | ? array_merge(Arr::only($this->data()->toArray(), $this->data()->getFillable()), $this->custom()) 72 | : $this->custom(); 73 | 74 | return SeoMeta::fromArray($data); 75 | } 76 | 77 | return SeoMeta::fromModel($this->data()) 78 | ->default(config('seo.default', [])); 79 | } 80 | 81 | public function cachedByUrl(): Model|Seo|null 82 | { 83 | $data = cache()->rememberForever( 84 | $this->getCacheKey(), 85 | fn() => $this->byUrl() ?? false 86 | ); 87 | 88 | return $data === false ? null : $data; 89 | } 90 | 91 | public function flushCache(string $key = null): void 92 | { 93 | cache()->forget($key ?? $this->getCacheKey()); 94 | 95 | $this->data = $this->cachedByUrl(); 96 | } 97 | 98 | public function byUrl(): Model|Seo|null 99 | { 100 | $url = (string)$this->url(); 101 | 102 | $seo = Seo::query()->where("url", $url)->first(); 103 | 104 | if ($seo) { 105 | return $seo; 106 | } 107 | 108 | $baseUrl = parse_url($url, PHP_URL_PATH); 109 | $seo = Seo::query()->where("url", $baseUrl)->first(); 110 | 111 | if ($seo) { 112 | return $seo; 113 | } 114 | 115 | return null; 116 | } 117 | 118 | public function __toString(): string 119 | { 120 | return $this->meta()->html(); 121 | } 122 | 123 | public function __call(string $name, array $arguments) 124 | { 125 | if (in_array($name, ['title', 'description', 'keywords', 'text', 'og'])) { 126 | $this->custom[$name] = $arguments[0] ?? ''; 127 | 128 | if ($arguments[1] ?? false) { 129 | $data = Seo::query()->updateOrCreate( 130 | ['url' => (string) $this->url()], 131 | Arr::except($this->custom, ['og']), 132 | ); 133 | 134 | $this->persisted[$data->url] = $data; 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/SeoMeta.php: -------------------------------------------------------------------------------- 1 | model = $model ?? new Seo(); 21 | $this->og = $og; 22 | } 23 | 24 | public static function fromModel(?Seo $model = null): SeoMeta 25 | { 26 | return new self($model); 27 | } 28 | 29 | public static function fromArray(array $data): SeoMeta 30 | { 31 | return new self( 32 | new Seo(collect($data)->except('og')->toArray()), 33 | $data['og'] ?? [] 34 | ); 35 | } 36 | 37 | public function default(array $default): self 38 | { 39 | $this->default = $default; 40 | 41 | return $this; 42 | } 43 | 44 | public function og(): array 45 | { 46 | return $this->og; 47 | } 48 | 49 | public function model(): Seo 50 | { 51 | return $this->model; 52 | } 53 | 54 | public function title(): ?string 55 | { 56 | return $this->model()->title ?? $this->default['title'] ?? null; 57 | } 58 | 59 | public function description(): ?string 60 | { 61 | return $this->model()->description ?? $this->default['description'] ?? null; 62 | } 63 | 64 | public function keywords(): ?string 65 | { 66 | return $this->model()->keywords ?? $this->default['keywords'] ?? null; 67 | } 68 | 69 | public function text(string $text = null): ?string 70 | { 71 | return $this->model()->text 72 | ? $this->model()->text 73 | : $text ?? $this->default['text'] ?? null; 74 | } 75 | 76 | public function html(): string 77 | { 78 | $html = str(''); 79 | 80 | if ($this->title()) { 81 | $html = $html->append("{$this->title()}")->append(PHP_EOL); 82 | $html = $html->append($this->metaTag($this->title(), name: 'title')); 83 | } 84 | 85 | if ($this->description()) { 86 | $html = $html->append($this->metaTag($this->description(), name: 'description')); 87 | } 88 | 89 | if ($this->keywords()) { 90 | $html = $html->append($this->metaTag($this->keywords(), name: 'keywords')); 91 | } 92 | 93 | if ($this->og()) { 94 | foreach ($this->og() as $name => $content) { 95 | $html = $html->append($this->metaTag($content, property: "og:$name")); 96 | } 97 | } 98 | 99 | return $html->value(); 100 | } 101 | 102 | private function metaTag(string $content, string $name = null, string $property = null): string 103 | { 104 | return "".PHP_EOL; 105 | } 106 | 107 | public function __toString(): string 108 | { 109 | return $this->html(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | showOnExport() 37 | ->useOnImport() 38 | ->sortable(), 39 | 40 | Text::make('Url') 41 | ->required() 42 | ->showOnExport() 43 | ->useOnImport(), 44 | 45 | Text::make('Title') 46 | ->required() 47 | ->showOnExport() 48 | ->useOnImport(), 49 | 50 | Text::make('Description') 51 | ->showOnExport() 52 | ->useOnImport(), 53 | 54 | Text::make('Keywords') 55 | ->showOnExport() 56 | ->useOnImport(), 57 | 58 | TinyMce::make('Text') 59 | ->hideOnIndex() 60 | ->showOnExport() 61 | ->useOnImport() 62 | ]) 63 | ]; 64 | } 65 | 66 | public function rules(Model $item): array 67 | { 68 | return [ 69 | 'title' => ['required', 'string', 'min:1'], 70 | 'url' => [ 71 | 'required', 72 | 'string', 73 | new UrlRule, 74 | Rule::unique('seo')->ignoreModel($item) 75 | ] 76 | ]; 77 | } 78 | 79 | public function search(): array 80 | { 81 | return ['id', 'url']; 82 | } 83 | 84 | public function filters(): array 85 | { 86 | return [ 87 | TextFilter::make('Url') 88 | ]; 89 | } 90 | 91 | public function itemActions(): array 92 | { 93 | return [ 94 | ItemAction::make('Go to', function (Seo $item) { 95 | header('Location:'.$item->url); 96 | die(); 97 | })->icon('clip') 98 | ]; 99 | } 100 | 101 | public function actions(): array 102 | { 103 | return [ 104 | FiltersAction::make('Filters'), 105 | ExportAction::make('Export'), 106 | ImportAction::make('Import') 107 | ]; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /stubs/moonshine_seo_resource_v2.stub: -------------------------------------------------------------------------------- 1 | showOnExport() 32 | ->useOnImport() 33 | ->sortable(), 34 | 35 | Text::make('Url') 36 | ->required() 37 | ->showOnExport() 38 | ->useOnImport(), 39 | 40 | Text::make('Title') 41 | ->required() 42 | ->showOnExport() 43 | ->useOnImport(), 44 | 45 | Text::make('Description') 46 | ->showOnExport() 47 | ->useOnImport(), 48 | 49 | Text::make('Keywords') 50 | ->showOnExport() 51 | ->useOnImport(), 52 | 53 | TinyMce::make('Text') 54 | ->hideOnIndex() 55 | ->showOnExport() 56 | ->useOnImport() 57 | ]) 58 | ]; 59 | } 60 | 61 | public function rules(Model $item): array 62 | { 63 | return [ 64 | 'title' => ['required', 'string', 'min:1'], 65 | 'url' => [ 66 | 'required', 67 | 'string', 68 | new UrlRule, 69 | Rule::unique('seo')->ignoreModel($item) 70 | ] 71 | ]; 72 | } 73 | 74 | public function search(): array 75 | { 76 | return ['id', 'url']; 77 | } 78 | 79 | public function filters(): array 80 | { 81 | return [ 82 | Text::make('Url')->required(), 83 | ]; 84 | } 85 | 86 | public function indexButtons(): array 87 | { 88 | return [ 89 | ActionButton::make('Go to', static fn (Seo $item) => $item->url) 90 | ->icon('heroicons.outline.paper-clip') 91 | ->blank() 92 | ]; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /stubs/moonshine_seo_resource_v3.stub: -------------------------------------------------------------------------------- 1 | required(), 45 | Text::make('Title') 46 | ->required(), 47 | Text::make('Description'), 48 | Text::make('Keywords'), 49 | TinyMce::make('Text'), 50 | ]) 51 | ]; 52 | } 53 | 54 | protected function indexFields(): iterable 55 | { 56 | return [ 57 | ID::make() 58 | ->sortable(), 59 | Text::make('Url'), 60 | Text::make('Title'), 61 | Text::make('Description'), 62 | Text::make('Keywords'), 63 | ]; 64 | } 65 | 66 | protected function detailFields(): iterable 67 | { 68 | return [ 69 | ID::make(), 70 | Text::make('Url'), 71 | Text::make('Title'), 72 | Text::make('Description'), 73 | Text::make('Keywords'), 74 | TinyMce::make('Text') 75 | ]; 76 | } 77 | 78 | protected function exportFields(): iterable 79 | { 80 | return [ 81 | ID::make(), 82 | Text::make('Url'), 83 | Text::make('Title'), 84 | Text::make('Description'), 85 | Text::make('Keywords'), 86 | ]; 87 | } 88 | 89 | protected function importFields(): iterable 90 | { 91 | return $this->exportFields(); 92 | } 93 | 94 | protected function rules($item): array 95 | { 96 | return [ 97 | 'title' => [ 98 | 'required', 99 | 'string', 100 | 'min:3' 101 | ], 102 | 'url' => [ 103 | 'required', 104 | 'string', 105 | new UrlRule, 106 | Rule::unique('seo')->ignoreModel($item) 107 | ] 108 | ]; 109 | } 110 | 111 | public function search(): array 112 | { 113 | return ['id', 'url', 'title']; 114 | } 115 | 116 | public function filters(): array 117 | { 118 | return [ 119 | Text::make('Url'), 120 | Text::make('Title'), 121 | ]; 122 | } 123 | 124 | public function import(): ?Handler 125 | { 126 | return ImportHandler::make('Импорт') 127 | ->notifyUsers(fn() => [auth()->id()]); 128 | } 129 | 130 | public function export(): ?Handler 131 | { 132 | return ExportHandler::make('Экспорт') 133 | ->filename('seo_resource_export__' . date('Ymd-His')) 134 | ->dir('export_files') 135 | ->notifyUsers(fn() => [auth()->id()]); 136 | } 137 | 138 | public function indexButtons(): ListOf 139 | { 140 | return parent::indexButtons()->add( 141 | ActionButton::make('На страницу', static fn (Seo $item) => $item->url) 142 | ->icon('arrow-top-right-on-square') 143 | ->blank() 144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/SeoFeaturesTest.php: -------------------------------------------------------------------------------- 1 | app->instance('request', Request::create('/test')); 22 | 23 | $seo = SeoModel::query()->create([ 24 | 'url' => '/test', 25 | 'title' => 'Title', 26 | 'description' => 'Description', 27 | ]); 28 | 29 | $this->assertEquals($seo->title, seo()->meta()->title()); 30 | 31 | $seo->update([ 32 | 'title' => 'New title', 33 | ]); 34 | 35 | $this->assertEquals($seo->title, seo()->meta()->title()); 36 | 37 | seo()->title('Custom title'); 38 | 39 | $this->assertEquals('Custom title', seo()->meta()->title()); 40 | $this->assertEquals('Description', seo()->meta()->description()); 41 | } 42 | 43 | /** 44 | * @test 45 | * @return void 46 | */ 47 | public function it_basic_usage_with_params(): void 48 | { 49 | $this->app->instance('request', Request::create('/test?page=2')); 50 | 51 | $seo = SeoModel::query()->create([ 52 | 'url' => '/test?page=2', 53 | 'title' => 'Title', 54 | 'description' => 'Description params', 55 | ]); 56 | 57 | $this->assertEquals($seo->title, seo()->meta()->title()); 58 | 59 | $seo->update([ 60 | 'title' => 'New title params', 61 | ]); 62 | 63 | $this->assertEquals($seo->title, seo()->meta()->title()); 64 | 65 | seo()->title('Custom title params'); 66 | 67 | $this->assertEquals('Custom title params', seo()->meta()->title()); 68 | $this->assertEquals('Description params', seo()->meta()->description()); 69 | } 70 | 71 | /** 72 | * @test 73 | * @return void 74 | */ 75 | public function it_success_url_generated(): void 76 | { 77 | $item = SeoModel::query()->create([ 78 | 'url' => 'https://google.com/path', 79 | 'title' => 'Title', 80 | ]); 81 | 82 | $this->assertEquals('/path', $item->url); 83 | 84 | $item = SeoModel::query()->create([ 85 | 'url' => 'https://google.com', 86 | 'title' => 'Title', 87 | ]); 88 | 89 | $this->assertEquals('/', $item->url); 90 | } 91 | 92 | /** 93 | * @test 94 | * @return void 95 | */ 96 | public function it_empty_seo(): void 97 | { 98 | $this->app->instance('request', Request::create('/empty')); 99 | 100 | $this->assertEquals('', seo()->meta()->title()); 101 | } 102 | 103 | /** 104 | * @test 105 | * @return void 106 | */ 107 | public function it_html_value(): void 108 | { 109 | seo()->title('Custom title'); 110 | 111 | $this->assertStringContainsString('Custom title', seo()->meta()->html()); 112 | } 113 | 114 | /** 115 | * @test 116 | * @return void 117 | */ 118 | public function it_html_og_value(): void 119 | { 120 | seo()->og(['image' => 'test']); 121 | 122 | $this->assertStringContainsString("", seo()->meta()->html()); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | artisan('cache:clear'); 20 | 21 | $this->refreshApplication(); 22 | $this->loadLaravelMigrations(); 23 | $this->loadMigrationsFrom(realpath('./database/migrations')); 24 | 25 | Factory::guessFactoryNamesUsing(function ($factory) { 26 | $factoryBasename = class_basename($factory); 27 | 28 | return "Leeto\Seo\Database\Factories\\$factoryBasename".'Factory'; 29 | }); 30 | } 31 | 32 | protected function getPackageProviders($app): array 33 | { 34 | return [ 35 | SeoServiceProvider::class, 36 | ]; 37 | } 38 | } 39 | --------------------------------------------------------------------------------