├── .github └── workflows │ ├── test-old.yaml │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── docker-compose.yaml ├── docker ├── php-5.x │ └── Dockerfile └── php │ ├── Dockerfile │ └── php.ini ├── infection.json ├── phpunit.xml.dist ├── psalm.xml ├── src ├── Event │ ├── FilterShortcodesEvent.php │ └── ReplaceShortcodesEvent.php ├── EventContainer │ ├── EventContainer.php │ └── EventContainerInterface.php ├── EventHandler │ ├── FilterRawEventHandler.php │ └── ReplaceJoinEventHandler.php ├── Events.php ├── Handler │ ├── ContentHandler.php │ ├── DeclareHandler.php │ ├── EmailHandler.php │ ├── NameHandler.php │ ├── NullHandler.php │ ├── PlaceholderHandler.php │ ├── RawHandler.php │ ├── SerializerHandler.php │ ├── UrlHandler.php │ └── WrapHandler.php ├── HandlerContainer │ ├── HandlerContainer.php │ ├── HandlerContainerInterface.php │ └── ImmutableHandlerContainer.php ├── Parser │ ├── ParserInterface.php │ ├── RegexParser.php │ ├── RegularParser.php │ └── WordpressParser.php ├── Processor │ ├── Processor.php │ ├── ProcessorContext.php │ └── ProcessorInterface.php ├── Serializer │ ├── JsonSerializer.php │ ├── SerializerInterface.php │ ├── TextSerializer.php │ ├── XmlSerializer.php │ └── YamlSerializer.php ├── Shortcode │ ├── AbstractShortcode.php │ ├── ParsedShortcode.php │ ├── ParsedShortcodeInterface.php │ ├── ProcessedShortcode.php │ ├── ReplacedShortcode.php │ ├── Shortcode.php │ └── ShortcodeInterface.php ├── ShortcodeFacade.php ├── Syntax │ ├── CommonSyntax.php │ ├── Syntax.php │ ├── SyntaxBuilder.php │ └── SyntaxInterface.php └── Utility │ └── RegexBuilderUtility.php └── tests ├── AbstractTestCase.php ├── EventsTest.php ├── FacadeTest.php ├── Fake └── ReverseShortcode.php ├── HandlerContainerTest.php ├── ParserTest.php ├── ProcessorTest.php ├── SerializerTest.php ├── ShortcodeTest.php └── SyntaxTest.php /.github/workflows/test-old.yaml: -------------------------------------------------------------------------------- 1 | name: TestOld 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: ~ 7 | workflow_dispatch: ~ 8 | 9 | jobs: 10 | test: 11 | runs-on: '${{ matrix.os }}' 12 | strategy: 13 | matrix: 14 | php: ['5.3', '5.4', '5.5', '5.6', '7.0', '7.1', '7.2', '7.3'] 15 | os: ['ubuntu-latest'] 16 | steps: 17 | - name: 'Checkout' 18 | uses: 'actions/checkout@v4' 19 | - name: 'Install PHP' 20 | uses: 'shivammathur/setup-php@v2' 21 | with: 22 | php-version: '${{ matrix.php }}' 23 | tools: 'composer:v1' 24 | coverage: 'xdebug' 25 | - name: 'PHP' 26 | run: 'php -v' 27 | 28 | - name: 'Composer' 29 | run: 'composer install' 30 | continue-on-error: '${{ matrix.failure }}' 31 | - name: 'PHPUnit' 32 | run: 'php vendor/bin/phpunit --coverage-text' 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: ~ 7 | workflow_dispatch: ~ 8 | 9 | jobs: 10 | test: 11 | runs-on: '${{ matrix.os }}' 12 | strategy: 13 | matrix: 14 | php: ['7.4', '8.0', '8.1', '8.2', '8.3'] 15 | os: ['ubuntu-latest'] 16 | failure: [false] 17 | include: 18 | - { php: '8.4', os: 'ubuntu-latest', failure: true } # Psalm does not support PHP 8.4 yet 19 | - { php: '8.5', os: 'ubuntu-latest', failure: true } # '8.5' means 'nightly' 20 | steps: 21 | - name: 'Checkout' 22 | uses: 'actions/checkout@v4' 23 | - name: 'Install PHP' 24 | uses: 'shivammathur/setup-php@v2' 25 | with: 26 | php-version: '${{ matrix.php }}' 27 | tools: 'composer:v2' 28 | coverage: 'xdebug' 29 | - name: 'PHP' 30 | run: 'php -v' 31 | 32 | - name: 'Composer' 33 | run: 'composer install' 34 | continue-on-error: '${{ matrix.failure }}' 35 | - name: 'PHPUnit' 36 | run: 'php vendor/bin/phpunit --coverage-text' 37 | continue-on-error: '${{ matrix.failure }}' 38 | - name: 'Psalm' 39 | run: | 40 | composer remove --dev -W 'phpunit/phpunit' 41 | composer require --dev -W 'vimeo/psalm=^5.0' 'nikic/php-parser=^4.0' 42 | php vendor/bin/psalm --shepherd --php-version=${{ matrix.php }} 43 | continue-on-error: '${{ matrix.failure }}' 44 | - name: 'Infection' 45 | run: | 46 | composer remove --dev -W 'vimeo/psalm' 47 | composer require --dev -W phpunit/phpunit infection/infection 48 | php vendor/bin/infection 49 | continue-on-error: '${{ matrix.failure }}' 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .phpunit.result.cache 3 | infection.log 4 | vendor 5 | composer.lock 6 | coverage 7 | coverage.xml 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 0.* 4 | 5 | * v0.6.2 (25.04.2016) 6 | 7 | * fixed issue with parsing shortcode tokens inside shortcode content. 8 | 9 | * v0.6.1 (25.02.2016) 10 | 11 | * fixed bug with not recalculating new text length after applying shortcode replacement which caused the replacements to be applied only up to the length of source text. 12 | 13 | * v0.6.0 (13.02.2016) 14 | 15 | * README was completely rewritten to take into account newest additions, 16 | * introduced `FilterShortcodesEvent` for modifying set of parsed shortcodes before processing them, 17 | * introduced `ReplaceShortcodesEvent` to alter the way shortcode replacements are applied to source text at each processing level, 18 | * introduced `EventContainerInterface` with default implementation `EventContainer` to store event handlers, 19 | * introduced events handling in `Processor` with events above, 20 | * added `Processor::withEventContainer()` to configure event handlers, 21 | * added `ProcessedShortcode::hasAncestor()` to detect if shortcode has any parent with given name, 22 | * introduced `ReplacedShortcode` which represents parsed shortcode data with replacement returned from its handler, 23 | * introduced ready to use event handlers classes: 24 | * `FilterRawEventHandler` allows to automatically configure shortcodes that should not have their content processed, 25 | * `ReplaceJoinEventHandler` discards the parent shortcode content and returns only concatenated replacements, 26 | * fixed `HandlerContainer::addAlias()` bug that may have silently added aliases for default handler, 27 | * added possibility to create `WordpressParser` with names from array or `HandlerContainer` to catch only those like WordPress does, 28 | * fixed differences between parsers and standardized validating allowed characters in their names in `RegexBuilderUtility::buildNameRegex()` 29 | * introduced several ready to use shortcode handlers described in dedicated section of README, 30 | * refactored `ShortcodeFacade` for better developer experience. 31 | 32 | * v0.5.3 (26.01.2016) 33 | * massive performance improvements in RegularParser, 34 | * fixed problem with multibyte characters in parsed texts, 35 | * fixed matching shortcodes with invalid names. 36 | 37 | * v0.5.2 (20.01.2016) 38 | * fixed bug with subsequent string tokens in RegularParser. 39 | 40 | * v0.5.1 (12.11.2015) 41 | 42 | * fixed bug leaving part of shortcode text when it contained multibyte characters. 43 | 44 | * v0.5.0 (28.10.2015) 45 | 46 | * fixed bug with parent shortcode not being correctly set when there was more than one shortcode at given recursion level, 47 | * fixed bug which caused shortcode content to be returned without modification when auto processing was enabled, there was no handler for that shortcode, but there were handlers for inner shortcodes, 48 | * added example demonstrating how to remove content outside shortcodes, 49 | * added `ProcessedShortcode::getTextContent()` to allow returning unprocessed content regardless of auto processing setting value, 50 | * added XML and YAML serializers, 51 | * AbstractShortcode::getParameter() does not throw exception for missing parameter without default value, 52 | * removed `create*()` methods from `ShortcodeFacade`, now all dependencies construction is inside the constructor, 53 | * removed classes and methods deprecated in previous releases, 54 | * removed `RegexExtractor` and `ExtractorInterface`, its functionality was moved to `Parser` - now it returns instances of `ParsedShortcodeInterface`, 55 | * removed `Match` and `MatchInterface`, 56 | * removed `HandlerInterface`, from now on handlers can be only closures and classes with `__invoke()` (`callable` typehint), 57 | * removed all handler-related methods from `Processor` (extracted to `HandlerContainer`): 58 | * `addHandler()`, 59 | * `addHandlerAlias()`, 60 | * `setDefaultHandler()`. 61 | * refactored `ShortcodeFacade` to also use `HandlerContainer`, also `SyntaxInterface` parameter is now required, 62 | * `Processor` is now immutable, options setters were refactored to return reconfigured clones: 63 | * `setRecursionDepth()` » `withRecursionDepth()`, 64 | * `setMaxIterations()` » `withMaxIterations()`, 65 | * `setAutoProcessContent()` » `withAutoProcessContent()`, 66 | * extracted `HandlerContainerInterface` and its default implementation `HandlerContainer` from `Processor`, 67 | * `Processor` now requires instance of `HandlerContainer`, 68 | * introduced `RegularParser` with dedicated parser implementation that correctly handles nested shortcodes, 69 | * introduced `WordpressParser` with slightly refactored implementation of WordPress' regex-based shortcodes in case anyone would like full compatibility, 70 | * introduced `ImmutableHandlerContainer` as an alternative implementation, 71 | * introduced `ProcessorContext` to store internal state when processing text, 72 | * introduced `AbstractShortcode`, restored `final` on regular `Shortcode`, 73 | * `ProcessedShortcode` can be now created with static method `createFromContext()` using instance of `ProcessorContext`, 74 | * introduced `ParsedShortcode` and `ParsedShortcodeInterface` that extends `ShortcodeInterface` with position and exact text match. 75 | 76 | * v0.4.0 (15.07.2015) 77 | 78 | * classes and interfaces were moved to their own namespaces, update your `use` clauses and use new names. Backward compatibility was fully maintained, but note that previous class files will be removed in the next release. Old class files contain call to `class_alias()` and empty implementation for IDE autocompletion, interfaces extend those from new locations. All modified elements are listed below: 79 | * `Extractor` » `Parser\RegexExtractor`, 80 | * `ExtractorInterface` » `Extractor\ExtractorInterface`, 81 | * `HandlerInterface` » `Extractor\HandlerInterface`, 82 | * `Parser` » `Parser\RegexParser`, 83 | * `ParserInterface` » `Parser\ParserInterface`, 84 | * `Processor` » `Processor\Processor`, 85 | * `ProcessorInterface` » `Processor\ProcessorInterface`, 86 | * `SerializerInterface` » `Serializer\SerializerInterface`, 87 | * `Shortcode` » `Shortcode\Shortcode`, 88 | * `Syntax` » `Syntax\Syntax`, 89 | * `SyntaxBuilder` » `Syntax\SyntaxBuilder`, 90 | * next version of this library will remove all files marked as deprecated (listed above) and will introduce backward incompatible changes to allow finishing refactorings for version 1.0. Sneak peek: 91 | * `Extractor` abstraction will be removed and its functionality will be merged with `Parser`, 92 | * processing shortcode content will be moved to its handler, 93 | * `ProcessedShortcode` will be aware of `ProcessorInterface` instance that is processing it, 94 | * `HandlerContainer` will be refactored outside `Processor` to remove SRP violation, 95 | * various methods will lose their ability to accept nullable parameters to enforce visibility of dependencies, 96 | * `ProcessedShortcode` will not extend `Shortcode` and `Shortcode` will be `final` again, 97 | * `Match` class will be removed and `ParsedShortcode` will be introduced in its place, 98 | * introduced `ShortcodeInterface` for reusable shortcode implementation, handlers should typehint it, 99 | * nearly all classes and interfaces were renamed and moved to their own namespaces, see UPGRADE, 100 | * introduced `ProcessedShortcode` to provide more runtime information about context in handlers, 101 | * strict syntax capabilities were removed (will be reimplemented in the future), 102 | * introduced `CommonSyntax` with default values, 103 | * introduced `RegexBuilderUtility` to separate regex building from `Syntax` class, 104 | * improved regular expressions which now offer more flexibility, 105 | * `HandlerInterface` was deprecated, please use classes with __invoke() method. 106 | 107 | * v0.3.0 (08.05.2015) 108 | 109 | * added support for `[self-closing /]` shortcodes, 110 | * added library facade for easier usage, 111 | * `Syntax` regular expressions are now built once in constructor, 112 | * added support for whitespace between shortcode fragments, ie. `[ code arg = val ] [ / code ]`, 113 | * `Syntax` and `SyntaxBuilder` support whitespaced and strict syntaxes. 114 | 115 | * v0.2.2 (26.04.2015) 116 | 117 | * fixed support for PHP 5.3. 118 | 119 | * v0.2.1 (23.04.2015) 120 | 121 | * fixed matching simple parameter values enclosed by delimiters, 122 | * fixed missing support for escaping characters inside parameter values. 123 | 124 | * v0.2.0 (17.04.2015) 125 | 126 | * added HandlerInterface to enable shortcode handlers with basic validation capabilities, 127 | * added default handler for processing shortcodes without registered name handlers, 128 | * added handler aliasing to reuse name handlers without manually registering them, 129 | * added recursive processing with ability to control recursion depth, 130 | * added iterative processing with ability to control maximum number of iterations, 131 | * added configurable syntax to enable different shortcode formats without modifying library code, 132 | * added syntax builder to ease building `Syntax` object, 133 | * added dash `-` to allowed characters in shortcode names, 134 | * deprecated `Processor::setRecursion()`, use `Processor::setRecursionDepth()` instead, 135 | * removed regular expressions constants from classes. 136 | 137 | * v0.1.0 (06.04.2015) 138 | 139 | * first library version. 140 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2025 Tomasz Kowalczyk 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 furnished 8 | 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 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PHP_VERSION ?= 8.0 2 | PHP := docker-compose run --rm php-${PHP_VERSION} 3 | 4 | php-version: 5 | ${PHP} php -v 6 | cache-clear: 7 | sudo rm -rfv coverage .phpunit.result.cache infection.log 8 | 9 | docker-build: 10 | docker-compose build 11 | 12 | composer-install: 13 | ${PHP} composer install 14 | composer-self-update: 15 | ${PHP} composer self-update 16 | composer-update: 17 | ${PHP} composer update 18 | composer-require: 19 | ${PHP} composer require ${PACKAGE} 20 | composer-require-dev: 21 | ${PHP} composer require --dev ${PACKAGE} 22 | 23 | test: test-phpunit test-infection qa-psalm 24 | test-phpunit: 25 | ${PHP} php -v 26 | ${PHP} php vendor/bin/phpunit --coverage-text 27 | test-phpunit-local: 28 | php -v 29 | php vendor/bin/phpunit --coverage-text 30 | php vendor/bin/psalm --no-cache 31 | php vendor/bin/infection 32 | test-infection: 33 | ${PHP} php vendor/bin/infection -j2 --min-msi=80 34 | 35 | travis: 36 | # PHP_VERSION=5.3 make travis-job 37 | PHP_VERSION=5.4 make travis-job 38 | PHP_VERSION=5.5 make travis-job 39 | PHP_VERSION=5.6 make travis-job 40 | PHP_VERSION=7.0 make travis-job 41 | PHP_VERSION=7.1 make travis-job 42 | PHP_VERSION=7.2 make travis-job 43 | PHP_VERSION=7.3 make travis-job 44 | PHP_VERSION=7.4 make travis-job 45 | PHP_VERSION=8.0 make travis-job 46 | travis-job: 47 | ${PHP} composer update --no-plugins 48 | ${PHP} php -v 49 | ${PHP} php vendor/bin/phpunit 50 | if ${PHP} php -r 'exit((int)(version_compare(PHP_VERSION, "7.1", ">=") === false));'; then \ 51 | ${PHP} composer require --dev vimeo/psalm infection/infection; \ 52 | ${PHP} vendor/bin/psalm --threads=1 --no-cache --shepherd --find-unused-psalm-suppress; \ 53 | ${PHP} vendor/bin/infection; \ 54 | ${PHP} composer remove --dev vimeo/psalm infection/infection; \ 55 | fi; 56 | 57 | qa-psalm: 58 | ${PHP} php vendor/bin/psalm --no-cache 59 | qa-psalm-suppressed: 60 | grep -rn psalm-suppress src 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shortcode 2 | 3 | [![Build Status](https://github.com/thunderer/Shortcode/actions/workflows/test.yaml/badge.svg)](https://github.com/thunderer/Shortcode) 4 | [![Latest Stable Version](https://poser.pugx.org/thunderer/shortcode/v/stable.svg)](https://packagist.org/packages/thunderer/shortcode) 5 | [![Total Downloads](https://poser.pugx.org/thunderer/shortcode/downloads)](https://packagist.org/packages/thunderer/shortcode) 6 | [![License](https://poser.pugx.org/thunderer/shortcode/license.svg)](https://packagist.org/packages/thunderer/shortcode) 7 | [![Psalm coverage](https://shepherd.dev/github/thunderer/Shortcode/coverage.svg)](https://shepherd.dev/github/thunderer/Shortcode) 8 | [![Code Coverage](https://scrutinizer-ci.com/g/thunderer/Shortcode/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/thunderer/Shortcode/?branch=master) 9 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/thunderer/Shortcode/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/thunderer/Shortcode/?branch=master) 10 | 11 | Shortcode is a framework agnostic PHP library allowing to find, extract and process text fragments called "shortcodes" or "BBCodes". Examples of their usual syntax and usage are shown below: 12 | 13 | ``` 14 | [user-profile /] 15 | [image width=600] 16 | [link href="http://google.pl" color=red] 17 | [quote="Thunderer"]This is a quote.[/quote] 18 | [text color="red"]This is a text.[/text] 19 | ``` 20 | 21 | The library is divided into several parts, each of them containing logic responsible for different stages and ways of processing data: 22 | 23 | - **parsers** extract shortcodes from text and transform them to objects, 24 | - **handlers** transform shortcodes into desired replacements, 25 | - **processors** use parsers and handlers to extract shortcodes, compute replacements, and apply them in text, 26 | - **events** alter the way processors work to provide better control over the whole process, 27 | - **serializers** convert shortcodes from and to different formats like Text, XML, JSON, and YAML. 28 | 29 | Each part is described in the dedicated section in this document. 30 | 31 | 32 | ## Installation 33 | 34 | There are no required dependencies and all PHP versions from 5.3 up to latest 8.x [are tested](https://github.com/thunderer/Shortcode/actions/workflows/test.yaml) and supported. This library is available on Composer/Packagist as `thunderer/shortcode`, to install it execute: 35 | 36 | ``` 37 | composer require thunderer/shortcode=^0.7 38 | ``` 39 | 40 | or manually update your `composer.json` with: 41 | 42 | ``` 43 | (...) 44 | "require": { 45 | "thunderer/shortcode": "^0.7" 46 | } 47 | (...) 48 | ``` 49 | 50 | and run `composer install` or `composer update` afterwards. If you're not using Composer, download sources from GitHub and load them as required. But really, please use Composer. 51 | 52 | ## Usage 53 | 54 | ### Facade 55 | 56 | To ease usage of this library there is a class `ShortcodeFacade` configured for most common needs. It contains shortcut methods for all features described in the sections below: 57 | 58 | - `addHandler()`: adds shortcode handlers, 59 | - `addHandlerAlias()`: adds shortcode handler alias, 60 | - `process()`: processes text and replaces shortcodes, 61 | - `parse()`: parses text into shortcodes, 62 | - `setParser()`: changes processor's parser, 63 | - `addEventHandler()`: adds event handler, 64 | - `serialize()`: serializes shortcode object to given format, 65 | - `unserialize()`: creates shortcode object from serialized input. 66 | 67 | ### Processing 68 | 69 | Shortcodes are processed using `Processor` which requires a parser and handlers. The example below shows how to implement an example that greets the person with name passed as an argument: 70 | 71 | ```php 72 | use Thunder\Shortcode\HandlerContainer\HandlerContainer; 73 | use Thunder\Shortcode\Parser\RegularParser; 74 | use Thunder\Shortcode\Processor\Processor; 75 | use Thunder\Shortcode\Shortcode\ShortcodeInterface; 76 | 77 | $handlers = new HandlerContainer(); 78 | $handlers->add('hello', function(ShortcodeInterface $s) { 79 | return sprintf('Hello, %s!', $s->getParameter('name')); 80 | }); 81 | $processor = new Processor(new RegularParser(), $handlers); 82 | 83 | $text = ' 84 |
[hello name="Thomas"]
85 |

Your shortcodes are very good, keep it up!

86 |
[hello name="Peter"]
87 | '; 88 | echo $processor->process($text); 89 | ``` 90 | 91 | Facade example: 92 | 93 | ```php 94 | use Thunder\Shortcode\ShortcodeFacade; 95 | use Thunder\Shortcode\Shortcode\ShortcodeInterface; 96 | 97 | $facade = new ShortcodeFacade(); 98 | $facade->addHandler('hello', function(ShortcodeInterface $s) { 99 | return sprintf('Hello, %s!', $s->getParameter('name')); 100 | }); 101 | 102 | $text = ' 103 |
[hello name="Thomas"]
104 |

Your shortcodes are very good, keep it up!

105 |
[hello name="Peter"]
106 | '; 107 | echo $facade->process($text); 108 | ``` 109 | 110 | Both result in: 111 | 112 | ``` 113 |
Hello, Thomas!
114 |

Your shortcodes are very good, keep it up!

