├── .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 | [](https://github.com/thunderer/Shortcode)
4 | [](https://packagist.org/packages/thunderer/shortcode)
5 | [](https://packagist.org/packages/thunderer/shortcode)
6 | [](https://packagist.org/packages/thunderer/shortcode)
7 | [](https://shepherd.dev/github/thunderer/Shortcode)
8 | [](https://scrutinizer-ci.com/g/thunderer/Shortcode/?branch=master)
9 | [](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, '', 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 |
--------------------------------------------------------------------------------