├── .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 |

315 |
316 |
317 |

318 |
319 |
320 |

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 |