115 |
Hello, Peter!
116 | ``` 117 | 118 | ### Configuration 119 | 120 | `Processor` has several configuration options available as `with*()` methods which return the new, changed instance to keep the object immutable. 121 | 122 | - `withRecursionDepth($depth)` controls the nesting level - how many levels of shortcodes are actually processed. If this limit is reached, all shortcodes deeper than level are ignored. If the `$depth` value is null (default value), nesting level is not checked, if it's zero then nesting is disabled (only topmost shortcodes are processed). Any integer greater than zero sets the nesting level limit, 123 | - `withMaxIterations($iterations)` controls the number of iterations that the source text is processed in. This means that source text is processed internally that number of times until the limit was reached or there are no shortcodes left. If the `$iterations` parameter value is null, there is no iterations limit, any integer greater than zero sets the limit. Defaults to one iteration, 124 | - `withAutoProcessContent($flag)` controls automatic processing of shortcode's content before calling its handler. If the `$flag` parameter is `true` then handler receives shortcode with already processed content, if `false` then handler must process nested shortcodes itself (or leave them for the remaining iterations). This is turned on by default, 125 | - `withEventContainer($events)` registers event container which provides handlers for all the events fired at various stages of processing text. Read more about events in the section dedicated to them. 126 | 127 | ### Events 128 | 129 | If processor was configured with events container there are several possibilities to alter the way shortcodes are processed: 130 | 131 | - `Events::FILTER_SHORTCODES` uses `FilterShortcodesEvent` class. It receives current parent shortcode and array of shortcodes from parser. Its purpose is to allow modifying that array before processing them, 132 | - `Events::REPLACE_SHORTCODES` uses `ReplaceShortcodesEvent` class and receives the parent shortcode, currently processed text, and array of replacements. It can alter the way shortcodes handlers results are applied to the source text. If none of the listeners set the result, the default method is used. 133 | 134 | There are several ready to use event handlers in the `Thunder\Shortcode\EventHandler` namespace: 135 | 136 | - `FilterRawEventHandler` implements `FilterShortcodesEvent` and allows to implement any number of "raw" shortcodes whose content is not processed, 137 | - `ReplaceJoinEventHandler` implements `ReplaceShortcodesEvent` and provides the mechanism to apply shortcode replacements by discarding text and returning just replacements. 138 | 139 | The example below shows how to manually implement a `[raw]` shortcode that returns its verbatim content without calling any handler for nested shortcodes: 140 | 141 | ```php 142 | use Thunder\Shortcode\Event\FilterShortcodesEvent; 143 | use Thunder\Shortcode\EventContainer\EventContainer; 144 | use Thunder\Shortcode\Events; 145 | use Thunder\Shortcode\HandlerContainer\HandlerContainer; 146 | use Thunder\Shortcode\Parser\RegularParser; 147 | use Thunder\Shortcode\Processor\Processor; 148 | use Thunder\Shortcode\Shortcode\ShortcodeInterface; 149 | 150 | $handlers = new HandlerContainer(); 151 | $handlers->add('raw', function(ShortcodeInterface $s) { return $s->getContent(); }); 152 | $handlers->add('n', function(ShortcodeInterface $s) { return $s->getName(); }); 153 | $handlers->add('c', function(ShortcodeInterface $s) { return $s->getContent(); }); 154 | 155 | $events = new EventContainer(); 156 | $events->addListener(Events::FILTER_SHORTCODES, function(FilterShortcodesEvent $event) { 157 | $parent = $event->getParent(); 158 | if($parent && ($parent->getName() === 'raw' || $parent->hasAncestor('raw'))) { 159 | $event->setShortcodes(array()); 160 | } 161 | }); 162 | 163 | $processor = new Processor(new RegularParser(), $handlers); 164 | $processor = $processor->withEventContainer($events); 165 | 166 | assert(' [n /] [c]cnt[/c] ' === $processor->process('[raw] [n /] [c]cnt[/c] [/raw]')); 167 | assert('n true [n /] ' === $processor->process('[n /] [c]true[/c] [raw] [n /] [/raw]')); 168 | ``` 169 | 170 | Facade example: 171 | 172 | ```php 173 | use Thunder\Shortcode\Event\FilterShortcodesEvent; 174 | use Thunder\Shortcode\Events; 175 | use Thunder\Shortcode\Shortcode\ShortcodeInterface; 176 | use Thunder\Shortcode\ShortcodeFacade; 177 | 178 | $facade = new ShortcodeFacade(); 179 | $facade->addHandler('raw', function(ShortcodeInterface $s) { return $s->getContent(); }); 180 | $facade->addHandler('n', function(ShortcodeInterface $s) { return $s->getName(); }); 181 | $facade->addHandler('c', function(ShortcodeInterface $s) { return $s->getContent(); }); 182 | 183 | $facade->addEventHandler(Events::FILTER_SHORTCODES, function(FilterShortcodesEvent $event) { 184 | $parent = $event->getParent(); 185 | if($parent && ($parent->getName() === 'raw' || $parent->hasAncestor('raw'))) { 186 | $event->setShortcodes(array()); 187 | } 188 | }); 189 | 190 | assert(' [n /] [c]cnt[/c] ' === $facade->process('[raw] [n /] [c]cnt[/c] [/raw]')); 191 | assert('n true [n /] ' === $facade->process('[n /] [c]true[/c] [raw] [n /] [/raw]')); 192 | ``` 193 | 194 | ## Parsing 195 | 196 | This section discusses available shortcode parsers. Regardless of the parser that you will choose, remember that: 197 | 198 | - shortcode names can be only aplhanumeric characters and dash `-`, basically must conform to the `[a-zA-Z0-9-]+` regular expression, 199 | - unsupported shortcodes (no registered handler or default handler) will be ignored and left as they are, 200 | - mismatching closing shortcode (`[code]content[/codex]`) will be ignored, opening tag will be interpreted as self-closing shortcode, eg. `[code /]`, 201 | - overlapping shortcodes (`[code]content[inner][/code]content[/inner]`) will be interpreted as self-closing, eg. `[code]content[inner /][/code]`, second closing tag will be ignored, 202 | 203 | There are three included parsers in this library: 204 | 205 | - `RegularParser` is the most powerful and correct parser available in this library. It contains the actual parser designed to handle all the issues with shortcodes like proper nesting or detecting invalid shortcode syntax. It is slightly slower than regex-based parser described below, 206 | - `RegexParser` uses a handcrafted regular expression dedicated to handle shortcode syntax as much as regex engine allows. It is fastest among the parsers included in this library, but it can't handle nesting properly, which means that nested shortcodes with the same name are also considered overlapping - (assume that shortcode `[c]` returns its content) string `[c]x[c]y[/c]z[/c]` will be interpreted as `xyz[/c]` (first closing tag was matched to first opening tag). This can be solved by aliasing handler name, because for example `[c]x[d]y[/d]z[/c]` will be processed correctly, 207 | - `WordpressParser` contains code copied from the latest currently available WordPress (4.3.1). It is also a regex-based parser, but the included regular expression is quite weak, it for example won't support BBCode syntax (`[name="param" /]`). This parser by default supports the shortcode name rule, but can break it when created with one of the named constructors (`createFromHandlers()` or `createFromNames()`) that change its behavior to catch only configured names. All of it is intentional to keep the compatibility with what WordPress is capable of if you need that compatibility. 208 | 209 | ### Syntax 210 | 211 | All parsers (except `WordpressParser`) support configurable shortcode syntax which can be configured by passing `SyntaxInterface` object as the first parameter. There is a convenience class `CommonSyntax` that contains default syntax. Usage is shown in the examples below: 212 | 213 | ```php 214 | use Thunder\Shortcode\HandlerContainer\HandlerContainer; 215 | use Thunder\Shortcode\Parser\RegexParser; 216 | use Thunder\Shortcode\Parser\RegularParser; 217 | use Thunder\Shortcode\Processor\Processor; 218 | use Thunder\Shortcode\Shortcode\ShortcodeInterface; 219 | use Thunder\Shortcode\Syntax\CommonSyntax; 220 | use Thunder\Shortcode\Syntax\Syntax; 221 | use Thunder\Shortcode\Syntax\SyntaxBuilder; 222 | 223 | $builder = new SyntaxBuilder(); 224 | ``` 225 | 226 | Default syntax (called "common" in this library): 227 | 228 | ``` 229 | $defaultSyntax = new Syntax(); // without any arguments it defaults to common syntax 230 | $defaultSyntax = new CommonSyntax(); // convenience class 231 | $defaultSyntax = new Syntax('[', ']', '/', '=', '"'); // created explicitly 232 | $defaultSyntax = $builder->getSyntax(); // builder defaults to common syntax 233 | ``` 234 | 235 | Syntax with doubled tokens: 236 | 237 | ```php 238 | $doubleSyntax = new Syntax('[[', ']]', '//', '==', '""'); 239 | $doubleSyntax = $builder // actually using builder 240 | ->setOpeningTag('[[') 241 | ->setClosingTag(']]') 242 | ->setClosingTagMarker('//') 243 | ->setParameterValueSeparator('==') 244 | ->setParameterValueDelimiter('""') 245 | ->getSyntax(); 246 | ``` 247 | 248 | Something entirely different just to show the possibilities: 249 | 250 | ```php 251 | $differentSyntax = new Syntax('@', '#', '!', '&', '~'); 252 | ``` 253 | 254 | Verify that each syntax works properly: 255 | 256 | ``` 257 | $handlers = new HandlerContainer(); 258 | $handlers->add('up', function(ShortcodeInterface $s) { 259 | return strtoupper($s->getContent()); 260 | }); 261 | 262 | $defaultRegex = new Processor(new RegexParser($defaultSyntax), $handlers); 263 | $doubleRegex = new Processor(new RegexParser($doubleSyntax), $handlers); 264 | $differentRegular = new Processor(new RegularParser($differentSyntax), $handlers); 265 | 266 | assert('a STRING z' === $defaultRegex->process('a [up]string[/up] z')); 267 | assert('a STRING z' === $doubleRegex->process('a [[up]]string[[//up]] z')); 268 | assert('a STRING z' === $differentRegular->process('a @up#string@!up# z')); 269 | ``` 270 | 271 | ## Serialization 272 | 273 | This library supports several (un)serialization formats - XML, YAML, JSON and Text. Examples below shows how to both serialize and unserialize the same shortcode in each format: 274 | 275 | ```php 276 | use Thunder\Shortcode\Serializer\JsonSerializer; 277 | use Thunder\Shortcode\Serializer\TextSerializer; 278 | use Thunder\Shortcode\Serializer\XmlSerializer; 279 | use Thunder\Shortcode\Serializer\YamlSerializer; 280 | use Thunder\Shortcode\Shortcode\Shortcode; 281 | 282 | $shortcode = new Shortcode('quote', array('name' => 'Thomas'), 'This is a quote!'); 283 | ``` 284 | 285 | Text: 286 | 287 | ```php 288 | $text = '[quote name=Thomas]This is a quote![/quote]'; 289 | $textSerializer = new TextSerializer(); 290 | 291 | $serializedText = $textSerializer->serialize($shortcode); 292 | assert($text === $serializedText); 293 | $unserializedFromText = $textSerializer->unserialize($serializedText); 294 | assert($unserializedFromText->getName() === $shortcode->getName()); 295 | ``` 296 | 297 | JSON: 298 | 299 | ```php 300 | $json = '{"name":"quote","parameters":{"name":"Thomas"},"content":"This is a quote!","bbCode":null}'; 301 | $jsonSerializer = new JsonSerializer(); 302 | $serializedJson = $jsonSerializer->serialize($shortcode); 303 | assert($json === $serializedJson); 304 | $unserializedFromJson = $jsonSerializer->unserialize($serializedJson); 305 | assert($unserializedFromJson->getName() === $shortcode->getName()); 306 | ``` 307 | 308 | YAML: 309 | 310 | ``` 311 | $yaml = "name: quote 312 | parameters: 313 | name: Thomas 314 | content: 'This is a quote!' 315 | bbCode: null 316 | "; 317 | $yamlSerializer = new YamlSerializer(); 318 | $serializedYaml = $yamlSerializer->serialize($shortcode); 319 | assert($yaml === $serializedYaml); 320 | $unserializedFromYaml = $yamlSerializer->unserialize($serializedYaml); 321 | assert($unserializedFromYaml->getName() === $shortcode->getName()); 322 | ``` 323 | 324 | XML: 325 | 326 | ``` 327 | $xml = ' 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | '; 336 | $xmlSerializer = new XmlSerializer(); 337 | $serializedXml = $xmlSerializer->serialize($shortcode); 338 | assert($xml === $serializedXml); 339 | $unserializedFromXml = $xmlSerializer->unserialize($serializedXml); 340 | assert($unserializedFromXml->getName() === $shortcode->getName()); 341 | ``` 342 | 343 | Facade also supports serialization in all available formats: 344 | 345 | ```php 346 | use Thunder\Shortcode\Shortcode\Shortcode; 347 | use Thunder\Shortcode\ShortcodeFacade; 348 | 349 | $facade = new ShortcodeFacade(); 350 | 351 | $shortcode = new Shortcode('name', array('arg' => 'val'), 'content', 'bbCode'); 352 | 353 | $text = $facade->serialize($shortcode, 'text'); 354 | $textShortcode = $facade->unserialize($text, 'text'); 355 | assert($shortcode->getName() === $textShortcode->getName()); 356 | 357 | $json = $facade->serialize($shortcode, 'json'); 358 | $jsonShortcode = $facade->unserialize($json, 'json'); 359 | assert($shortcode->getName() === $jsonShortcode->getName()); 360 | 361 | $yaml = $facade->serialize($shortcode, 'yaml'); 362 | $yamlShortcode = $facade->unserialize($yaml, 'yaml'); 363 | assert($shortcode->getName() === $yamlShortcode->getName()); 364 | 365 | $xml = $facade->serialize($shortcode, 'xml'); 366 | $xmlShortcode = $facade->unserialize($xml, 'xml'); 367 | assert($shortcode->getName() === $xmlShortcode->getName()); 368 | ``` 369 | 370 | ## Handlers 371 | 372 | There are several builtin shortcode handlers available in `Thunder\Shortcode\Handler` namespace. Description below assumes that given handler was registered with `xyz` name: 373 | 374 | - `NameHandler` always returns shortcode's name. `[xyz arg=val]content[/xyz]` becomes `xyz`, 375 | - `ContentHandler` always returns shortcode's content. It discards its opening and closing tag. `[xyz]code[/xyz]` becomes `code`, 376 | - `RawHandler` returns unprocessed shortcode content. Its behavior is different than `FilterRawEventHandler` because if content auto processing is turned on, then nested shortcodes handlers were called, just their result was discarded, 377 | - `NullHandler` completely removes shortcode with all nested shortcodes, 378 | - `DeclareHandler` allows to dynamically create shortcode handler with name as first parameter that will also replace all placeholders in text passed as arguments. Example: `[declare xyz]Your age is %age%.[/declare]` created handler for shortcode `xyz` and when used like `[xyz age=18]` the result is `Your age is 18.`, 379 | - `EmailHandler` replaces the email address or shortcode content as clickable `mailto:` link: 380 | - `[xyz="email@example.com" /]` becomes `email@example.com`, 381 | - `[xyz]email@example.com[/xyz]` becomes `email@example.com`, 382 | - `[xyz="email@example.com"]Contact me![/xyz]` becomes `Contact me!`, 383 | - `PlaceholderHandler` replaces all placeholders in shortcode's content with values of passed arguments. `[xyz year=1970]News from year %year%.[/xyz]` becomes `News from year 1970.`, 384 | - `SerializerHandler` replaces shortcode with its serialized value using serializer passed as an argument in class' constructor. If configured with `JsonSerializer`, `[xyz /]` becomes `{"name":"json", "arguments": [], "content": null, "bbCode": null}`. This could be useful for debugging your shortcodes, 385 | - `UrlHandler` replaces its content with a clickable link: 386 | - `[xyz]http://example.com[/xyz]` becomes `http://example.com`, 387 | - `[xyz="http://example.com"]Visit my site![/xyz]` becomes `Visit my site!`, 388 | - `WrapHandler` allows to specify the value that should be placed before and after shortcode content. If configured with `` and ``, the text `[xyz]Bold text.[/xyz]` becomes `Bold text.`. 389 | 390 | ## Contributing 391 | 392 | Want to contribute? Perfect! Submit an issue or Pull Request and explain what would you like to see in this library. 393 | 394 | ## License 395 | 396 | See LICENSE file in the main directory of this library. 397 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thunderer/shortcode", 3 | "description": "Advanced shortcode (BBCode) parser and engine for PHP", 4 | "keywords": ["shortcode", "bbcode", "parser", "engine", "library"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Tomasz Kowalczyk", 9 | "email": "tomasz@kowalczyk.cc" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=5.3" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": ">=4.1", 17 | "symfony/yaml": ">=2.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "Thunder\\Shortcode\\": "src/" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "Thunder\\Shortcode\\Tests\\": "tests/" 27 | } 28 | }, 29 | "suggest": { 30 | "symfony/yaml": "if you want to use YAML serializer", 31 | "ext-dom": "if you want to use XML serializer", 32 | "ext-json": "if you want to use JSON serializer" 33 | }, 34 | "config": { 35 | "allow-plugins": { 36 | "infection/extension-installer": true 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | x-php: &php 4 | volumes: ['.:/app', './docker/php/php.ini:/usr/local/etc/php/conf.d/php.ini'] 5 | working_dir: '/app' 6 | 7 | services: 8 | # PHP 5.3 contains neither mbstring extension nor docker-php-ext-install script 9 | # Original Dockerfile can be found here https://github.com/docker-library/php/pull/20/files 10 | # Unfortunately it fails to build now because GPG signatures do not exist anymore 11 | # php-5.3: { build: { context: docker/php-5.x, args: { PHP_VERSION: 5.3 } } } 12 | php-5.4: { <<: *php, build: { context: docker/php-5.x, args: { PHP_VERSION: 5.4 } } } 13 | php-5.5: { <<: *php, build: { context: docker/php-5.x, args: { PHP_VERSION: 5.5 } } } 14 | php-5.6: { <<: *php, build: { context: docker/php-5.x, args: { PHP_VERSION: 5.6 } } } 15 | php-7.0: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.0 } } } 16 | php-7.1: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.1.3 } } } 17 | php-7.2: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.2 } } } 18 | php-7.3: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.3 } } } 19 | php-7.4: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.4 } } } 20 | php-8.0: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.0 } } } 21 | -------------------------------------------------------------------------------- /docker/php-5.x/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=7.4 2 | FROM php:$PHP_VERSION 3 | 4 | RUN apt update && apt install -y --force-yes libonig-dev libzip-dev 5 | RUN docker-php-ext-install mbstring zip 6 | 7 | RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ 8 | && php -r "if (hash_file('sha384', 'composer-setup.php') === 'c31c1e292ad7be5f49291169c0ac8f683499edddcfd4e42232982d0fd193004208a58ff6f353fde0012d35fdd72bc394') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \ 9 | && php composer-setup.php \ 10 | && php -r "unlink('composer-setup.php');" \ 11 | && mv composer.phar /usr/local/bin/composer 12 | -------------------------------------------------------------------------------- /docker/php/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=8.0 2 | FROM php:$PHP_VERSION 3 | 4 | RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ 5 | && php -r "if (hash_file('sha384', 'composer-setup.php') === '906a84df04cea2aa72f40b5f787e49f22d4c2f19492ac310e8cba5b96ac8b64115ac402c8cd292b8a03482574915d1a8') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \ 6 | && php composer-setup.php \ 7 | && php -r "unlink('composer-setup.php');" \ 8 | && mv composer.phar /usr/local/bin/composer 9 | 10 | RUN apt update && apt install -y libonig-dev libzip-dev 11 | RUN docker-php-ext-install mbstring zip 12 | RUN pecl install xdebug && docker-php-ext-enable xdebug 13 | -------------------------------------------------------------------------------- /docker/php/php.ini: -------------------------------------------------------------------------------- 1 | xdebug.mode=coverage 2 | -------------------------------------------------------------------------------- /infection.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": ["src"] 4 | }, 5 | "logs": { 6 | "text": "infection.log" 7 | }, 8 | "timeout": 2, 9 | "mutators": { 10 | "@default": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | src 14 | 15 | 16 | 17 | 18 | 19 | tests 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Event/FilterShortcodesEvent.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class FilterShortcodesEvent 15 | { 16 | /** @var ProcessedShortcode|null */ 17 | private $parent; 18 | /** @var ParsedShortcodeInterface[] */ 19 | private $shortcodes = array(); 20 | 21 | /** 22 | * @param ParsedShortcodeInterface[] $shortcodes 23 | * @param ProcessedShortcode|null $parent 24 | */ 25 | public function __construct(array $shortcodes, $parent = null) 26 | { 27 | if(null !== $parent && false === $parent instanceof ProcessedShortcode) { 28 | throw new \LogicException('Parameter $parent must be an instance of ProcessedShortcode.'); 29 | } 30 | 31 | $this->parent = $parent; 32 | $this->setShortcodes($shortcodes); 33 | } 34 | 35 | /** @return ParsedShortcodeInterface[] */ 36 | public function getShortcodes() 37 | { 38 | return $this->shortcodes; 39 | } 40 | 41 | /** @return ProcessedShortcode|null */ 42 | public function getParent() 43 | { 44 | return $this->parent; 45 | } 46 | 47 | /** 48 | * @param ParsedShortcodeInterface[] $shortcodes 49 | * 50 | * @return void 51 | */ 52 | public function setShortcodes(array $shortcodes) 53 | { 54 | $this->shortcodes = array(); 55 | foreach($shortcodes as $shortcode) { 56 | $this->addShortcode($shortcode); 57 | } 58 | } 59 | 60 | /** 61 | * @param ParsedShortcodeInterface $shortcode 62 | * 63 | * @return void 64 | */ 65 | private function addShortcode(ParsedShortcodeInterface $shortcode) 66 | { 67 | $this->shortcodes[] = $shortcode; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Event/ReplaceShortcodesEvent.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class ReplaceShortcodesEvent 15 | { 16 | /** @var ShortcodeInterface|null */ 17 | private $shortcode; 18 | /** @var string */ 19 | private $text; 20 | /** @var ReplacedShortcode[] */ 21 | private $replacements = array(); 22 | /** @var string|null */ 23 | private $result; 24 | 25 | /** 26 | * @param string $text 27 | * @param ReplacedShortcode[] $replacements 28 | * @param ShortcodeInterface|null $shortcode 29 | */ 30 | public function __construct($text, array $replacements, $shortcode = null) 31 | { 32 | if(null !== $shortcode && false === $shortcode instanceof ShortcodeInterface) { 33 | throw new \LogicException('Parameter $shortcode must be an instance of ShortcodeInterface.'); 34 | } 35 | 36 | $this->shortcode = $shortcode; 37 | $this->text = $text; 38 | 39 | $this->setReplacements($replacements); 40 | } 41 | 42 | /** 43 | * @param ReplacedShortcode[] $replacements 44 | * 45 | * @return void 46 | */ 47 | private function setReplacements(array $replacements) 48 | { 49 | foreach($replacements as $replacement) { 50 | $this->addReplacement($replacement); 51 | } 52 | } 53 | 54 | /** @return void */ 55 | private function addReplacement(ReplacedShortcode $replacement) 56 | { 57 | $this->replacements[] = $replacement; 58 | } 59 | 60 | /** @return string */ 61 | public function getText() 62 | { 63 | return $this->text; 64 | } 65 | 66 | /** @return ReplacedShortcode[] */ 67 | public function getReplacements() 68 | { 69 | return $this->replacements; 70 | } 71 | 72 | /** @return ShortcodeInterface|null */ 73 | public function getShortcode() 74 | { 75 | return $this->shortcode; 76 | } 77 | 78 | /** 79 | * @param string $result 80 | * 81 | * @return void 82 | */ 83 | public function setResult($result) 84 | { 85 | $this->result = $result; 86 | } 87 | 88 | /** @return string|null */ 89 | public function getResult() 90 | { 91 | return $this->result; 92 | } 93 | 94 | /** @return bool */ 95 | public function hasResult() 96 | { 97 | return null !== $this->result; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/EventContainer/EventContainer.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class EventContainer implements EventContainerInterface 10 | { 11 | /** @psalm-var array> */ 12 | private $listeners = array(); 13 | 14 | public function __construct() 15 | { 16 | } 17 | 18 | /** 19 | * @param string $event 20 | * @param callable $handler 21 | * 22 | * @return void 23 | */ 24 | public function addListener($event, $handler) 25 | { 26 | if(!\in_array($event, Events::getEvents(), true)) { 27 | throw new \InvalidArgumentException(sprintf('Unsupported event %s!', $event)); 28 | } 29 | 30 | if(!array_key_exists($event, $this->listeners)) { 31 | $this->listeners[$event] = array(); 32 | } 33 | 34 | $this->listeners[$event][] = $handler; 35 | } 36 | 37 | /** 38 | * @param string $event 39 | * 40 | * @psalm-return list 41 | */ 42 | public function getListeners($event) 43 | { 44 | return $this->hasEvent($event) ? $this->listeners[$event] : array(); 45 | } 46 | 47 | /** 48 | * @param string $name 49 | * 50 | * @return bool 51 | */ 52 | private function hasEvent($name) 53 | { 54 | return array_key_exists($name, $this->listeners); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/EventContainer/EventContainerInterface.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | interface EventContainerInterface 8 | { 9 | /** 10 | * @param string $event 11 | * 12 | * @return callable[] 13 | */ 14 | public function getListeners($event); 15 | } 16 | -------------------------------------------------------------------------------- /src/EventHandler/FilterRawEventHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class FilterRawEventHandler 10 | { 11 | /** @var string[] */ 12 | private $names = array(); 13 | 14 | public function __construct(array $names) 15 | { 16 | foreach($names as $name) { 17 | if(false === is_string($name)) { 18 | throw new \InvalidArgumentException('Expected array of strings!'); 19 | } 20 | 21 | $this->names[] = $name; 22 | } 23 | } 24 | 25 | public function __invoke(FilterShortcodesEvent $event) 26 | { 27 | $parent = $event->getParent(); 28 | if($parent && \in_array($parent->getName(), $this->names, true)) { 29 | $event->setShortcodes(array()); 30 | 31 | return; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/EventHandler/ReplaceJoinEventHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class ReplaceJoinEventHandler 10 | { 11 | /** @var string[] */ 12 | private $names = array(); 13 | 14 | public function __construct(array $names) 15 | { 16 | foreach($names as $name) { 17 | if(false === is_string($name)) { 18 | throw new \InvalidArgumentException('Expected array of strings!'); 19 | } 20 | 21 | $this->names[] = $name; 22 | } 23 | } 24 | 25 | public function __invoke(ReplaceShortcodesEvent $event) 26 | { 27 | $shortcode = $event->getShortcode(); 28 | if($shortcode && in_array($shortcode->getName(), $this->names)) { 29 | $replaces = array(); 30 | foreach($event->getReplacements() as $r) { 31 | $replaces[] = $r->getReplacement(); 32 | } 33 | 34 | $event->setResult(implode('', $replaces)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Events.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class Events 8 | { 9 | const FILTER_SHORTCODES = 'event.filter-shortcodes'; 10 | const REPLACE_SHORTCODES = 'event.replace-shortcodes'; 11 | 12 | /** @return string[] */ 13 | public static function getEvents() 14 | { 15 | return array(static::FILTER_SHORTCODES, static::REPLACE_SHORTCODES); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Handler/ContentHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class ContentHandler 10 | { 11 | /** 12 | * [content]text to display[/content] 13 | * 14 | * @param ShortcodeInterface $shortcode 15 | * 16 | * @return null|string 17 | */ 18 | public function __invoke(ShortcodeInterface $shortcode) 19 | { 20 | return $shortcode->getContent(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Handler/DeclareHandler.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class DeclareHandler 11 | { 12 | /** @var HandlerContainer */ 13 | private $handlers; 14 | /** @var string */ 15 | private $delimiter; 16 | 17 | /** @param string $delimiter */ 18 | public function __construct(HandlerContainer $container, $delimiter = '%') 19 | { 20 | $this->handlers = $container; 21 | $this->delimiter = $delimiter; 22 | } 23 | 24 | /** 25 | * [declare name]Your name is %value%[/declare] 26 | * [name value="Thomas" /] 27 | * 28 | * @param ShortcodeInterface $shortcode 29 | */ 30 | public function __invoke(ShortcodeInterface $shortcode) 31 | { 32 | $args = $shortcode->getParameters(); 33 | if(empty($args)) { 34 | return; 35 | } 36 | $keys = array_keys($args); 37 | $name = array_shift($keys); 38 | $content = (string)$shortcode->getContent(); 39 | $delimiter = $this->delimiter; 40 | 41 | $this->handlers->add($name, function(ShortcodeInterface $shortcode) use($content, $delimiter) { 42 | $args = $shortcode->getParameters(); 43 | $keys = array_map(function($key) use($delimiter) { return $delimiter.$key.$delimiter; }, array_keys($args)); 44 | /** @var string[] $values */ 45 | $values = array_values($args); 46 | 47 | return str_replace($keys, $values, $content); 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Handler/EmailHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class EmailHandler 10 | { 11 | /** 12 | * [email="example@example.org"]Contact me![/email] 13 | * [email="example@example.org" /] 14 | * [email]example@example.org[/email] 15 | * 16 | * @param ShortcodeInterface $shortcode 17 | * 18 | * @return string 19 | */ 20 | public function __invoke(ShortcodeInterface $shortcode) 21 | { 22 | $email = null !== $shortcode->getBbCode() ? $shortcode->getBbCode() : $shortcode->getContent(); 23 | $content = $shortcode->getContent() === null ? $email : $shortcode->getContent(); 24 | 25 | return ''.(string)$content.''; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Handler/NameHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class NameHandler 10 | { 11 | /** 12 | * [name /] 13 | * [name]content is ignored[/name] 14 | * 15 | * @param ShortcodeInterface $shortcode 16 | * 17 | * @return string 18 | */ 19 | public function __invoke(ShortcodeInterface $shortcode) 20 | { 21 | return $shortcode->getName(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Handler/NullHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class NullHandler 10 | { 11 | /** 12 | * Special shortcode to discard any input and return empty text 13 | * 14 | * @param ShortcodeInterface $shortcode 15 | * 16 | * @return null 17 | */ 18 | public function __invoke(ShortcodeInterface $shortcode) 19 | { 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Handler/PlaceholderHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class PlaceholderHandler 10 | { 11 | /** @var string */ 12 | private $delimiter; 13 | 14 | /** @param string $delimiter */ 15 | public function __construct($delimiter = '%') 16 | { 17 | $this->delimiter = $delimiter; 18 | } 19 | 20 | /** 21 | * [placeholder value=18]You age is %value%[/placeholder] 22 | * 23 | * @param ShortcodeInterface $shortcode 24 | * 25 | * @return mixed 26 | */ 27 | public function __invoke(ShortcodeInterface $shortcode) 28 | { 29 | $args = $shortcode->getParameters(); 30 | $delimiter = $this->delimiter; 31 | $keys = array_map(function($key) use($delimiter) { return $delimiter.$key.$delimiter; }, array_keys($args)); 32 | /** @var string[] $values */ 33 | $values = array_values($args); 34 | 35 | return str_replace($keys, $values, (string)$shortcode->getContent()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Handler/RawHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class RawHandler 10 | { 11 | public function __construct() 12 | { 13 | } 14 | 15 | /** 16 | * [raw]any content [with] or [without /] shortcodes[/raw] 17 | * 18 | * @param ProcessedShortcode $shortcode 19 | * 20 | * @return string 21 | */ 22 | public function __invoke(ProcessedShortcode $shortcode) 23 | { 24 | return $shortcode->getTextContent(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Handler/SerializerHandler.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class SerializerHandler 11 | { 12 | /** @var SerializerInterface */ 13 | private $serializer; 14 | 15 | public function __construct(SerializerInterface $serializer) 16 | { 17 | $this->serializer = $serializer; 18 | } 19 | 20 | /** 21 | * [text arg=val /] 22 | * [text arg=val]content[/text] 23 | * [json arg=val /] 24 | * [json arg=val]content[/json] 25 | * [xml arg=val /] 26 | * [xml arg=val]content[/xml] 27 | * [yaml arg=val /] 28 | * [yaml arg=val]content[/yaml] 29 | * 30 | * @param ShortcodeInterface $shortcode 31 | * 32 | * @return string 33 | */ 34 | public function __invoke(ShortcodeInterface $shortcode) 35 | { 36 | return $this->serializer->serialize($shortcode); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Handler/UrlHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class UrlHandler 10 | { 11 | /** 12 | * [url="http://example.org"]Click![/url] 13 | * [url="http://example.org" /] 14 | * [url]http://example.org[/url] 15 | * 16 | * @param ShortcodeInterface $shortcode 17 | * 18 | * @return string 19 | */ 20 | public function __invoke(ShortcodeInterface $shortcode) 21 | { 22 | $url = null !== $shortcode->getBbCode() ? $shortcode->getBbCode() : $shortcode->getContent(); 23 | 24 | return ''.(string)$shortcode->getContent().''; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Handler/WrapHandler.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class WrapHandler 10 | { 11 | /** @var string */ 12 | private $before; 13 | /** @var string */ 14 | private $after; 15 | 16 | /** 17 | * @param string $before 18 | * @param string $after 19 | */ 20 | public function __construct($before, $after) 21 | { 22 | $this->before = $before; 23 | $this->after = $after; 24 | } 25 | 26 | /** @return self */ 27 | public static function createBold() 28 | { 29 | return new self('', ''); 30 | } 31 | 32 | /** 33 | * [b]content[b] 34 | * [strong]content[/strong] 35 | * 36 | * @param ShortcodeInterface $shortcode 37 | * 38 | * @return string 39 | */ 40 | public function __invoke(ShortcodeInterface $shortcode) 41 | { 42 | return $this->before.(string)$shortcode->getContent().$this->after; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/HandlerContainer/HandlerContainer.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class HandlerContainer implements HandlerContainerInterface 10 | { 11 | /** @psalm-var array */ 12 | private $handlers = array(); 13 | /** @psalm-var (callable(ShortcodeInterface):string)|null */ 14 | private $default = null; 15 | 16 | /** 17 | * @param string $name 18 | * @param callable $handler 19 | * @psalm-param callable(ShortcodeInterface):string $handler 20 | * 21 | * @return $this 22 | */ 23 | public function add($name, $handler) 24 | { 25 | $this->guardHandler($handler); 26 | 27 | if (empty($name) || $this->has($name)) { 28 | $msg = 'Invalid name or duplicate shortcode handler for %s!'; 29 | throw new \RuntimeException(sprintf($msg, $name)); 30 | } 31 | 32 | $this->handlers[$name] = $handler; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * @param string $alias 39 | * @param string $name 40 | * 41 | * @return $this 42 | */ 43 | public function addAlias($alias, $name) 44 | { 45 | if (false === $this->has($name)) { 46 | $msg = 'Failed to add an alias %s to non existent handler %s!'; 47 | throw new \RuntimeException(sprintf($msg, $alias, $name)); 48 | } 49 | 50 | /** @psalm-suppress PossiblyNullArgument */ 51 | return $this->add($alias, $this->get($name)); 52 | } 53 | 54 | /** 55 | * @param string $name 56 | * 57 | * @return void 58 | */ 59 | public function remove($name) 60 | { 61 | if (false === $this->has($name)) { 62 | $msg = 'Failed to remove non existent handler %s!'; 63 | throw new \RuntimeException(sprintf($msg, $name)); 64 | } 65 | 66 | unset($this->handlers[$name]); 67 | } 68 | 69 | /** 70 | * @param callable $handler 71 | * @psalm-param callable(ShortcodeInterface):string $handler 72 | * 73 | * @return $this 74 | */ 75 | public function setDefault($handler) 76 | { 77 | $this->guardHandler($handler); 78 | 79 | $this->default = $handler; 80 | 81 | return $this; 82 | } 83 | 84 | /** @return string[] */ 85 | public function getNames() 86 | { 87 | return array_keys($this->handlers); 88 | } 89 | 90 | /** 91 | * @param string $name 92 | * 93 | * @return callable|null 94 | * @psalm-return (callable(ShortcodeInterface):string)|null 95 | */ 96 | public function get($name) 97 | { 98 | return $this->has($name) ? $this->handlers[$name] : $this->default; 99 | } 100 | 101 | /** 102 | * @param string $name 103 | * 104 | * @return bool 105 | */ 106 | public function has($name) 107 | { 108 | return array_key_exists($name, $this->handlers); 109 | } 110 | 111 | /** 112 | * @param callable $handler 113 | * 114 | * @return void 115 | */ 116 | private function guardHandler($handler) 117 | { 118 | if (!is_callable($handler)) { 119 | throw new \RuntimeException('Shortcode handler must be callable!'); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/HandlerContainer/HandlerContainerInterface.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | interface HandlerContainerInterface 10 | { 11 | /** 12 | * Returns handler for given shortcode name or default if it was set before. 13 | * If no handler is found, returns null. 14 | * 15 | * @param string $name Shortcode name 16 | * 17 | * @return callable|null 18 | * @psalm-return (callable(ShortcodeInterface):string)|null 19 | */ 20 | public function get($name); 21 | } 22 | -------------------------------------------------------------------------------- /src/HandlerContainer/ImmutableHandlerContainer.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class ImmutableHandlerContainer implements HandlerContainerInterface 10 | { 11 | /** @var HandlerContainer */ 12 | private $handlers; 13 | 14 | public function __construct(HandlerContainer $handlers) 15 | { 16 | $this->handlers = clone $handlers; 17 | } 18 | 19 | /** 20 | * @param string $name 21 | * 22 | * @return callable|null 23 | * @psalm-return (callable(ShortcodeInterface):string)|null 24 | */ 25 | public function get($name) 26 | { 27 | return $this->handlers->get($name); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Parser/ParserInterface.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | interface ParserInterface 10 | { 11 | /** 12 | * Parse single shortcode match into object 13 | * 14 | * @param string $text 15 | * 16 | * @return ParsedShortcodeInterface[] 17 | */ 18 | public function parse($text); 19 | } 20 | -------------------------------------------------------------------------------- /src/Parser/RegexParser.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class RegexParser implements ParserInterface 14 | { 15 | /** @var SyntaxInterface */ 16 | private $syntax; 17 | /** @var non-empty-string */ 18 | private $shortcodeRegex; 19 | /** @var non-empty-string */ 20 | private $singleShortcodeRegex; 21 | /** @var non-empty-string */ 22 | private $parametersRegex; 23 | 24 | /** @param SyntaxInterface|null $syntax */ 25 | public function __construct($syntax = null) 26 | { 27 | if(null !== $syntax && false === $syntax instanceof SyntaxInterface) { 28 | throw new \LogicException('Parameter $syntax must be an instance of SyntaxInterface.'); 29 | } 30 | 31 | $this->syntax = $syntax ?: new Syntax(); 32 | $this->shortcodeRegex = RegexBuilderUtility::buildShortcodeRegex($this->syntax); 33 | $this->singleShortcodeRegex = RegexBuilderUtility::buildSingleShortcodeRegex($this->syntax); 34 | $this->parametersRegex = RegexBuilderUtility::buildParametersRegex($this->syntax); 35 | } 36 | 37 | /** 38 | * @param string $text 39 | * 40 | * @return ParsedShortcode[] 41 | */ 42 | public function parse($text) 43 | { 44 | preg_match_all($this->shortcodeRegex, $text, $matches, PREG_OFFSET_CAPTURE); 45 | 46 | // loop instead of array_map to pass the arguments explicitly 47 | $shortcodes = array(); 48 | foreach($matches[0] as $match) { 49 | $offset = mb_strlen(substr($text, 0, $match[1]), 'utf-8'); 50 | $shortcodes[] = $this->parseSingle($match[0], $offset); 51 | } 52 | 53 | return array_filter($shortcodes); 54 | } 55 | 56 | /** 57 | * @param string $text 58 | * @param int $offset 59 | * 60 | * @return ParsedShortcode 61 | */ 62 | private function parseSingle($text, $offset) 63 | { 64 | preg_match($this->singleShortcodeRegex, $text, $matches, PREG_OFFSET_CAPTURE); 65 | 66 | /** @psalm-var array $matches */ 67 | $name = $matches['name'][0]; 68 | $parameters = isset($matches['parameters'][0]) ? $this->parseParameters($matches['parameters'][0]) : array(); 69 | $bbCode = isset($matches['bbCode'][0]) && $matches['bbCode'][1] !== -1 70 | ? $this->extractValue($matches['bbCode'][0]) 71 | : null; 72 | $content = isset($matches['content'][0]) && $matches['content'][1] !== -1 ? $matches['content'][0] : null; 73 | 74 | return new ParsedShortcode(new Shortcode($name, $parameters, $content, $bbCode), $text, $offset); 75 | } 76 | 77 | /** 78 | * @param string $text 79 | * 80 | * @psalm-return array 81 | */ 82 | private function parseParameters($text) 83 | { 84 | preg_match_all($this->parametersRegex, $text, $argsMatches); 85 | 86 | // loop because PHP 5.3 can't handle $this properly and I want separate methods 87 | $return = array(); 88 | foreach ($argsMatches[1] as $item) { 89 | /** @psalm-var array{0:string,1:string} $parts */ 90 | $parts = explode($this->syntax->getParameterValueSeparator(), $item, 2); 91 | $return[trim($parts[0])] = $this->parseValue(isset($parts[1]) ? $parts[1] : null); 92 | } 93 | 94 | return $return; 95 | } 96 | 97 | /** 98 | * @param string|null $value 99 | * 100 | * @return string|null 101 | */ 102 | private function parseValue($value) 103 | { 104 | return null === $value ? null : $this->extractValue(trim($value)); 105 | } 106 | 107 | /** 108 | * @param string $value 109 | * 110 | * @return string 111 | */ 112 | private function extractValue($value) 113 | { 114 | $length = strlen($this->syntax->getParameterValueDelimiter()); 115 | 116 | return $this->isDelimitedValue($value) ? substr($value, $length, -1 * $length) : $value; 117 | } 118 | 119 | /** 120 | * @param string $value 121 | * 122 | * @return bool 123 | */ 124 | private function isDelimitedValue($value) 125 | { 126 | return preg_match('/^'.$this->syntax->getParameterValueDelimiter().'/us', $value) 127 | && preg_match('/'.$this->syntax->getParameterValueDelimiter().'$/us', $value); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Parser/RegularParser.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class RegularParser implements ParserInterface 14 | { 15 | /** @var non-empty-string */ 16 | private $lexerRegex; 17 | /** @var non-empty-string */ 18 | private $nameRegex; 19 | /** @psalm-var list */ 20 | private $tokens = array(); 21 | /** @var int */ 22 | private $tokensCount = 0; 23 | /** @var int */ 24 | private $position = 0; 25 | /** @var int[] */ 26 | private $backtracks = array(); 27 | /** @var int */ 28 | private $lastBacktrack = 0; 29 | 30 | const TOKEN_OPEN = 1; 31 | const TOKEN_CLOSE = 2; 32 | const TOKEN_MARKER = 3; 33 | const TOKEN_SEPARATOR = 4; 34 | const TOKEN_DELIMITER = 5; 35 | const TOKEN_STRING = 6; 36 | const TOKEN_WS = 7; 37 | 38 | /** @param SyntaxInterface|null $syntax */ 39 | public function __construct($syntax = null) 40 | { 41 | if(null !== $syntax && false === $syntax instanceof SyntaxInterface) { 42 | throw new \LogicException('Parameter $syntax must be an instance of SyntaxInterface.'); 43 | } 44 | 45 | $this->lexerRegex = $this->prepareLexer($syntax ?: new CommonSyntax()); 46 | $this->nameRegex = '~^'.RegexBuilderUtility::buildNameRegex().'$~us'; 47 | } 48 | 49 | /** 50 | * @param string $text 51 | * 52 | * @return ParsedShortcode[] 53 | */ 54 | public function parse($text) 55 | { 56 | $nestingLevel = ini_set('xdebug.max_nesting_level', '-1'); 57 | $this->tokens = $this->tokenize($text); 58 | $this->backtracks = array(); 59 | $this->lastBacktrack = 0; 60 | $this->position = 0; 61 | $this->tokensCount = \count($this->tokens); 62 | 63 | $shortcodes = array(); 64 | while($this->position < $this->tokensCount) { 65 | while($this->position < $this->tokensCount && false === $this->lookahead(self::TOKEN_OPEN)) { 66 | $this->position++; 67 | } 68 | $names = array(); 69 | $this->beginBacktrack(); 70 | $matches = $this->shortcode($names); 71 | if(false === $matches) { 72 | $this->backtrack(); 73 | $this->match(null, true); 74 | continue; 75 | } 76 | if(\is_array($matches)) { 77 | foreach($matches as $shortcode) { 78 | $shortcodes[] = $shortcode; 79 | } 80 | } 81 | } 82 | ini_set('xdebug.max_nesting_level', $nestingLevel); 83 | 84 | return $shortcodes; 85 | } 86 | 87 | /** 88 | * @param string $name 89 | * @psalm-param array $parameters 90 | * @param string|null $bbCode 91 | * @param int $offset 92 | * @param string|null $content 93 | * @param string $text 94 | * 95 | * @return ParsedShortcode 96 | */ 97 | private function getObject($name, $parameters, $bbCode, $offset, $content, $text) 98 | { 99 | return new ParsedShortcode(new Shortcode($name, $parameters, $content, $bbCode), $text, $offset); 100 | } 101 | 102 | /* --- RULES ----------------------------------------------------------- */ 103 | 104 | /** 105 | * @param string[] $names 106 | * @psalm-param list $names 107 | * FIXME: investigate the reason Psalm complains about references 108 | * @psalm-suppress ReferenceConstraintViolation 109 | * 110 | * @return ParsedShortcode[]|string|false 111 | */ 112 | private function shortcode(array &$names) 113 | { 114 | if(!$this->match(self::TOKEN_OPEN, false)) { return false; } 115 | $offset = $this->tokens[$this->position - 1][2]; 116 | $this->match(self::TOKEN_WS, false); 117 | if('' === $name = $this->match(self::TOKEN_STRING, false)) { return false; } 118 | if($this->lookahead(self::TOKEN_STRING)) { return false; } 119 | if(1 !== preg_match($this->nameRegex, $name, $matches)) { return false; } 120 | $this->match(self::TOKEN_WS, false); 121 | // bbCode 122 | $bbCode = $this->match(self::TOKEN_SEPARATOR, true) ? $this->value() : null; 123 | if(false === $bbCode) { return false; } 124 | // parameters 125 | if(false === ($parameters = $this->parameters())) { return false; } 126 | 127 | // self-closing 128 | if($this->match(self::TOKEN_MARKER, true)) { 129 | if(!$this->match(self::TOKEN_CLOSE, false)) { return false; } 130 | 131 | return array($this->getObject($name, $parameters, $bbCode, $offset, null, $this->getBacktrack())); 132 | } 133 | 134 | // just-closed or with-content 135 | if(!$this->match(self::TOKEN_CLOSE, false)) { return false; } 136 | $this->beginBacktrack(); 137 | $names[] = $name; 138 | 139 | // begin inlined content() 140 | $content = ''; 141 | $shortcodes = array(); 142 | $closingName = null; 143 | 144 | while($this->position < $this->tokensCount) { 145 | while($this->position < $this->tokensCount && false === $this->lookahead(self::TOKEN_OPEN)) { 146 | $content .= $this->match(null, true); 147 | } 148 | 149 | $this->beginBacktrack(); 150 | $contentMatchedShortcodes = $this->shortcode($names); 151 | if(\is_string($contentMatchedShortcodes)) { 152 | $closingName = $contentMatchedShortcodes; 153 | break; 154 | } 155 | if(\is_array($contentMatchedShortcodes)) { 156 | foreach($contentMatchedShortcodes as $matchedShortcode) { 157 | $shortcodes[] = $matchedShortcode; 158 | } 159 | continue; 160 | } 161 | $this->backtrack(); 162 | 163 | $this->beginBacktrack(); 164 | if(false !== ($closingName = $this->close($names))) { 165 | $this->backtrack(); 166 | $shortcodes = array(); 167 | break; 168 | } 169 | $closingName = null; 170 | $this->backtrack(); 171 | 172 | $content .= $this->match(null, false); 173 | } 174 | $content = $this->position < $this->tokensCount ? $content : false; 175 | // end inlined content() 176 | 177 | if(null !== $closingName && $closingName !== $name) { 178 | array_pop($names); 179 | array_pop($this->backtracks); 180 | array_pop($this->backtracks); 181 | 182 | return $closingName; 183 | } 184 | if(false === $content || $closingName !== $name) { 185 | $this->backtrack(false); 186 | $text = $this->backtrack(false); 187 | array_pop($names); 188 | 189 | return array_merge(array($this->getObject($name, $parameters, $bbCode, $offset, null, $text)), $shortcodes); 190 | } 191 | $content = $this->getBacktrack(); 192 | /** @psalm-suppress RiskyTruthyFalsyComparison */ 193 | if(!$this->close($names)) { return false; } 194 | array_pop($names); 195 | 196 | return array($this->getObject($name, $parameters, $bbCode, $offset, $content, $this->getBacktrack())); 197 | } 198 | 199 | /** 200 | * @param string[] $names 201 | * 202 | * @return string|false 203 | */ 204 | private function close(array &$names) 205 | { 206 | if(!$this->match(self::TOKEN_OPEN, true)) { return false; } 207 | if(!$this->match(self::TOKEN_MARKER, true)) { return false; } 208 | if(!$closingName = $this->match(self::TOKEN_STRING, true)) { return false; } 209 | if(!$this->match(self::TOKEN_CLOSE, false)) { return false; } 210 | 211 | return \in_array($closingName, $names, true) ? $closingName : false; 212 | } 213 | 214 | /** @psalm-return array|false */ 215 | private function parameters() 216 | { 217 | $parameters = array(); 218 | 219 | while(true) { 220 | $this->match(self::TOKEN_WS, false); 221 | if($this->lookahead(self::TOKEN_MARKER) || $this->lookahead(self::TOKEN_CLOSE)) { break; } 222 | if(!$name = $this->match(self::TOKEN_STRING, true)) { return false; } 223 | if(!$this->match(self::TOKEN_SEPARATOR, true)) { $parameters[$name] = null; continue; } 224 | if(false === ($value = $this->value())) { return false; } 225 | $this->match(self::TOKEN_WS, false); 226 | 227 | $parameters[$name] = $value; 228 | } 229 | 230 | return $parameters; 231 | } 232 | 233 | /** @return false|string */ 234 | private function value() 235 | { 236 | $value = ''; 237 | 238 | if($this->match(self::TOKEN_DELIMITER, false)) { 239 | while($this->position < $this->tokensCount && false === $this->lookahead(self::TOKEN_DELIMITER)) { 240 | $value .= $this->match(null, false); 241 | } 242 | 243 | return $this->match(self::TOKEN_DELIMITER, false) ? $value : false; 244 | } 245 | 246 | if('' !== $tmp = $this->match(self::TOKEN_STRING, false)) { 247 | $value .= $tmp; 248 | while('' !== $tmp = $this->match(self::TOKEN_STRING, false)) { 249 | $value .= $tmp; 250 | } 251 | 252 | return $value; 253 | } 254 | 255 | return false; 256 | } 257 | 258 | /* --- PARSER ---------------------------------------------------------- */ 259 | 260 | /** @return void */ 261 | private function beginBacktrack() 262 | { 263 | $this->backtracks[] = $this->position; 264 | $this->lastBacktrack = $this->position; 265 | } 266 | 267 | /** @return string */ 268 | private function getBacktrack() 269 | { 270 | $position = array_pop($this->backtracks); 271 | $backtrack = ''; 272 | for($i = $position; $i < $this->position; $i++) { 273 | $backtrack .= $this->tokens[$i][1]; 274 | } 275 | 276 | return $backtrack; 277 | } 278 | 279 | /** 280 | * @param bool $modifyPosition 281 | * 282 | * @return string 283 | */ 284 | private function backtrack($modifyPosition = true) 285 | { 286 | $position = array_pop($this->backtracks); 287 | if($modifyPosition) { 288 | $this->position = $position; 289 | } 290 | 291 | $backtrack = ''; 292 | for($i = $position; $i < $this->lastBacktrack; $i++) { 293 | $backtrack .= $this->tokens[$i][1]; 294 | } 295 | $this->lastBacktrack = $position; 296 | 297 | return $backtrack; 298 | } 299 | 300 | /** 301 | * @param int $type 302 | * 303 | * @return bool 304 | */ 305 | private function lookahead($type) 306 | { 307 | return $this->position < $this->tokensCount && $this->tokens[$this->position][0] === $type; 308 | } 309 | 310 | /** 311 | * @param int|null $type 312 | * @param bool $ws 313 | * 314 | * @return string 315 | */ 316 | private function match($type, $ws) 317 | { 318 | if($this->position >= $this->tokensCount) { 319 | return ''; 320 | } 321 | 322 | $token = $this->tokens[$this->position]; 323 | /** @psalm-suppress RiskyTruthyFalsyComparison */ 324 | if(!empty($type) && $token[0] !== $type) { 325 | return ''; 326 | } 327 | 328 | $this->position++; 329 | if($ws && $this->position < $this->tokensCount && $this->tokens[$this->position][0] === self::TOKEN_WS) { 330 | $this->position++; 331 | } 332 | 333 | return $token[1]; 334 | } 335 | 336 | /* --- LEXER ----------------------------------------------------------- */ 337 | 338 | /** 339 | * @param string $text 340 | * 341 | * @psalm-return list 342 | */ 343 | private function tokenize($text) 344 | { 345 | $count = preg_match_all($this->lexerRegex, $text, $matches, PREG_SET_ORDER); 346 | if(false === $count || preg_last_error() !== PREG_NO_ERROR) { 347 | throw new \RuntimeException(sprintf('PCRE failure `%s`.', preg_last_error())); 348 | } 349 | 350 | $tokens = array(); 351 | $position = 0; 352 | 353 | foreach($matches as $match) { 354 | switch(true) { 355 | case array_key_exists('close', $match): { $token = $match['close']; $type = self::TOKEN_CLOSE; break; } 356 | case array_key_exists('open', $match): { $token = $match['open']; $type = self::TOKEN_OPEN; break; } 357 | case array_key_exists('separator', $match): { $token = $match['separator']; $type = self::TOKEN_SEPARATOR; break; } 358 | case array_key_exists('delimiter', $match): { $token = $match['delimiter']; $type = self::TOKEN_DELIMITER; break; } 359 | case array_key_exists('marker', $match): { $token = $match['marker']; $type = self::TOKEN_MARKER; break; } 360 | case array_key_exists('ws', $match): { $token = $match['ws']; $type = self::TOKEN_WS; break; } 361 | case array_key_exists('string', $match): { $token = $match['string']; $type = self::TOKEN_STRING; break; } 362 | default: { throw new \RuntimeException('Invalid token.'); } 363 | } 364 | $tokens[] = array($type, $token, $position); 365 | $position += mb_strlen($token, 'utf-8'); 366 | } 367 | 368 | return $tokens; 369 | } 370 | 371 | /** @return non-empty-string */ 372 | private function prepareLexer(SyntaxInterface $syntax) 373 | { 374 | // FIXME: for some reason Psalm does not understand the `@psalm-var callable() $var` annotation 375 | /** @psalm-suppress MissingClosureParamType, MissingClosureReturnType */ 376 | $group = function($text, $group) { 377 | return '(?<'.(string)$group.'>'.preg_replace('/(.)/us', '\\\\$0', (string)$text).')'; 378 | }; 379 | /** @psalm-suppress MissingClosureParamType, MissingClosureReturnType */ 380 | $quote = function($text) { 381 | return preg_replace('/(.)/us', '\\\\$0', (string)$text); 382 | }; 383 | 384 | $rules = array( 385 | '(?\\\\.|(?:(?!'.implode('|', array( 386 | $quote($syntax->getOpeningTag()), 387 | $quote($syntax->getClosingTag()), 388 | $quote($syntax->getClosingTagMarker()), 389 | $quote($syntax->getParameterValueSeparator()), 390 | $quote($syntax->getParameterValueDelimiter()), 391 | '\s+', 392 | )).').)+)', 393 | '(?\s+)', 394 | $group($syntax->getClosingTagMarker(), 'marker'), 395 | $group($syntax->getParameterValueDelimiter(), 'delimiter'), 396 | $group($syntax->getParameterValueSeparator(), 'separator'), 397 | $group($syntax->getOpeningTag(), 'open'), 398 | $group($syntax->getClosingTag(), 'close'), 399 | ); 400 | 401 | return '~('.implode('|', $rules).')~us'; 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/Parser/WordpressParser.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | final class WordpressParser implements ParserInterface 29 | { 30 | /** @var non-empty-string */ 31 | private static $shortcodeRegex = '/\\[(\\[?)()(?![\\w-])([^\\]\\/]*(?:\\/(?!\\])[^\\]\\/]*)*?)(?:(\\/)\\]|\\](?:([^\\[]*+(?:\\[(?!\\/\\2\\])[^\\[]*+)*+)\\[\\/\\2\\])?)(\\]?)/s'; 32 | /** @var non-empty-string */ 33 | private static $argumentsRegex = '/([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*\'([^\']*)\'(?:\s|$)|([\w-]+)\s*=\s*([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|(\S+)(?:\s|$)/'; 34 | 35 | /** @var string[] */ 36 | private $names = array(); 37 | 38 | public function __construct() 39 | { 40 | } 41 | 42 | /** @return self */ 43 | public static function createFromHandlers(HandlerContainer $handlers) 44 | { 45 | return static::createFromNames($handlers->getNames()); 46 | } 47 | 48 | /** 49 | * @param string[] $names 50 | * 51 | * @return self 52 | */ 53 | public static function createFromNames(array $names) 54 | { 55 | foreach($names as $name) { 56 | /** @psalm-suppress DocblockTypeContradiction, RedundantConditionGivenDocblockType */ 57 | if(false === is_string($name)) { 58 | throw new \InvalidArgumentException('Shortcode name must be a string!'); 59 | } 60 | } 61 | 62 | $self = new self(); 63 | $self->names = $names; 64 | 65 | return $self; 66 | } 67 | 68 | /** 69 | * @param string $text 70 | * 71 | * @return ParsedShortcode[] 72 | */ 73 | public function parse($text) 74 | { 75 | $names = $this->names 76 | ? implode('|', array_map(function($name) { return preg_quote($name, '/'); }, $this->names)) 77 | : RegexBuilderUtility::buildNameRegex(); 78 | /** @var non-empty-string $regex */ 79 | $regex = str_replace('', $names, static::$shortcodeRegex); 80 | preg_match_all($regex, $text, $matches, PREG_OFFSET_CAPTURE); 81 | 82 | $shortcodes = array(); 83 | $count = count($matches[0]); 84 | for($i = 0; $i < $count; $i++) { 85 | $name = $matches[2][$i][0]; 86 | $parameters = static::parseParameters($matches[3][$i][0]); 87 | $content = $matches[5][$i][1] !== -1 ? $matches[5][$i][0] : null; 88 | $match = $matches[0][$i][0]; 89 | $offset = mb_strlen(substr($text, 0, $matches[0][$i][1]), 'utf-8'); 90 | 91 | $shortcode = new Shortcode($name, $parameters, $content, null); 92 | $shortcodes[] = new ParsedShortcode($shortcode, $match, $offset); 93 | } 94 | 95 | return $shortcodes; 96 | } 97 | 98 | /** 99 | * @param string $text 100 | * 101 | * @psalm-return array 102 | */ 103 | private static function parseParameters($text) 104 | { 105 | $text = preg_replace('/[\x{00a0}\x{200b}]+/u', ' ', $text); 106 | 107 | if(!preg_match_all(static::$argumentsRegex, $text, $matches, PREG_SET_ORDER)) { 108 | return ltrim($text) ? array(ltrim($text) => null) : array(); 109 | } 110 | 111 | $parameters = array(); 112 | foreach($matches as $match) { 113 | if(!empty($match[1])) { 114 | $parameters[strtolower($match[1])] = stripcslashes($match[2]); 115 | } elseif(!empty($match[3])) { 116 | $parameters[strtolower($match[3])] = stripcslashes($match[4]); 117 | } elseif(!empty($match[5])) { 118 | $parameters[strtolower($match[5])] = stripcslashes($match[6]); 119 | } elseif(isset($match[7]) && $match[7] !== '') { 120 | $parameters[stripcslashes($match[7])] = null; 121 | } elseif(isset($match[8])) { 122 | $parameters[stripcslashes($match[8])] = null; 123 | } 124 | } 125 | 126 | foreach($parameters as $key => $value) { 127 | // NOTE: the `?: ''` fallback is the only change from the way WordPress parses shortcodes to satisfy Psalm's PossiblyNullArgument 128 | $value = $value ?: ''; 129 | if(false !== strpos($value, '<') && 1 !== preg_match('/^[^<]*+(?:<[^>]*+>[^<]*+)*+$/', $value)) { 130 | $parameters[$key] = ''; 131 | } 132 | } 133 | 134 | return $parameters; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Processor/Processor.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class Processor implements ProcessorInterface 19 | { 20 | /** @var Handlers */ 21 | private $handlers; 22 | /** @var ParserInterface */ 23 | private $parser; 24 | /** @var EventContainerInterface|null */ 25 | private $eventContainer; 26 | 27 | /** @var int|null */ 28 | private $recursionDepth; // infinite recursion 29 | /** @var int|null */ 30 | private $maxIterations = 1; // one iteration 31 | /** @var bool */ 32 | private $autoProcessContent = true; // automatically process shortcode content 33 | 34 | public function __construct(ParserInterface $parser, Handlers $handlers) 35 | { 36 | $this->parser = $parser; 37 | $this->handlers = $handlers; 38 | } 39 | 40 | /** 41 | * Entry point for shortcode processing. Implements iterative algorithm for 42 | * both limited and unlimited number of iterations. 43 | * 44 | * @param string $text Text to process 45 | * 46 | * @return string 47 | */ 48 | public function process($text) 49 | { 50 | $iterations = $this->maxIterations === null ? 1 : $this->maxIterations; 51 | $context = new ProcessorContext(); 52 | $context->processor = $this; 53 | 54 | while ($iterations--) { 55 | $context->iterationNumber++; 56 | $newText = $this->processIteration($text, $context, null); 57 | if ($newText === $text) { 58 | break; 59 | } 60 | $text = $newText; 61 | $iterations += $this->maxIterations === null ? 1 : 0; 62 | } 63 | 64 | return $text; 65 | } 66 | 67 | /** 68 | * @param string $name 69 | * @param object $event 70 | * 71 | * @return object 72 | */ 73 | private function dispatchEvent($name, $event) 74 | { 75 | if(null === $this->eventContainer) { 76 | return $event; 77 | } 78 | 79 | $handlers = $this->eventContainer->getListeners($name); 80 | foreach($handlers as $handler) { 81 | $handler($event); 82 | } 83 | 84 | return $event; 85 | } 86 | 87 | /** 88 | * @param string $text 89 | * @param ProcessedShortcode|null $parent 90 | * 91 | * @return string 92 | */ 93 | private function processIteration($text, ProcessorContext $context, $parent = null) 94 | { 95 | if(null !== $parent && false === $parent instanceof ProcessedShortcode) { 96 | throw new \LogicException('Parameter $parent must be an instance of ProcessedShortcode.'); 97 | } 98 | if (null !== $this->recursionDepth && $context->recursionLevel > $this->recursionDepth) { 99 | return $text; 100 | } 101 | 102 | $context->parent = $parent; 103 | $context->text = $text; 104 | $filterEvent = new FilterShortcodesEvent($this->parser->parse($text), $parent); 105 | $this->dispatchEvent(Events::FILTER_SHORTCODES, $filterEvent); 106 | $shortcodes = $filterEvent->getShortcodes(); 107 | $replaces = array(); 108 | $baseOffset = $parent && $shortcodes 109 | ? (int)mb_strpos($parent->getShortcodeText(), $shortcodes[0]->getText(), 0, 'utf-8') - $shortcodes[0]->getOffset() + $parent->getOffset() 110 | : 0; 111 | foreach ($shortcodes as $shortcode) { 112 | $name = $shortcode->getName(); 113 | $hasNamePosition = array_key_exists($name, $context->namePosition); 114 | 115 | $context->baseOffset = $baseOffset + $shortcode->getOffset(); 116 | $context->position++; 117 | $context->namePosition[$name] = $hasNamePosition ? $context->namePosition[$name] + 1 : 1; 118 | $context->shortcodeText = $shortcode->getText(); 119 | $context->offset = $shortcode->getOffset(); 120 | $context->shortcode = $shortcode; 121 | $context->textContent = (string)$shortcode->getContent(); 122 | 123 | $handler = $this->handlers->get($name); 124 | $replace = $this->processHandler($shortcode, $context, $handler); 125 | 126 | $replaces[] = new ReplacedShortcode($shortcode, $replace); 127 | } 128 | 129 | $applyEvent = new ReplaceShortcodesEvent($text, $replaces, $parent); 130 | $this->dispatchEvent(Events::REPLACE_SHORTCODES, $applyEvent); 131 | 132 | return $applyEvent->hasResult() ? (string)$applyEvent->getResult() : $this->applyReplaces($text, $replaces); 133 | } 134 | 135 | /** 136 | * @param string $text 137 | * @param ReplacedShortcode[] $replaces 138 | * 139 | * @return string 140 | */ 141 | private function applyReplaces($text, array $replaces) 142 | { 143 | foreach(array_reverse($replaces) as $s) { 144 | $offset = $s->getOffset(); 145 | $length = mb_strlen($s->getText(), 'utf-8'); 146 | $textLength = mb_strlen($text, 'utf-8'); 147 | 148 | $text = mb_substr($text, 0, $offset, 'utf-8').$s->getReplacement().mb_substr($text, $offset + $length, $textLength, 'utf-8'); 149 | } 150 | 151 | return $text; 152 | } 153 | 154 | /** 155 | * @psalm-param (callable(ShortcodeInterface):string)|null $handler 156 | * @return string 157 | */ 158 | private function processHandler(ParsedShortcodeInterface $parsed, ProcessorContext $context, $handler) 159 | { 160 | $processed = ProcessedShortcode::createFromContext(clone $context); 161 | $content = $this->processRecursion($processed, $context); 162 | $processed = $processed->withContent($content); 163 | 164 | if(null !== $handler) { 165 | return $handler($processed); 166 | } 167 | 168 | $state = $parsed->getText(); 169 | /** @psalm-suppress RedundantCast */ 170 | $length = (int)mb_strlen($processed->getTextContent(), 'utf-8'); 171 | $offset = (int)mb_strrpos($state, $processed->getTextContent(), 0, 'utf-8'); 172 | 173 | return mb_substr($state, 0, $offset, 'utf-8').(string)$processed->getContent().mb_substr($state, $offset + $length, mb_strlen($state, 'utf-8'), 'utf-8'); 174 | } 175 | 176 | /** @return string|null */ 177 | private function processRecursion(ProcessedShortcode $shortcode, ProcessorContext $context) 178 | { 179 | $content = $shortcode->getContent(); 180 | if ($this->autoProcessContent && null !== $content) { 181 | $context->recursionLevel++; 182 | // this is safe from using max iterations value because it's manipulated in process() method 183 | $content = $this->processIteration($content, clone $context, $shortcode); 184 | $context->recursionLevel--; 185 | 186 | return $content; 187 | } 188 | 189 | return $content; 190 | } 191 | 192 | /** 193 | * Container for event handlers used in this processor. 194 | * 195 | * @param EventContainerInterface $eventContainer 196 | * 197 | * @return self 198 | */ 199 | public function withEventContainer(EventContainerInterface $eventContainer) 200 | { 201 | $self = clone $this; 202 | $self->eventContainer = $eventContainer; 203 | 204 | return $self; 205 | } 206 | 207 | /** 208 | * Recursion depth level, null means infinite, any integer greater than or 209 | * equal to zero sets value (number of recursion levels). Zero disables 210 | * recursion. Defaults to null. 211 | * 212 | * @param int|null $depth 213 | * 214 | * @return self 215 | */ 216 | public function withRecursionDepth($depth) 217 | { 218 | /** @psalm-suppress DocblockTypeContradiction */ 219 | if (null !== $depth && !(is_int($depth) && $depth >= 0)) { 220 | $msg = 'Recursion depth must be null (infinite) or integer >= 0!'; 221 | throw new \InvalidArgumentException($msg); 222 | } 223 | 224 | $self = clone $this; 225 | $self->recursionDepth = $depth; 226 | 227 | return $self; 228 | } 229 | 230 | /** 231 | * Maximum number of iterations, null means infinite, any integer greater 232 | * than zero sets value. Zero is invalid because there must be at least one 233 | * iteration. Defaults to 1. Loop breaks if result of two consequent 234 | * iterations shows no change in processed text. 235 | * 236 | * @param int|null $iterations 237 | * 238 | * @return self 239 | */ 240 | public function withMaxIterations($iterations) 241 | { 242 | /** @psalm-suppress DocblockTypeContradiction */ 243 | if (null !== $iterations && !(is_int($iterations) && $iterations > 0)) { 244 | $msg = 'Maximum number of iterations must be null (infinite) or integer > 0!'; 245 | throw new \InvalidArgumentException($msg); 246 | } 247 | 248 | $self = clone $this; 249 | $self->maxIterations = $iterations; 250 | 251 | return $self; 252 | } 253 | 254 | /** 255 | * Whether shortcode content will be automatically processed and handler 256 | * already receives shortcode with processed content. If false, every 257 | * shortcode handler needs to process content on its own. Default true. 258 | * 259 | * @param bool $flag True if enabled (default), false otherwise 260 | * 261 | * @return self 262 | */ 263 | public function withAutoProcessContent($flag) 264 | { 265 | /** @psalm-suppress DocblockTypeContradiction */ 266 | if (!is_bool($flag)) { 267 | $msg = 'Auto processing flag must be a boolean value!'; 268 | throw new \InvalidArgumentException($msg); 269 | } 270 | 271 | $self = clone $this; 272 | $self->autoProcessContent = $flag; 273 | 274 | return $self; 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/Processor/ProcessorContext.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ProcessorContext 11 | { 12 | /** 13 | * @var ShortcodeInterface 14 | * @psalm-suppress PropertyNotSetInConstructor 15 | */ 16 | public $shortcode; 17 | 18 | /** @var ProcessedShortcode|null */ 19 | public $parent = null; 20 | 21 | /** 22 | * @var ProcessorInterface 23 | * @psalm-suppress PropertyNotSetInConstructor 24 | */ 25 | public $processor; 26 | 27 | /** 28 | * @var string 29 | * @psalm-suppress PropertyNotSetInConstructor 30 | */ 31 | public $textContent; 32 | /** @var int */ 33 | public $position = 0; 34 | /** @psalm-var array */ 35 | public $namePosition = array(); 36 | /** @var string */ 37 | public $text = ''; 38 | /** @var string */ 39 | public $shortcodeText = ''; 40 | /** @var int */ 41 | public $iterationNumber = 0; 42 | /** @var int */ 43 | public $recursionLevel = 0; 44 | /** 45 | * @var int 46 | * @psalm-suppress PropertyNotSetInConstructor 47 | */ 48 | public $offset; 49 | /** 50 | * @var int 51 | * @psalm-suppress PropertyNotSetInConstructor 52 | */ 53 | public $baseOffset; 54 | 55 | public function __construct() 56 | { 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Processor/ProcessorInterface.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | interface ProcessorInterface 8 | { 9 | /** 10 | * Process text using registered shortcode handlers 11 | * 12 | * @param string $text Text containing shortcodes 13 | * 14 | * @return string Text with replaced shortcodes 15 | */ 16 | public function process($text); 17 | } 18 | -------------------------------------------------------------------------------- /src/Serializer/JsonSerializer.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class JsonSerializer implements SerializerInterface 11 | { 12 | public function serialize(ShortcodeInterface $shortcode) 13 | { 14 | return json_encode(array( 15 | 'name' => $shortcode->getName(), 16 | 'parameters' => $shortcode->getParameters(), 17 | 'content' => $shortcode->getContent(), 18 | 'bbCode' => $shortcode->getBbCode(), 19 | )); 20 | } 21 | 22 | /** 23 | * @param string $text 24 | * 25 | * @return Shortcode 26 | */ 27 | public function unserialize($text) 28 | { 29 | /** @psalm-var array{name:string,parameters:array,bbCode:string|null,content:string|null}|null $data */ 30 | $data = json_decode($text, true); 31 | 32 | if (!is_array($data)) { 33 | throw new \InvalidArgumentException('Invalid JSON, cannot unserialize Shortcode!'); 34 | } 35 | if (!array_diff_key($data, array('name', 'parameters', 'content'))) { 36 | throw new \InvalidArgumentException('Malformed Shortcode JSON, expected name, parameters, and content!'); 37 | } 38 | 39 | /** @var string $name */ 40 | $name = array_key_exists('name', $data) ? $data['name'] : null; 41 | $parameters = array_key_exists('parameters', $data) ? $data['parameters'] : array(); 42 | $content = array_key_exists('content', $data) ? $data['content'] : null; 43 | $bbCode = array_key_exists('bbCode', $data) ? $data['bbCode'] : null; 44 | 45 | /** @psalm-suppress DocblockTypeContradiction */ 46 | if(!is_array($parameters)) { 47 | throw new \InvalidArgumentException('Parameters must be an array!'); 48 | } 49 | 50 | return new Shortcode($name, $parameters, $content, $bbCode); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Serializer/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | interface SerializerInterface 10 | { 11 | /** 12 | * Serializes Shortcode class instance into given format 13 | * 14 | * @param ShortcodeInterface $shortcode Instance to serialize 15 | * 16 | * @return string 17 | */ 18 | public function serialize(ShortcodeInterface $shortcode); 19 | 20 | /** 21 | * Loads back Shortcode instance from serialized format 22 | * 23 | * @param string $text 24 | * 25 | * @return ShortcodeInterface 26 | */ 27 | public function unserialize($text); 28 | } 29 | -------------------------------------------------------------------------------- /src/Serializer/TextSerializer.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | final class TextSerializer implements SerializerInterface 14 | { 15 | /** @var SyntaxInterface */ 16 | private $syntax; 17 | 18 | /** @param SyntaxInterface|null $syntax */ 19 | public function __construct($syntax = null) 20 | { 21 | if(null !== $syntax && false === $syntax instanceof SyntaxInterface) { 22 | throw new \LogicException('Parameter $syntax must be an instance of SyntaxInterface.'); 23 | } 24 | 25 | $this->syntax = $syntax ?: new Syntax(); 26 | } 27 | 28 | /** @inheritDoc */ 29 | public function serialize(ShortcodeInterface $shortcode) 30 | { 31 | $open = $this->syntax->getOpeningTag(); 32 | $close = $this->syntax->getClosingTag(); 33 | $marker = $this->syntax->getClosingTagMarker(); 34 | 35 | $parameters = $this->serializeParameters($shortcode->getParameters()); 36 | $bbCode = null !== $shortcode->getBbCode() 37 | ? $this->serializeValue($shortcode->getBbCode()) 38 | : ''; 39 | $return = $open.$shortcode->getName().$bbCode.$parameters; 40 | 41 | return null === $shortcode->getContent() 42 | ? $return.' '.$marker.$close 43 | : $return.$close.(string)$shortcode->getContent().$open.$marker.$shortcode->getName().$close; 44 | } 45 | 46 | /** 47 | * @psalm-param array $parameters 48 | * 49 | * @return string 50 | */ 51 | private function serializeParameters(array $parameters) 52 | { 53 | // unfortunately array_reduce() does not support keys 54 | $return = ''; 55 | foreach ($parameters as $key => $value) { 56 | $return .= ' '.$key.$this->serializeValue($value); 57 | } 58 | 59 | return $return; 60 | } 61 | 62 | /** 63 | * @param string|null $value 64 | * 65 | * @return string 66 | */ 67 | private function serializeValue($value) 68 | { 69 | if (null === $value) { 70 | return ''; 71 | } 72 | 73 | $delimiter = $this->syntax->getParameterValueDelimiter(); 74 | $separator = $this->syntax->getParameterValueSeparator(); 75 | 76 | return $separator.(preg_match('/^\w+$/u', $value) 77 | ? $value 78 | : $delimiter.$value.$delimiter); 79 | } 80 | 81 | public function unserialize($text) 82 | { 83 | $parser = new RegexParser(); 84 | 85 | $shortcodes = $parser->parse($text); 86 | 87 | if (empty($shortcodes)) { 88 | $msg = 'Failed to unserialize shortcode from text %s!'; 89 | throw new \InvalidArgumentException(sprintf($msg, $text)); 90 | } 91 | if (count($shortcodes) > 1) { 92 | $msg = 'Provided text %s contains more than one shortcode!'; 93 | throw new \InvalidArgumentException(sprintf($msg, $text)); 94 | } 95 | 96 | /** @var ShortcodeInterface $parsed */ 97 | $parsed = array_shift($shortcodes); 98 | 99 | $name = $parsed->getName(); 100 | $parameters = $parsed->getParameters(); 101 | $content = $parsed->getContent(); 102 | $bbCode = $parsed->getBbCode(); 103 | 104 | return new Shortcode($name, $parameters, $content, $bbCode); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Serializer/XmlSerializer.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class XmlSerializer implements SerializerInterface 11 | { 12 | /** 13 | * 14 | * BBCODE 15 | * 16 | * VALUE 17 | * VALUE 18 | * 19 | * CONTENT> 20 | * 21 | * 22 | * @param ShortcodeInterface $shortcode 23 | * 24 | * @return string 25 | */ 26 | public function serialize(ShortcodeInterface $shortcode) 27 | { 28 | $doc = new \DOMDocument('1.0', 'UTF-8'); 29 | $doc->preserveWhiteSpace = false; 30 | $doc->formatOutput = true; 31 | 32 | $code = $doc->createElement('shortcode'); 33 | $code->setAttribute('name', $shortcode->getName()); 34 | $xml = $doc->appendChild($code); 35 | $xml->appendChild($this->createCDataNode($doc, 'bbCode', $shortcode->getBbCode())); 36 | 37 | $parameters = $xml->appendChild($doc->createElement('parameters')); 38 | foreach($shortcode->getParameters() as $key => $value) { 39 | $parameter = $doc->createElement('parameter'); 40 | $parameter->setAttribute('name', $key); 41 | if(null !== $value) { 42 | $parameter->appendChild($doc->createCDATASection($value)); 43 | } 44 | 45 | $parameters->appendChild($parameter); 46 | } 47 | 48 | $xml->appendChild($this->createCDataNode($doc, 'content', $shortcode->getContent())); 49 | 50 | return $doc->saveXML(); 51 | } 52 | 53 | /** 54 | * @param \DOMDocument $doc 55 | * @param string $name 56 | * @param string|null $content 57 | * 58 | * @return \DOMElement 59 | */ 60 | private function createCDataNode(\DOMDocument $doc, $name, $content) 61 | { 62 | $node = $doc->createElement($name); 63 | 64 | if(null !== $content) { 65 | $node->appendChild($doc->createCDATASection($content)); 66 | } 67 | 68 | return $node; 69 | } 70 | 71 | /** 72 | * @param string $text 73 | * 74 | * @return Shortcode 75 | */ 76 | public function unserialize($text) 77 | { 78 | $xml = new \DOMDocument(); 79 | $internalErrors = libxml_use_internal_errors(true); 80 | if(!$text || !$xml->loadXML($text)) { 81 | libxml_use_internal_errors($internalErrors); 82 | throw new \InvalidArgumentException('Failed to parse provided XML!'); 83 | } 84 | libxml_use_internal_errors($internalErrors); 85 | 86 | $xpath = new \DOMXPath($xml); 87 | $shortcode = $xpath->query('/shortcode'); 88 | if($shortcode->length !== 1) { 89 | throw new \InvalidArgumentException('Invalid shortcode XML!'); 90 | } 91 | /** @psalm-suppress PossiblyNullArgument */ 92 | $name = $this->getAttribute($shortcode->item(0), 'name'); 93 | 94 | $bbCode = $this->getValue($xpath->query('/shortcode/bbCode')); 95 | $content = $this->getValue($xpath->query('/shortcode/content')); 96 | 97 | $parameters = array(); 98 | $elements = $xpath->query('/shortcode/parameters/parameter'); 99 | for($i = 0; $i < $elements->length; $i++) { 100 | $node = $elements->item($i); 101 | 102 | /** @psalm-suppress PossiblyNullReference */ 103 | $parameters[$this->getAttribute($node, 'name')] = $node->hasChildNodes() ? $node->nodeValue : null; 104 | } 105 | 106 | return new Shortcode($name, $parameters, $content, $bbCode); 107 | } 108 | 109 | /** 110 | * @param \DOMNodeList $node 111 | * 112 | * @return string|null 113 | */ 114 | private function getValue(\DOMNodeList $node) 115 | { 116 | /** @psalm-suppress PossiblyNullReference,PossiblyNullPropertyFetch */ 117 | return $node->length === 1 && $node->item(0)->hasChildNodes() 118 | ? $node->item(0)->nodeValue 119 | : null; 120 | } 121 | 122 | /** 123 | * @param \DOMNode $node 124 | * @param string $name 125 | * 126 | * @return string 127 | */ 128 | private function getAttribute(\DOMNode $node, $name) 129 | { 130 | /** 131 | * @var \DOMNode $attribute 132 | * @psalm-suppress NullReference 133 | */ 134 | $attribute = $node->attributes->getNamedItem($name); 135 | 136 | /** @psalm-suppress DocblockTypeContradiction,RiskyTruthyFalsyComparison */ 137 | if(!$attribute || !$attribute->nodeValue) { 138 | throw new \InvalidArgumentException('Invalid shortcode XML!'); 139 | } 140 | 141 | return $attribute->nodeValue; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Serializer/YamlSerializer.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class YamlSerializer implements SerializerInterface 12 | { 13 | public function serialize(ShortcodeInterface $shortcode) 14 | { 15 | return Yaml::dump(array( 16 | 'name' => $shortcode->getName(), 17 | 'parameters' => $shortcode->getParameters(), 18 | 'content' => $shortcode->getContent(), 19 | 'bbCode' => $shortcode->getBbCode(), 20 | )); 21 | } 22 | 23 | /** 24 | * @param string $text 25 | * 26 | * @return Shortcode 27 | */ 28 | public function unserialize($text) 29 | { 30 | /** @psalm-var array{name:string,parameters:array,bbCode:string|null,content:string|null}|null $data */ 31 | $data = Yaml::parse($text); 32 | 33 | if(!is_array($data)) { 34 | throw new \InvalidArgumentException('Invalid YAML, cannot unserialize Shortcode!'); 35 | } 36 | if (!array_intersect(array_keys($data), array('name', 'parameters', 'content'))) { 37 | throw new \InvalidArgumentException('Malformed shortcode YAML, expected name, parameters, and content!'); 38 | } 39 | 40 | /** @var string $name */ 41 | $name = array_key_exists('name', $data) ? $data['name'] : null; 42 | $parameters = array_key_exists('parameters', $data) ? $data['parameters'] : array(); 43 | $content = array_key_exists('content', $data) ? $data['content'] : null; 44 | $bbCode = array_key_exists('bbCode', $data) ? $data['bbCode'] : null; 45 | 46 | /** @psalm-suppress DocblockTypeContradiction */ 47 | if(!is_array($parameters)) { 48 | throw new \InvalidArgumentException('Parameters must be an array!'); 49 | } 50 | 51 | return new Shortcode($name, $parameters, $content, $bbCode); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Shortcode/AbstractShortcode.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | abstract class AbstractShortcode 8 | { 9 | /** @var string */ 10 | protected $name; 11 | /** @psalm-var array */ 12 | protected $parameters = array(); 13 | /** @var string|null */ 14 | protected $content; 15 | /** @var string|null */ 16 | protected $bbCode; 17 | 18 | /** @return bool */ 19 | public function hasContent() 20 | { 21 | return $this->content !== null; 22 | } 23 | 24 | /** @return string */ 25 | public function getName() 26 | { 27 | return $this->name; 28 | } 29 | 30 | /** @psalm-return array */ 31 | public function getParameters() 32 | { 33 | return $this->parameters; 34 | } 35 | 36 | /** 37 | * @param string $name 38 | * 39 | * @return bool 40 | */ 41 | public function hasParameter($name) 42 | { 43 | return array_key_exists($name, $this->parameters); 44 | } 45 | 46 | /** @return bool */ 47 | public function hasParameters() 48 | { 49 | return (bool)$this->parameters; 50 | } 51 | 52 | /** 53 | * @param string $name 54 | * @param string|null $default 55 | * 56 | * @psalm-return string|null 57 | */ 58 | public function getParameter($name, $default = null) 59 | { 60 | return $this->hasParameter($name) ? $this->parameters[$name] : $default; 61 | } 62 | 63 | /** 64 | * @param int $index 65 | * 66 | * @return string|null 67 | */ 68 | public function getParameterAt($index) 69 | { 70 | $keys = array_keys($this->parameters); 71 | 72 | return array_key_exists($index, $keys) ? $keys[$index] : null; 73 | } 74 | 75 | /** @return string|null */ 76 | public function getContent() 77 | { 78 | return $this->content; 79 | } 80 | 81 | /** @return string|null */ 82 | public function getBbCode() 83 | { 84 | return $this->bbCode; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Shortcode/ParsedShortcode.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class ParsedShortcode extends AbstractShortcode implements ParsedShortcodeInterface 8 | { 9 | /** @var string */ 10 | private $text; 11 | /** @var int */ 12 | private $offset; 13 | 14 | /** 15 | * @param string $text 16 | * @param int $offset 17 | */ 18 | public function __construct(ShortcodeInterface $shortcode, $text, $offset) 19 | { 20 | $this->name = $shortcode->getName(); 21 | $this->parameters = $shortcode->getParameters(); 22 | $this->content = $shortcode->getContent(); 23 | $this->bbCode = $shortcode->getBbCode(); 24 | $this->text = $text; 25 | $this->offset = $offset; 26 | } 27 | 28 | public function withContent($content) 29 | { 30 | $self = clone $this; 31 | $self->content = $content; 32 | 33 | return $self; 34 | } 35 | 36 | /** @return string */ 37 | public function getText() 38 | { 39 | return $this->text; 40 | } 41 | 42 | /** @return int */ 43 | public function getOffset() 44 | { 45 | return $this->offset; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Shortcode/ParsedShortcodeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | interface ParsedShortcodeInterface extends ShortcodeInterface 8 | { 9 | /** 10 | * Returns exact shortcode text 11 | * 12 | * @return string 13 | */ 14 | public function getText(); 15 | 16 | /** 17 | * Returns string position in the parent text 18 | * 19 | * @return int 20 | */ 21 | public function getOffset(); 22 | } 23 | -------------------------------------------------------------------------------- /src/Shortcode/ProcessedShortcode.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | final class ProcessedShortcode extends AbstractShortcode implements ParsedShortcodeInterface 11 | { 12 | /** @var ProcessedShortcode|null */ 13 | private $parent; 14 | /** @var int */ 15 | private $position; 16 | /** @var int */ 17 | private $namePosition; 18 | /** @var string */ 19 | private $text; 20 | /** @var string */ 21 | private $textContent; 22 | /** @var int */ 23 | private $offset; 24 | /** @var int */ 25 | private $baseOffset; 26 | /** @var string */ 27 | private $shortcodeText; 28 | /** @var int */ 29 | private $iterationNumber; 30 | /** @var int */ 31 | private $recursionLevel; 32 | /** @var ProcessorInterface */ 33 | private $processor; 34 | 35 | private function __construct(ProcessorContext $context) 36 | { 37 | // basic properties 38 | $this->name = $context->shortcode->getName(); 39 | $this->parameters = $context->shortcode->getParameters(); 40 | $this->content = $context->shortcode->getContent(); 41 | $this->bbCode = $context->shortcode->getBbCode(); 42 | $this->textContent = $context->textContent; 43 | 44 | // runtime context 45 | $this->parent = $context->parent; 46 | $this->position = $context->position; 47 | $this->namePosition = $context->namePosition[$this->name]; 48 | $this->text = $context->text; 49 | $this->shortcodeText = $context->shortcodeText; 50 | 51 | // processor state 52 | $this->iterationNumber = $context->iterationNumber; 53 | $this->recursionLevel = $context->recursionLevel; 54 | $this->processor = $context->processor; 55 | 56 | // text context 57 | $this->offset = $context->offset; 58 | $this->baseOffset = $context->baseOffset; 59 | } 60 | 61 | /** @return self */ 62 | public static function createFromContext(ProcessorContext $context) 63 | { 64 | return new self($context); 65 | } 66 | 67 | /** 68 | * @param string|null $content 69 | * 70 | * @return self 71 | */ 72 | public function withContent($content) 73 | { 74 | $self = clone $this; 75 | $self->content = $content; 76 | 77 | return $self; 78 | } 79 | 80 | /** 81 | * @param string $name 82 | * 83 | * @return bool 84 | */ 85 | public function hasAncestor($name) 86 | { 87 | $self = $this; 88 | 89 | while($self = $self->getParent()) { 90 | if($self->getName() === $name) { 91 | return true; 92 | } 93 | } 94 | 95 | return false; 96 | } 97 | 98 | /** @return ProcessedShortcode|null */ 99 | public function getParent() 100 | { 101 | return $this->parent; 102 | } 103 | 104 | /** @return string */ 105 | public function getTextContent() 106 | { 107 | return $this->textContent; 108 | } 109 | 110 | /** @return int */ 111 | public function getPosition() 112 | { 113 | return $this->position; 114 | } 115 | 116 | /** @return int */ 117 | public function getNamePosition() 118 | { 119 | return $this->namePosition; 120 | } 121 | 122 | /** @return string */ 123 | public function getText() 124 | { 125 | return $this->text; 126 | } 127 | 128 | /** @return string */ 129 | public function getShortcodeText() 130 | { 131 | return $this->shortcodeText; 132 | } 133 | 134 | /** @return int */ 135 | public function getOffset() 136 | { 137 | return $this->offset; 138 | } 139 | 140 | /** @return int */ 141 | public function getBaseOffset() 142 | { 143 | return $this->baseOffset; 144 | } 145 | 146 | /** @return int */ 147 | public function getIterationNumber() 148 | { 149 | return $this->iterationNumber; 150 | } 151 | 152 | /** @return int */ 153 | public function getRecursionLevel() 154 | { 155 | return $this->recursionLevel; 156 | } 157 | 158 | /** @return ProcessorInterface */ 159 | public function getProcessor() 160 | { 161 | return $this->processor; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Shortcode/ReplacedShortcode.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class ReplacedShortcode extends AbstractShortcode 8 | { 9 | /** @var string */ 10 | private $replacement; 11 | /** @var string */ 12 | private $text; 13 | /** @var int */ 14 | private $offset; 15 | 16 | /** @param string $replacement */ 17 | public function __construct(ParsedShortcodeInterface $shortcode, $replacement) 18 | { 19 | $this->name = $shortcode->getName(); 20 | $this->parameters = $shortcode->getParameters(); 21 | $this->content = $shortcode->getContent(); 22 | $this->bbCode = $shortcode->getBbCode(); 23 | $this->text = $shortcode->getText(); 24 | $this->offset = $shortcode->getOffset(); 25 | 26 | $this->replacement = $replacement; 27 | } 28 | 29 | /** @return string */ 30 | public function getReplacement() 31 | { 32 | return $this->replacement; 33 | } 34 | 35 | /** @return string */ 36 | public function getText() 37 | { 38 | return $this->text; 39 | } 40 | 41 | /** @return int */ 42 | public function getOffset() 43 | { 44 | return $this->offset; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Shortcode/Shortcode.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class Shortcode extends AbstractShortcode implements ShortcodeInterface 8 | { 9 | /** 10 | * @param string $name 11 | * @param array $parameters 12 | * @psalm-param array $parameters 13 | * @param string|null $content 14 | * @param string|null $bbCode 15 | */ 16 | public function __construct($name, array $parameters, $content, $bbCode = null) 17 | { 18 | /** @psalm-suppress RedundantConditionGivenDocblockType, DocblockTypeContradiction */ 19 | if(false === is_string($name) || '' === $name) { 20 | throw new \InvalidArgumentException('Shortcode name must be a non-empty string!'); 21 | } 22 | 23 | /** @psalm-suppress MissingClosureParamType, MissingClosureReturnType */ 24 | $isStringOrNull = function($value) { return is_string($value) || null === $value; }; 25 | if(count(array_filter($parameters, $isStringOrNull)) !== count($parameters)) { 26 | throw new \InvalidArgumentException('Parameter values must be either string or empty (null)!'); 27 | } 28 | 29 | $this->name = $name; 30 | $this->parameters = $parameters; 31 | $this->content = $content; 32 | $this->bbCode = $bbCode; 33 | } 34 | 35 | public function withContent($content) 36 | { 37 | return new self($this->name, $this->parameters, $content, $this->bbCode); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Shortcode/ShortcodeInterface.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | interface ShortcodeInterface 8 | { 9 | /** 10 | * Returns new instance of given shortcode with changed content 11 | * 12 | * @param string $content 13 | * 14 | * @return self 15 | */ 16 | public function withContent($content); 17 | 18 | /** 19 | * Returns shortcode name 20 | * 21 | * @return string 22 | */ 23 | public function getName(); 24 | 25 | /** 26 | * Returns associative array(name => value) of shortcode parameters 27 | * 28 | * @return array 29 | * @psalm-return array 30 | */ 31 | public function getParameters(); 32 | 33 | /** 34 | * Returns parameter value using its name, will return null for parameter 35 | * without value 36 | * 37 | * @param string $name Parameter name 38 | * @param string|null $default Value returned if there is no parameter with given name 39 | * 40 | * @return string|null 41 | */ 42 | public function getParameter($name, $default = null); 43 | 44 | /** 45 | * Returns shortcode content (data between opening and closing tag). Null 46 | * means that shortcode had no content (was self closing), do not confuse 47 | * that with empty string (hint: use strict comparison operator ===). 48 | * 49 | * @return string|null 50 | */ 51 | public function getContent(); 52 | 53 | /** 54 | * Returns the so-called "BBCode" fragment when shortcode name is treated 55 | * like a parameter, eg.: [name="value" /] 56 | * 57 | * @return string|null 58 | */ 59 | public function getBbCode(); 60 | } 61 | -------------------------------------------------------------------------------- /src/ShortcodeFacade.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | class ShortcodeFacade 25 | { 26 | /** @var ProcessorInterface */ 27 | private $processor; 28 | /** @var ParserInterface */ 29 | private $parser; 30 | /** @var SyntaxInterface */ 31 | private $syntax; 32 | 33 | /** @var HandlerContainer */ 34 | private $handlers; 35 | /** @var EventContainer */ 36 | private $events; 37 | 38 | /** @var SerializerInterface */ 39 | private $textSerializer; 40 | /** @var SerializerInterface */ 41 | private $jsonSerializer; 42 | /** @var SerializerInterface */ 43 | private $xmlSerializer; 44 | /** @var SerializerInterface */ 45 | private $yamlSerializer; 46 | 47 | public function __construct() 48 | { 49 | $this->syntax = new CommonSyntax(); 50 | $this->handlers = new HandlerContainer(); 51 | $this->events = new EventContainer(); 52 | 53 | $this->parser = new RegularParser($this->syntax); 54 | $this->rebuildProcessor(); 55 | 56 | $this->textSerializer = new TextSerializer(); 57 | $this->jsonSerializer = new JsonSerializer(); 58 | $this->yamlSerializer = new YamlSerializer(); 59 | $this->xmlSerializer = new XmlSerializer(); 60 | } 61 | 62 | /** 63 | * @deprecated use constructor and customize using exposed methods 64 | * @return self 65 | */ 66 | public static function create(HandlerContainerInterface $handlers, SyntaxInterface $syntax) 67 | { 68 | $self = new self(); 69 | 70 | /** @psalm-suppress PropertyTypeCoercion */ 71 | $self->handlers = $handlers; 72 | $self->syntax = $syntax; 73 | $self->rebuildProcessor(); 74 | 75 | return $self; 76 | } 77 | 78 | /** @return void */ 79 | private function rebuildProcessor() 80 | { 81 | $this->processor = new Processor($this->parser, $this->handlers); 82 | $this->processor = $this->processor->withEventContainer($this->events); 83 | } 84 | 85 | /** 86 | * @param string $text 87 | * 88 | * @return string 89 | */ 90 | public function process($text) 91 | { 92 | return $this->processor->process($text); 93 | } 94 | 95 | /** 96 | * @param string $text 97 | * 98 | * @return ParsedShortcodeInterface[] 99 | */ 100 | public function parse($text) 101 | { 102 | return $this->parser->parse($text); 103 | } 104 | 105 | /** @return $this */ 106 | public function setParser(ParserInterface $parser) 107 | { 108 | $this->parser = $parser; 109 | $this->rebuildProcessor(); 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @param string $name 116 | * @psalm-param callable(ShortcodeInterface):string $handler 117 | * 118 | * @return $this 119 | */ 120 | public function addHandler($name, $handler) 121 | { 122 | $this->handlers->add($name, $handler); 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * @param string $alias 129 | * @param string $name 130 | * 131 | * @return $this 132 | */ 133 | public function addHandlerAlias($alias, $name) 134 | { 135 | $this->handlers->addAlias($alias, $name); 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * @param string $name 142 | * @param callable $handler 143 | * 144 | * @return $this 145 | */ 146 | public function addEventHandler($name, $handler) 147 | { 148 | $this->events->addListener($name, $handler); 149 | 150 | return $this; 151 | } 152 | 153 | /* --- SERIALIZATION --------------------------------------------------- */ 154 | 155 | /** 156 | * @param string $format 157 | * 158 | * @return string 159 | */ 160 | public function serialize(ShortcodeInterface $shortcode, $format) 161 | { 162 | switch($format) { 163 | case 'text': return $this->textSerializer->serialize($shortcode); 164 | case 'json': return $this->jsonSerializer->serialize($shortcode); 165 | case 'yaml': return $this->yamlSerializer->serialize($shortcode); 166 | case 'xml': return $this->xmlSerializer->serialize($shortcode); 167 | default: throw new \InvalidArgumentException(sprintf('Invalid serialization format %s!', $format)); 168 | } 169 | } 170 | 171 | /** 172 | * @param string $text 173 | * @param string $format 174 | * 175 | * @return ShortcodeInterface 176 | */ 177 | public function unserialize($text, $format) 178 | { 179 | switch($format) { 180 | case 'text': return $this->textSerializer->unserialize($text); 181 | case 'json': return $this->jsonSerializer->unserialize($text); 182 | case 'yaml': return $this->yamlSerializer->unserialize($text); 183 | case 'xml': return $this->xmlSerializer->unserialize($text); 184 | default: throw new \InvalidArgumentException(sprintf('Invalid unserialization format %s!', $format)); 185 | } 186 | } 187 | 188 | /** 189 | * @deprecated use serialize($shortcode, $format) 190 | * @return string 191 | */ 192 | public function serializeToText(ShortcodeInterface $s) { return $this->serialize($s, 'text'); } 193 | 194 | /** 195 | * @deprecated use serialize($shortcode, $format) 196 | * @return string 197 | */ 198 | public function serializeToJson(ShortcodeInterface $s) { return $this->serialize($s, 'json'); } 199 | 200 | /** 201 | * @deprecated use serialize($shortcode, $format) 202 | * @param string $text 203 | * 204 | * @return ShortcodeInterface 205 | */ 206 | public function unserializeFromText($text) { return $this->unserialize($text, 'text'); } 207 | 208 | /** 209 | * @deprecated use serialize($shortcode, $format) 210 | * @param string $text 211 | * 212 | * @return ShortcodeInterface 213 | */ 214 | public function unserializeFromJson($text) { return $this->unserialize($text, 'json'); } 215 | } 216 | -------------------------------------------------------------------------------- /src/Syntax/CommonSyntax.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class CommonSyntax implements SyntaxInterface 8 | { 9 | /** @return non-empty-string */ 10 | public function getOpeningTag() 11 | { 12 | return '['; 13 | } 14 | 15 | /** @return non-empty-string */ 16 | public function getClosingTag() 17 | { 18 | return ']'; 19 | } 20 | 21 | /** @return non-empty-string */ 22 | public function getClosingTagMarker() 23 | { 24 | return '/'; 25 | } 26 | 27 | /** @return non-empty-string */ 28 | public function getParameterValueSeparator() 29 | { 30 | return '='; 31 | } 32 | 33 | /** @return non-empty-string */ 34 | public function getParameterValueDelimiter() 35 | { 36 | return '"'; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Syntax/Syntax.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class Syntax implements SyntaxInterface 8 | { 9 | /** @var non-empty-string|null */ 10 | private $openingTag; 11 | /** @var non-empty-string|null */ 12 | private $closingTag; 13 | /** @var non-empty-string|null */ 14 | private $closingTagMarker; 15 | /** @var non-empty-string|null */ 16 | private $parameterValueSeparator; 17 | /** @var non-empty-string|null */ 18 | private $parameterValueDelimiter; 19 | 20 | /** 21 | * @param non-empty-string|null $openingTag 22 | * @param non-empty-string|null $closingTag 23 | * @param non-empty-string|null $closingTagMarker 24 | * @param non-empty-string|null $parameterValueSeparator 25 | * @param non-empty-string|null $parameterValueDelimiter 26 | */ 27 | public function __construct( 28 | $openingTag = null, 29 | $closingTag = null, 30 | $closingTagMarker = null, 31 | $parameterValueSeparator = null, 32 | $parameterValueDelimiter = null 33 | ) { 34 | $this->openingTag = $openingTag; 35 | $this->closingTag = $closingTag; 36 | $this->closingTagMarker = $closingTagMarker; 37 | $this->parameterValueSeparator = $parameterValueSeparator; 38 | $this->parameterValueDelimiter = $parameterValueDelimiter; 39 | } 40 | 41 | /** @return non-empty-string */ 42 | public function getOpeningTag() 43 | { 44 | return null !== $this->openingTag ? $this->openingTag : '['; 45 | } 46 | 47 | /** @return non-empty-string */ 48 | public function getClosingTag() 49 | { 50 | return null !== $this->closingTag ? $this->closingTag : ']'; 51 | } 52 | 53 | /** @return non-empty-string */ 54 | public function getClosingTagMarker() 55 | { 56 | return null !== $this->closingTagMarker ? $this->closingTagMarker : '/'; 57 | } 58 | 59 | /** @return non-empty-string */ 60 | public function getParameterValueSeparator() 61 | { 62 | return null !== $this->parameterValueSeparator ? $this->parameterValueSeparator : '='; 63 | } 64 | 65 | /** @return non-empty-string */ 66 | public function getParameterValueDelimiter() 67 | { 68 | return null !== $this->parameterValueDelimiter ? $this->parameterValueDelimiter : '"'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Syntax/SyntaxBuilder.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | final class SyntaxBuilder 8 | { 9 | /** @var non-empty-string|null */ 10 | private $openingTag; 11 | /** @var non-empty-string|null */ 12 | private $closingTag; 13 | /** @var non-empty-string|null */ 14 | private $closingTagMarker; 15 | /** @var non-empty-string|null */ 16 | private $parameterValueSeparator; 17 | /** @var non-empty-string|null */ 18 | private $parameterValueDelimiter; 19 | 20 | public function __construct() 21 | { 22 | } 23 | 24 | /** @return Syntax */ 25 | public function getSyntax() 26 | { 27 | return new Syntax( 28 | $this->openingTag, 29 | $this->closingTag, 30 | $this->closingTagMarker, 31 | $this->parameterValueSeparator, 32 | $this->parameterValueDelimiter 33 | ); 34 | } 35 | 36 | /** 37 | * @param non-empty-string $tag 38 | * 39 | * @return $this 40 | */ 41 | public function setOpeningTag($tag) 42 | { 43 | $this->openingTag = $tag; 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @param non-empty-string $tag 50 | * 51 | * @return $this 52 | */ 53 | public function setClosingTag($tag) 54 | { 55 | $this->closingTag = $tag; 56 | 57 | return $this; 58 | } 59 | 60 | /** 61 | * @param non-empty-string $marker 62 | * 63 | * @return $this 64 | */ 65 | public function setClosingTagMarker($marker) 66 | { 67 | $this->closingTagMarker = $marker; 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * @param non-empty-string $separator 74 | * 75 | * @return $this 76 | */ 77 | public function setParameterValueSeparator($separator) 78 | { 79 | $this->parameterValueSeparator = $separator; 80 | 81 | return $this; 82 | } 83 | 84 | /** 85 | * @param non-empty-string $delimiter 86 | * 87 | * @return $this 88 | */ 89 | public function setParameterValueDelimiter($delimiter) 90 | { 91 | $this->parameterValueDelimiter = $delimiter; 92 | 93 | return $this; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Syntax/SyntaxInterface.php: -------------------------------------------------------------------------------- 1 | 6 | */ 7 | interface SyntaxInterface 8 | { 9 | /** @return non-empty-string */ 10 | public function getOpeningTag(); 11 | 12 | /** @return non-empty-string */ 13 | public function getClosingTag(); 14 | 15 | /** @return non-empty-string */ 16 | public function getClosingTagMarker(); 17 | 18 | /** @return non-empty-string */ 19 | public function getParameterValueSeparator(); 20 | 21 | /** @return non-empty-string */ 22 | public function getParameterValueDelimiter(); 23 | } 24 | -------------------------------------------------------------------------------- /src/Utility/RegexBuilderUtility.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | final class RegexBuilderUtility 10 | { 11 | /** @return non-empty-string */ 12 | public static function buildNameRegex() 13 | { 14 | return '[a-zA-Z0-9-_\\*]+'; 15 | } 16 | 17 | /** @return non-empty-string */ 18 | public static function buildShortcodeRegex(SyntaxInterface $syntax) 19 | { 20 | return '~('.self::createShortcodeRegexContent($syntax).')~us'; 21 | } 22 | 23 | /** @return non-empty-string */ 24 | public static function buildSingleShortcodeRegex(SyntaxInterface $syntax) 25 | { 26 | return '~(\A'.self::createShortcodeRegexContent($syntax).'\Z)~us'; 27 | } 28 | 29 | /** @return non-empty-string */ 30 | public static function buildParametersRegex(SyntaxInterface $syntax) 31 | { 32 | $equals = self::quote($syntax->getParameterValueSeparator()); 33 | $string = self::quote($syntax->getParameterValueDelimiter()); 34 | 35 | $space = '\s*'; 36 | // lookahead test for either space or end of string 37 | $empty = '(?=\s|$)'; 38 | // equals sign and alphanumeric value 39 | $simple = $space.$equals.$space.'[^\s]+'; 40 | // equals sign and value without unescaped string delimiters enclosed in them 41 | $complex = $space.$equals.$space.$string.'([^'.$string.'\\\\]*(?:\\\\.[^'.$string.'\\\\]*)*?)'.$string; 42 | 43 | return '~(?:\s*(\w+(?:'.$complex.'|'.$simple.'|'.$empty.')))~us'; 44 | } 45 | 46 | /** @return non-empty-string */ 47 | private static function createShortcodeRegexContent(SyntaxInterface $syntax) 48 | { 49 | $open = self::quote($syntax->getOpeningTag()); 50 | $slash = self::quote($syntax->getClosingTagMarker()); 51 | $close = self::quote($syntax->getClosingTag()); 52 | $equals = self::quote($syntax->getParameterValueSeparator()); 53 | $string = self::quote($syntax->getParameterValueDelimiter()); 54 | 55 | $space = '\s*'; 56 | 57 | // parameter and value separator can have any number of spaces around itself 58 | $equalsSpaced = $space.$equals.$space; 59 | // lookahead test for space, closing tag, self-closing tag or end of string 60 | $empty = '(?=\s|'.$close.'|'.$slash.$space.$close.'|$)'; 61 | // equals sign and alphanumeric value 62 | $simple = '((?:(?!=\s*|'.$close.'|'.$slash.$close.')[^\s])+)'; 63 | // equals sign and value without unescaped string delimiters enclosed in them 64 | $complex = $string.'(?:[^'.$string.'\\\\]*(?:\\\\.[^'.$string.'\\\\]*)*)'.$string; 65 | // complete parameters matching regex 66 | $parameters = '(?(?:\s*(?:\w+(?:'.$equalsSpaced.$complex.'|'.$equalsSpaced.$simple.'|'.$empty.')))*)'; 67 | // BBCode is the part after name that makes it behave like a non-empty parameter value 68 | $bbCode = '(?:'.$equals.$space.'(?'.$complex.'|'.$simple.'))?'; 69 | 70 | // alphanumeric characters and dash 71 | $name = '(?'.static::buildNameRegex().')'; 72 | // non-greedy match for any characters 73 | $content = '(?.*?)'; 74 | 75 | // equal beginning for each variant: open tag, name and parameters 76 | $common = $open.$space.$name.$space.$bbCode.$space.$parameters.$space; 77 | // closing tag variants: just closing tag, self closing tag or content 78 | // and closing block with backreference name validation 79 | $justClosed = $close; 80 | $selfClosed = '(?'.$slash.')'.$space.$close; 81 | $withContent = $close.$content.$open.$space.'(?'.$slash.')'.$space.'(\k)'.$space.$close; 82 | 83 | return '(?:'.$common.'(?:'.$withContent.'|'.$justClosed.'|'.$selfClosed.'))'; 84 | } 85 | 86 | /** 87 | * @param non-empty-string $text 88 | * 89 | * @return non-empty-string 90 | */ 91 | private static function quote($text) 92 | { 93 | /** @var non-empty-string $quoted */ 94 | $quoted = preg_replace('/(.)/us', '\\\\$0', $text); 95 | 96 | return $quoted; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/AbstractTestCase.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | abstract class AbstractTestCase extends TestCase 10 | { 11 | public function willThrowException($exception) 12 | { 13 | version_compare(phpversion(), '7.0.0') > 0 14 | ? $this->expectException($exception) 15 | : $this->setExpectedException($exception); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/EventsTest.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class EventsTest extends AbstractTestCase 20 | { 21 | public function testRaw() 22 | { 23 | $times = 0; 24 | $handlers = new HandlerContainer(); 25 | $handlers->add('raw', function(ShortcodeInterface $s) { return $s->getContent(); }); 26 | $handlers->add('n', function(ShortcodeInterface $s) use(&$times) { ++$times; return $s->getName(); }); 27 | $handlers->add('c', function(ShortcodeInterface $s) use(&$times) { ++$times; return $s->getContent(); }); 28 | 29 | $events = new EventContainer(); 30 | $events->addListener(Events::FILTER_SHORTCODES, new FilterRawEventHandler(array('raw'))); 31 | 32 | $processor = new Processor(new RegularParser(), $handlers); 33 | $processor = $processor->withEventContainer($events); 34 | 35 | $this->assertSame(' [n] [c]cnt[/c] [/n] ', $processor->process('[raw] [n] [c]cnt[/c] [/n] [/raw]')); 36 | $this->assertSame('x un [n] [c]cnt[/c] [/n] y', $processor->process('x [c]u[n][/c][raw] [n] [c]cnt[/c] [/n] [/raw] y')); 37 | $this->assertEquals(2, $times); 38 | } 39 | 40 | public function testStripContentOutsideShortcodes() 41 | { 42 | $handlers = new HandlerContainer(); 43 | $handlers->add('name', function(ShortcodeInterface $s) { return $s->getName(); }); 44 | $handlers->add('content', function(ShortcodeInterface $s) { return $s->getContent(); }); 45 | $handlers->add('root', function(ProcessedShortcode $s) { return 'root['.$s->getContent().']'; }); 46 | 47 | $events = new EventContainer(); 48 | $events->addListener(Events::REPLACE_SHORTCODES, new ReplaceJoinEventHandler(array('root'))); 49 | 50 | $processor = new Processor(new RegularParser(), $handlers); 51 | $processor = $processor->withEventContainer($events); 52 | 53 | $this->assertSame('a root[name name name] b', $processor->process('a [root]x [name] c[content] [name /] [/content] y[name/][/root] b')); 54 | } 55 | 56 | public function testDefaultApplier() 57 | { 58 | $handlers = new HandlerContainer(); 59 | $handlers->add('name', function(ShortcodeInterface $s) { return $s->getName(); }); 60 | $handlers->add('content', function(ShortcodeInterface $s) { return $s->getContent(); }); 61 | $handlers->add('root', function(ProcessedShortcode $s) { return 'root['.$s->getContent().']'; }); 62 | 63 | $events = new EventContainer(); 64 | $events->addListener(Events::REPLACE_SHORTCODES, function(ReplaceShortcodesEvent $event) { 65 | $event->setResult(array_reduce(array_reverse($event->getReplacements()), function($state, ReplacedShortcode $r) { 66 | $offset = $r->getOffset(); 67 | $length = mb_strlen($r->getText()); 68 | 69 | return mb_substr($state, 0, $offset).$r->getReplacement().mb_substr($state, $offset + $length); 70 | }, $event->getText())); 71 | }); 72 | 73 | $processor = new Processor(new RegularParser(), $handlers); 74 | $processor = $processor->withEventContainer($events); 75 | 76 | $this->assertSame('a root[x name c name y] b', $processor->process('a [root]x [name] c[content] [name /] [/content] y[/root] b')); 77 | } 78 | 79 | public function testExceptionOnHandlerForUnknownEvent() 80 | { 81 | $events = new EventContainer(); 82 | $this->willThrowException('InvalidArgumentException'); 83 | $events->addListener('invalid', function() {}); 84 | } 85 | 86 | public function testInvalidFilterRawShortcodesNames() 87 | { 88 | $this->willThrowException('InvalidArgumentException'); 89 | new FilterRawEventHandler(array(new \stdClass())); 90 | } 91 | 92 | public function testInvalidReplaceJoinNames() 93 | { 94 | $this->willThrowException('InvalidArgumentException'); 95 | new ReplaceJoinEventHandler(array(new \stdClass())); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/FacadeTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class FacadeTest extends AbstractTestCase 17 | { 18 | public function testFacade() 19 | { 20 | $handlers = new HandlerContainer(); 21 | $handlers 22 | ->add('name', function (ShortcodeInterface $s) { return $s->getName(); }) 23 | ->addAlias('n', 'name'); 24 | 25 | $facade = ShortcodeFacade::create($handlers, new CommonSyntax()); 26 | $facade->addHandler('content', function (ShortcodeInterface $s) { return $s->getContent(); }); 27 | $facade->addHandlerAlias('c', 'content'); 28 | $facade->setParser(new RegexParser()); 29 | 30 | $this->assertSame('n', $facade->process('[n]')); 31 | $this->assertSame('c', $facade->process('[c]c[/c]')); 32 | 33 | $shortcodes = $facade->parse('[b]'); 34 | $this->assertInstanceOf('Thunder\\Shortcode\\Shortcode\\ParsedShortcodeInterface', $shortcodes[0]); 35 | } 36 | 37 | public function testFacadeEvents() 38 | { 39 | $facade = new ShortcodeFacade(); 40 | $facade->addHandler('n', function (ShortcodeInterface $s) { return $s->getName(); }); 41 | $facade->addEventHandler(Events::FILTER_SHORTCODES, new FilterRawEventHandler(array('raw'))); 42 | 43 | $this->assertSame('[raw] [n] [/raw]', $facade->process('[raw] [n] [/raw]')); 44 | } 45 | 46 | public function testSerialization() 47 | { 48 | $facade = new ShortcodeFacade(); 49 | 50 | $s = new Shortcode('c', array(), null); 51 | $this->assertSame('[c /]', $facade->serializeToText($s)); 52 | $this->assertSame('c', $facade->unserializeFromText('[c]')->getName()); 53 | $this->assertSame('[c /]', $facade->serialize($s, 'text')); 54 | $this->assertSame('c', $facade->unserialize('[c]', 'text')->getName()); 55 | 56 | $json = '{"name":"c","parameters":[],"content":null,"bbCode":null}'; 57 | $this->assertSame($json, $facade->serializeToJson($s)); 58 | $this->assertSame('c', $facade->unserializeFromJson($json)->getName()); 59 | $this->assertSame($json, $facade->serialize($s, 'json')); 60 | $this->assertSame('c', $facade->unserialize($json, 'json')->getName()); 61 | 62 | $yaml = <<assertSame($yaml, $facade->serialize($s, 'yaml')); 70 | $this->assertSame('c', $facade->unserialize($yaml, 'yaml')->getName()); 71 | 72 | $xml = << 74 | 75 | 76 | 77 | 78 | 79 | 80 | EOF; 81 | $this->assertSame($xml, $facade->serialize($s, 'xml')); 82 | $this->assertSame('c', $facade->unserialize($xml, 'xml')->getName()); 83 | } 84 | 85 | public function testInvalidSerializationFormatException() 86 | { 87 | $this->willThrowException('InvalidArgumentException'); 88 | $facade = new ShortcodeFacade(); 89 | $facade->serialize(new Shortcode('name', array(), null), 'invalid'); 90 | } 91 | 92 | public function testInvalidUnserializationFormatException() 93 | { 94 | $this->willThrowException('InvalidArgumentException'); 95 | $facade = new ShortcodeFacade(); 96 | $facade->unserialize('[c]', 'invalid'); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Fake/ReverseShortcode.php: -------------------------------------------------------------------------------- 1 | getContent()); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/HandlerContainerTest.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | final class HandlerContainerTest extends AbstractTestCase 12 | { 13 | public function testExceptionOnDuplicateHandler() 14 | { 15 | $handlers = new HandlerContainer(); 16 | $handlers->add('name', function () {}); 17 | $this->willThrowException('RuntimeException'); 18 | $handlers->add('name', function () {}); 19 | } 20 | 21 | public function testRemove() 22 | { 23 | $handlers = new HandlerContainer(); 24 | static::assertFalse($handlers->has('code')); 25 | $handlers->add('code', function(ShortcodeInterface $s) {}); 26 | static::assertTrue($handlers->has('code')); 27 | $handlers->remove('code'); 28 | static::assertFalse($handlers->has('code')); 29 | } 30 | 31 | public function testRemoveException() 32 | { 33 | $handlers = new HandlerContainer(); 34 | $this->willThrowException('RuntimeException'); 35 | $handlers->remove('code'); 36 | } 37 | 38 | public function testNames() 39 | { 40 | $handlers = new HandlerContainer(); 41 | static::assertEmpty($handlers->getNames()); 42 | $handlers->add('code', function(ShortcodeInterface $s) {}); 43 | static::assertSame(array('code'), $handlers->getNames()); 44 | $handlers->addAlias('c', 'code'); 45 | static::assertSame(array('code', 'c'), $handlers->getNames()); 46 | } 47 | 48 | public function testHandlerContainer() 49 | { 50 | $x = function () {}; 51 | 52 | $handler = new HandlerContainer(); 53 | $handler->add('x', $x); 54 | $handler->addAlias('y', 'x'); 55 | 56 | static::assertSame($x, $handler->get('x')); 57 | } 58 | 59 | public function testInvalidHandler() 60 | { 61 | $handlers = new HandlerContainer(); 62 | $this->willThrowException('RuntimeException'); 63 | $handlers->add('invalid', new \stdClass()); 64 | } 65 | 66 | public function testDefaultHandler() 67 | { 68 | $handlers = new HandlerContainer(); 69 | static::assertNull($handlers->get('missing')); 70 | 71 | $handlers->setDefault(function () {}); 72 | static::assertNotNull($handlers->get('missing')); 73 | } 74 | 75 | public function testExceptionIfAliasingNonExistentHandler() 76 | { 77 | $handlers = new HandlerContainer(); 78 | $this->willThrowException('RuntimeException'); 79 | $handlers->addAlias('m', 'missing'); 80 | } 81 | 82 | public function testImmutableHandlerContainer() 83 | { 84 | $handlers = new HandlerContainer(); 85 | $handlers->add('code', function () {}); 86 | $handlers->addAlias('c', 'code'); 87 | $imHandlers = new ImmutableHandlerContainer($handlers); 88 | $handlers->add('not', function() {}); 89 | 90 | static::assertNull($imHandlers->get('missing')); 91 | static::assertNotNull($imHandlers->get('code')); 92 | static::assertNotNull($imHandlers->get('c')); 93 | static::assertNull($imHandlers->get('not')); 94 | 95 | $defaultHandlers = new HandlerContainer(); 96 | $defaultHandlers->setDefault(function () {}); 97 | $imDefaultHandlers = new ImmutableHandlerContainer($defaultHandlers); 98 | static::assertNotNull($imDefaultHandlers->get('missing')); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/ParserTest.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class ParserTest extends AbstractTestCase 18 | { 19 | /** 20 | * @param ParserInterface $parser 21 | * @param string $code 22 | * @param ParsedShortcode[] $expected 23 | * 24 | * @dataProvider provideShortcodes 25 | */ 26 | public function testParser(ParserInterface $parser, $code, array $expected) 27 | { 28 | $this->assertShortcodes($parser->parse($code), $expected); 29 | } 30 | 31 | private function assertShortcodes(array $actual, array $expected) 32 | { 33 | $count = count($actual); 34 | static::assertCount($count, $expected, 'counts'); 35 | for ($i = 0; $i < $count; $i++) { 36 | static::assertSame($actual[$i]->getName(), $expected[$i]->getName(), 'name'); 37 | static::assertSame($actual[$i]->getParameters(), $expected[$i]->getParameters(), 'parameters'); 38 | static::assertSame($actual[$i]->getContent(), $expected[$i]->getContent(), 'content'); 39 | static::assertSame($actual[$i]->getText(), $expected[$i]->getText(), 'text'); 40 | static::assertSame($actual[$i]->getOffset(), $expected[$i]->getOffset(), 'offset'); 41 | static::assertSame($actual[$i]->getBbCode(), $expected[$i]->getBbCode(), 'bbCode'); 42 | } 43 | } 44 | 45 | public static function provideShortcodes() 46 | { 47 | $s = new CommonSyntax(); 48 | 49 | $tests = array( 50 | // invalid 51 | array($s, '', array()), 52 | array($s, '[]', array()), 53 | array($s, '![](image.jpg)', array()), 54 | array($s, 'x html([a. title][, alt][, classes]) x', array()), 55 | array($s, '[/y]', array()), 56 | array($s, '[sc', array()), 57 | array($s, '[sc / [/sc]', array()), 58 | array($s, '[sc arg="val', array()), 59 | 60 | // single shortcodes 61 | array($s, '[sc]', array( 62 | new ParsedShortcode(new Shortcode('sc', array(), null), '[sc]', 0), 63 | )), 64 | array($s, '[sc arg=val]', array( 65 | new ParsedShortcode(new Shortcode('sc', array('arg' => 'val'), null), '[sc arg=val]', 0), 66 | )), 67 | array($s, '[sc novalue arg="complex value"]', array( 68 | new ParsedShortcode(new Shortcode('sc', array('novalue' => null, 'arg' => 'complex value'), null), '[sc novalue arg="complex value"]', 0), 69 | )), 70 | array($s, '[sc x="ąćęłńóśżź ĄĆĘŁŃÓŚŻŹ"]', array( 71 | new ParsedShortcode(new Shortcode('sc', array('x' => 'ąćęłńóśżź ĄĆĘŁŃÓŚŻŹ'), null), '[sc x="ąćęłńóśżź ĄĆĘŁŃÓŚŻŹ"]', 0), 72 | )), 73 | array($s, '[sc x="multi'."\n".'line"]', array( 74 | new ParsedShortcode(new Shortcode('sc', array('x' => 'multi'."\n".'line'), null), '[sc x="multi'."\n".'line"]', 0), 75 | )), 76 | array($s, '[sc noval x="val" y]content[/sc]', array( 77 | new ParsedShortcode(new Shortcode('sc', array('noval' => null, 'x' => 'val', 'y' => null), 'content'), '[sc noval x="val" y]content[/sc]', 0), 78 | )), 79 | array($s, '[sc x="{..}"]', array( 80 | new ParsedShortcode(new Shortcode('sc', array('x' => '{..}'), null), '[sc x="{..}"]', 0), 81 | )), 82 | array($s, '[sc a="x y" b="x" c=""]', array( 83 | new ParsedShortcode(new Shortcode('sc', array('a' => 'x y', 'b' => 'x', 'c' => ''), null), '[sc a="x y" b="x" c=""]', 0), 84 | )), 85 | array($s, '[sc a="a \"\" b"]', array( 86 | new ParsedShortcode(new Shortcode('sc', array('a' => 'a \"\" b'), null), '[sc a="a \"\" b"]', 0), 87 | )), 88 | array($s, '[sc/]', array( 89 | new ParsedShortcode(new Shortcode('sc', array(), null), '[sc/]', 0), 90 | )), 91 | array($s, '[sc /]', array( 92 | new ParsedShortcode(new Shortcode('sc', array(), null), '[sc /]', 0), 93 | )), 94 | array($s, '[sc arg=val cmp="a b"/]', array( 95 | new ParsedShortcode(new Shortcode('sc', array('arg' => 'val', 'cmp' => 'a b'), null), '[sc arg=val cmp="a b"/]', 0), 96 | )), 97 | array($s, '[sc x y /]', array( 98 | new ParsedShortcode(new Shortcode('sc', array('x' => null, 'y' => null), null), '[sc x y /]', 0), 99 | )), 100 | array($s, '[sc x="\ " /]', array( 101 | new ParsedShortcode(new Shortcode('sc', array('x' => '\ '), null), '[sc x="\ " /]', 0), 102 | )), 103 | array($s, '[ sc x = "\ " y = value z / ]', array( 104 | new ParsedShortcode(new Shortcode('sc', array('x' => '\ ', 'y' => 'value', 'z' => null), null), '[ sc x = "\ " y = value z / ]', 0), 105 | )), 106 | array($s, '[ sc x= "\ " y =value ] vv [ / sc ]', array( 107 | new ParsedShortcode(new Shortcode('sc', array('x' => '\ ', 'y' => 'value'), ' vv '), '[ sc x= "\ " y =value ] vv [ / sc ]', 0), 108 | )), 109 | array($s, '[sc url="http://giggle.com/search" /]', array( 110 | new ParsedShortcode(new Shortcode('sc', array('url' => 'http://giggle.com/search'), null), '[sc url="http://giggle.com/search" /]', 0), 111 | )), 112 | 113 | // bbcode 114 | array($s, '[sc = "http://giggle.com/search" /]', array( 115 | new ParsedShortcode(new Shortcode('sc', array(), null, 'http://giggle.com/search'), '[sc = "http://giggle.com/search" /]', 0), 116 | )), 117 | 118 | // multiple shortcodes 119 | array($s, 'Lorem [ipsum] random [code-code arg=val] which is here', array( 120 | new ParsedShortcode(new Shortcode('ipsum', array(), null), '[ipsum]', 6), 121 | new ParsedShortcode(new Shortcode('code-code', array('arg' => 'val'), null), '[code-code arg=val]', 21), 122 | )), 123 | array($s, 'x [aa] x [aa] x', array( 124 | new ParsedShortcode(new Shortcode('aa', array(), null), '[aa]', 2), 125 | new ParsedShortcode(new Shortcode('aa', array(), null), '[aa]', 9), 126 | )), 127 | array($s, 'x [x]a[/x] x [x]a[/x] x', array( 128 | new ParsedShortcode(new Shortcode('x', array(), 'a'), '[x]a[/x]', 2), 129 | new ParsedShortcode(new Shortcode('x', array(), 'a'), '[x]a[/x]', 13), 130 | )), 131 | array($s, 'x [x x y=z a="b c"]a[/x] x [x x y=z a="b c"]a[/x] x', array( 132 | new ParsedShortcode(new Shortcode('x', array('x' => null, 'y' => 'z', 'a' => 'b c'), 'a'), '[x x y=z a="b c"]a[/x]', 2), 133 | new ParsedShortcode(new Shortcode('x', array('x' => null, 'y' => 'z', 'a' => 'b c'), 'a'), '[x x y=z a="b c"]a[/x]', 27), 134 | )), 135 | array($s, 'x [code /] y [code]z[/code] x [code] y [code/] a', array( 136 | new ParsedShortcode(new Shortcode('code', array(), null), '[code /]', 2), 137 | new ParsedShortcode(new Shortcode('code', array(), 'z'), '[code]z[/code]', 13), 138 | new ParsedShortcode(new Shortcode('code', array(), null), '[code]', 30), 139 | new ParsedShortcode(new Shortcode('code', array(), null), '[code/]', 39), 140 | )), 141 | array($s, 'x [code arg=val /] y [code cmp="xx"/] x [code x=y/] a', array( 142 | new ParsedShortcode(new Shortcode('code', array('arg' => 'val'), null), '[code arg=val /]', 2), 143 | new ParsedShortcode(new Shortcode('code', array('cmp' => 'xx'), null), '[code cmp="xx"/]', 21), 144 | new ParsedShortcode(new Shortcode('code', array('x' => 'y'), null), '[code x=y/]', 40), 145 | )), 146 | array($s, 'x [ code arg=val /]a[ code/]c[x / ] m [ y ] c [ / y]', array( 147 | new ParsedShortcode(new Shortcode('code', array('arg' => 'val'), null), '[ code arg=val /]', 2), 148 | new ParsedShortcode(new Shortcode('code', array(), null), '[ code/]', 23), 149 | new ParsedShortcode(new Shortcode('x', array(), null), '[x / ]', 32), 150 | new ParsedShortcode(new Shortcode('y', array(), ' c '), '[ y ] c [ / y]', 47), 151 | )), 152 | 153 | // other syntax 154 | array(new Syntax('[[', ']]', '//', '==', '""'), '[[code arg==""val oth""]]cont[[//code]]', array( 155 | new ParsedShortcode(new Shortcode('code', array('arg' => 'val oth'), 'cont'), '[[code arg==""val oth""]]cont[[//code]]', 0), 156 | )), 157 | array(new Syntax('^', '$', '&', '!!!', '@@'), '^code a!!!@@\"\"@@ b!!!@@x\"y@@ c$cnt^&code$', array( 158 | new ParsedShortcode(new Shortcode('code', array('a' => '\"\"', 'b' => 'x\"y', 'c' => null), 'cnt'), '^code a!!!@@\"\"@@ b!!!@@x\"y@@ c$cnt^&code$', 0), 159 | )), 160 | 161 | // UTF-8 sequences 162 | array($s, '’’’’[sc]’’[sc]', array( 163 | new ParsedShortcode(new Shortcode('sc', array(), null), '[sc]', 4), 164 | new ParsedShortcode(new Shortcode('sc', array(), null), '[sc]', 10), 165 | )), 166 | 167 | // performance 168 | // array($s, 'x [[aa]] y', array()), 169 | array($s, str_repeat('[a]', 20), array_map(function($offset) { // 20 170 | return new ParsedShortcode(new Shortcode('a', array(), null), '[a]', $offset); 171 | }, range(0, 57, 3))), 172 | array($s, '[b][a]x[a][/a][/a][/b] [b][a][a][/a]y[/a][/b]', array( 173 | new ParsedShortcode(new Shortcode('b', array(), '[a]x[a][/a][/a]'), '[b][a]x[a][/a][/a][/b]', 0), 174 | new ParsedShortcode(new Shortcode('b', array(), '[a][a][/a]y[/a]'), '[b][a][a][/a]y[/a][/b]', 23), 175 | )), 176 | array($s, '[b] [a][a][a] [/b] [b] [a][a][a] [/b]', array( 177 | new ParsedShortcode(new Shortcode('b', array(), ' [a][a][a] '), '[b] [a][a][a] [/b]', 0), 178 | new ParsedShortcode(new Shortcode('b', array(), ' [a][a][a] '), '[b] [a][a][a] [/b]', 19), 179 | )), 180 | array($s, '[name]random[/other]', array( 181 | new ParsedShortcode(new Shortcode('name', array(), null), '[name]', 0), 182 | )), 183 | array($s, '[0][1][2][3]', array( 184 | new ParsedShortcode(new Shortcode('0', array(), null), '[0]', 0), 185 | new ParsedShortcode(new Shortcode('1', array(), null), '[1]', 3), 186 | new ParsedShortcode(new Shortcode('2', array(), null), '[2]', 6), 187 | new ParsedShortcode(new Shortcode('3', array(), null), '[3]', 9), 188 | )), 189 | array($s, '[_][na_me][_name][name_][n_am_e][_n_]', array( 190 | new ParsedShortcode(new Shortcode('_', array(), null), '[_]', 0), 191 | new ParsedShortcode(new Shortcode('na_me', array(), null), '[na_me]', 3), 192 | new ParsedShortcode(new Shortcode('_name', array(), null), '[_name]', 10), 193 | new ParsedShortcode(new Shortcode('name_', array(), null), '[name_]', 17), 194 | new ParsedShortcode(new Shortcode('n_am_e', array(), null), '[n_am_e]', 24), 195 | new ParsedShortcode(new Shortcode('_n_', array(), null), '[_n_]', 32), 196 | )), 197 | array($s, '[x]/[/x] [x]"[/x] [x]=[/x] [x]][/x] [x] [/x] [x]x[/x]', array( 198 | new ParsedShortcode(new Shortcode('x', array(), '/'), '[x]/[/x]', 0), 199 | new ParsedShortcode(new Shortcode('x', array(), '"'), '[x]"[/x]', 9), 200 | new ParsedShortcode(new Shortcode('x', array(), '='), '[x]=[/x]', 18), 201 | new ParsedShortcode(new Shortcode('x', array(), ']'), '[x]][/x]', 27), 202 | new ParsedShortcode(new Shortcode('x', array(), ' '), '[x] [/x]', 36), 203 | new ParsedShortcode(new Shortcode('x', array(), 'x'), '[x]x[/x]', 45), 204 | )), 205 | array($s, '[a]0[/a]', array( 206 | new ParsedShortcode(new Shortcode('a', array(), '0'), '[a]0[/a]', 0), 207 | )), 208 | array($s, '[fa icon=fa-camera /] [fa icon=fa-camera extras=fa-4x /]', array( 209 | new ParsedShortcode(new Shortcode('fa', array('icon' => 'fa-camera'), null), '[fa icon=fa-camera /]', 0), 210 | new ParsedShortcode(new Shortcode('fa', array('icon' => 'fa-camera', 'extras' => 'fa-4x'), null), '[fa icon=fa-camera extras=fa-4x /]', 22), 211 | )), 212 | array($s, '[fa icon=fa-circle-o-notch extras=fa-spin,fa-3x /]', array( 213 | new ParsedShortcode(new Shortcode('fa', array('icon' => 'fa-circle-o-notch', 'extras' => 'fa-spin,fa-3x'), null), '[fa icon=fa-circle-o-notch extras=fa-spin,fa-3x /]', 0), 214 | )), 215 | array($s, '[z =]', array()), 216 | array($s, '[x=#F00 one=#F00 two="#F00"]', array( 217 | new ParsedShortcode(new Shortcode('x', array('one' => '#F00', 'two' => '#F00'), null, '#F00'), '[x=#F00 one=#F00 two="#F00"]', 0), 218 | )), 219 | array($s, '[*] [* xyz arg=val]', array( 220 | new ParsedShortcode(new Shortcode('*', array(), null, null), '[*]', 0), 221 | new ParsedShortcode(new Shortcode('*', array('xyz' => null, 'arg' => 'val'), null, null), '[* xyz arg=val]', 4), 222 | )), 223 | array($s, '[*=bb x=y]cnt[/*]', array( 224 | new ParsedShortcode(new Shortcode('*', array('x' => 'y'), 'cnt', 'bb'), '[*=bb x=y]cnt[/*]', 0), 225 | )), 226 | array($s, '[ [] ] [x] [ ] [/x] ] [] [ [y] ] [] [ [z] [/#] [/z] [ [] ] [/] [/y] ] [z] [ [/ [/] /] ] [/z]', array( 227 | new ParsedShortcode(new Shortcode('x', array(), ' [ ] ', null), '[x] [ ] [/x]', 7), 228 | new ParsedShortcode(new Shortcode('y', array(), ' ] [] [ [z] [/#] [/z] [ [] ] [/] ', null), '[y] ] [] [ [z] [/#] [/z] [ [] ] [/] [/y]', 27), 229 | new ParsedShortcode(new Shortcode('z', array(), ' [ [/ [/] /] ] ', null), '[z] [ [/ [/] /] ] [/z]', 70), 230 | )), 231 | // falsy string values 232 | array($s, '[a=0 b=0]0[/a]', array( 233 | new ParsedShortcode(new Shortcode('a', array('b' => '0'), '0', '0'), '[a=0 b=0]0[/a]', 0), 234 | )), 235 | ); 236 | 237 | /** 238 | * WordPress can't handle: 239 | * - incorrect shortcode opening tag (blindly matches everything 240 | * between opening token and closing token) 241 | * - spaces between shortcode open tag and its name ([ name]), 242 | * - spaces around BBCode part ([name = "bbcode"]), 243 | * - escaped tokens anywhere in the arguments ([x arg=" \" "]), 244 | * - configurable syntax (that's intended), 245 | * - numbers in shortcode name. 246 | * 247 | * Tests cases from array above with identifiers in the array below must be skipped. 248 | */ 249 | $wordpressSkip = array(3, 6, 16, 21, 22, 23, 25, 32, 33, 34, 46, 47, 49, 51); 250 | $result = array(); 251 | foreach($tests as $key => $test) { 252 | $syntax = array_shift($test); 253 | 254 | $result[] = array_merge(array(new RegexParser($syntax)), $test); 255 | $result[] = array_merge(array(new RegularParser($syntax)), $test); 256 | if(!in_array($key, $wordpressSkip, true)) { 257 | $result[] = array_merge(array(new WordpressParser()), $test); 258 | } 259 | } 260 | 261 | return $result; 262 | } 263 | 264 | public function testIssue77() 265 | { 266 | $parser = new RegularParser(); 267 | 268 | $this->assertShortcodes($parser->parse('[a][x][/x][x k="v][/x][y]x[/y]'), array( 269 | new ParsedShortcode(new Shortcode('a', array(), null, null), '[a]', 0), 270 | new ParsedShortcode(new Shortcode('x', array(), '', null), '[x][/x]', 3), 271 | new ParsedShortcode(new Shortcode('y', array(), 'x', null), '[y]x[/y]', 22), 272 | )); 273 | 274 | $this->assertShortcodes($parser->parse('[a k="v][x][/x]'), array( 275 | new ParsedShortcode(new Shortcode('x', array(), '', null), '[x][/x]', 8), 276 | )); 277 | } 278 | 279 | public function testWordPress() 280 | { 281 | $parser = new WordpressParser(); 282 | 283 | $this->testParser($parser, '[code arg=" '', 'oth' => 'val'), null), '[code arg="testParser($parser, '[code "xxx"]', array( 287 | new ParsedShortcode(new Shortcode('code', array('xxx' => null), null, null), '[code "xxx"]', 0) 288 | )); 289 | $this->testParser($parser, '[code="xxx"] [code=yyy-aaa]', array( 290 | new ParsedShortcode(new Shortcode('code', array('="xxx"' => null), null), '[code="xxx"]', 0), 291 | new ParsedShortcode(new Shortcode('code', array('=yyy-aaa' => null), null), '[code=yyy-aaa]', 13) 292 | )); 293 | 294 | $handlers = new HandlerContainer(); 295 | $handlers->add('_', function() {}); 296 | $handlers->add('na_me', function() {}); 297 | $handlers->add('_n_', function() {}); 298 | $this->testParser(WordpressParser::createFromHandlers($handlers), '[_][na_me][_name][name_][n_am_e][_n_]', array( 299 | new ParsedShortcode(new Shortcode('_', array(), null), '[_]', 0), 300 | new ParsedShortcode(new Shortcode('na_me', array(), null), '[na_me]', 3), 301 | new ParsedShortcode(new Shortcode('_n_', array(), null), '[_n_]', 32), 302 | )); 303 | $this->testParser(WordpressParser::createFromNames(array('_', 'na_me', '_n_')), '[_][na_me][_name][name_][n_am_e][_n_]', array( 304 | new ParsedShortcode(new Shortcode('_', array(), null), '[_]', 0), 305 | new ParsedShortcode(new Shortcode('na_me', array(), null), '[na_me]', 3), 306 | new ParsedShortcode(new Shortcode('_n_', array(), null), '[_n_]', 32), 307 | )); 308 | } 309 | 310 | public function testWordpressInvalidNamesException() 311 | { 312 | $this->willThrowException('InvalidArgumentException'); 313 | WordpressParser::createFromNames(array('string', new \stdClass())); 314 | } 315 | 316 | public function testInstances() 317 | { 318 | static::assertInstanceOf('Thunder\Shortcode\Parser\WordPressParser', new WordpressParser()); 319 | static::assertInstanceOf('Thunder\Shortcode\Parser\RegularParser', new RegularParser()); 320 | } 321 | } 322 | -------------------------------------------------------------------------------- /tests/ProcessorTest.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | final class ProcessorTest extends AbstractTestCase 30 | { 31 | private function getHandlers() 32 | { 33 | $handlers = new HandlerContainer(); 34 | $handlers 35 | ->add('name', function (ShortcodeInterface $s) { return $s->getName(); }) 36 | ->add('content', function (ShortcodeInterface $s) { return $s->getContent(); }) 37 | ->add('reverse', new ReverseShortcode()) 38 | ->add('url', function(ShortcodeInterface $s) { 39 | $url = $s->getParameter('url', $s->getBbCode()); 40 | 41 | return ''.$url.''; 42 | }) 43 | ->addAlias('c', 'content') 44 | ->addAlias('n', 'name'); 45 | 46 | return $handlers; 47 | } 48 | 49 | public function testReplaceWithoutContentOffset() 50 | { 51 | $text = ' [x value=" [name]yyy[/name] "] [name]yyy[/name] [/x] '; 52 | $result = ' [x value=" [name]yyy[/name] "] name [/x] '; 53 | 54 | $processor = new Processor(new RegexParser(), $this->getHandlers()); 55 | 56 | static::assertSame($result, $processor->process($text)); 57 | } 58 | 59 | /** 60 | * @param string $text 61 | * @param string $result 62 | * 63 | * @dataProvider provideTexts 64 | */ 65 | public function testProcessorProcess($text, $result) 66 | { 67 | $processor = new Processor(new RegexParser(), $this->getHandlers()); 68 | 69 | static::assertSame($result, $processor->process($text)); 70 | } 71 | 72 | public static function provideTexts() 73 | { 74 | return array( 75 | array('[name]', 'name'), 76 | array('[content]random[/content]', 'random'), 77 | array('[content]象形字[/content]', '象形字'), 78 | array('xxx [content]象形字[/content] yyy', 'xxx 象形字 yyy'), 79 | array('xxx [content]ąćęłńóśżź ąćęłńóśżź[/content] yyy', 'xxx ąćęłńóśżź ąćęłńóśżź yyy'), 80 | array('[name]random[/other]', 'namerandom[/other]'), 81 | array('[name][other]random[/other]', 'name[other]random[/other]'), 82 | array('[content]random-[name]-random[/content]', 'random-name-random'), 83 | array('random [content]other[/content] various', 'random other various'), 84 | array('x [content]a-[name]-b[/content] y', 'x a-name-b y'), 85 | array('x [c]a-[n][/n]-b[/c] y', 'x a-n-b y'), 86 | array('x [content]a-[c]v[/c]-b[/content] y', 'x a-v-b y'), 87 | array('x [html]bold[/html] z', 'x [html]bold[/html] z'), 88 | array('x [reverse]abc xyz[/reverse] z', 'x zyx cba z'), 89 | array('x [i /][i]i[/i][i /][i]i[/i][i /] z', 'x [i /][i]i[/i][i /][i]i[/i][i /] z'), 90 | array('x [url url="http://giggle.com/search" /] z', 'x http://giggle.com/search z'), 91 | array('x [url="http://giggle.com/search"] z', 'x http://giggle.com/search z'), 92 | ); 93 | } 94 | 95 | public function testProcessorParentContext() 96 | { 97 | $handlers = new HandlerContainer(); 98 | $handlers->add('outer', function (ProcessedShortcode $s) { 99 | $name = $s->getParent() ? $s->getParent()->getName() : 'root'; 100 | 101 | return $name.'['.$s->getContent().']'; 102 | }); 103 | $handlers->addAlias('inner', 'outer'); 104 | $handlers->addAlias('level', 'outer'); 105 | 106 | $processor = new Processor(new RegexParser(), $handlers); 107 | 108 | $text = 'x [outer]a [inner]c [level]x[/level] d[/inner] b[/outer] y'; 109 | $result = 'x root[a outer[c inner[x] d] b] y'; 110 | static::assertSame($result, $processor->process($text)); 111 | static::assertSame($result.$result, $processor->process($text.$text)); 112 | } 113 | 114 | public function testReplacesLongerThanInputText() 115 | { 116 | $handlers = new HandlerContainer(); 117 | $handlers->add('x', function() { return ''; }); 118 | $processor = new Processor(new RegularParser(), $handlers); 119 | 120 | static::assertSame('', $processor->process('[x][x][x]')); 121 | } 122 | 123 | public function testProcessorWithoutRecursion() 124 | { 125 | $processor = new Processor(new RegexParser(), $this->getHandlers()); 126 | $text = 'x [content]a-[name][/name]-b[/content] y'; 127 | 128 | static::assertSame('x a-[name][/name]-b y', $processor->withRecursionDepth(0)->process($text)); 129 | } 130 | 131 | public function testProcessContentIfHasChildHandlerButNotParent() 132 | { 133 | $handlers = new HandlerContainer(); 134 | $handlers->add('valid', function (ShortcodeInterface $s) { return $s->getName(); }); 135 | 136 | $text = 'x [invalid ] [valid /] [/invalid] y'; 137 | $processor = new Processor(new RegexParser(), $handlers); 138 | 139 | static::assertSame('x [invalid ] valid [/invalid] y', $processor->withAutoProcessContent(true)->process($text)); 140 | static::assertSame('x [invalid ] [valid /] [/invalid] y', $processor->withAutoProcessContent(false)->process($text)); 141 | } 142 | 143 | public function testProcessorWithoutContentAutoProcessing() 144 | { 145 | $processor = new Processor(new RegexParser(), $this->getHandlers()); 146 | $text = 'x [content]a-[name][/name]-b[/content] y'; 147 | 148 | static::assertSame('x a-[name][/name]-b y', $processor->withAutoProcessContent(false)->process($text)); 149 | } 150 | 151 | public function testProcessorShortcodePositions() 152 | { 153 | $handlers = new HandlerContainer(); 154 | $handlers->add('p', function (ProcessedShortcode $s) { return $s->getPosition(); }); 155 | $handlers->add('n', function (ProcessedShortcode $s) { return $s->getNamePosition(); }); 156 | $processor = new Processor(new RegexParser(), $handlers); 157 | 158 | static::assertSame('123', $processor->process('[n][n][n]'), '3n'); 159 | static::assertSame('123', $processor->process('[p][p][p]'), '3p'); 160 | static::assertSame('113253', $processor->process('[p][n][p][n][p][n]'), 'pnpnpn'); 161 | static::assertSame('1231567', $processor->process('[p][p][p][n][p][p][p]'), 'pppnppp'); 162 | } 163 | 164 | /** 165 | * @dataProvider provideBuiltInTests 166 | */ 167 | public function testBuiltInHandlers($text, $result) 168 | { 169 | $handlers = new HandlerContainer(); 170 | $handlers 171 | ->add('content', new ContentHandler()) 172 | ->add('name', new NameHandler()) 173 | ->add('null', new NullHandler()) 174 | ->add('json', new SerializerHandler(new JsonSerializer())) 175 | ->add('text', new SerializerHandler(new TextSerializer())) 176 | ->add('placeholder', new PlaceholderHandler()) 177 | ->add('b', new WrapHandler('', '')) 178 | ->add('bb', WrapHandler::createBold()) 179 | ->add('declare', new DeclareHandler($handlers)) 180 | ->add('url', new UrlHandler()) 181 | ->add('email', new EmailHandler()) 182 | ->add('raw', new RawHandler()); 183 | $processor = new Processor(new RegexParser(), $handlers); 184 | 185 | static::assertSame($result, $processor->process($text)); 186 | } 187 | 188 | public static function provideBuiltInTests() 189 | { 190 | return array( 191 | array('[declare date]%year%.%month%.%day%[/declare][date year=2015 month=08 day=26]', '2015.08.26'), 192 | array('[declare sample]%param%[/declare][invalid param=value]', '[invalid param=value]'), 193 | array('[declare]%param%[/declare][invalid param=value]', '[invalid param=value]'), 194 | array('[url]http://kowalczyk.cc[/url]', 'http://kowalczyk.cc'), 195 | array('[url="http://kowalczyk.cc"]Visit![/url]', 'Visit!'), 196 | array('[email]tomasz@kowalczyk.cc[/email]', 'tomasz@kowalczyk.cc'), 197 | array('[email="tomasz@kowalczyk.cc"]Send![/email]', 'Send!'), 198 | array('[email="tomasz@kowalczyk.cc" /]', 'tomasz@kowalczyk.cc'), 199 | array('[b]text[/b]', 'text'), 200 | array('[bb]text[/bb]', 'text'), 201 | array('[json arg=val]value[/json]', '{"name":"json","parameters":{"arg":"val"},"content":"value","bbCode":null}'), 202 | array('[text arg=val]value[/text]', '[text arg=val]value[/text]'), 203 | array('[null arg=val]value[/null]', ''), 204 | array('[name /]', 'name'), 205 | array('[content]cnt[/content]', 'cnt'), 206 | array('[placeholder param=val]%param%[/placeholder]', 'val'), 207 | array('[placeholder param=val]%param%[/placeholder]', 'val'), 208 | array('[raw][null][content]cnt[/content][name /][/raw]', '[null][content]cnt[/content][name /]'), 209 | array('[listing-link id="12345"]Holá[/listing-link]', '[listing-link id="12345"]Holá[/listing-link]'), 210 | ); 211 | } 212 | 213 | public function testProcessorDeclare() 214 | { 215 | $handlers = new HandlerContainer(); 216 | $handlers->add('declare', function (ProcessedShortcode $s) use ($handlers) { 217 | $handlers->add($s->getParameterAt(0), function (ShortcodeInterface $x) use ($s) { 218 | $keys = array_map(function ($item) { 219 | return '%'.$item.'%'; 220 | }, array_keys($x->getParameters())); 221 | $values = array_values($x->getParameters()); 222 | 223 | return str_replace($keys, $values, $s->getContent()); 224 | }); 225 | }); 226 | $processor = new Processor(new RegexParser(), $handlers); 227 | 228 | static::assertSame('You are 18 years old.', trim($processor->process(' 229 | [declare age]You are %age% years old.[/declare] 230 | [age age=18] 231 | '))); 232 | } 233 | 234 | public function testBaseOffset() 235 | { 236 | $handlers = new HandlerContainer(); 237 | $handlers->setDefault(function(ProcessedShortcode $s) { 238 | return '['.$s->getBaseOffset().']'.$s->getContent(); 239 | }); 240 | $processor = new Processor(new RegularParser(new CommonSyntax()), $handlers); 241 | 242 | static::assertSame('[0][3] ’[8][11]’ [20]', $processor->process('[a][b] ’[c][d]’ [/b][e]')); 243 | } 244 | 245 | public function testProcessorIterative() 246 | { 247 | $handlers = new HandlerContainer(); 248 | $handlers 249 | ->add('name', function (ShortcodeInterface $s) { return $s->getName(); }) 250 | ->add('content', function (ShortcodeInterface $s) { return $s->getContent(); }) 251 | ->addAlias('c', 'content') 252 | ->addAlias('n', 'name') 253 | ->addAlias('d', 'c') 254 | ->addAlias('e', 'c'); 255 | $processor = new Processor(new RegexParser(), $handlers); 256 | 257 | /** @var $processor Processor */ 258 | $processor = $processor->withRecursionDepth(0)->withMaxIterations(2); 259 | static::assertSame('x a y', $processor->process('x [c]a[/c] y')); 260 | static::assertSame('x abc y', $processor->process('x [c]a[d]b[/d]c[/c] y')); 261 | static::assertSame('x ab[e]c[/e]de y', $processor->process('x [c]a[d]b[e]c[/e]d[/d]e[/c] y')); 262 | 263 | $processor = $processor->withMaxIterations(null); 264 | static::assertSame('x abcde y', $processor->process('x [c]a[d]b[e]c[/e]d[/d]e[/c] y')); 265 | } 266 | 267 | public function testExceptionOnInvalidRecursionDepth() 268 | { 269 | $processor = new Processor(new RegularParser(), new HandlerContainer()); 270 | $this->willThrowException('InvalidArgumentException'); 271 | $processor->withRecursionDepth(new \stdClass()); 272 | } 273 | 274 | public function testExceptionOnInvalidMaxIterations() 275 | { 276 | $processor = new Processor(new RegularParser(), new HandlerContainer()); 277 | $this->willThrowException('InvalidArgumentException'); 278 | $processor->withMaxIterations(new \stdClass()); 279 | } 280 | 281 | public function testExceptionOnInvalidAutoProcessFlag() 282 | { 283 | $processor = new Processor(new RegularParser(), new HandlerContainer()); 284 | $this->willThrowException('InvalidArgumentException'); 285 | $processor->withAutoProcessContent(new \stdClass()); 286 | } 287 | 288 | public function testDefaultHandler() 289 | { 290 | $handlers = new HandlerContainer(); 291 | $handlers->setDefault(function (ShortcodeInterface $s) { return $s->getName(); }); 292 | $processor = new Processor(new RegexParser(), $handlers); 293 | 294 | static::assertSame('namerandom', $processor->process('[name][other][/name][random]')); 295 | } 296 | 297 | public function testStripOuter() 298 | { 299 | $handlers = new HandlerContainer(); 300 | $handlers->add('q', function(ShortcodeInterface $s) { 301 | return $s->getContent(); 302 | }); 303 | $handlers->add('p', function(ProcessedShortcode $s) use(&$handlers) { 304 | $parser = new RegexParser(); 305 | $processor = new Processor($parser, $handlers); 306 | $shortcodes = $parser->parse($s->getTextContent()); 307 | 308 | return array_reduce($shortcodes, function($result, ParsedShortcodeInterface $s) use($processor) { 309 | return $result.$processor->process($s->getText()); 310 | }, ''); 311 | }); 312 | $processor = new Processor(new RegexParser(), $handlers); 313 | 314 | static::assertSame('x ab y', $processor->process('x [p] [q]a[/q] [q]b[/q] [/p] y')); 315 | static::assertSame('x ab c y', $processor->process('x [p] [q]a[/q] [q]b [q]c[/q][/q] [/p] y')); 316 | } 317 | 318 | public function testOriginalContent() 319 | { 320 | $handlers = new HandlerContainer(); 321 | $handlers->add('p', function(ProcessedShortcode $s) { return $s->getTextContent(); }); 322 | $handlers->addAlias('q', 'p'); 323 | $processor = new Processor(new RegexParser(), $handlers); 324 | 325 | static::assertSame('x [q]a[/q] [q]b[/q] y', $processor->process('x [p] [q]a[/q] [q]b[/q] [/p] y')); 326 | } 327 | 328 | public function testMultipleParent() 329 | { 330 | $parents = 0; 331 | $handlers = new HandlerContainer(); 332 | $handlers->add('p', function(ProcessedShortcode $s) use(&$parents) { $parents += $s->getParent() ? 1 : 0; }); 333 | $handlers->addAlias('q', 'p'); 334 | $processor = new Processor(new RegexParser(), $handlers); 335 | $processor->process('x [p] [q]a[/q] [q]b[/q] [q]c[/q] [/p] y'); 336 | 337 | static::assertSame(3, $parents); 338 | } 339 | 340 | public function testPreventInfiniteLoop() 341 | { 342 | $handlers = new HandlerContainer(); 343 | $handlers 344 | ->add('self', function () { return '[self]'; }) 345 | ->add('other', function () { return '[self]'; }) 346 | ->add('random', function () { return '[other]'; }); 347 | $processor = new Processor(new RegexParser(), $handlers); 348 | $processor->withMaxIterations(null); 349 | 350 | static::assertSame('[self]', $processor->process('[self]')); 351 | static::assertSame('[self]', $processor->process('[other]')); 352 | static::assertSame('[other]', $processor->process('[random]')); 353 | } 354 | 355 | public function testValidProcessAfterHandlerRemoval() 356 | { 357 | $handlers = new HandlerContainer(); 358 | $handlers->add('name', function(ShortcodeInterface $s) { return $s->getName(); }); 359 | $handlers->addAlias('n', 'name'); 360 | $processor = new Processor(new RegexParser(), $handlers); 361 | static::assertSame('n', $processor->process('[n]')); 362 | static::assertSame('name', $processor->process('[name]')); 363 | $handlers->remove('name'); 364 | static::assertSame('n', $processor->process('[n]')); 365 | static::assertSame('[name]', $processor->process('[name]')); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /tests/SerializerTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class SerializerTest extends AbstractTestCase 17 | { 18 | /** 19 | * @dataProvider provideShortcodes 20 | */ 21 | public function testSerializer(SerializerInterface $serializer, ShortcodeInterface $test) 22 | { 23 | $result = $serializer->serialize($test); 24 | $tested = $serializer->unserialize($result); 25 | 26 | static::assertSame($test->getName(), $tested->getName(), 'name: '.$result); 27 | static::assertSame($test->getParameters(), $tested->getParameters(), 'parameters: '.$result); 28 | static::assertSame($test->getContent(), $tested->getContent(), 'content: '.$result); 29 | static::assertSame($test->getBbCode(), $tested->getBbCode(), 'bbCode: '.$result); 30 | } 31 | 32 | public static function provideShortcodes() 33 | { 34 | $shortcodes = array( 35 | new Shortcode('x', array(), null), 36 | new Shortcode('x', array('arg' => 'val'), null), 37 | new Shortcode('x', array('arg' => null), null), 38 | new Shortcode('x', array('arg' => ''), null), 39 | new Shortcode('x', array('arg' => 'val'), 'cnt'), 40 | new ParsedShortcode(new Shortcode('self-closed', array(), null), '[self-closed /]', 0), 41 | new Shortcode('self-closed', array(), null, 'bb code'."\n".' value'), 42 | ); 43 | 44 | $serializers = array( 45 | new TextSerializer(), 46 | new JsonSerializer(), 47 | new XmlSerializer(), 48 | new YamlSerializer(), 49 | ); 50 | 51 | $tests = array(); 52 | foreach($shortcodes as $shortcode) { 53 | foreach($serializers as $serializer) { 54 | $tests[] = array($serializer, $shortcode); 55 | } 56 | } 57 | 58 | return $tests; 59 | } 60 | 61 | /** 62 | * @dataProvider provideUnserialized 63 | */ 64 | public function testUnserialize(SerializerInterface $serializer, ShortcodeInterface $test, $text) 65 | { 66 | $tested = $serializer->unserialize($text); 67 | 68 | static::assertSame($test->getName(), $tested->getName(), 'name: '.$text); 69 | static::assertSame($test->getParameters(), $tested->getParameters(), 'parameters: '.$text); 70 | static::assertSame($test->getContent(), $tested->getContent(), 'content: '.$text); 71 | static::assertSame($test->getBbCode(), $tested->getBbCode(), 'bbCode: '.$text); 72 | } 73 | 74 | public static function provideUnserialized() 75 | { 76 | return array( 77 | array(new JsonSerializer(), new Shortcode('x', array(), null), '{"name":"x"}'), 78 | array(new JsonSerializer(), new Shortcode('x', array('arg' => 'val'), null), '{"name":"x","parameters":{"arg":"val"}}'), 79 | array(new JsonSerializer(), new Shortcode('x', array(), 'cnt'), '{"name":"x","content":"cnt"}'), 80 | array(new YamlSerializer(), new Shortcode('x', array(), null), 'name: x'), 81 | array(new YamlSerializer(), new Shortcode('x', array('arg' => 'val'), null), 'name: x'."\n".'parameters:'."\n".' arg: val'), 82 | array(new YamlSerializer(), new Shortcode('x', array(), 'cnt'), 'name: x'."\n".'content: cnt'), 83 | array(new XmlSerializer(), new Shortcode('x', array(), null), ''), 84 | array(new XmlSerializer(), new Shortcode('x', array('arg' => 'val'), null), 'val'), 85 | array(new XmlSerializer(), new Shortcode('x', array(), 'cnt'), 'cnt'), 86 | ); 87 | } 88 | 89 | /** 90 | * @dataProvider provideExceptions 91 | */ 92 | public function testSerializerExceptions(SerializerInterface $serializer, $value, $exceptionClass) 93 | { 94 | $this->willThrowException($exceptionClass); 95 | $serializer->unserialize($value); 96 | } 97 | 98 | public static function provideExceptions() 99 | { 100 | $xml = new XmlSerializer(); 101 | $yaml = new YamlSerializer(); 102 | $text = new TextSerializer(); 103 | $json = new JsonSerializer(); 104 | 105 | return array( 106 | array($text, '[sc /] c [xx]', 'InvalidArgumentException'), 107 | array($text, '[/sc]', 'InvalidArgumentException'), 108 | array($json, '{}', 'InvalidArgumentException'), 109 | array($json, '', 'InvalidArgumentException'), 110 | array($json, '{"name":"x","parameters":null}', 'InvalidArgumentException'), 111 | array($json, '{"name":"x","parameters":{"key":[]}}', 'InvalidArgumentException'), 112 | array($yaml, 'shortcode: ', 'InvalidArgumentException'), 113 | array($yaml, '', 'InvalidArgumentException'), 114 | array($yaml, 'name: x'."\n".'parameters: string', 'InvalidArgumentException'), 115 | array($xml, '', 'InvalidArgumentException'), 116 | array($xml, 'sss', 'InvalidArgumentException'), 117 | array($xml, 'xxsss', 'InvalidArgumentException'), 118 | array($xml, 'xxsss', 'InvalidArgumentException'), 119 | array($xml, 'xxsss', 'InvalidArgumentException'), 120 | array($xml, '', 'InvalidArgumentException'), 121 | array($xml, '', 'InvalidArgumentException'), 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/ShortcodeTest.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class ShortcodeTest extends AbstractTestCase 17 | { 18 | /** 19 | * @dataProvider provideShortcodes 20 | */ 21 | public function testShortcode($expected, $name, array $args, $content) 22 | { 23 | $s = new Shortcode($name, $args, $content); 24 | $textSerializer = new TextSerializer(); 25 | 26 | static::assertSame($name, $s->getName()); 27 | static::assertSame($args, $s->getParameters()); 28 | static::assertSame($content, $s->getContent()); 29 | static::assertSame($expected, $textSerializer->serialize($s)); 30 | static::assertSame('arg', $s->getParameterAt(0)); 31 | static::assertTrue($s->hasParameters()); 32 | } 33 | 34 | public static function provideShortcodes() 35 | { 36 | return array( 37 | array('[x arg=val /]', 'x', array('arg' => 'val'), null), 38 | array('[x arg=val][/x]', 'x', array('arg' => 'val'), ''), 39 | array('[x arg=val]inner[/x]', 'x', array('arg' => 'val'), 'inner'), 40 | array('[x arg="val val"]inner[/x]', 'x', array('arg' => 'val val'), 'inner'), 41 | ); 42 | } 43 | 44 | public function testObject() 45 | { 46 | $shortcode = new Shortcode('random', array('arg' => 'value', 'none' => null), 'something'); 47 | 48 | static::assertTrue($shortcode->hasParameter('arg')); 49 | static::assertFalse($shortcode->hasParameter('invalid')); 50 | static::assertNull($shortcode->getParameter('none')); 51 | static::assertSame('value', $shortcode->getParameter('arg')); 52 | static::assertSame('', $shortcode->getParameter('invalid', '')); 53 | static::assertSame(42, $shortcode->getParameter('invalid', 42)); 54 | 55 | static::assertNotSame($shortcode, $shortcode->withContent('x')); 56 | } 57 | 58 | public function testProcessedShortcode() 59 | { 60 | $processor = new Processor(new RegexParser(), new HandlerContainer()); 61 | 62 | $context = new ProcessorContext(); 63 | $context->shortcode = new Shortcode('code', array('arg' => 'val'), 'content'); 64 | $context->processor = $processor; 65 | $context->position = 20; 66 | $context->namePosition = array('code' => 10); 67 | $context->text = ' [code] '; 68 | $context->shortcodeText = '[code]'; 69 | $context->offset = 1; 70 | $context->iterationNumber = 1; 71 | $context->recursionLevel = 0; 72 | $context->parent = null; 73 | 74 | $processed = ProcessedShortcode::createFromContext($context); 75 | 76 | static::assertSame('code', $processed->getName()); 77 | static::assertSame(array('arg' => 'val'), $processed->getParameters()); 78 | static::assertSame('content', $processed->getContent()); 79 | 80 | static::assertSame(20, $processed->getPosition()); 81 | static::assertSame(10, $processed->getNamePosition()); 82 | static::assertSame(' [code] ', $processed->getText()); 83 | static::assertSame(1, $processed->getOffset()); 84 | static::assertSame('[code]', $processed->getShortcodeText()); 85 | static::assertSame(1, $processed->getIterationNumber()); 86 | static::assertSame(0, $processed->getRecursionLevel()); 87 | static::assertSame(null, $processed->getParent()); 88 | static::assertSame($processor, $processed->getProcessor()); 89 | } 90 | 91 | public function testProcessedShortcodeParents() 92 | { 93 | $context = new ProcessorContext(); 94 | $context->shortcode = new Shortcode('p1', array(), null); 95 | $context->parent = null; 96 | $context->namePosition = array('p1' => 0, 'p2' => 0, 'p3' => 0); 97 | $p1 = ProcessedShortcode::createFromContext($context); 98 | $context->shortcode = new Shortcode('p2', array(), null); 99 | $context->parent = $p1; 100 | $p2 = ProcessedShortcode::createFromContext($context); 101 | $context->shortcode = new Shortcode('p3', array(), null); 102 | $context->parent = $p2; 103 | $p3 = ProcessedShortcode::createFromContext($context); 104 | 105 | static::assertSame('p3', $p3->getName()); 106 | static::assertSame('p2', $p3->getParent()->getName()); 107 | static::assertSame('p1', $p3->getParent()->getParent()->getName()); 108 | static::assertFalse($p1->hasAncestor('p3')); 109 | static::assertFalse($p1->hasAncestor('p1')); 110 | static::assertTrue($p2->hasAncestor('p1')); 111 | static::assertFalse($p2->hasAncestor('p3')); 112 | static::assertTrue($p3->hasAncestor('p1')); 113 | static::assertTrue($p3->hasAncestor('p2')); 114 | static::assertFalse($p3->hasAncestor('p4')); 115 | } 116 | 117 | public function testParsedShortcode() 118 | { 119 | $shortcode = new ParsedShortcode(new Shortcode('name', array('arg' => 'val'), 'content'), 'text', 12); 120 | 121 | static::assertSame('name', $shortcode->getName()); 122 | static::assertSame(array('arg' => 'val'), $shortcode->getParameters()); 123 | static::assertSame('content', $shortcode->getContent()); 124 | static::assertSame('text', $shortcode->getText()); 125 | static::assertSame(12, $shortcode->getOffset()); 126 | static::assertTrue($shortcode->hasContent()); 127 | 128 | static::assertFalse($shortcode->withContent(null)->hasContent()); 129 | static::assertSame('another', $shortcode->withContent('another')->getContent()); 130 | } 131 | 132 | public function testShortcodeEmptyNameException() 133 | { 134 | $this->willThrowException('InvalidArgumentException'); 135 | new Shortcode('', array(), null); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/SyntaxTest.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class SyntaxTest extends AbstractTestCase 13 | { 14 | /** 15 | * @dataProvider provideSyntaxes 16 | */ 17 | public function testSyntax(SyntaxInterface $syntax, $open, $close, $slash, $parameter, $value) 18 | { 19 | static::assertSame($open, $syntax->getOpeningTag()); 20 | static::assertSame($close, $syntax->getClosingTag()); 21 | static::assertSame($slash, $syntax->getClosingTagMarker()); 22 | static::assertSame($parameter, $syntax->getParameterValueSeparator()); 23 | static::assertSame($value, $syntax->getParameterValueDelimiter()); 24 | } 25 | 26 | public static function provideSyntaxes() 27 | { 28 | return array( 29 | array(new Syntax(), '[', ']', '/', '=', '"'), 30 | array(new Syntax('[[', ']]', '//', '==', '""'), '[[', ']]', '//', '==', '""'), 31 | array(new CommonSyntax(), '[', ']', '/', '=', '"'), 32 | ); 33 | } 34 | 35 | /** 36 | * Note: do not merge this test with data provider above, code coverage 37 | * does not understand this and marks builder class as untested. 38 | */ 39 | public function testBuilder() 40 | { 41 | $builder = new SyntaxBuilder(); 42 | $this->testSyntax($builder->getSyntax(), '[', ']', '/', '=', '"'); 43 | 44 | $builder = new SyntaxBuilder(); 45 | $doubleBuiltSyntax = $builder 46 | ->setOpeningTag('[[') 47 | ->setClosingTag(']]') 48 | ->setClosingTagMarker('//') 49 | ->setParameterValueSeparator('==') 50 | ->setParameterValueDelimiter('""') 51 | ->getSyntax(); 52 | $this->testSyntax($doubleBuiltSyntax, '[[', ']]', '//', '==', '""'); 53 | } 54 | } 55 | --------------------------------------------------------------------------------