├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── automated-tests.yml │ ├── code-standards-inspection.yml │ └── update-changelog.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── UPGRADING.md ├── composer.json ├── config └── shortcodes.php ├── phpunit.xml.dist ├── src ├── Compiler.php ├── Console │ └── Commands │ │ └── MakeShortcode.php ├── Providers │ └── ShortcodesServiceProvider.php └── Shortcode.php ├── stubs └── Shortcode.stub └── tests ├── MakeShortcodeTest.php ├── ShortcodeTest.php ├── Shortcodes ├── CastArray.php ├── CastBoolean.php ├── CastCollection.php ├── CastDate.php ├── CastEncrypted.php ├── CastFloat.php ├── CastHashed.php ├── CastInteger.php ├── CastJson.php ├── CastObject.php ├── CastString.php ├── OutputAttributes.php └── OutputBody.php └── TestCase.php /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/automated-tests.yml: -------------------------------------------------------------------------------- 1 | name: Automated Tests 2 | on: 3 | pull_request: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [8.2, 8.3, 8.4] 13 | laravel: [^11.0, ^12.0] 14 | dependency-version: [prefer-lowest, prefer-stable] 15 | include: 16 | 17 | - laravel: ^11.0 18 | testbench: ^9.0 19 | phpunit: ^10.5 20 | 21 | - laravel: ^12.0 22 | testbench: ^10.0 23 | phpunit: ^11.5.3 24 | 25 | name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - (${{ matrix.dependency-version }} 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@2.28.0 33 | with: 34 | php-version: ${{ matrix.php }} 35 | tools: composer 36 | coverage: none 37 | 38 | - name: Install dependencies 39 | run: | 40 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "phpunit/phpunit:${{ matrix.phpunit }}" --dev --no-interaction --no-update 41 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 42 | 43 | - name: Execute tests 44 | run: composer test 45 | -------------------------------------------------------------------------------- /.github/workflows/code-standards-inspection.yml: -------------------------------------------------------------------------------- 1 | name: Code Standards Inspection 2 | 3 | on: 4 | pull_request: 5 | branches-ignore: 6 | - 'dependabot/**' 7 | 8 | jobs: 9 | sniff: 10 | runs-on: ubuntu-latest 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-sniff-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: '8.2' 24 | tools: composer 25 | coverage: none 26 | 27 | - name: Install dependencies 28 | uses: ramsey/composer-install@v3 29 | 30 | - name: Sniff 31 | run: composer sniff 32 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | with: 15 | ref: master 16 | 17 | - name: Update Changelog 18 | uses: stefanzweifel/changelog-updater-action@v1 19 | with: 20 | latest-version: ${{ github.event.release.name }} 21 | release-notes: ${{ github.event.release.body }} 22 | 23 | - name: Commit updated CHANGELOG 24 | uses: stefanzweifel/git-auto-commit-action@v5 25 | with: 26 | branch: master 27 | commit_message: Update CHANGELOG 28 | file_pattern: CHANGELOG.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | vendor 4 | .php_cs.cache 5 | coverage 6 | .phpunit.result.cache 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Any notable changes to `Laravel Shortcodes` will be documented in this file. 4 | 5 | ## v2.0.0 (XX-XX-XXXX) 6 | 7 | ### Versions compatibility changed 8 | 9 | Older versions of Laravel and PHP have been dropped. 10 | 11 | Laravel 11 and 12 as well as PHP 8.2, 8.3, and 8.4 are now supported. 12 | 13 | | Laravel | PHP | Branch | 14 | |---|---|---| 15 | | 11+ | 8.2+ | [master](https://github.com/tehwave/laravel-shortcodes/tree/master) | 16 | | 10 and below | 8.1 and below | [1.x](https://github.com/tehwave/laravel-shortcodes/tree/1.x) | 17 | 18 | ### Additions to attribute handling 19 | 20 | #### Attribute Casting 21 | 22 | The attribute casting feature has been introduced. Ensure that any attributes that need specific types are defined in the `$casts` property of your shortcode classes. 23 | 24 | #### Accessing Attributes as Properties 25 | 26 | Attributes can now be accessed directly as properties of the shortcode instance. This allows for more intuitive and readable code within the `handle` method of your shortcode classes. 27 | 28 | #### Example 29 | 30 | ```php 31 | 'boolean', 41 | ]; 42 | 43 | public function handle(): ?string 44 | { 45 | return $this->is_active === true ? 'Yes' : 'No'; 46 | } 47 | } 48 | ``` 49 | 50 | ```php 51 | [!NOTE] 61 | > All values in the `attributes` array are cast to `string` type when parsed unless specifically cast to a type via the `$casts` property. 62 | 63 | ```php 64 | attributes['escape_html']) && $this->attributes['escape_html'] === 'true')) { 83 | return sprintf('%s', htmlspecialchars($this->body)); 84 | } 85 | 86 | return sprintf('%s', $this->body); 87 | } 88 | } 89 | ``` 90 | 91 | #### Naming 92 | 93 | The shortcode's tag is derived from the class name to snake_case. 94 | 95 | You may specify a custom tag using the `tag` property or by overwriting the `getTag` method. 96 | 97 | Shortcode tags must be alpha-numeric characters and may include underscores. 98 | 99 | ```php 100 | Hello World[/italics]'); 127 | 128 | // <b>Hello World</b> 129 | ``` 130 | 131 | You may specify a list of instantiated `Shortcode` classes to limit what shortcodes are parsed. 132 | 133 | ```php 134 | 'boolean', 186 | 'count' => 'integer', 187 | 'price' => 'float', 188 | 'name' => 'string', 189 | 'tags' => 'array', 190 | 'options' => 'collection', 191 | 'metadata' => 'object', 192 | 'config' => 'json', 193 | 'published_at' => 'date', 194 | ]; 195 | 196 | /** 197 | * The code to run when the Shortcode is being compiled. 198 | * 199 | * @return string|null 200 | */ 201 | public function handle(): ?string 202 | { 203 | $publishedAt = $this->attributes['published_at'] instanceof Carbon 204 | ? $this->attributes['published_at']->toFormattedDateString() 205 | : 'N/A'; 206 | 207 | $tags = implode(', ', $this->attributes['tags']); 208 | 209 | $options = $this->attributes['options']->implode(', '); 210 | 211 | return sprintf( 212 | 'Active: %s, Count: %d, Price: %.2f, Name: %s, Published At: %s, Tags: %s, Options: %s', 213 | $this->attributes['is_active'] === true ? 'Yes' : 'No', 214 | $this->attributes['count'], 215 | $this->attributes['price'], 216 | $this->attributes['name'], 217 | $publishedAt, 218 | $tags, 219 | $options 220 | ); 221 | } 222 | } 223 | ``` 224 | 225 | When you compile content with this shortcode, the attributes will be automatically cast to the specified types. 226 | 227 | ```php 228 | 'boolean', 252 | 'count' => 'integer', 253 | ]; 254 | 255 | public function handle(): ?string 256 | { 257 | // Access attributes as properties 258 | $isActive = $this->is_active; 259 | $count = $this->count; 260 | 261 | return sprintf('Active: %s, Count: %d', $isActive === true ? 'Yes' : 'No', $count); 262 | } 263 | } 264 | ``` 265 | 266 | ### Example 267 | 268 | I developed `Laravel Shortcodes` for use with user provided content on [gm48.net](https://gm48.net). 269 | 270 | The content is parsed using a Markdown converter called Parsedown, and because users can't be trusted, the content has to be escaped. 271 | 272 | Unfortunately, this escapes the attribute syntax with double quotes, but singular quotes can still be used as well as just omitting any quotes. 273 | 274 | > [!NOTE] 275 | > Quotes are required for any attribute values that contain whitespace. 276 | 277 | Let's take a look at the following content with some basic `Row`, `Column`and `Image` shortcodes. 278 | 279 | ``` 280 | # Controls: 281 | 282 | [row] 283 | [column] 284 | [image align=left src=http://i.imgur.com/6CNoFYx.png alt='Move player character'] 285 | [/column] 286 | [column] 287 | [image align=center src=http://i.imgur.com/8nwaVo0.png alt=Jump] 288 | [/column] 289 | [column] 290 | [image align=right src=http://i.imgur.com/QsbkkuZ.png alt='Go down through platforms'] 291 | [/column] 292 | [/row] 293 | ``` 294 | 295 | When running the content through the following code: 296 | 297 | ```php 298 | $parsedDescription = (new Parsedown()) 299 | ->setSafeMode(true) 300 | ->setUrlsLinked(false) 301 | ->text($this->description); 302 | 303 | $compiledDescription = Shortcode::compile($parsedDescription); 304 | ``` 305 | 306 | We can expect to see the following output: 307 | 308 | ```html 309 |

Controls:

310 |

311 |
312 |
313 |
314 | Move player character 315 |
316 |
317 | Jump 318 |
319 |
320 | Go down through platforms 321 |
322 |
323 |
324 | ``` 325 | 326 | You should still escape any user input within your shortcodes' `handle`. 327 | 328 | ## Tests 329 | 330 | Run the following command to test the package. 331 | 332 | ```bash 333 | composer test 334 | ``` 335 | 336 | ## Security 337 | 338 | For any security related issues, send a mail to [peterchrjoergensen+shortcodes@gmail.com](mailto:peterchrjoergensen+shortcodes@gmail.com) instead of using the issue tracker. 339 | 340 | ## Changelog 341 | 342 | See [CHANGELOG](CHANGELOG.md) for details on what has changed. 343 | 344 | ## Upgrade Guide 345 | 346 | See [UPGRADING.md](UPGRADING.md) for details on how to upgrade. 347 | 348 | ## Contributions 349 | 350 | See [CONTRIBUTING](CONTRIBUTING.md) for details on how to contribute. 351 | 352 | ## Credits 353 | 354 | - [Peter Jørgensen](https://github.com/tehwave) 355 | - [All Contributors](../../contributors) 356 | 357 | Inspired by https://github.com/webwizo/laravel-shortcodes and https://github.com/spatie/laravel-blade-x 358 | 359 | ## About 360 | 361 | I work as a Web Developer in Denmark on Laravel and WordPress websites. 362 | 363 | Follow me [@tehwave](https://twitter.com/tehwave) on Twitter! 364 | 365 | ## License 366 | 367 | [MIT License](LICENSE) 368 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | For any security related issues, send a mail to [peterchrjoergensen+shortcodes@gmail.com](mailto:peterchrjoergensen+shortcodes@gmail.com) instead of using the issue tracker. 4 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Upgrading from 1.x to 2.0 4 | 5 | ### Version Compatibility 6 | 7 | Older versions of Laravel and PHP have been dropped. 8 | 9 | Laravel 11 and 12 as well as PHP 8.2, 8.3, and 8.4 are now supported. 10 | 11 | | Laravel | PHP | Branch | 12 | |---|---|---| 13 | | 11+ | 8.2+ | [master](https://github.com/tehwave/laravel-shortcodes/tree/master) | 14 | | 10 and below | 8.1 and below | [1.x](https://github.com/tehwave/laravel-shortcodes/tree/1.x) | 15 | 16 | ### Incompatible Changes 17 | 18 | #### PHP and Laravel Version Requirements 19 | 20 | - The minimum PHP version has been increased to 8.2. 21 | - The minimum Laravel version has been increased to 11. 22 | 23 | #### Attribute Casting 24 | 25 | The attribute casting feature has been introduced. 26 | 27 | Cast attributes by defining a `$casts` property on the base shortcode class. This may break existing code that was already using the property. 28 | 29 | In addition, to support casting, the base shortcode class implements `HasAttributes` trait from Laravel, which introduces new methods and properties to the base shortcode class. 30 | 31 | #### Accessing Attributes as Properties 32 | 33 | Attributes can now be accessed directly as properties of the shortcode instance. 34 | 35 | This change may also break existing code that defines a property on the shortcode that conflicts with an attribute. Ensure that there are no naming conflicts between your shortcode properties and attributes. 36 | 37 | #### Example 38 | 39 | 40 | ```php 41 | 'boolean', 51 | ]; 52 | 53 | public function handle(): ?string 54 | { 55 | return $this->is_active === true ? 'Yes' : 'No'; 56 | } 57 | } 58 | ``` 59 | 60 | ```php 61 | 2 | 4 | 5 | 6 | src/ 7 | 8 | 9 | 10 | 11 | tests 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Compiler.php: -------------------------------------------------------------------------------- 1 | reduce([static::class, 'parse'], $content); 16 | } 17 | 18 | /** 19 | * Parse content by matching up against shortcodes 20 | * and dispatching them to handle the input. 21 | * 22 | * @param \tehwave\Shortcodes\Shortcode $shortcode 23 | */ 24 | public static function parse(string $content, $shortcode): string 25 | { 26 | $pattern = static::shortcodeRegex($shortcode->getTag()); 27 | 28 | $parsedContent = preg_replace_callback( 29 | "/$pattern/", 30 | [$shortcode, 'dispatch'], 31 | $content 32 | ); 33 | 34 | return $parsedContent; 35 | } 36 | 37 | /** 38 | * Retrieve the regular expression used to match shortcodes. Thanks Wordpress! 39 | * 40 | * @link https://developer.wordpress.org/reference/functions/get_shortcode_regex/ 41 | */ 42 | protected static function shortcodeRegex(string $tag): string 43 | { 44 | // phpcs:disable Squiz.Strings.ConcatenationSpacing.PaddingFound -- don't remove regex indentation 45 | return 46 | '\\[' // Opening bracket 47 | .'(\\[?)' // 1: Optional second opening bracket for escaping shortcodes: [[tag]] 48 | ."($tag)" // 2: Shortcode name 49 | .'(?![\\w-])' // Not followed by word character or hyphen 50 | .'(' // 3: Unroll the loop: Inside the opening shortcode tag 51 | .'[^\\]\\/]*' // Not a closing bracket or forward slash 52 | .'(?:' 53 | .'\\/(?!\\])' // A forward slash not followed by a closing bracket 54 | .'[^\\]\\/]*' // Not a closing bracket or forward slash 55 | .')*?' 56 | .')' 57 | .'(?:' 58 | .'(\\/)' // 4: Self closing tag ... 59 | .'\\]' // ... and closing bracket 60 | .'|' 61 | .'\\]' // Closing bracket 62 | .'(?:' 63 | .'(' // 5: Unroll the loop: Optionally, anything between the opening and closing shortcode tags 64 | .'[^\\[]*+' // Not an opening bracket 65 | .'(?:' 66 | .'\\[(?!\\/\\2\\])' // An opening bracket not followed by the closing shortcode tag 67 | .'[^\\[]*+' // Not an opening bracket 68 | .')*+' 69 | .')' 70 | .'\\[\\/\\2\\]' // Closing shortcode tag 71 | .')?' 72 | .')' 73 | .'(\\]?)'; 74 | } 75 | 76 | /** 77 | * Resolve key-value array from string. Thanks Wordpress! 78 | * 79 | * @link https://developer.wordpress.org/reference/functions/shortcode_parse_atts/ 80 | */ 81 | public static function resolveAttributes(string $attributesText): ?array 82 | { 83 | $attributesText = preg_replace("/[\x{00a0}\x{200b}]+/u", ' ', $attributesText); 84 | 85 | $attributes = collect([]); 86 | 87 | if (preg_match_all(static::attributeRegex(), $attributesText, $matches, PREG_SET_ORDER)) { 88 | foreach ($matches as $match) { 89 | if (! empty($match[1])) { 90 | $attributes[strtolower($match[1])] = stripcslashes($match[2]); 91 | } elseif (! empty($match[3])) { 92 | $attributes[strtolower($match[3])] = stripcslashes($match[4]); 93 | } elseif (! empty($match[5])) { 94 | $attributes[strtolower($match[5])] = stripcslashes($match[6]); 95 | } elseif (isset($match[7]) && strlen($match[7])) { 96 | $attributes[] = stripcslashes($match[7]); 97 | } elseif (isset($match[8]) && strlen($match[8])) { 98 | $attributes[] = stripcslashes($match[8]); 99 | } elseif (isset($match[9])) { 100 | $attributes[] = stripcslashes($match[9]); 101 | } 102 | } 103 | 104 | // Reject any unclosed HTML elements. 105 | $filteredAttributes = $attributes->filter(function ($attribute) { 106 | if (strpos($attribute, '<') === false) { 107 | return true; 108 | } 109 | 110 | return preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $attribute); 111 | }); 112 | 113 | if ($filteredAttributes->isEmpty()) { 114 | return null; 115 | } 116 | 117 | return $filteredAttributes->toArray(); 118 | } 119 | 120 | return null; 121 | } 122 | 123 | /** 124 | * Retrieve the regular expression used to match attributes. Thanks Wordpress! 125 | * 126 | * @link https://developer.wordpress.org/reference/functions/get_shortcode_atts_regex/ 127 | */ 128 | protected static function attributeRegex(): string 129 | { 130 | return '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|\'([^\']*)\'(?:\s|$)|(\S+)(?:\s|$)/'; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Console/Commands/MakeShortcode.php: -------------------------------------------------------------------------------- 1 | publishes([ 18 | __DIR__.'/../../config/shortcodes.php' => config_path('shortcodes.php'), 19 | ], 'shortcodes-config'); 20 | 21 | if ($this->app->runningInConsole()) { 22 | $this->commands([ 23 | MakeShortcode::class, 24 | ]); 25 | } 26 | } 27 | 28 | /** 29 | * Register any application services. 30 | * 31 | * @return void 32 | */ 33 | public function register() 34 | { 35 | $this->mergeConfigFrom(__DIR__.'/../../config/shortcodes.php', 'shortcodes'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Shortcode.php: -------------------------------------------------------------------------------- 1 | $attributes 46 | */ 47 | public function __construct(array $attributes = [], ?string $body = null) 48 | { 49 | $this->attributes = $attributes; 50 | 51 | $this->body = (string) $body; 52 | } 53 | 54 | /** 55 | * Retrieve the tag to match in content. 56 | * 57 | * Should the tag not be pre-defined, we will resolve 58 | * the tag from the class name into snake_case. 59 | */ 60 | public function getTag(): string 61 | { 62 | if (! isset($this->tag)) { 63 | $className = class_basename($this); 64 | 65 | $snakedClassName = Str::snake($className); 66 | 67 | $this->tag = $snakedClassName; 68 | } 69 | 70 | return $this->tag; 71 | } 72 | 73 | /** 74 | * The code to run when the Shortcode is being compiled. 75 | * 76 | * You may return a string from here, that will then 77 | * be inserted into the content being compiled. 78 | */ 79 | abstract public function handle(): ?string; 80 | 81 | /** 82 | * This method runs when the shortcode has been parsed from content. 83 | */ 84 | public function dispatch(array $matches): ?string 85 | { 86 | // Let's make these matches human readable. 87 | [$shortcode, $prefix, $tag, $attributes, $tagClose, $body, $suffix] = $matches; 88 | 89 | // Allows escaping shortcodes by wrapping in square brackets. 90 | if ($prefix === '[' && $suffix === ']') { 91 | return substr($shortcode, 1, -1); 92 | } 93 | 94 | // Set up our inputs and run our handle. 95 | $this->attributes = Compiler::resolveAttributes($attributes); 96 | 97 | foreach ((array) $this->attributes as $key => $value) { 98 | 99 | // Cast the attribute if it has a cast. 100 | if (is_string($key) && $this->hasCast($key)) { 101 | try { 102 | $this->attributes[$key] = $this->castAttribute($key, $value); 103 | } catch (Exception) { 104 | // For whatever reason, we couldn't cast the attribute. 105 | $this->attributes[$key] = null; 106 | } 107 | } 108 | } 109 | 110 | $this->body = $body; 111 | 112 | return $this->handle(); 113 | } 114 | 115 | /** 116 | * Retrieve all of the Shortcode classes. 117 | */ 118 | public static function getClasses(): Collection 119 | { 120 | if (! isset(static::$classesCache)) { 121 | $directory = app()->path('Shortcodes'); 122 | 123 | $classes = collect(scandir($directory)) 124 | ->diff(['..', '.']) 125 | ->values(); 126 | 127 | static::$classesCache = $classes; 128 | } 129 | 130 | return static::$classesCache; 131 | } 132 | 133 | /** 134 | * Retrieve all of the Shortcode classes in namespace. 135 | */ 136 | public static function getNamespacedClasses(): Collection 137 | { 138 | if (! isset(static::$namespacedClassesCache)) { 139 | $namespacedClasses = static::getClasses() 140 | ->transform(function (string $class): string { 141 | return sprintf( 142 | '%sShortcodes\%s', 143 | app()->getNamespace(), 144 | rtrim($class, '.php') 145 | ); 146 | }); 147 | 148 | static::$namespacedClassesCache = $namespacedClasses; 149 | } 150 | 151 | return static::$namespacedClassesCache; 152 | } 153 | 154 | /** 155 | * Retrieve all of the Shortcode classes as instances. 156 | */ 157 | public static function getInstantiatedClasses(): Collection 158 | { 159 | return self::getNamespacedClasses() 160 | ->transform(function (string|self $class): self { 161 | return new $class; 162 | }); 163 | } 164 | 165 | /** 166 | * Clears the classes cache. 167 | */ 168 | public static function clearCache(): void 169 | { 170 | static::$classesCache = null; 171 | static::$namespacedClassesCache = null; 172 | } 173 | 174 | /** 175 | * A shorthand method for getNamespacedClasses. 176 | */ 177 | public static function all(): Collection 178 | { 179 | return static::getInstantiatedClasses(); 180 | } 181 | 182 | /** 183 | * A shorthand method for compile method on Compiler. 184 | */ 185 | public static function compile(string $content, ?Collection $shortcodes = null): string 186 | { 187 | return Compiler::compile($content, $shortcodes); 188 | } 189 | 190 | /** 191 | * Get the attributes that should be cast. 192 | * 193 | * @return array 194 | */ 195 | public function getCasts() 196 | { 197 | // Lowercase the keys because we normalize attribute keys when parsing from regex, 198 | // and we need the cast keys to match with the attribute keys when casting. 199 | return array_change_key_case($this->casts, CASE_LOWER); 200 | } 201 | 202 | /** 203 | * Determine whether an attribute should be cast to a native type. 204 | * 205 | * @param string $key 206 | * @param array|string|null $types 207 | * @return bool 208 | */ 209 | public function hasCast($key, $types = null) 210 | { 211 | if (array_key_exists($key, $this->getCasts())) { 212 | return $types ? in_array($this->getCastType($key), (array) $types, true) : true; 213 | } 214 | 215 | return false; 216 | } 217 | 218 | /** 219 | * Get the format for database stored dates. 220 | * 221 | * @return string 222 | */ 223 | public function getDateFormat() 224 | { 225 | return $this->dateFormat ?: app('db')->connection()->getQueryGrammar()->getDateFormat(); 226 | } 227 | 228 | /** 229 | * Dynamically retrieve attributes on the shortcode. 230 | * 231 | * @return mixed 232 | */ 233 | public function __get(string $key) 234 | { 235 | if (! $key) { 236 | return; 237 | } 238 | 239 | // Key must be lowercase as this is how we store and normalize attributes. 240 | if (isset($this->attributes[strtolower($key)])) { 241 | 242 | // Any attributes with casts are already casted. 243 | return $this->attributes[strtolower($key)]; 244 | } 245 | 246 | // Not looking for an attribute. 247 | return $this->{$key} ?? null; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /stubs/Shortcode.stub: -------------------------------------------------------------------------------- 1 | attributes 20 | // $this->body 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/MakeShortcodeTest.php: -------------------------------------------------------------------------------- 1 | app->path('Shortcodes').'/HelloWorld.php')) { 15 | unlink($path); 16 | } 17 | } 18 | 19 | /** 20 | * Test the console command. 21 | */ 22 | public function test_command_makes_file(): void 23 | { 24 | $this->artisan('make:shortcode', ['name' => 'HelloWorld'])->assertExitCode(0); 25 | 26 | $this->assertFileExists($this->app->path('Shortcodes').'/HelloWorld.php'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/ShortcodeTest.php: -------------------------------------------------------------------------------- 1 | shortcodes = collect([ 40 | new OutputBody, 41 | new OutputAttributes, 42 | new CastBoolean, 43 | new CastDate, 44 | new CastFloat, 45 | new CastInteger, 46 | new CastArray, 47 | new CastDate, 48 | new CastCollection, 49 | new CastHashed, 50 | new CastJson, 51 | new CastObject, 52 | new CastString, 53 | new CastEncrypted, 54 | ]); 55 | } 56 | 57 | /** 58 | * Test that non-existing shortcodes are not being compiled. 59 | */ 60 | public function test_shortcode_not_is_compiled(): void 61 | { 62 | if (file_exists($path = $this->app->path('Shortcodes').'/HelloWorld.php')) { 63 | unlink($path); 64 | 65 | Shortcode::clearCache(); 66 | } 67 | 68 | $content = '[hello_world]'; 69 | 70 | $compiledContent = Shortcode::compile($content); 71 | 72 | $this->assertSame($compiledContent, $content); 73 | } 74 | 75 | /** 76 | * Test that shortcode syntax can be escaped. 77 | */ 78 | public function test_shortcode_is_escaped(): void 79 | { 80 | $compiledContent = Compiler::compile('[[output_body]]', $this->shortcodes); 81 | 82 | $this->assertSame('[output_body]', $compiledContent); 83 | } 84 | 85 | /** 86 | * Test that shortcode syntax can be escaped. 87 | */ 88 | public function test_shortcode_body_is_parsed(): void 89 | { 90 | $compiledContent = Compiler::compile('[output_body]Hello World[/output_body]', $this->shortcodes); 91 | 92 | $this->assertSame('Hello World', $compiledContent); 93 | } 94 | 95 | /** 96 | * Test that no attributes are being parsed. 97 | */ 98 | public function test_shortcode_attributes_is_not_parsed(): void 99 | { 100 | $compiledContent = Compiler::compile('[output_attributes]', $this->shortcodes); 101 | 102 | $expected = print_r(null, true); 103 | 104 | $this->assertSame($expected, $compiledContent); 105 | } 106 | 107 | /** 108 | * Test that any unclosed HTML tags are being rejected from attributes. 109 | */ 110 | public function test_shortcode_attributes_reject_unclosed_html_tags(): void 111 | { 112 | $compiledContent = Compiler::compile('[output_attributes html="

Hello World<"]', $this->shortcodes); 113 | 114 | $expected = print_r(null, true); 115 | 116 | $this->assertSame($expected, $compiledContent); 117 | } 118 | 119 | /** 120 | * Test the various syntaxes for attributes. 121 | * 122 | * @link https://unit-tests.svn.wordpress.org/trunk/tests/shortcode.php 123 | */ 124 | public function test_shortcode_attributes_syntaxes(): void 125 | { 126 | collect([ 127 | '[output_attributes /]' => '', 128 | '[output_attributes https://www.youtube.com/watch?v=dQw4w9WgXcQ]' => [0 => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'], 129 | '[output_attributes foo]' => [0 => 'foo'], 130 | '[output_attributes foo="bar"]' => ['foo' => 'bar'], 131 | '[output_attributes foo="bar" /]' => ['foo' => 'bar'], 132 | "[output_attributes 'foo bar']" => [0 => 'foo bar'], 133 | "[output_attributes foo='bar']" => ['foo' => 'bar'], 134 | '[output_attributes 123 http://wordpress.com/ 0 "foo" bar]' => [0 => '123', 1 => 'http://wordpress.com/', 2 => 0, 3 => 'foo', 4 => 'bar'], 135 | '[output_attributes 123 url=http://wordpress.com/ foo bar="baz"]' => [0 => '123', 'url' => 'http://wordpress.com/', 1 => 'foo', 'bar' => 'baz'], 136 | '[output_attributes foo="bar" baz="bing"]content[/output_attributes]' => ['foo' => 'bar', 'baz' => 'bing'], 137 | '[output_attributes foobar="hello" fooBar="world"]' => ['foobar' => 'world'], // test normalizing 138 | ])->each(function ($output, $tag) { 139 | $compiledContent = Compiler::compile($tag, $this->shortcodes); 140 | 141 | $expected = $output; 142 | 143 | if (is_array($output)) { 144 | $expected = print_r($output, true); 145 | } 146 | 147 | $this->assertSame($expected, $compiledContent); 148 | }); 149 | } 150 | 151 | /** 152 | * Test that the getTag method returns the correct tag. 153 | */ 154 | public function test_get_tag(): void 155 | { 156 | $shortcode = new OutputBody; 157 | $this->assertSame('output_body', $shortcode->getTag()); 158 | 159 | $shortcode = new OutputAttributes; 160 | $this->assertSame('output_attributes', $shortcode->getTag()); 161 | } 162 | 163 | /** 164 | * Test that the dispatch method correctly handles shortcodes. 165 | */ 166 | public function test_dispatch(): void 167 | { 168 | $shortcode = new class extends Shortcode 169 | { 170 | public function handle(): ?string 171 | { 172 | return 'Handled Content'; 173 | } 174 | }; 175 | 176 | $matches = [ 177 | '[example]', // shortcode 178 | '', // prefix 179 | 'example', // tag 180 | '', // attributes 181 | '', // tagClose 182 | '', // body 183 | '', // suffix 184 | ]; 185 | 186 | $this->assertSame('Handled Content', $shortcode->dispatch($matches)); 187 | } 188 | 189 | /** 190 | * Test that the getClasses method returns the correct classes. 191 | */ 192 | public function test_get_classes(): void 193 | { 194 | $classes = Shortcode::getClasses(); 195 | 196 | $this->assertInstanceOf(Collection::class, $classes); 197 | } 198 | 199 | /** 200 | * Test that the getNamespacedClasses method returns the correct namespaced classes. 201 | */ 202 | public function test_get_namespaced_classes(): void 203 | { 204 | $namespacedClasses = Shortcode::getNamespacedClasses(); 205 | 206 | $this->assertInstanceOf(Collection::class, $namespacedClasses); 207 | } 208 | 209 | /** 210 | * Test that the getInstantiatedClasses method returns the correct instances. 211 | */ 212 | public function test_get_instantiated_classes(): void 213 | { 214 | $instances = Shortcode::getInstantiatedClasses(); 215 | 216 | $this->assertInstanceOf(Collection::class, $instances); 217 | } 218 | 219 | /** 220 | * Test that the all method returns all instantiated classes. 221 | */ 222 | public function test_all(): void 223 | { 224 | $all = Shortcode::all(); 225 | 226 | $this->assertInstanceOf(Collection::class, $all); 227 | } 228 | 229 | /** 230 | * Test that the compile method compiles content correctly. 231 | */ 232 | public function test_compile(): void 233 | { 234 | $content = '[example]'; 235 | $compiledContent = Shortcode::compile($content); 236 | 237 | $this->assertSame($content, $compiledContent); 238 | } 239 | 240 | /** 241 | * Data provider for test_shortcode_attributes_casting. 242 | */ 243 | public static function shortcodeAttributesCastingProvider(): array 244 | { 245 | return [ 246 | 'boolean true' => ['[cast_boolean testBoolean="1"]', 'true'], 247 | 'boolean false' => ['[cast_boolean testBoolean="0"]', 'true'], 248 | 'date 2023-06-29' => ['[cast_date testDate="2023-06-29"]', 'true'], 249 | 'date 2020-01-01' => ['[cast_date testDate="2020-01-01"]', 'true'], 250 | 'integer 3' => ['[cast_integer testInt="3"]', 'true'], 251 | 'integer 35460' => ['[cast_integer testInt="35460"]', 'true'], 252 | 'float 5.67' => ['[cast_float testFloat="5.67"]', 'true'], 253 | 'float 15.011' => ['[cast_float testFloat="15.011"]', 'true'], 254 | 'string example' => ['[cast_string testString="example"]', 'true'], 255 | 'array 1,2,3' => ['[cast_array testArray=\'{"key":"value"}\']', 'true'], 256 | 'collection a,b,c' => ['[cast_collection testCollection=\'{"key":"value"}\']', 'true'], 257 | 'object {"key":"value"}' => ['[cast_object testObject=\'{"key":"value"}\']', 'true'], 258 | 'json {"key":"value"}' => ['[cast_json testJson=\'{"key":"value"}\']', 'true'], 259 | 'string empty' => ['[cast_string testString=""]', 'true'], 260 | 'boolean empty' => ['[cast_boolean testBoolean=""]', 'true'], 261 | 'date empty' => ['[cast_date testDate=""]', 'true'], 262 | 'integer empty' => ['[cast_integer testInt=""]', 'true'], 263 | 'float empty' => ['[cast_float testFloat=""]', 'true'], 264 | 'array empty' => ['[cast_array testArray=""]', 'false'], 265 | 'collection empty' => ['[cast_collection testCollection=""]', 'true'], 266 | 'object empty' => ['[cast_object testObject=""]', 'false'], 267 | 'json empty' => ['[cast_json testJson=""]', 'false'], 268 | // FIXME `A facade root has not been set.` 269 | // 'encrypted secret' => [sprintf('[cast_encrypted testEncrypted="%s"]', Crypt::encrypt('secret')), 'true', 'encrypted'], 270 | // 'hashed password' => [sprintf('[cast_hashed testHashed="%s"]', Crypt::encrypt('password')), 'true', 'hashed'], 271 | ]; 272 | } 273 | 274 | /** 275 | * @dataProvider shortcodeAttributesCastingProvider 276 | */ 277 | public function test_shortcode_attributes_casting(string $tag, string $expected): void 278 | { 279 | $compiledContent = Compiler::compile($tag, $this->shortcodes); 280 | 281 | $this->assertSame($expected, $compiledContent); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastArray.php: -------------------------------------------------------------------------------- 1 | 'array', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_array($this->testArray) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastBoolean.php: -------------------------------------------------------------------------------- 1 | 'boolean', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_bool($this->testBoolean) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastCollection.php: -------------------------------------------------------------------------------- 1 | 'collection', 12 | ]; 13 | 14 | /** 15 | * The code to run when the Shortcode is being compiled. 16 | * 17 | * You may return a string from here, that will then 18 | * be inserted into the content being compiled. 19 | */ 20 | public function handle(): ?string 21 | { 22 | return $this->testCollection instanceof Collection ? 'true' : 'false'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastDate.php: -------------------------------------------------------------------------------- 1 | 'date', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return $this->testDate instanceof \Illuminate\Support\Carbon ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastEncrypted.php: -------------------------------------------------------------------------------- 1 | 'encrypted', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_string($this->testEncrypted) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastFloat.php: -------------------------------------------------------------------------------- 1 | 'float', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_float($this->testFloat) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastHashed.php: -------------------------------------------------------------------------------- 1 | 'hashed', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | dump($this->testHashed); 22 | 23 | return is_string($this->testHashed) ? 'true' : 'false'; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastInteger.php: -------------------------------------------------------------------------------- 1 | 'integer', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_int($this->testInt) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastJson.php: -------------------------------------------------------------------------------- 1 | 'json', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_array($this->testJson) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastObject.php: -------------------------------------------------------------------------------- 1 | 'object', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_object($this->testObject) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/CastString.php: -------------------------------------------------------------------------------- 1 | 'string', 11 | ]; 12 | 13 | /** 14 | * The code to run when the Shortcode is being compiled. 15 | * 16 | * You may return a string from here, that will then 17 | * be inserted into the content being compiled. 18 | */ 19 | public function handle(): ?string 20 | { 21 | return is_string($this->testString) ? 'true' : 'false'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Shortcodes/OutputAttributes.php: -------------------------------------------------------------------------------- 1 | attributes, true); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Shortcodes/OutputBody.php: -------------------------------------------------------------------------------- 1 | body; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |