├── LICENSE ├── README.md ├── composer.json └── src ├── Model └── Behavior │ └── SlugBehavior.php ├── SlugPlugin.php ├── Slugger ├── CakeSlugger.php └── CocurSlugger.php └── SluggerInterface.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-Present Use Muffin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slug 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/UseMuffin/Slug/ci.yml?style=flat-square 4 | )](https://github.com/UseMuffin/Slug/actions) 5 | [![Coverage](https://img.shields.io/codecov/c/github/UseMuffin/Slug/master.svg?style=flat-square)](https://codecov.io/github/UseMuffin/Slug) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/muffin/slug.svg?style=flat-square)](https://packagist.org/packages/muffin/slug) 7 | [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) 8 | 9 | Slugging for CakePHP 10 | 11 | ## Installation 12 | 13 | Using [Composer][composer]: 14 | 15 | ```bash 16 | composer require muffin/slug 17 | ``` 18 | 19 | Load the plugin using the CLI command: 20 | 21 | ```bash 22 | ./bin/cake plugin load Muffin/Slug 23 | ``` 24 | 25 | ## Usage 26 | To enable slugging add the behavior to your table classes in the 27 | `initialize()` method. 28 | 29 | ```php 30 | public function initialize(array $config): void 31 | { 32 | //etc 33 | $this->addBehavior('Muffin/Slug.Slug', [ 34 | // Optionally define your custom options here (see Configuration) 35 | ]); 36 | } 37 | ``` 38 | 39 | > Please note that Slug expects a database column named `slug` to function. 40 | > If you prefer to use another column make sure to specify the `field` 41 | > configuration option. 42 | 43 | ### Searching 44 | If you want to find a record using its slug, a custom finder is provided by the plugin. 45 | 46 | ```php 47 | // src/Controller/ExamplesController.php 48 | $example = $this->Examples->find('slugged', slug: $slug); 49 | ``` 50 | 51 | ## Configuration 52 | 53 | Slug comes with the following configuration options: 54 | 55 | - `field`: name of the field (column) to hold the slug. Defaults to `slug`. 56 | - `displayField`: name of the field(s) to build the slug from. Defaults to 57 | the `\Cake\ORM\Table::displayField()`. 58 | - `separator`: defaults to `-`. 59 | - `replacements`: hash of characters (or strings) to custom replace before 60 | generating the slug. 61 | - `maxLength`: maximum length of a slug. Defaults to the field's limit as 62 | defined in the schema (when possible). Otherwise, no limit. 63 | - `slugger`: class that implements the `Muffin\Slug\SlugInterface`. Defaults 64 | to `Muffin\Slug\Slugger\CakeSlugger`. 65 | - `unique:`: tells if slugs should be unique. Set this to a callable if you 66 | want to customize how unique slugs are generated. Defaults to `true`. 67 | - `scope`: extra conditions used when checking a slug for uniqueness. 68 | - `implementedEvents`: events this behavior listens to. Defaults to 69 | `['Model.buildValidator' => 'buildValidator', 'Model.beforeSave' => 'beforeSave']`. 70 | By default the behavior adds validation for the `displayField` fields to make 71 | them required on record creating. If you don't want these auto added validations 72 | you can set `implementedEvents` to just `['Model.beforeSave' => 'beforeSave']`. 73 | - `onUpdate`: Boolean indicating whether slug should be updated when updating 74 | record, defaults to `false`. 75 | - `onDirty`: Boolean indicating whether slug should be updated when slug field 76 | is dirty (has a preset value custom value), defaults to `false`. 77 | 78 | ## Sluggers 79 | 80 | The plugin contains two sluggers: 81 | 82 | ### CakeSlugger 83 | 84 | The `CakeSlugger` uses `\Cake\Utility\Text::slug()` to generate slugs. In the 85 | behavior config you can set the `slugger` key as shown below to pass options to 86 | the `$options` arguments of `Text::slug()`. 87 | 88 | ```php 89 | 'slugger' => [ 90 | 'className' => \Muffin\Slug\Slugger\CakeSlugger::class, 91 | 'transliteratorId' => '' 92 | ] 93 | ``` 94 | 95 | ### ConcurSlugger 96 | 97 | The `ConcurSlugger` uses [concur/slugify](https://github.com/cocur/slugify) to generate slugs. 98 | You can use config array similar to the one shown above to pass options to 99 | `Cocur\Slugify\Slugify`'s constructor. 100 | 101 | ## Patches & Features 102 | 103 | * Fork 104 | * Mod, fix 105 | * Test - this is important, so it's not unintentionally broken 106 | * Commit - do not mess with license, todo, version, etc. (if you do change any, bump them into commits of 107 | their own that I can ignore when I pull) 108 | * Pull request - bonus point for topic branches 109 | 110 | To ensure your PRs are considered for upstream, you MUST follow the CakePHP coding standards. 111 | 112 | ## Bugs & Feedback 113 | 114 | http://github.com/usemuffin/slug/issues 115 | 116 | ## License 117 | 118 | Copyright (c) 2015-Present, [Use Muffin][muffin] and licensed under [The MIT License][mit]. 119 | 120 | [cakephp]:http://cakephp.org 121 | [composer]:http://getcomposer.org 122 | [mit]:http://www.opensource.org/licenses/mit-license.php 123 | [muffin]:http://usemuffin.com 124 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "muffin/slug", 3 | "description": "Slugging support for CakePHP ORM", 4 | "type": "cakephp-plugin", 5 | "keywords": [ 6 | "cakephp", 7 | "muffin", 8 | "slug", 9 | "orm" 10 | ], 11 | "homepage": "https://github.com/usemuffin/slug", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Jad Bitar", 16 | "homepage": "http://jadb.io", 17 | "role": "Author" 18 | }, 19 | { 20 | "name": "ADmad", 21 | "homepage": "https://github.com/ADmad", 22 | "role": "Author" 23 | }, 24 | { 25 | "name": "Others", 26 | "homepage": "https://github.com/usemuffin/slug/graphs/contributors" 27 | } 28 | ], 29 | "support": { 30 | "issues": "https://github.com/usemuffin/slug/issues", 31 | "source": "https://github.com/usemuffin/slug" 32 | }, 33 | "require": { 34 | "cakephp/orm": "^5.0" 35 | }, 36 | "require-dev": { 37 | "cakephp/cakephp": "^5.0", 38 | "phpunit/phpunit": "^10.1 || ^11.1", 39 | "cocur/slugify": "^4.3" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Muffin\\Slug\\": "src" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Muffin\\Slug\\Test\\": "tests" 49 | } 50 | }, 51 | "config": { 52 | "sort-packages": true, 53 | "allow-plugins": { 54 | "dealerdirect/phpcodesniffer-composer-installer": true 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Model/Behavior/SlugBehavior.php: -------------------------------------------------------------------------------- 1 | 'buildValidator', 'Model.beforeSave' => 'beforeSave']`. 44 | * By default the behavior adds validation for the `displayField` fields 45 | * to make them required on record creating. If you don't want these auto 46 | * added validations you can set `implementedEvents` to just 47 | * `['Model.beforeSave' => 'beforeSave']`. 48 | * - onUpdate: Boolean indicating whether slug should be updated when 49 | * updating record, defaults to `false`. 50 | * - onDirty: Boolean indicating whether slug should be updated when 51 | * slug field is dirty, defaults to `false`. 52 | * 53 | * @var array 54 | */ 55 | protected array $_defaultConfig = [ 56 | 'field' => 'slug', 57 | 'displayField' => null, 58 | 'separator' => '-', 59 | 'replacements' => [ 60 | '#' => 'hash', 61 | '?' => 'question', 62 | '+' => 'and', 63 | '&' => 'and', 64 | '"' => '', 65 | "'" => '', 66 | ], 67 | 'maxLength' => null, 68 | 'slugger' => CakeSlugger::class, 69 | 'unique' => true, 70 | 'scope' => [], 71 | 'implementedEvents' => [ 72 | 'Model.buildValidator' => 'buildValidator', 73 | 'Model.beforeSave' => 'beforeSave', 74 | ], 75 | 'implementedFinders' => [ 76 | 'slugged' => 'findSlugged', 77 | ], 78 | 'implementedMethods' => [ 79 | 'slug' => 'slug', 80 | ], 81 | 'onUpdate' => false, 82 | 'onDirty' => false, 83 | ]; 84 | 85 | /** 86 | * Slugger instance. 87 | * 88 | * @var \Muffin\Slug\SluggerInterface|null 89 | */ 90 | protected ?SluggerInterface $_slugger = null; 91 | 92 | /** 93 | * Constructor. 94 | * 95 | * @param \Cake\ORM\Table $table The table this behavior is attached to. 96 | * @param array $config The config for this behavior. 97 | */ 98 | public function __construct(Table $table, array $config = []) 99 | { 100 | if (!empty($config['implementedEvents'])) { 101 | $this->_defaultConfig['implementedEvents'] = $config['implementedEvents']; 102 | unset($config['implementedEvents']); 103 | } 104 | parent::__construct($table, $config); 105 | } 106 | 107 | /** 108 | * Initialize behavior 109 | * 110 | * @param array $config The configuration settings provided to this behavior. 111 | * @return void 112 | */ 113 | public function initialize(array $config): void 114 | { 115 | if (!$this->getConfig('displayField')) { 116 | $this->setConfig('displayField', $this->_table->getDisplayField()); 117 | } 118 | 119 | if ($this->getConfig('maxLength') === null) { 120 | /** @psalm-suppress PossiblyNullArrayAccess */ 121 | $this->setConfig( 122 | 'maxLength', 123 | $this->_table->getSchema()->getColumn($this->getConfig('field'))['length'] 124 | ); 125 | } 126 | 127 | if ($this->getConfig('unique') === true) { 128 | $this->setConfig('unique', $this->_uniqueSlug(...)); 129 | } 130 | } 131 | 132 | /** 133 | * Get slugger instance. 134 | * 135 | * @return \Muffin\Slug\SluggerInterface 136 | */ 137 | public function getSlugger(): SluggerInterface 138 | { 139 | return $this->_slugger ??= $this->_createSlugger($this->getConfig('slugger')); 140 | } 141 | 142 | /** 143 | * Set slugger instance. 144 | * 145 | * @param \Muffin\Slug\SluggerInterface|array|class-string<\Muffin\Slug\SluggerInterface> $slugger Sets slugger instance. 146 | * Can be SluggerInterface instance or class name or config array. 147 | * @return void 148 | */ 149 | public function setSlugger(SluggerInterface|array|string $slugger): void 150 | { 151 | $this->_slugger = $this->_createSlugger($slugger); 152 | } 153 | 154 | /** 155 | * Create slugger instance 156 | * 157 | * @param \Muffin\Slug\SluggerInterface|array|class-string<\Muffin\Slug\SluggerInterface> $slugger Sets slugger instance. 158 | * Can be SluggerInterface instance or class name or config array. 159 | * @return \Muffin\Slug\SluggerInterface 160 | */ 161 | protected function _createSlugger(SluggerInterface|array|string $slugger): SluggerInterface 162 | { 163 | if (is_string($slugger)) { 164 | return new $slugger(); 165 | } 166 | 167 | if (is_array($slugger)) { 168 | /** @var class-string<\Muffin\Slug\SluggerInterface> $className */ 169 | $className = $slugger['className']; 170 | unset($slugger['className']); 171 | 172 | return new $className($slugger); 173 | } 174 | 175 | return $slugger; 176 | } 177 | 178 | /** 179 | * Returns list of event this behavior is interested in. 180 | * 181 | * @return array 182 | */ 183 | public function implementedEvents(): array 184 | { 185 | return $this->getConfig('implementedEvents'); 186 | } 187 | 188 | /** 189 | * Callback for Model.buildValidator event. 190 | * 191 | * @param \Cake\Event\EventInterface $event The beforeSave event that was fired. 192 | * @param \Cake\Validation\Validator $validator Validator instance. 193 | * @param string $name Validator name. 194 | * @return void 195 | */ 196 | public function buildValidator(EventInterface $event, Validator $validator, string $name): void 197 | { 198 | /** @var string $field */ 199 | foreach ((array)$this->getConfig('displayField') as $field) { 200 | if (strpos($field, '.') === false) { 201 | $validator->requirePresence($field, 'create') 202 | ->notEmptyString($field); 203 | } 204 | } 205 | } 206 | 207 | /** 208 | * Callback for Model.beforeSave event. 209 | * 210 | * @param \Cake\Event\EventInterface $event The afterSave event that was fired. 211 | * @param \Cake\Datasource\EntityInterface $entity The entity that was saved. 212 | * @param \ArrayObject $options Options. 213 | * @return void 214 | */ 215 | public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void 216 | { 217 | $isNew = $entity->isNew(); 218 | if (!$isNew && !$this->getConfig('onUpdate')) { 219 | return; 220 | } 221 | 222 | $onDirty = $this->getConfig('onDirty'); 223 | $field = $this->getConfig('field'); 224 | $slugFieldDirty = $entity->isDirty($field); 225 | 226 | if ($onDirty && !$slugFieldDirty && !$isNew) { 227 | $displayFieldDirty = false; 228 | foreach ((array)$this->getConfig('displayField') as $df) { 229 | if ($entity->isDirty($df)) { 230 | $displayFieldDirty = true; 231 | break; 232 | } 233 | } 234 | 235 | if (!$displayFieldDirty) { 236 | return; 237 | } 238 | } 239 | 240 | $separator = $this->getConfig('separator'); 241 | if ($slugFieldDirty && !empty($entity->{$field})) { 242 | $slug = $this->slug($entity, $entity->{$field}, $separator); 243 | $entity->set($field, $slug); 244 | 245 | return; 246 | } 247 | 248 | $parts = $this->_getPartsFromEntity($entity); 249 | if (empty($parts)) { 250 | return; 251 | } 252 | 253 | $slug = $this->slug($entity, implode($separator, $parts), $separator); 254 | $entity->set($field, $slug); 255 | } 256 | 257 | /** 258 | * Gets the parts from an entity 259 | * 260 | * @param \Cake\Datasource\EntityInterface $entity Entity 261 | * @return array 262 | */ 263 | protected function _getPartsFromEntity(EntityInterface $entity): array 264 | { 265 | $parts = []; 266 | foreach ((array)$this->getConfig('displayField') as $displayField) { 267 | $value = Hash::get($entity, $displayField); 268 | 269 | if ($value === null && !$entity->isNew()) { 270 | return []; 271 | } 272 | 273 | if (!empty($value) || is_numeric($value)) { 274 | $parts[] = $value; 275 | } 276 | } 277 | 278 | return $parts; 279 | } 280 | 281 | /** 282 | * Custom finder. 283 | * 284 | * @param \Cake\ORM\Query\SelectQuery $query Query. 285 | * @param string $slug Slug to search for. 286 | * @return \Cake\ORM\Query\SelectQuery Query. 287 | */ 288 | public function findSlugged(SelectQuery $query, string $slug): SelectQuery 289 | { 290 | return $query->where([ 291 | $this->_table->aliasField($this->getConfig('field')) => $slug, 292 | ]); 293 | } 294 | 295 | /** 296 | * Generates slug. 297 | * 298 | * @param \Cake\Datasource\EntityInterface|string $entity Entity to create slug for 299 | * @param string $string String to create slug for. 300 | * @param string|null $separator Separator. 301 | * @return string Slug. 302 | */ 303 | public function slug(EntityInterface|string $entity, ?string $string = null, ?string $separator = null): string 304 | { 305 | $separator ??= $this->getConfig('separator'); 306 | 307 | if (is_string($entity)) { 308 | if ($string !== null) { 309 | $separator = $string; 310 | } 311 | $string = $entity; 312 | } elseif ($string === null) { 313 | $string = $this->_getSlugStringFromEntity($entity, $separator); 314 | } 315 | 316 | $slug = $this->_slug($string, $separator); 317 | 318 | $unique = $this->getConfig('unique'); 319 | if (!is_string($entity) && $unique) { 320 | $slug = $unique($entity, $slug, $separator); 321 | } 322 | 323 | return $slug; 324 | } 325 | 326 | /** 327 | * Gets the slug string based on an entity 328 | * 329 | * @param \Cake\Datasource\EntityInterface $entity Entity 330 | * @param string $separator Separator 331 | * @return string 332 | */ 333 | protected function _getSlugStringFromEntity(EntityInterface $entity, string $separator): string 334 | { 335 | $string = []; 336 | foreach ((array)$this->getConfig('displayField') as $field) { 337 | if ($entity->getError($field)) { 338 | throw new InvalidArgumentException(sprintf( 339 | 'Error while generating the slug, the field `%s` contains an invalid value.', 340 | $field 341 | )); 342 | } 343 | $string[] = Hash::get($entity, $field); 344 | } 345 | 346 | return implode($separator, $string); 347 | } 348 | 349 | /** 350 | * Builds the conditions 351 | * 352 | * @param \Cake\Datasource\EntityInterface $entity Entity. 353 | * @param string $slug Slug 354 | * @return array 355 | */ 356 | protected function _conditions(EntityInterface $entity, string $slug): array 357 | { 358 | /** @var string $primaryKey */ 359 | $primaryKey = $this->_table->getPrimaryKey(); 360 | $field = $this->_table->aliasField($this->getConfig('field')); 361 | 362 | $conditions = [$field => $slug]; 363 | 364 | $scope = $this->getConfig('scope'); 365 | if (is_array($scope)) { 366 | $conditions += $scope; 367 | } else { 368 | $conditions += $scope($entity); 369 | } 370 | 371 | $id = $entity->get($primaryKey); 372 | if ($id !== null) { 373 | /** @psalm-suppress PossiblyInvalidArrayOffset */ 374 | $conditions['NOT'][$this->_table->aliasField($primaryKey)] = $id; 375 | } 376 | 377 | return $conditions; 378 | } 379 | 380 | /** 381 | * Returns a unique slug. 382 | * 383 | * @param \Cake\Datasource\EntityInterface $entity Entity. 384 | * @param string $slug Slug. 385 | * @param string $separator Separator. 386 | * @return string Unique slug. 387 | */ 388 | protected function _uniqueSlug(EntityInterface $entity, string $slug, string $separator): string 389 | { 390 | $field = $this->_table->aliasField($this->getConfig('field')); 391 | $conditions = $this->_conditions($entity, $slug); 392 | 393 | $i = 0; 394 | $suffix = ''; 395 | $length = (int)$this->getConfig('maxLength'); 396 | 397 | while ($this->_table->exists($conditions)) { 398 | $i++; 399 | $suffix = $separator . $i; 400 | if ($length && $length < mb_strlen($slug . $suffix)) { 401 | $slug = mb_substr($slug, 0, $length - mb_strlen($suffix)); 402 | } 403 | $conditions[$field] = $slug . $suffix; 404 | } 405 | 406 | return $slug . $suffix; 407 | } 408 | 409 | /** 410 | * Proxies the defined slugger's `slug` method. 411 | * 412 | * @param string $string String to create a slug from. 413 | * @param string $separator String to use as separator/separator. 414 | * @return string Slug. 415 | */ 416 | protected function _slug(string $string, string $separator): string 417 | { 418 | $replacements = $this->getConfig('replacements'); 419 | $slugger = $this->getSlugger(); 420 | $slug = $slugger->slug(str_replace(array_keys($replacements), $replacements, $string), $separator); 421 | if ($this->getConfig('maxLength')) { 422 | $slug = Text::truncate( 423 | $slug, 424 | $this->getConfig('maxLength'), 425 | ['ellipsis' => ''] 426 | ); 427 | } 428 | 429 | return $slug; 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/SlugPlugin.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected array $config = [ 24 | 'lowercase' => true, 25 | ]; 26 | 27 | /** 28 | * Constructor 29 | * 30 | * @param array $config Configuration. 31 | */ 32 | public function __construct(array $config = []) 33 | { 34 | $this->config = $config + $this->config; 35 | } 36 | 37 | /** 38 | * Generate slug. 39 | * 40 | * @param string $string Input string. 41 | * @param string $separator Replacement string. 42 | * @return string Sluggified string. 43 | */ 44 | public function slug(string $string, string $separator = '-'): string 45 | { 46 | $config = $this->config; 47 | $config['replacement'] = $separator; 48 | $string = Text::slug($string, $config); 49 | 50 | if ($config['lowercase']) { 51 | return mb_strtolower($string); 52 | } 53 | 54 | return $string; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Slugger/CocurSlugger.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected array $config = [ 24 | 'regex' => null, 25 | 'lowercase' => true, 26 | ]; 27 | 28 | /** 29 | * Constructor 30 | * 31 | * @param array $config Configuration. 32 | */ 33 | public function __construct(array $config = []) 34 | { 35 | $this->config = $config + $this->config; 36 | } 37 | 38 | /** 39 | * Generate slug. 40 | * 41 | * @param string $string Input string. 42 | * @param string $separator Replacement string. 43 | * @return string Sluggified string. 44 | */ 45 | public function slug(string $string, string $separator = '-'): string 46 | { 47 | return Slugify::create($this->config)->slugify($string, $separator); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/SluggerInterface.php: -------------------------------------------------------------------------------- 1 |