├── .github └── workflows │ └── ci.yml ├── CHANGELOG.md ├── README.md ├── classes ├── local │ ├── processor │ │ ├── processor.php │ │ └── standard_processor.php │ └── registry │ │ ├── plugin_registry.php │ │ ├── registry.php │ │ └── static_registry.php ├── privacy │ └── provider.php ├── shortcodes.php └── text_filter.php ├── db ├── access.php ├── caches.php ├── install.php └── shortcodes.php ├── filter.php ├── index.php ├── lang └── en │ └── filter_shortcodes.php ├── lib └── helpers.php ├── tests ├── lib_helpers_test.php ├── plugin_registry_test.php ├── standard_processor_test.php └── static_registry_test.php └── version.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Moodle Plugin CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | services: 10 | postgres: 11 | image: postgres:14 12 | env: 13 | POSTGRES_USER: 'postgres' 14 | POSTGRES_HOST_AUTH_METHOD: 'trust' 15 | ports: 16 | - 5432:5432 17 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 18 | 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | php: ['8.1', '8.2', '8.3'] 23 | moodle-branch: ['MOODLE_401_STABLE', 'MOODLE_404_STABLE', 'MOODLE_405_STABLE', 'v5.0.0-rc2'] 24 | database: [pgsql] 25 | include: 26 | # PHP 7.4, solely on Moodle 4.1. 27 | - moodle-branch: MOODLE_401_STABLE 28 | database: pgsql 29 | php: 7.4 30 | 31 | # PHP 8.0, with Moodle 4.1. 32 | - moodle-branch: MOODLE_401_STABLE 33 | database: pgsql 34 | php: 8.0 35 | 36 | # PHP 8.4, with Moodle 5.0. 37 | - moodle-branch: v5.0.0-rc2 38 | database: pgsql 39 | php: 8.4 40 | 41 | exclude: 42 | - moodle-branch: MOODLE_401_STABLE 43 | php: 8.2 44 | - moodle-branch: MOODLE_401_STABLE 45 | php: 8.3 46 | - moodle-branch: v5.0.0-rc2 47 | php: 8.1 48 | 49 | steps: 50 | - name: Check out repository code 51 | uses: actions/checkout@v4 52 | with: 53 | path: plugin 54 | 55 | - name: Setup PHP ${{ matrix.php }} 56 | uses: shivammathur/setup-php@v2 57 | with: 58 | php-version: ${{ matrix.php }} 59 | extensions: ${{ matrix.extensions }} 60 | ini-values: max_input_vars=5000 61 | # If you are not using code coverage, keep "none". Otherwise, use "pcov" (Moodle 3.10 and up) or "xdebug". 62 | # If you try to use code coverage with "none", it will fallback to phpdbg (which has known problems). 63 | coverage: none 64 | 65 | - name: Initialise moodle-plugin-ci 66 | run: | 67 | composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 68 | echo $(cd ci/bin; pwd) >> $GITHUB_PATH 69 | echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH 70 | sudo locale-gen en_AU.UTF-8 71 | echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV 72 | 73 | - name: Install moodle-plugin-ci 74 | run: | 75 | moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 76 | env: 77 | DB: ${{ matrix.database }} 78 | MOODLE_BRANCH: ${{ matrix.moodle-branch }} 79 | IGNORE_PATHS: 'vendor/,node_modules/' 80 | 81 | - name: PHP Lint 82 | if: ${{ !cancelled() }} 83 | run: moodle-plugin-ci phplint 84 | 85 | - name: Moodle Code Checker 86 | if: ${{ !cancelled() }} 87 | run: moodle-plugin-ci phpcs --max-warnings 0 88 | 89 | - name: Moodle PHPDoc Checker 90 | if: ${{ !cancelled() }} 91 | run: moodle-plugin-ci phpdoc --max-warnings 0 92 | 93 | - name: Validating 94 | if: ${{ !cancelled() }} 95 | run: moodle-plugin-ci validate 96 | 97 | - name: Check upgrade savepoints 98 | if: ${{ !cancelled() }} 99 | run: moodle-plugin-ci savepoints 100 | 101 | - name: Mustache Lint 102 | if: ${{ !cancelled() }} 103 | run: moodle-plugin-ci mustache 104 | 105 | - name: Grunt 106 | if: ${{ !cancelled() }} 107 | run: moodle-plugin-ci grunt --max-lint-warnings 0 108 | 109 | - name: PHPUnit tests 110 | if: ${{ !cancelled() }} 111 | run: moodle-plugin-ci phpunit --fail-on-warning 112 | 113 | - name: Behat features 114 | if: ${{ !cancelled() }} 115 | run: moodle-plugin-ci behat --profile chrome 116 | 117 | - name: Mark cancelled jobs as failed. 118 | if: ${{ cancelled() }} 119 | run: exit 1 120 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | v1.1.1 5 | ------ 6 | 7 | - Compatibility with Moodle 5.0 8 | - Minor coding style updates 9 | 10 | v1.1.0 11 | ------ 12 | 13 | - Compatibility with Moodle 4.5 14 | 15 | v1.0.6 16 | ------ 17 | 18 | - Minor coding style updates 19 | 20 | v1.0.5 21 | ------ 22 | 23 | - Built-in `[firstname]` shortcode escapes firstname 24 | 25 | v1.0.4 26 | ------ 27 | 28 | - Filter is no longer automatically enabled in unit tests 29 | 30 | v1.0.3 31 | ------ 32 | 33 | - Exception assertion in tests updated for latest PHP Unit 34 | 35 | v1.0.2 36 | ------ 37 | 38 | - Include missing language string for cache definition 39 | 40 | v1.0.1 41 | ------ 42 | 43 | - Implement privacy API 44 | - Minor coding style changes 45 | 46 | v1.0.0 47 | ------ 48 | 49 | - Initial release 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Filter Shortcodes ![GitHub tag](https://img.shields.io/github/tag/branchup/moodle-filter_shortcodes.svg) ![Build status](https://img.shields.io/github/actions/workflow/status/branchup/moodle-filter_shortcodes/ci.yml) 2 | ================= 3 | 4 | Enables users to inject content using shortcodes. The shortcodes are provided by Moodle plugins. 5 | 6 | - [Filter Shortcodes ](#filter-shortcodes--) 7 | - [Why this plugin?](#why-this-plugin) 8 | - [Requirements](#requirements) 9 | - [Installation](#installation) 10 | - [Usage](#usage) 11 | - [Built-in shortcodes](#built-in-shortcodes) 12 | - [firstname](#firstname) 13 | - [fullname](#fullname) 14 | - [off (wraps content)](#off-wraps-content) 15 | - [Compatible plugins](#compatible-plugins) 16 | - [End-user documentation](#end-user-documentation) 17 | - [How-to for developers](#how-to-for-developers) 18 | - [Create a shortcode definition](#create-a-shortcode-definition) 19 | - [Create the handling method](#create-the-handling-method) 20 | - [Developer documentation](#developer-documentation) 21 | - [Shortcode attributes](#shortcode-attributes) 22 | - [Shortcode definition](#shortcode-definition) 23 | - [Callback arguments](#callback-arguments) 24 | - [Limitations](#limitations) 25 | - [Backup and restore](#backup-and-restore) 26 | - [Name conflicts](#name-conflicts) 27 | - [Nested shortcodes](#nested-shortcodes) 28 | - [Unrestricted usage](#unrestricted-usage) 29 | - [Ideas](#ideas) 30 | - [Provided by](#provided-by) 31 | - [License](#license) 32 | 33 | Why this plugin? 34 | ---------------- 35 | 36 | For two reasons: 37 | 38 | 1. Creating a consistent way for content creators to use shortcodes _à la Wordpress_. 39 | 2. Providing an API for developers to build upon without having to create a separate plugin and re-invent the wheel. 40 | 41 | By having a standard way to create shortcodes, documentation can be generated automatically for the end-users. End-users only need to be taught once and do not need to know about the intricacies of every single _shortcode-like_ plugin. Administrators also do not need to install a matching _filter_ plugin for each plugin with content-based logic. 42 | 43 | And for developers! For example, [Level up!](https://moodle.org/plugins/block_xp) may want to offer teachers the ability to include their student's level and badge in a _page_. The plugin [Stash](https://moodle.org/plugins/block_stash) offers teachers the ability to hide items throughout the course. Not to mention themes who could offer various handy tools, from creating a contact form to formatting content in a theme-specific way, etc... 44 | 45 | Neither of these scenarios are achievable without a _filter_ plugin, and that's why we created this plugin, to be the only filter plugin needed for all other plugins to build upon. 46 | 47 | Requirements 48 | ------------ 49 | 50 | Moodle 3.1 or greater. 51 | 52 | Installation 53 | ------------ 54 | 55 | _Until the plugin is approved on the plugin's directory on moodle.org, please install the plugin manually._ 56 | 57 | 1. Download the _zip_ of the [latest release](https://github.com/branchup/moodle-filter_shortcodes/tags) 58 | 2. Extract the content in `filter/shortcodes` 59 | 3. Navigate to Site administration > Notifications 60 | 4. Follow the prompt to upgrade your Moodle site 61 | 62 | Usage 63 | ----- 64 | 65 | A shortcode is constituted of a word between square brackets. There are two types of shortcodes: the ones that wrap content, and the ones that do not. Those that do wrap content MUST have a closing tag. Here is an example using `[useremail`] which prints the current user's email, and `[toupper]` which wraps content and makes it uppercase. 66 | 67 | ``` 68 | Your registered email address is: [useremail]. 69 | 70 | [toupper]This text will be uppercased[/toupper]. 71 | ``` 72 | 73 | You can also nest the shortcodes, let's make the user's email address uppercase. 74 | 75 | ``` 76 | Your registered email address is: [toupper][useremail][/toupper]. 77 | ``` 78 | 79 | Some shortcodes support arguments. Those are declared in the same manner as HTML attributes. Here is an example of a shortcode that would add a collapsible section with a height of 100, and which would be collapsed by default: 80 | 81 | ``` 82 | [section height="100" collapsed] 83 | ``` 84 | 85 | Attribute values do not require to be wrapped between double quotes, but it is recommended. When the attribute does not have a value, it is considered to be `true`. Single quotes cannot be used in lieu of double quotes. 86 | 87 | Built-in shortcodes 88 | ------------------- 89 | 90 | Here are some shortcodes provided by this plugin: 91 | 92 | ### firstname 93 | 94 | Displays the current user's first name. 95 | 96 | ### fullname 97 | 98 | Displays the current user's full name. 99 | 100 | ### off (wraps content) 101 | 102 | Disables the processing of the shortcodes present between its opening and closing tag. 103 | 104 | ``` 105 | [off] 106 | The shortcode [usermail] prints the current user's email. 107 | [/off] 108 | ``` 109 | 110 | Compatible plugins 111 | ------------------ 112 | 113 | Here is a list of plugins supporting shortcodes: 114 | 115 | - [Level up!](https://moodle.org/plugins/block_xp) 116 | - [Stash](https://moodle.org/plugins/block_stash) 117 | 118 | _Does your plugin support shortcodes? Send a pull request to add it here!_ 119 | 120 | End-user documentation 121 | ---------------------- 122 | 123 | A list of all the available shortcodes as well as documentation how to use them is available to users at the URL `https://moodle.example.com/filter/shortcodes/index.php`. The latter page is accessible to all logged in users by default, but that can be tailored using the capability `filter/shortcodes:viewlist`. 124 | 125 | The page is not automatically added to the navigation to avoid being too intrusive, we rely on administrators to make this link available to the end-users in their own way. 126 | 127 | When the permission to view the list is given in another context than the system context (e.g. given to teachers in courses), the URL should include the parameter `?contextid=123`, where `123` is the context to use to check the permissions. 128 | 129 | How-to for developers 130 | --------------------- 131 | 132 | Declaring a shortcode is very simple, let's create a shortcode returning the current user's email, we will name it `useremail`. We will assume that you are working on a plugin named `local_yourplugin`. 133 | 134 | ### Create a shortcode definition 135 | 136 | Shortcode definitions are set in the file `db/shortcodes.php`, within the variable `$shortcodes`. It uses a similar pattern to capabilities, event observers, cache definitions, etc... The keys of the array will be the name of the shortcode, and the values will be an array of properties. Note that shortcode names can only contain letter and numbers. 137 | 138 | There is only one mandatory property to a shortcode definition: `callback`. The callback is a [callable](http://php.net/manual/en/language.types.callable.php) pointing to the autoloaded class method which will handle your shortcode. Ours will be `local_yourplugin\shortcodes::useremail`, which translates to the method `useremail` in the class located at `local/yourplugin/classes/shortcodes.php`. 139 | 140 | ```php 141 | [ 146 | 'callback' => 'local_yourplugin\shortcodes::usermail' 147 | ] 148 | ]; 149 | ``` 150 | 151 | 152 | ### Create the handling method 153 | 154 | Whenever the shortcode is found in content, your callback will be called. Let's create the class and method we defined previously, and return the current user's email from there. 155 | 156 | ```php 157 | namespace local_yourplugin; 158 | defined('MOODLE_INTERNAL') || die(); 159 | 160 | class shortcodes { 161 | 162 | public static function useremail() { 163 | global $USER; 164 | return $USER->email; 165 | } 166 | 167 | } 168 | ``` 169 | 170 | That's it, your shortcode is now functional. Note that you will need to increase the version number of your plugin in order to force a cache reset, else the new shortcode will not detected. When you are developing, you can simply purge caches between your attempts. 171 | 172 | For simplicity we omitted the arguments passed to the method `useremail`, more information in [Callback arguments](#callback-arguments). 173 | 174 | Developer documentation 175 | ----------------------- 176 | 177 | ### Shortcode attributes 178 | 179 | Shortcodes support attributes. Those attributes will be passed to the callback method. When values are not attached to an attribute it is assumed _true_, similarly to HTML5 attributes. There are no limitations to the format of the attribute names. Double quotes must be used to wrap spaces and equal signs, and to include a double quote within content, escape it with `\`. Single quotes have no special meaning. 180 | 181 | ``` 182 | [shortcode id=2 uid="1234-5678" disabled "Need \"spaces\"?" "Oh my"=w'or'd!] 183 | ``` 184 | 185 | The above example is parsed as: 186 | 187 | ``` 188 | [ 189 | 'id' => '2', 190 | 'uid' => '1234-5678', 191 | 'disabled' => true, 192 | 'Need "spaces"?' => true, 193 | 'Oh my' => "w'or'd!" 194 | ] 195 | ``` 196 | 197 | ### Shortcode definition 198 | 199 | They are defined in `db/shortcodes.php` under the array `$shortcodes`. The keys of the array are shortcode names and their values are an array properties. The shortcode names can only contain lowercased letters and numbers (`[a-z0-9]`). Consider using a common short prefix when your shortcodes can conflict with other plugins. The available properties are: 200 | 201 | - `callback (callable)` The autoloaded class method to use. 202 | - `wraps (bool) [Optional]` When the shortcode wraps content, and as such has a closing tag, set this to `true`. 203 | - `description (string) [Optional]` The name of the language string (in your component) describing your shortcode. 204 | 205 | When you have defined a `description`, you can also define another language string of the same name followed by `_help`. The latter should contain more information about the shortcode, its attributes and how to use it. You may use the Markdown format in the help string. 206 | 207 | ```php 208 | // db/shortcodes.php 209 | $shortcodes = [ 210 | 'weather' => [ 211 | 'callback' => 'myplugin\myclass::weather', 212 | 'wraps' => false, 213 | 'description' => 'shortcodeweather' 214 | ] 215 | ] 216 | ``` 217 | 218 | ```php 219 | // lang/en/myplugin.php 220 | $string['shortcodeweather'] = 'Displays the weather forecast.'; 221 | $string['shortcodeweather_help'] = ' 222 | The following attributes can (or must) be used: 223 | 224 | - `city` (required) The name of the city to get the forecast for. 225 | - `fahrenheit` (optional) When set, the temperatures will be in Fahrenheit instead of Celcius. 226 | 227 | Example: 228 | 229 | [weather city="Perth"] 230 | [weather city="New York" fahrenheit] 231 | '; 232 | ``` 233 | 234 | 235 | ### Callback arguments 236 | 237 | A total of 5 arguments are passed to your callback method. 238 | 239 | ```php 240 | public static function mycallback($shortcode, $args, $content, $env, $next); 241 | ``` 242 | 243 | - `$shortcode (string)` Is the name of the shortcode found. 244 | - `$args (array)` An associative array of the shortcode arguments. 245 | - `$content (string|null)` When the shortcode `wraps`: the wrapped content. 246 | - `$env (object)` The filter environment object, amongst other things contains the `context`. 247 | - `$next (Closure)` The function to pass the content through when embedded shortcodes should apply. 248 | 249 | Here is a complex example of a callback which handles two types of shortcodes: 250 | 251 | ```php 252 | public static function mycallback($shortcode, $args, $content, $env, $next) { 253 | global $USER; 254 | 255 | if (!has_capability('moodle/site:config', $env->context)) { 256 | return ''; 257 | } 258 | 259 | if ($shortcode == 'toupper') { 260 | // Process embedded content first, then change to uppercase. 261 | return strtoupper($next($content)); 262 | 263 | } else if ($shortcode == 'usermail') { 264 | return $USER->email; 265 | } 266 | 267 | return ''; 268 | } 269 | ``` 270 | 271 | Limitations 272 | ----------- 273 | 274 | ### Backup and restore 275 | 276 | Shortcodes making use of an object ID in their attributes will likely become invalid upon restore if the resource is missing, or the site is different. Let's take the following example which prints a banner for a course: 277 | 278 | [coursebanner id="123"] 279 | 280 | When backing up the course, the content will retain `123` as the ID of reference, but when restored the ID will be different and as such the banner will be that of another course, if it exists where the course was restored. To remedy this, we recommend that developers do not use IDs in their shortcodes but unique identifiers, either self-generated or not. In which case, we could have either of the following: 281 | 282 | [coursebanner shortname="my_course_shortname"] 283 | [coursebanner uid="AbCdE123"] 284 | 285 | ### Name conflicts 286 | 287 | When two plugins define the same shortcode, only one of them will work. It is advised that developers try to make their shortcode as descriptive as possible in order to avoid such conflicts. We intentionally do not require the shortcodes to include their component's name, to keep it simple and more verbose to the end-user. Some plugins, however, may find it useful to use a small prefix for their shortcodes. It is possible that at a later stage we'll enable conflicts to be resolved either through the admin settings, or by allowing the shortcodes to be specific themselves. 288 | 289 | ### Nested shortcodes 290 | 291 | Shortcodes can be nested, however it is up to the shortcodes to determine whether the content they encapsulate should be processed or not. 292 | 293 | ``` 294 | [code1] 295 | [code2] 296 | [code3] 297 | This works. 298 | [/code3] 299 | [/code2] 300 | [code2]Neighbouring content[/code2] 301 | [/code1] 302 | ``` 303 | 304 | Note that a shortcode cannot wrap another shortcode of the same name. The following will __not__ work as intended: 305 | 306 | ``` 307 | [code1] 308 | [code1] 309 | This does not work. 310 | [/code1] 311 | [code2]Neighbouring content[/code2] 312 | [/code1] 313 | ``` 314 | 315 | ### Unrestricted usage 316 | 317 | Due to the design of Moodle filters, any user can submit content including any shortcode. Because the shortcode is applied when someone views the content, we cannot restrict the usage of the shortcodes at the source. For example, a student could include as many shortcodes as they want in a forum post, and see what those get transformed into. If they found out about a _secret_ shortcode, they could gain access to information they should not have access to. So, when it is desired for the content displayed by a shortcode to only be available to certain group of users, developers have two options: 318 | 319 | __a) Using capabilities__ 320 | 321 | In their shortcode callback, developers will ensure that the current user has the permissions to view the content. If not, they can return an empty string in order to completely hide the presence of the shortcode. If we had a shortcode displaying a summary of all students' grades in the course, the shortcode callback would validate the permissions of the current user to ensure that they can view those. 322 | 323 | __B) Using a secret__ 324 | 325 | A more advanced usage of shortcodes is Easter egg hunting. Imagine a [plugin](https://moodle.org/plugins/block_stash) that enables a teacher to create eggs and hide them throughout a course. Such eggs could be included in the form of `[egg id="1"]`. However, as students can post any content they want, they could post content containing 1000 shortcodes with the IDs from 1 to 1000. Their chances of discovering hidden eggs by cheating would be very high. 326 | 327 | So, firstly the shortcode [should not include an ID](#backup-and-restore), but even if it didn't, to protect our `egg` shortcode from being used by unintended users, we recommend the usage of _secrets_. Secrets could be generated by your plugin, and would be included in the shortcode. When processing the shortcode, the callback would ensure that the secret is valid prior to processing the code, therefore validating that an authorised person included the shortcode in the content. Example: 328 | 329 | ``` 330 | [egg id="1" secret="AbCdEf123"] 331 | ``` 332 | 333 | Ideas 334 | ----- 335 | 336 | - Support for filtering shortcodes that require a logged in user. 337 | - Support for shortcodes to declare the context they are available in. Example, if a course is needed, the shortcode does not apply elsewhere. 338 | - Support for shortcodes to declare whether the current user can use the code, for display purposes only. 339 | - Provide a helper to help generating short unique identifiers. 340 | - Use a DI container to allow 3rd party devs to manually render some shortcodes, as such that they will be agnostic of current and future implementation of registry and processor. 341 | 342 | Provided by 343 | ----------- 344 | 345 | [![Branch Up](https://branchup.tech/branch-up-logo-x30.svg)](https://branchup.tech) 346 | 347 | License 348 | ------- 349 | 350 | Licensed under the [GNU GPL License](http://www.gnu.org/copyleft/gpl.html). 351 | -------------------------------------------------------------------------------- /classes/local/processor/processor.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Processor. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes\local\processor; 27 | 28 | /** 29 | * Processor interface. 30 | * 31 | * @package filter_shortcodes 32 | * @copyright 2018 Frédéric Massart 33 | * @author Frédéric Massart 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | interface processor { 37 | 38 | /** 39 | * The filtering occurs here. 40 | * 41 | * @param string $text The content to process. 42 | * @return string The resulting text. 43 | */ 44 | public function process($text); 45 | 46 | } 47 | -------------------------------------------------------------------------------- /classes/local/processor/standard_processor.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Standard processor. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes\local\processor; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | use stdClass; 30 | use filter_shortcodes\local\registry\registry; 31 | 32 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 33 | 34 | /** 35 | * Standard processor class. 36 | * 37 | * @package filter_shortcodes 38 | * @copyright 2018 Frédéric Massart 39 | * @author Frédéric Massart 40 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 41 | */ 42 | class standard_processor implements processor { 43 | 44 | /** @var env The environment. */ 45 | protected $env; 46 | /** @var registry The registry. */ 47 | protected $registry; 48 | 49 | /** 50 | * Constructor 51 | * 52 | * @param registry $registry The registry. 53 | */ 54 | public function __construct(registry $registry) { 55 | $this->registry = $registry; 56 | } 57 | 58 | /** 59 | * Internal processing. 60 | * 61 | * @param string $text The text. 62 | * @param Closure $next The function to pipe the resulting content through, if needed. 63 | * @return string 64 | */ 65 | protected function internal_process($text, $next) { 66 | return filter_shortcodes_process_text($text, function($shortcode) use ($next) { 67 | $handler = $this->registry->get_handler($shortcode); 68 | if (!$handler) { 69 | return; 70 | } 71 | $processor = $handler->processor; 72 | return (object) [ 73 | 'hascontent' => $handler->wraps, 74 | 'contentprocessor' => function($args, $content) use ($processor, $shortcode, $next) { 75 | // We decorate the handler method to pass through the other needed arguments. 76 | return $processor($shortcode, $args, $content, $this->env, $next); 77 | }, 78 | ]; 79 | }); 80 | } 81 | 82 | /** 83 | * The filtering occurs here. 84 | * 85 | * @param string $text The content to process. 86 | * @return string The resulting text. 87 | */ 88 | public function process($text) { 89 | if ($this->env === null) { 90 | throw new \coding_exception('The environment must be set between process calls.'); 91 | } 92 | 93 | $env = $this->env; 94 | $result = $this->internal_process($text, function($text) use ($env) { 95 | $result = $this->process($text); 96 | $this->set_env($env); 97 | return $result; 98 | }); 99 | 100 | $this->env = null; 101 | return $result; 102 | } 103 | 104 | /** 105 | * Set the environment. 106 | * 107 | * @param stdClass $env The environment, must conform to filter_shortcodes_make_env. 108 | */ 109 | public function set_env(stdClass $env) { 110 | $this->env = $env; 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /classes/local/registry/plugin_registry.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Plugin registry. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes\local\registry; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | use cache; 30 | use core_component; 31 | 32 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 33 | 34 | /** 35 | * Plugin registry class. 36 | * 37 | * This browses Moodle plugins to find shortcodes and caches the result. 38 | * 39 | * @package filter_shortcodes 40 | * @copyright 2018 Frédéric Massart 41 | * @author Frédéric Massart 42 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 43 | */ 44 | class plugin_registry implements registry { 45 | 46 | /** @var cache The cache to browse for definitions. */ 47 | protected $cache; 48 | /** @var registry The static registry. */ 49 | protected $registry; 50 | 51 | /** 52 | * Constructor. 53 | */ 54 | public function __construct() { 55 | $this->cache = cache::make('filter_shortcodes', 'handlers'); 56 | } 57 | 58 | /** 59 | * Get the definitions. 60 | * 61 | * @return \Iterator 62 | */ 63 | public function get_definitions() { 64 | $this->init(); 65 | return $this->registry->get_definitions(); 66 | } 67 | 68 | /** 69 | * Get a handler. 70 | * 71 | * @param string $shortcode The shortcode. 72 | * @return object|null 73 | */ 74 | public function get_handler($shortcode) { 75 | $this->init(); 76 | return $this->registry->get_handler($shortcode); 77 | } 78 | 79 | /** 80 | * Fetch all the definitions. 81 | * 82 | * @return array 83 | */ 84 | protected function fetch_definitions() { 85 | $saferead = function($file){ 86 | $shortcodes = []; 87 | include($file); 88 | return $shortcodes; 89 | }; 90 | 91 | $pluginman = \core_plugin_manager::instance(); 92 | $stringman = get_string_manager(); 93 | $definitions = []; 94 | 95 | $types = core_component::get_plugin_types(); 96 | foreach ($types as $plugintype => $typedir) { 97 | 98 | $plugins = core_component::get_plugin_list($plugintype); 99 | foreach ($plugins as $name => $rootdir) { 100 | $component = $plugintype . '_' . $name; 101 | $info = $pluginman->get_plugin_info($component); 102 | 103 | // Skip unfound or disabled plugins. Note that the plugin manager can return null when 104 | // the status is unknown. In that case we keep the plugin (e.g. local plugins). 105 | if (!$info || $info->is_enabled() === false) { 106 | continue; 107 | } 108 | 109 | // Is the file there? I wish we could use core_component::get_plugin_list_with_file(). 110 | // But we cannot because only a few files are mapped, and ours isn't. 111 | $file = $rootdir . '/db/shortcodes.php'; 112 | if (!file_exists($file)) { 113 | continue; 114 | } 115 | 116 | $shortcodes = $saferead($file); 117 | foreach ($shortcodes as $shortcode => $data) { 118 | $data['component'] = $component; 119 | $definitions[] = filter_shortcodes_definition_from_data($shortcode, $data); 120 | } 121 | } 122 | } 123 | 124 | return $definitions; 125 | } 126 | 127 | /** 128 | * Load the things if need be. 129 | * 130 | * @return void 131 | */ 132 | protected function init() { 133 | if ($this->registry === null) { 134 | $definitions = $this->cache->get('definitions'); 135 | if ($definitions === false) { 136 | $definitions = $this->fetch_definitions(); 137 | $this->cache->set('definitions', $definitions); 138 | } 139 | $this->registry = new static_registry($definitions); 140 | } 141 | } 142 | 143 | } 144 | -------------------------------------------------------------------------------- /classes/local/registry/registry.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Registry. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes\local\registry; 27 | 28 | /** 29 | * Registry interface. 30 | * 31 | * @package filter_shortcodes 32 | * @copyright 2018 Frédéric Massart 33 | * @author Frédéric Massart 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | interface registry { 37 | 38 | /** 39 | * Get the definitions. 40 | * 41 | * @return \Iterator 42 | */ 43 | public function get_definitions(); 44 | 45 | /** 46 | * Get a handler. 47 | * 48 | * The handler is an object confirming to the result of 49 | * {@see \filter_shortcodes_handler_from_definition}, or null. 50 | * 51 | * @param string $shortcode The shortcode. 52 | * @return object|null 53 | */ 54 | public function get_handler($shortcode); 55 | 56 | } 57 | -------------------------------------------------------------------------------- /classes/local/registry/static_registry.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Static registry. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes\local\registry; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 30 | 31 | /** 32 | * Static registry class. 33 | * 34 | * @package filter_shortcodes 35 | * @copyright 2018 Frédéric Massart 36 | * @author Frédéric Massart 37 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 38 | */ 39 | final class static_registry implements registry { 40 | 41 | /** @var array Static cache. */ 42 | protected $cache = []; 43 | /** @var array The definitions arranged per shortcode, supports multiple definitions per shortcode. */ 44 | protected $definitions; 45 | 46 | /** 47 | * Constructor. 48 | * 49 | * @param array $definitions The definition objects. 50 | */ 51 | public function __construct(array $definitions) { 52 | $this->definitions = array_reduce($definitions, function($carry, $definition) { 53 | $tag = $definition->shortcode; 54 | if (!isset($carry[$tag])) { 55 | $carry[$tag] = []; 56 | } 57 | $carry[$tag][] = $definition; 58 | return $carry; 59 | }, []); 60 | } 61 | 62 | /** 63 | * Get the definitions. 64 | * 65 | * @return \Iterator 66 | */ 67 | public function get_definitions() { 68 | return new \RecursiveIteratorIterator( 69 | new \RecursiveArrayIterator( 70 | $this->definitions, 71 | \RecursiveArrayIterator::CHILD_ARRAYS_ONLY 72 | ) 73 | ); 74 | } 75 | 76 | /** 77 | * Get a handler. 78 | * 79 | * @param string $shortcode The shortcode. 80 | * @return object|null 81 | */ 82 | public function get_handler($shortcode) { 83 | if (!array_key_exists($shortcode, $this->cache)) { 84 | $handler = null; 85 | if (array_key_exists($shortcode, $this->definitions)) { 86 | $handler = filter_shortcodes_handler_from_definition(reset($this->definitions[$shortcode])); 87 | } 88 | $this->cache[$shortcode] = $handler; 89 | } 90 | return $this->cache[$shortcode]; 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /classes/privacy/provider.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Provider. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes\privacy; 27 | 28 | /** 29 | * Provider. 30 | * 31 | * @package filter_shortcodes 32 | * @copyright 2018 Frédéric Massart 33 | * @author Frédéric Massart 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | class provider implements \core_privacy\local\metadata\null_provider { 37 | 38 | use \core_privacy\local\legacy_polyfill; 39 | 40 | /** 41 | * Get the reason. 42 | * 43 | * @return string 44 | */ 45 | public static function _get_reason() { // @codingStandardsIgnoreLine 46 | return 'privacy:metadata'; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /classes/shortcodes.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Shortcodes handler. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes; 27 | 28 | /** 29 | * Shortcodes handler. 30 | * 31 | * @package filter_shortcodes 32 | * @copyright 2018 Frédéric Massart 33 | * @author Frédéric Massart 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | class shortcodes { 37 | 38 | /** 39 | * Handle shortcodes. 40 | * 41 | * @param string $shortcode The shortcode. 42 | * @param object $args The arguments of the code. 43 | * @param string|null $content The content, if the shortcode wraps content. 44 | * @param object $env The filter environment (contains context, noclean and originalformat). 45 | * @param Closure $next The function to pass the content through to process sub shortcodes. 46 | * @return string The new content. 47 | */ 48 | public static function handle($shortcode, $args, $content, $env, $next) { 49 | global $USER; 50 | if ($shortcode === 'off') { 51 | return $content; 52 | } else if ($shortcode === 'firstname') { 53 | return s($USER->firstname); 54 | } else if ($shortcode === 'fullname') { 55 | return fullname($USER); 56 | } 57 | return $next($content); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /classes/text_filter.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Filter file. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2024 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes; 27 | 28 | defined('MOODLE_INTERNAL') || die(); 29 | 30 | use filter_shortcodes\local\processor\standard_processor; 31 | use filter_shortcodes\local\registry\plugin_registry; 32 | 33 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 34 | 35 | if (class_exists(\core_filters\text_filter::class)) { 36 | // phpcs:disable Generic.Classes.DuplicateClassName.Found 37 | /** 38 | * Parent class. 39 | */ 40 | abstract class core_text_filter extends \core_filters\text_filter { 41 | } 42 | } else { 43 | require_once($CFG->libdir . '/filterlib.php'); 44 | 45 | // phpcs:disable Generic.Classes.DuplicateClassName.Found 46 | /** 47 | * Parent class. 48 | */ 49 | abstract class core_text_filter extends \moodle_text_filter { 50 | } 51 | } 52 | 53 | /** 54 | * Filter class. 55 | * 56 | * @package filter_shortcodes 57 | * @copyright 2024 Frédéric Massart 58 | * @author Frédéric Massart 59 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 60 | */ 61 | class text_filter extends core_text_filter { 62 | 63 | /** @var processor The processor. */ 64 | private $processor; 65 | 66 | /** 67 | * The filtering occurs here. 68 | * 69 | * @param string $text HTML content. 70 | * @param array $options Options passed to the filter. 71 | * @return string The new content. 72 | */ 73 | public function filter($text, array $options = []) { 74 | $env = filter_shortcodes_make_env($this->context, $options); 75 | $processor = $this->get_processor(); 76 | $processor->set_env($env); 77 | return $processor->process($text); 78 | } 79 | 80 | /** 81 | * Get the processor. 82 | * 83 | * @return standard_processor 84 | */ 85 | private function get_processor() { 86 | if ($this->processor === null) { 87 | $this->processor = new standard_processor(new plugin_registry()); 88 | } 89 | return $this->processor; 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /db/access.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Permissions. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $capabilities = [ 29 | 30 | // Whether the user can view the list of shortcodes. 31 | 'filter/shortcodes:viewlist' => [ 32 | 'captype' => 'read', 33 | 'contextlevel' => CONTEXT_COURSE, 34 | 'archetypes' => [ 35 | 'user' => CAP_ALLOW, 36 | ], 37 | ], 38 | 39 | ]; 40 | -------------------------------------------------------------------------------- /db/caches.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Cache definitions. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $definitions = [ 29 | 'handlers' => [ 30 | 'mode' => cache_store::MODE_APPLICATION, 31 | 'simplekeys' => true, 32 | 'staticacceleration' => true, 33 | ], 34 | ]; 35 | -------------------------------------------------------------------------------- /db/install.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Filter install. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | /** 27 | * Filter install function. 28 | */ 29 | function xmldb_filter_shortcodes_install() { 30 | 31 | if (!defined('PHPUNIT_TEST') || !PHPUNIT_TEST) { 32 | // Enable the filter by default. 33 | filter_set_global_state('shortcodes', TEXTFILTER_ON); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /db/shortcodes.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Shortcodes definitions. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $shortcodes = [ 29 | 'firstname' => [ 30 | 'callback' => 'filter_shortcodes\shortcodes::handle', 31 | 'description' => 'shortcode:firstname', 32 | ], 33 | 'fullname' => [ 34 | 'callback' => 'filter_shortcodes\shortcodes::handle', 35 | 'description' => 'shortcode:fullname', 36 | ], 37 | 'off' => [ 38 | 'wraps' => true, 39 | 'callback' => 'filter_shortcodes\shortcodes::handle', 40 | 'description' => 'shortcode:off', 41 | ], 42 | ]; 43 | -------------------------------------------------------------------------------- /filter.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Filter file. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | class_alias(\filter_shortcodes\text_filter::class, 'filter_shortcodes'); 27 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * List the available shortcodes. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | require('../../config.php'); 27 | require_once($CFG->libdir . '/tablelib.php'); 28 | 29 | $contextid = optional_param('contextid', SYSCONTEXTID, PARAM_INT); 30 | $context = context::instance_by_id($contextid); 31 | 32 | $url = new moodle_url('/filter/shortcodes/index.php', ['contextid' => $contextid]); 33 | 34 | require_login(); 35 | require_capability('filter/shortcodes:viewlist', $context); 36 | 37 | $title = get_string('shortcodeslist', 'filter_shortcodes'); 38 | 39 | $PAGE->set_context($context); 40 | $PAGE->set_url($url); 41 | $PAGE->set_pagelayout('popup'); 42 | $PAGE->set_title($title); 43 | $PAGE->set_heading($title); 44 | 45 | echo $OUTPUT->header(); 46 | 47 | $table = new flexible_table('filter_shortcodes'); 48 | $table->define_baseurl($url); 49 | $table->define_columns([ 50 | 'shortcode', 51 | 'description', 52 | 'component', 53 | ]); 54 | $table->define_headers([ 55 | get_string('shortcode', 'filter_shortcodes'), 56 | get_string('description', 'filter_shortcodes'), 57 | get_string('plugin', 'core'), 58 | ]); 59 | $table->setup(); 60 | 61 | $stringman = get_string_manager(); 62 | $registry = new filter_shortcodes\local\registry\plugin_registry(); 63 | 64 | $PAGE->requires->string_for_js('more', 'filter_shortcodes'); 65 | $PAGE->requires->string_for_js('less', 'filter_shortcodes'); 66 | $PAGE->requires->js_amd_inline(<<get_definitions() as $def) { 90 | $description = ''; 91 | if ($def->description) { 92 | $description = get_string($def->description, $def->component); 93 | if ($stringman->string_exists($def->description . '_help', $def->component)) { 94 | $id = uniqid(); 95 | $help = markdown_to_html(get_string($def->description . '_help', $def->component)); 96 | $description = html_writer::div( 97 | $description . 98 | ' ' . 99 | html_writer::tag('a', get_string('more', 'filter_shortcodes'), [ 100 | 'href' => '#', 101 | 'class' => 'shortcode-show-more', 102 | 'aria-expanded' => 'false', 103 | 'aria-controls' => "#{$id}", 104 | ]) . 105 | html_writer::div($help, '', [ 106 | 'id' => $id, 107 | 'style' => 'display: none; margin-top: 1em;', 108 | ]) 109 | ); 110 | } 111 | } 112 | $table->add_data([ 113 | $def->shortcode, 114 | $description, 115 | $def->component, 116 | ]); 117 | } 118 | 119 | $table->finish_output(); 120 | 121 | echo $OUTPUT->footer(); 122 | -------------------------------------------------------------------------------- /lang/en/filter_shortcodes.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Language file. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | * @codingStandardsIgnoreFile 25 | */ 26 | 27 | $string['cachedef_handlers'] = 'Shortcodes handlers'; 28 | $string['description'] = 'Description'; 29 | $string['filtername'] = 'Shortcodes'; 30 | $string['less'] = 'Less'; 31 | $string['more'] = 'More'; 32 | $string['pluginname'] = 'Shortcodes'; 33 | $string['privacy:metadata'] = 'The plugin does not store any user information.'; 34 | $string['shortcode'] = 'Shortcode'; 35 | $string['shortcode:firstname'] = 'The current user\'s first name.'; 36 | $string['shortcode:fullname'] = 'The current user\'s full name.'; 37 | $string['shortcode:off'] = 'Disables the processing of the shortcodes present between its opening and closing tag.'; 38 | $string['shortcode:off_help'] = ' 39 | ``` 40 | [off] 41 | Those tags will remain as is: 42 | [onetag] 43 | [thirdtag]Hello world![/thirdtag] 44 | [/off] 45 | ``` 46 | '; 47 | $string['shortcodes:viewlist'] = 'Viewing the list of shortcodes'; 48 | $string['shortcodeslist'] = 'List of shortcodes'; 49 | -------------------------------------------------------------------------------- /lib/helpers.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Helpers. 19 | * 20 | * I'd like to use namespaced functions, but that requires PHP 5.6 and we support 21 | * Moodle 3.1 which is compatible with PHP 5.4. 22 | * 23 | * @package filter_shortcodes 24 | * @copyright 2018 Frédéric Massart 25 | * @author Frédéric Massart 26 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 27 | */ 28 | 29 | /** 30 | * Create a definition from data. 31 | * 32 | * This defines what a definition looks like, and what keys are expected to be given as argument. 33 | * 34 | * @param string $shortcode The shortcode. 35 | * @param array $data The data. 36 | * @return object 37 | */ 38 | function filter_shortcodes_definition_from_data($shortcode, array $data) { 39 | global $CFG; 40 | 41 | if ($CFG->debugdeveloper) { 42 | validate_param($shortcode, PARAM_ALPHANUM); 43 | 44 | if (!isset($data['callback']) || !is_callable($data['callback'])) { 45 | throw new coding_exception("The callback for shortcode '{$shortcode}' is invalid."); 46 | } 47 | 48 | if (!isset($data['component'])) { 49 | throw new coding_exception("A shortcode must belong to a component."); 50 | } 51 | 52 | if (isset($data['description'])) { 53 | $stringman = get_string_manager(); 54 | if (!$stringman->string_exists($data['description'], $data['component'])) { 55 | debugging("The definition string for shortcode '{$shortcode}' is invalid.", DEBUG_DEVELOPER); 56 | $data['description'] = null; 57 | } 58 | } 59 | } 60 | 61 | return (object) [ 62 | 'shortcode' => $shortcode, 63 | 'callback' => $data['callback'], 64 | 'component' => $data['component'], 65 | 'description' => isset($data['description']) ? $data['description'] : null, 66 | 'wraps' => isset($data['wraps']) ? (bool) $data['wraps'] : false, 67 | ]; 68 | } 69 | 70 | /** 71 | * Create a handler from a definition. 72 | * 73 | * A handler contains information defining how to handle the processing 74 | * from a definition, it is agnostic of the type of everything else. 75 | * 76 | * The object returned contains the key `processor` and `wraps`. The latter 77 | * determines whether the shortcode is expected to look for a closing tag or 78 | * not. While the `processor` is a function that pipes the details to the 79 | * callback in the definition. 80 | * 81 | * The processor receives, and forwards: 82 | * 83 | * string $shortcode The shortcode operated on, so the callback does not have to be unique per shortcode. 84 | * array $args The arguments found with the shortcode. 85 | * string|null $content The content in the shortcode, when it wraps. 86 | * object $env Environment variables related to the filter. 87 | * Closure $next The function to pass the content through to process inner shortcodes. 88 | * 89 | * It must return the new content. 90 | * 91 | * @param stdClass $definition The definition. 92 | * @return object 93 | */ 94 | function filter_shortcodes_handler_from_definition(stdClass $definition) { 95 | $callback = $definition->callback; 96 | return (object) [ 97 | 'wraps' => $definition->wraps, 98 | 'processor' => function($shortcode, $args, $content, $env, $next) use ($callback) { 99 | return call_user_func($callback, $shortcode, $args, $content, $env, $next); 100 | }, 101 | ]; 102 | } 103 | 104 | /** 105 | * Create the processing env 106 | * 107 | * @param context $context The content. 108 | * @param array $options The filter options. 109 | * @return object 110 | */ 111 | function filter_shortcodes_make_env(context $context, array $options = []) { 112 | return (object) array_merge([ 113 | 'context' => $context, 114 | 'noclean' => false, 115 | 'originalformat' => FORMAT_PLAIN, 116 | ], $options); 117 | } 118 | 119 | /** 120 | * Parse a string of attributes. 121 | * 122 | * Attributes and values can contain any character. 123 | * Spaces around the equal sign are not allowed. 124 | * Attributes without value are true. 125 | * To include spaces and special characters in attributes, double quotes must be used. 126 | * 127 | * @param string $text The string to parse. 128 | * @return array 129 | */ 130 | function filter_shortcodes_parse_attributes($text) { 131 | $attrs = []; 132 | 133 | $pos = 0; 134 | $end = core_text::strlen($text); 135 | 136 | $inkey = true; 137 | $inquote = false; 138 | 139 | $key = ''; 140 | $value = ''; 141 | 142 | do { 143 | $char = core_text::substr($text, $pos, 1); 144 | if (!$inquote) { 145 | if ($char == ' ') { 146 | if ($key != '') { 147 | $attrs[$key] = $value != '' ? $value : true; 148 | } 149 | $key = $value = ''; 150 | 151 | $inkey = true; 152 | $pos++; 153 | continue; 154 | 155 | } else if ($char == '=' && $inkey && $key != '') { 156 | $inkey = false; 157 | $pos++; 158 | continue; 159 | 160 | } else if ($char == '"') { 161 | $inquote = true; 162 | $pos++; 163 | continue; 164 | } 165 | 166 | } else { 167 | if ($char == '"' && $pos && core_text::substr($text, $pos - 1, 1) != '\\') { 168 | // Detect when we reached the end of a quoted text. 169 | $inquote = false; 170 | $pos++; 171 | continue; 172 | 173 | } else if ($char == '\\' && core_text::substr($text, $pos, 2) == '\\"') { 174 | // When the quote is being escaped, remove the escaping character. 175 | $char = '"'; 176 | $pos++; 177 | } 178 | } 179 | 180 | // Append the character.. 181 | if ($inkey) { 182 | $key .= $char; 183 | } else { 184 | $value .= $char; 185 | } 186 | 187 | $pos++; 188 | } while ($pos < $end); 189 | 190 | // Final push. 191 | if ($key != '') { 192 | $attrs[$key] = $value != '' ? $value : true; 193 | } 194 | 195 | return $attrs; 196 | } 197 | 198 | /** 199 | * Process a text. 200 | * 201 | * @param string $text The text to parse and replace shortcodes in. 202 | * @param callable $informant Function returning information about the tag and how to handle it. 203 | * @return string 204 | */ 205 | function filter_shortcodes_process_text($text, callable $informant) { 206 | $charregex = '/^[a-z0-9\[]$/'; 207 | $pos = 0; 208 | $end = null; 209 | 210 | while (($firstfind = core_text::strpos($text, '[', $pos)) !== false) { 211 | $lastcloseself = core_text::strrpos($text, ']', $firstfind); 212 | 213 | // The tag cannot be closed. 214 | if ($lastcloseself === false) { 215 | return $text; 216 | } 217 | 218 | $start = $firstfind; 219 | $end = $end === null ? core_text::strlen($text) : $end; 220 | $pos = $firstfind + 1; 221 | 222 | // Find out what the tag is. 223 | $tag = ''; 224 | do { 225 | $char = core_text::substr($text, $pos, 1); 226 | if (!preg_match($charregex, $char)) { 227 | break; 228 | } 229 | $tag .= $char; 230 | $pos++; 231 | } while ($pos < $end); 232 | 233 | // We have a tag and can we handle it? 234 | if ($tag && is_object($info = $informant($tag))) { 235 | 236 | if ($lastcloseself < $pos) { 237 | // The tag does not have an end. 238 | continue; 239 | } 240 | 241 | $tagclosed = false; 242 | $attrs = ''; 243 | $inquote = false; 244 | 245 | do { 246 | $char = core_text::substr($text, $pos, 1); 247 | 248 | // Detect when we are in quotes, in order to avoid closing the tag too early. 249 | if ($char == '"') { 250 | if ($inquote) { 251 | $inquote = core_text::substr($text, $pos - 1, 1) !== '\\' ? false : true; 252 | } else { 253 | $inquote = true; 254 | } 255 | } 256 | 257 | // We found the end \o/. 258 | if (!$inquote && $char == ']') { 259 | $tagclosed = true; 260 | $pos++; 261 | break; 262 | } 263 | 264 | $attrs .= $char; 265 | $pos++; 266 | 267 | } while ($pos <= $lastcloseself); // Stop when we know there is no remaining closing tag. 268 | 269 | // The tag was never closed and we reached the end, leave. 270 | if (!$tagclosed) { 271 | return $text; 272 | } 273 | 274 | // Find the content. 275 | $content = null; 276 | if ($info->hascontent) { 277 | $closingpos = core_text::strpos($text, "[/$tag]", $pos); 278 | if ($closingpos === false) { 279 | // The tag is never closed, we ignore the tag and resume browsing from here. 280 | continue; 281 | } 282 | $content = core_text::substr($text, $pos, $closingpos - $pos); 283 | $pos = $closingpos + core_text::strlen("[/$tag]"); 284 | } 285 | 286 | // Parse the filters, replace the text, and adjust the bounderies of our search. 287 | $attrs = filter_shortcodes_parse_attributes($attrs); 288 | $contentprocessor = $info->contentprocessor; 289 | $newcontent = $contentprocessor($attrs, $content); 290 | $newlength = core_text::strlen($newcontent); 291 | $text = core_text::substr($text, 0, $start) . $newcontent . core_text::substr($text, $pos); 292 | $end = $end + ($newlength - ($pos - $start)); 293 | $pos = $start + $newlength; 294 | } 295 | } 296 | 297 | return $text; 298 | } 299 | -------------------------------------------------------------------------------- /tests/lib_helpers_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Lib helpers tests. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | global $CFG; 30 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 31 | 32 | /** 33 | * Lib helpers tests. 34 | * 35 | * @package filter_shortcodes 36 | * @copyright 2018 Frédéric Massart 37 | * @author Frédéric Massart 38 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 39 | */ 40 | final class lib_helpers_test extends \advanced_testcase { 41 | 42 | /** 43 | * Parse attributes data provider. 44 | * 45 | * @return array 46 | */ 47 | public static function parse_attributes_provider(): array { 48 | return [ 49 | [ 50 | '', 51 | [], 52 | ], 53 | [ 54 | 'a=1', 55 | ['a' => '1'], 56 | ], 57 | [ 58 | 'myVar=myValue', 59 | ['myVar' => 'myValue'], 60 | ], 61 | [ 62 | 'myVar=myValue andThis=that', 63 | ['myVar' => 'myValue', 'andThis' => 'that'], 64 | ], 65 | [ 66 | 'myVar="myValue" "and This"=that', 67 | ['myVar' => 'myValue', 'and This' => 'that'], 68 | ], 69 | [ 70 | ' there="are" spaces="in everything " ', 71 | ['there' => 'are', 'spaces' => 'in everything '], 72 | ], 73 | [ 74 | 'noValueIsTrue and 1 23 too', 75 | ['noValueIsTrue' => true, 'and' => true, '1' => true, '23' => true, 'too' => true], 76 | ], 77 | [ 78 | 'name="Kučerová Matěj" t€xt="I love \"apples\", do you?"', 79 | ['name' => 'Kučerová Matěj', 't€xt' => 'I love "apples", do you?'], 80 | ], 81 | [ 82 | '123=456 \/;#@a="We accept too much?"', 83 | ['123' => '456', '\/;#@a' => 'We accept too much?'], 84 | ], 85 | [ 86 | ' 😀=🏁 🤔', 87 | ['😀' => '🏁', '🤔' => true], 88 | ], 89 | [ 90 | 'thisIsNotClosed="So, where does it stop ', 91 | ['thisIsNotClosed' => 'So, where does it stop '], 92 | ], 93 | [ 94 | 'id=2 uid="1234-5678" disabled "Need \"spaces\"?" "Oh my"=w\'or\'d!', 95 | [ 96 | 'id' => '2', 97 | 'uid' => '1234-5678', 98 | 'disabled' => true, 99 | 'Need "spaces"?' => true, 100 | 'Oh my' => "w'or'd!", 101 | ], 102 | ], 103 | ]; 104 | } 105 | 106 | /** 107 | * Test parse attributes. 108 | * 109 | * @dataProvider parse_attributes_provider 110 | * @param string $attributes The attributes to parse. 111 | * @param array $expected The expected result. 112 | * @covers \filter_shortcodes_parse_attributes 113 | */ 114 | public function test_parse_attributes($attributes, $expected): void { 115 | $this->assertEquals($expected, filter_shortcodes_parse_attributes($attributes)); 116 | } 117 | 118 | /** 119 | * Parse attributes data provider. 120 | * 121 | * @return array 122 | */ 123 | public static function process_text_provider(): array { 124 | $noop = function() { 125 | }; 126 | $informantsingle = (object) [ 127 | 'hascontent' => false, 128 | 'contentprocessor' => function($attrs, $content) { 129 | return isset($attrs['text']) ? $attrs['text'] : 'banana'; 130 | }, 131 | ]; 132 | $informantcontent = (object) [ 133 | 'hascontent' => true, 134 | 'contentprocessor' => function($attrs, $content) { 135 | return strtoupper($content); 136 | }, 137 | ]; 138 | $informantmaker = function($nextinformant) { 139 | return (object) [ 140 | 'hascontent' => true, 141 | 'contentprocessor' => function($attrs, $content) use ($nextinformant) { 142 | return strtoupper(filter_shortcodes_process_text($content, $nextinformant)); 143 | }, 144 | ]; 145 | }; 146 | return [ 147 | [ 148 | 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 149 | $noop, 150 | 'Lorem ipsum dolor sit amet, consectetur adipisicing elit.', 151 | ], 152 | [ 153 | 'Lorem ipsum [dolor sit amet, consectetur adipisicing elit.', 154 | $noop, 155 | 'Lorem ipsum [dolor sit amet, consectetur adipisicing elit.', 156 | ], 157 | [ 158 | 'Lorem ipsum dolor sit amet, consectetur] adipisicing elit.', 159 | $noop, 160 | 'Lorem ipsum dolor sit amet, consectetur] adipisicing elit.', 161 | ], 162 | [ 163 | 'Lorem ipsum] dolor sit [amet, consectetur adipisicing elit.', 164 | $noop, 165 | 'Lorem ipsum] dolor sit [amet, consectetur adipisicing elit.', 166 | ], 167 | [ 168 | 'Lorem ipsum dolor sit [amet], consectetur adipisicing elit.', 169 | $noop, 170 | 'Lorem ipsum dolor sit [amet], consectetur adipisicing elit.', 171 | ], 172 | [ 173 | 'Lorem ipsum [dolor] sit [/amet], consectetur adipisicing elit.', 174 | $noop, 175 | 'Lorem ipsum [dolor] sit [/amet], consectetur adipisicing elit.', 176 | ], 177 | [ 178 | 'Lorem ipsum [dolor] sit amet, [a]consectetur adipisicing[/a] elit.', 179 | function($tag) use ($informantsingle, $informantcontent) { 180 | if ($tag == 'dolor') { 181 | return $informantsingle; 182 | } else if ($tag == 'a') { 183 | return $informantcontent; 184 | } 185 | }, 186 | 'Lorem ipsum banana sit amet, CONSECTETUR ADIPISICING elit.', 187 | ], 188 | [ 189 | 'Lorem ipsum [dolor text="abc"] sit amet, consectetur adipisicing elit.', 190 | function($tag) use ($informantsingle, $informantcontent) { 191 | return $informantsingle; 192 | }, 193 | 'Lorem ipsum abc sit amet, consectetur adipisicing elit.', 194 | ], 195 | [ 196 | 'Lorem ipsum [dolor param="contains ] <-- this and \"this\""] sit amet, consectetur adipisicing elit.', 197 | function($tag) use ($informantsingle, $informantcontent) { 198 | return $informantsingle; 199 | }, 200 | 'Lorem ipsum banana sit amet, consectetur adipisicing elit.', 201 | ], 202 | [ 203 | 'Lorem ipsum [dolor text="abc"] sit amet[/dolor], consectetur adipisicing elit.', 204 | function($tag) use ($informantsingle, $informantcontent) { 205 | return $informantsingle; 206 | }, 207 | 'Lorem ipsum abc sit amet[/dolor], consectetur adipisicing elit.', 208 | ], 209 | [ 210 | 'Lorem ipsum [dolor text="abc"] sit amet[/dolor], consectetur adipisicing elit.', 211 | function($tag) use ($informantsingle, $informantcontent) { 212 | return $informantcontent; 213 | }, 214 | 'Lorem ipsum SIT AMET, consectetur adipisicing elit.', 215 | ], 216 | [ 217 | 'Lorem ipsum [dolor text="abc"] sit amet[dolor], consectetur adipisicing[/dolor] elit.', 218 | function($tag) use ($informantsingle, $informantcontent) { 219 | return $informantcontent; 220 | }, 221 | 'Lorem ipsum SIT AMET[DOLOR], CONSECTETUR ADIPISICING elit.', 222 | ], 223 | [ 224 | 'Lorem [a] ipsum [dolor text="abc"] sit amet[a], consectetur adipisicing elit.', 225 | function($tag) use ($informantsingle, $informantcontent) { 226 | return $tag == 'a' ? $informantsingle : $informantcontent; 227 | }, 228 | 'Lorem banana ipsum [dolor text="abc"] sit ametbanana, consectetur adipisicing elit.', 229 | ], 230 | [ 231 | '[dolor text="Lorem "][upperme][decorate]banana[/decorate][dolor text=" ipsum"][/upperme] ' . 232 | 'dolor [a][dolor text=" sit amet"]', 233 | function($tag) use ($informantsingle, $informantcontent, $informantmaker) { 234 | if ($tag == 'upperme') { 235 | return $informantmaker(function($tag) use ($informantsingle) { 236 | if ($tag == 'decorate') { 237 | return (object) [ 238 | 'hascontent' => true, 239 | 'contentprocessor' => function($args, $content) { 240 | return '@' . $content . '@'; 241 | }, 242 | ]; 243 | } 244 | return $informantsingle; 245 | }); 246 | } 247 | return $informantsingle; 248 | }, 249 | 'Lorem @BANANA@ IPSUM dolor banana sit amet', 250 | ], 251 | ]; 252 | } 253 | 254 | /** 255 | * Test process text. 256 | * 257 | * @dataProvider process_text_provider 258 | * @param string $text The text to parse. 259 | * @param \Closure $informant The informant function. 260 | * @param string $expected The expected result. 261 | * @covers \filter_shortcodes_process_text 262 | */ 263 | public function test_process_text($text, $informant, $expected): void { 264 | $this->assertEquals($expected, filter_shortcodes_process_text($text, $informant)); 265 | } 266 | 267 | /** 268 | * Test definition maker. 269 | * 270 | * @covers \filter_shortcodes_definition_from_data 271 | */ 272 | public function test_filter_shortcodes_definition_from_data_invalid_code(): void { 273 | $this->expectException('invalid_parameter_exception'); 274 | $this->expectExceptionMessage('Invalid parameter value detected'); 275 | filter_shortcodes_definition_from_data('abc:d', []); 276 | } 277 | 278 | /** 279 | * Test definition maker. 280 | * 281 | * @covers \filter_shortcodes_definition_from_data 282 | */ 283 | public function test_filter_shortcodes_definition_from_data_invalid_code_too(): void { 284 | $this->expectException('invalid_parameter_exception'); 285 | $this->expectExceptionMessage('Invalid parameter value detected'); 286 | filter_shortcodes_definition_from_data('ab_c', []); 287 | } 288 | 289 | /** 290 | * Test definition maker. 291 | * 292 | * @covers \filter_shortcodes_definition_from_data 293 | */ 294 | public function test_filter_shortcodes_definition_from_data_invalid_callback(): void { 295 | $this->expectException('coding_exception'); 296 | $this->expectExceptionMessage("The callback for shortcode 'abc' is invalid."); 297 | filter_shortcodes_definition_from_data('abc', ['callback' => 'donot::exist']); 298 | } 299 | 300 | /** 301 | * Test definition maker. 302 | * 303 | * @covers \filter_shortcodes_definition_from_data 304 | */ 305 | public function test_filter_shortcodes_definition_from_data_missing_component(): void { 306 | $this->expectException('coding_exception'); 307 | $this->expectExceptionMessage("A shortcode must belong to a component."); 308 | filter_shortcodes_definition_from_data('abc', ['callback' => 'intval']); 309 | } 310 | 311 | /** 312 | * Test definition maker. 313 | * 314 | * @covers \filter_shortcodes_definition_from_data 315 | */ 316 | public function test_filter_shortcodes_definition_from_data(): void { 317 | $result = filter_shortcodes_definition_from_data('abc', ['callback' => 'intval', 'component' => 'b']); 318 | $this->assertEquals('abc', $result->shortcode); 319 | $this->assertEquals('intval', $result->callback); 320 | $this->assertEquals('b', $result->component); 321 | $this->assertEquals(null, $result->description); 322 | 323 | $result = filter_shortcodes_definition_from_data('abc', ['callback' => 'intval', 'component' => 'core', 324 | 'description' => 'adddots', ]); 325 | $this->assertEquals('abc', $result->shortcode); 326 | $this->assertEquals('intval', $result->callback); 327 | $this->assertEquals('core', $result->component); 328 | $this->assertEquals('adddots', $result->description); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /tests/plugin_registry_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Plugin registry tests. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | use context_system; 30 | use filter_shortcodes\local\registry\plugin_registry; 31 | 32 | global $CFG; 33 | 34 | /** 35 | * Plugin registry tests. 36 | * 37 | * @package filter_shortcodes 38 | * @copyright 2018 Frédéric Massart 39 | * @author Frédéric Massart 40 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 41 | */ 42 | final class plugin_registry_test extends \advanced_testcase { 43 | 44 | /** 45 | * Get definitions. 46 | * 47 | * @covers \filter_shortcodes\local\registry\plugin_registry::get_definitions 48 | */ 49 | public function test_get_definitions(): void { 50 | $this->resetAfterTest(); 51 | filter_set_global_state('shortcodes', TEXTFILTER_ON); 52 | 53 | $registry = new plugin_registry(); 54 | $defs = iterator_to_array($registry->get_definitions(), false); 55 | 56 | // We do not know about the other plugins that may be installed on the system, so 57 | // let's just check that we find our own shortcodes. 58 | $this->assertTrue(count($defs) >= 1); 59 | $this->assertNotEmpty(array_filter($defs, function($def) { 60 | return $def->shortcode == 'off' && $def->component == 'filter_shortcodes'; 61 | })); 62 | } 63 | 64 | /** 65 | * Get handler. 66 | * 67 | * @covers \filter_shortcodes\local\registry\plugin_registry::get_handler 68 | */ 69 | public function test_get_handler(): void { 70 | $this->resetAfterTest(); 71 | filter_set_global_state('shortcodes', TEXTFILTER_ON); 72 | 73 | $registry = new plugin_registry(); 74 | $handler = $registry->get_handler('off'); 75 | $this->assertTrue($handler->wraps); 76 | 77 | $noop = function($text) { 78 | return $text; 79 | }; 80 | $env = filter_shortcodes_make_env(context_system::instance()); 81 | $processor = $handler->processor; 82 | $content = 'is [not] processed'; 83 | $this->assertEquals($content, $processor('off', [], $content, $env, $noop)); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /tests/standard_processor_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Standard processor tests. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | use context_system; 30 | use core_text; 31 | use filter_shortcodes\local\registry\static_registry; 32 | use filter_shortcodes\local\processor\standard_processor; 33 | 34 | global $CFG; 35 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 36 | 37 | /** 38 | * Standard processor testcase 39 | * 40 | * @package filter_shortcodes 41 | * @copyright 2018 Frédéric Massart 42 | * @author Frédéric Massart 43 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 44 | */ 45 | final class standard_processor_test extends \advanced_testcase { 46 | 47 | /** 48 | * Process. 49 | * 50 | * @covers \filter_shortcodes\local\processor\standard_processor::process 51 | */ 52 | public function test_process(): void { 53 | $this->resetAfterTest(); 54 | $dg = $this->getDataGenerator(); 55 | $u1 = $dg->create_user(['firstname' => 'François', 'lastname' => 'O\'Brian']); 56 | $registry = new static_registry([ 57 | filter_shortcodes_definition_from_data('decorate', ['component' => 'core_test', 58 | 'callback' => 'filter_shortcodes\filter_shortcodes_standard_processor_decorate', 'wraps' => true, ]), 59 | filter_shortcodes_definition_from_data('fullname', ['component' => 'core_test', 60 | 'callback' => 'filter_shortcodes\filter_shortcodes_standard_processor_fullname', ]), 61 | filter_shortcodes_definition_from_data('uppercase', ['component' => 'core_test', 62 | 'callback' => 'filter_shortcodes\filter_shortcodes_standard_processor_uppercase', 'wraps' => true, ]), 63 | ]); 64 | $processor = new standard_processor($registry); 65 | $env = filter_shortcodes_make_env(context_system::instance()); 66 | 67 | $this->setUser($u1); 68 | $processor->set_env($env); 69 | $content = "Hello [fullname], welcome to the [uppercase][decorate]best[/decorate] school[/uppercase] ever!"; 70 | $expected = 'Hello François O\'Brian, welcome to the @BEST@ SCHOOL ever!'; 71 | $this->assertEquals($expected, $processor->process($content)); 72 | } 73 | 74 | } 75 | 76 | /** 77 | * Fixture processor. 78 | * 79 | * @param string $tag The tag. 80 | * @param array $args The arguments. 81 | * @param string $content The content. 82 | * @param object $env The env. 83 | * @param Closure $next The next function. 84 | * @return string 85 | */ 86 | function filter_shortcodes_standard_processor_decorate($tag, $args, $content, $env, $next) { 87 | $decorator = isset($args['decorator']) ? $args['decorator'] : '@'; 88 | return $next("{$decorator}{$content}{$decorator}"); 89 | } 90 | 91 | /** 92 | * Fixture processor. 93 | * 94 | * @param string $tag The tag. 95 | * @param array $args The arguments. 96 | * @param string $content The content. 97 | * @param object $env The env. 98 | * @param Closure $next The next function. 99 | * @return string 100 | */ 101 | function filter_shortcodes_standard_processor_uppercase($tag, $args, $content, $env, $next) { 102 | return core_text::strtoupper($next($content)); 103 | } 104 | 105 | /** 106 | * Fixture processor. 107 | * 108 | * @param string $tag The tag. 109 | * @param array $args The arguments. 110 | * @param string $content The content. 111 | * @param object $env The env. 112 | * @param Closure $next The next function. 113 | * @return string 114 | */ 115 | function filter_shortcodes_standard_processor_fullname($tag, $args, $content, $env, $next) { 116 | global $USER; 117 | return fullname($USER); 118 | } 119 | -------------------------------------------------------------------------------- /tests/static_registry_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Static registry tests. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | namespace filter_shortcodes; 27 | defined('MOODLE_INTERNAL') || die(); 28 | 29 | use filter_shortcodes\local\registry\static_registry; 30 | 31 | global $CFG; 32 | require_once($CFG->dirroot . '/filter/shortcodes/lib/helpers.php'); 33 | 34 | 35 | /** 36 | * Static registry tests. 37 | * 38 | * @package filter_shortcodes 39 | * @copyright 2018 Frédéric Massart 40 | * @author Frédéric Massart 41 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 42 | */ 43 | final class static_registry_test extends \advanced_testcase { 44 | 45 | /** 46 | * Get definitions. 47 | * 48 | * @covers \filter_shortcodes\local\registry\static_registry::get_definitions 49 | */ 50 | public function test_get_definitions(): void { 51 | $registry = new static_registry([ 52 | filter_shortcodes_definition_from_data('abc', ['component' => 'core', 'callback' => 'intval']), 53 | filter_shortcodes_definition_from_data('def', ['component' => 'filter_shortcodes', 'callback' => 'strlen']), 54 | filter_shortcodes_definition_from_data('def', ['component' => 'core_course', 'callback' => 'next']), 55 | filter_shortcodes_definition_from_data('ghi', ['component' => 'core_course', 'callback' => 'next']), 56 | ]); 57 | $defs = iterator_to_array($registry->get_definitions(), false); 58 | 59 | $this->assertCount(4, $defs); 60 | $this->assertEquals('abc', $defs[0]->shortcode); 61 | $this->assertEquals('intval', $defs[0]->callback); 62 | $this->assertEquals('def', $defs[1]->shortcode); 63 | $this->assertEquals('strlen', $defs[1]->callback); 64 | $this->assertEquals('def', $defs[2]->shortcode); 65 | $this->assertEquals('next', $defs[2]->callback); 66 | $this->assertEquals('ghi', $defs[3]->shortcode); 67 | $this->assertEquals('next', $defs[3]->callback); 68 | } 69 | 70 | /** 71 | * Get handler. 72 | * 73 | * @covers \filter_shortcodes\local\registry\static_registry::get_handler 74 | */ 75 | public function test_get_handler(): void { 76 | $noop = function($text) { 77 | return $text; 78 | }; 79 | $registry = new static_registry([ 80 | filter_shortcodes_definition_from_data('abc', ['component' => 'core', 81 | 'callback' => 'filter_shortcodes\filter_shortcodes_fixture_return_two', 'wraps' => true, ]), 82 | filter_shortcodes_definition_from_data('def', ['component' => 'filter_shortcodes', 83 | 'callback' => 'filter_shortcodes\filter_shortcodes_fixture_return_two', ]), 84 | filter_shortcodes_definition_from_data('def', ['component' => 'core_course', 85 | 'callback' => 'filter_shortcodes\filter_shortcodes_fixture_return_one', 'wraps' => true, ]), 86 | filter_shortcodes_definition_from_data('ghi', ['component' => 'core_course', 87 | 'callback' => 'filter_shortcodes\filter_shortcodes_fixture_return_one', ]), 88 | ]); 89 | 90 | // Not handled. 91 | $handler = $registry->get_handler('notthere'); 92 | $this->assertNull($handler); 93 | 94 | // Typical response. 95 | $handler = $registry->get_handler('abc'); 96 | $processor = $handler->processor; 97 | $this->assertTrue($handler->wraps); 98 | $this->assertEquals('two', $processor('abc', [], null, (object) [], $noop)); 99 | 100 | // When duplicates we pick the first. 101 | $handler = $registry->get_handler('def'); 102 | $processor = $handler->processor; 103 | $this->assertFalse($handler->wraps); 104 | $this->assertEquals('two', $processor('def', [], null, (object) [], $noop)); 105 | 106 | // Second call returns the same instance. 107 | $this->assertSame($handler, $registry->get_handler('def')); 108 | 109 | // Yet another one. 110 | $handler = $registry->get_handler('ghi'); 111 | $processor = $handler->processor; 112 | $this->assertFalse($handler->wraps); 113 | $this->assertEquals('one', $processor('ghi', [], null, (object) [], $noop)); 114 | } 115 | 116 | } 117 | 118 | /** 119 | * Fixture function always returning one. 120 | * 121 | * @return string 122 | */ 123 | function filter_shortcodes_fixture_return_one() { 124 | return 'one'; 125 | } 126 | 127 | /** 128 | * Fixture function always returning two. 129 | * 130 | * @return string 131 | */ 132 | function filter_shortcodes_fixture_return_two() { 133 | return 'two'; 134 | } 135 | -------------------------------------------------------------------------------- /version.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Version file. 19 | * 20 | * @package filter_shortcodes 21 | * @copyright 2018 Frédéric Massart 22 | * @author Frédéric Massart 23 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | $plugin->version = 2025041100; 29 | $plugin->requires = 2016052300; // Moodle 3.1.0. 30 | $plugin->component = 'filter_shortcodes'; 31 | $plugin->maturity = MATURITY_STABLE; 32 | $plugin->release = '1.1.1'; 33 | --------------------------------------------------------------------------------