diff --git a/.gitattributes b/.gitattributes index 21851e2..8f057ea 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,6 @@ /docs export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore +/.github export-ignore /.tx export-ignore /.scrutinizer.yml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..28b9efb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,214 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + name: SS${{ matrix.silverstripe }} / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + # One job per supported Silverstripe major, at the oldest and newest PHP that major is used + # with here. The module's composer.json is the source of truth for the supported range; this + # matrix must follow it. + - silverstripe: '5' + php: '8.1' + recipe: '^5' + testing: '^3' + - silverstripe: '5' + php: '8.3' + recipe: '^5' + testing: '^3' + - silverstripe: '6' + php: '8.3' + recipe: '^6' + testing: '^4' + - silverstripe: '6' + php: '8.4' + recipe: '^6' + testing: '^4' + + services: + database: + # MariaDB rather than MySQL: it matches both the local dev machine and the Forge servers, so a + # version-specific failure here means something real. + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: ss_ci + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s --health-timeout=5s --health-retries=10 + + steps: + - name: Check out the module + uses: actions/checkout@v4 + with: + path: module + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, pdo_mysql, mysqli, gd, zip, dom, xml, tokenizer, simplexml, ctype, hash, session + tools: composer:v2 + coverage: none + + - name: Build a host project + # The module cannot be tested on its own: it needs a booted Silverstripe app. + # The module is required through a PATH repository pointing at the checkout, and symlinked + # rather than mirrored ON PURPOSE - `/tests` is `export-ignore` in .gitattributes, so a dist or + # mirrored install contains no tests at all. + run: | + mkdir -p app-host + cd app-host + cat > composer.json < phpunit.xml <<'XML' + + + + + + vendor/restruct/silverstripe-shortcodable/tests + + + app/tests + + + + XML + + - name: Add a consumer-shape database test + # An unbuildable fixture in this module's tests/ dir fatals the temp-database build in every + # CONSUMING project, and the module's own suite structurally cannot catch it (its fixtures are + # loaded by PHPUnit, not registered in the class manifest, so no tables are built for them). + run: | + mkdir -p app-host/app/tests + cat > app-host/app/tests/ConsumerDbTest.php <<'PHP' + 'x']); + $m->write(); + $this->assertGreaterThan(0, $m->ID); + } + } + PHP + + - name: Run tests + working-directory: app-host + env: + SS_DATABASE_CLASS: MySQLDatabase + SS_DATABASE_SERVER: 127.0.0.1 + SS_DATABASE_PORT: 3306 + SS_DATABASE_USERNAME: root + SS_DATABASE_PASSWORD: root + SS_DATABASE_NAME: ss_ci + SS_ENVIRONMENT_TYPE: dev + # The manifest is flushed differently per major: + # - Silverstripe 5 (framework 5 / PHPUnit 9): framework 5 does not read SS_PHPUNIT_FLUSH, only a + # `flush` argument, and that argument only works after a test PATH (next to --testsuite PHPUnit + # 9 takes `flush=1` for a file: 'Cannot open file "flush=1"'), so these legs run path-first. + # - Silverstripe 6 (PHPUnit 11): a `flush=1` argument is ignored; framework 6 reads + # SS_PHPUNIT_FLUSH instead, so it is set for those legs only. + run: | + if [ "${{ matrix.silverstripe }}" = "6" ]; then + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite shortcodable + SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit --testsuite consumer + else + vendor/bin/phpunit vendor/restruct/silverstripe-shortcodable/tests flush=1 + vendor/bin/phpunit app/tests flush=1 + fi + + - name: Boot the app and build the database + # The suite proves the classes behave; this proves the module does not break a real boot. + working-directory: app-host + env: + SS_DATABASE_CLASS: MySQLDatabase + SS_DATABASE_SERVER: 127.0.0.1 + SS_DATABASE_PORT: 3306 + SS_DATABASE_USERNAME: root + SS_DATABASE_PASSWORD: root + SS_DATABASE_NAME: ss_ci + SS_ENVIRONMENT_TYPE: dev + run: | + if [ "${{ matrix.silverstripe }}" = "6" ]; then + vendor/bin/sake db:build --flush + else + vendor/bin/sake dev/build flush=1 + fi + + lint: + name: Lint PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The floor and the ceiling of the module's declared php constraint. + php: ['8.1', '8.4'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Syntax check every PHP file + run: find src tests -name '*.php' -print0 | xargs -0 -n1 php -l && php -l _config.php diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 64ca3cd..0000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -# See https://github.com/silverstripe-labs/silverstripe-travis-support for setup details - -sudo: false - -language: php - -php: - - 5.3 - - 5.4 - - 5.5 - - 5.6 - - 7.0 - -env: - - DB=MYSQL CORE_RELEASE=3.2 - -matrix: - include: - - php: 5.6 - env: DB=MYSQL CORE_RELEASE=3 - - php: 5.6 - env: DB=MYSQL CORE_RELEASE=3.1 - - php: 5.6 - env: DB=PGSQL CORE_RELEASE=3.2 - allow_failures: - - php: 7.0 - -before_script: - - composer self-update || true - - git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support - - php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss - - cd ~/builds/ss - - composer install - -script: - - vendor/bin/phpunit shortcodable/tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a475b05 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +## 5.1.0 (2026-09-25) + +**One line for Silverstripe 5 and 6.** `main` now declares `silverstripe/framework ^5 || ^6` on PHP +`^8.1`. Silverstripe 4 is no longer supported or tested; the `4.x` tags stay available for projects +still on it. Upgrade notes: [UPGRADING.md](UPGRADING.md). + +The placeholder image fix below was also released for the 4.x line as 4.0.17 (branch `v4`, formerly `ss4-5`). + +### Fixed + +- **The 5.0.x releases could not be installed from Packagist.** They required + `restruct/silverstripe-simpler ~1.0`, and simpler has no 1.x release, so Composer refused every + 5.0.x tag. The constraint is now `~0.2 || ^1`: the simpler line matching each Silverstripe major. +- **Silverstripe 6 without `silverstripe/htmleditor-tinymce` fatal on every request** with + `Class "SilverStripe\TinyMCE\TinyMCEConfig" not found` from `_config.php`. `recipe-cms` 6 does not + include TinyMCE, so a default install hit this. The editor config is now fetched through + `HTMLEditorConfig::get()`, and the plugin and button are only added to a TinyMCE config + (`Shortcodable::is_tinymce_config()`); other editors are skipped. +- **The shortcode dialog's modal is loaded explicitly** through simpler's `AdminExtension` with + `simpler_include_modal: true`, as on the 4.x line. `simpler-silverstripe.js` on its own does not + provide `simpler.modal`, which the dialog uses. +- **The placeholder image endpoint (`admin/shortcodable/placehold.img`) validates and escapes its + parameters.** They were interpolated into the SVG unescaped. Sizes now accept a positive integer or + `100%`, colours 3 or 6 hex digits; anything else falls back to `default_placeholder`. Font and text + are escaped for XML. Upgrading is recommended. +- **Placeholder text with non-ASCII characters no longer breaks the SVG.** `htmlentities()` produced + HTML named entities such as `é`, which XML does not define. + +### Changed + +- `ShortcodableAdminController::shortcodePlaceholderImage()` returns its `HTTPResponse` instead of + calling `output()` itself. +- The editor stylesheet is registered on whichever `TinyMCEConfig` class the installed Silverstripe + provides (`SilverStripe\Forms\HTMLEditor\TinyMCEConfig` on 5, `SilverStripe\TinyMCE\TinyMCEConfig` + on 6). +- `composer.json`: `silverstripe/vendor-plugin ^2 || ^3`, explicit `php ^8.1`, a `suggest` for + `silverstripe/htmleditor-tinymce`, and a `funding` entry. +- `composer.json` requires `silverstripe/admin ^2 || ^3`. The module's config and controller build on + `LeftAndMain`, which is part of admin, not framework; it was previously only pulled in indirectly. + +### Added + +- A behavioural PHPUnit suite (registration, tag and label maps, the TinyMCE button, the block-level + wrapper rewrite, the admin endpoints and their permission check), run by GitHub Actions against + Silverstripe 5 and 6. The Silverstripe 3 era `.travis.yml` is removed. +- README: requirements, every configuration option with its default, and how to run the tests. + +## 5.0.0 - 5.0.5 + +Silverstripe 6 only, with TinyMCE 6 selection handling and inline Elemental editor placeholder fixes. +Not installable from Packagist (see 5.1.0). diff --git a/README.md b/README.md index 550caae..784dced 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Silverstripe shortcode module +*Maintained by [Restruct](https://github.com/restruct). If this module saves you time, you can +[support ongoing maintenance](https://github.com/sponsors/restruct).* + Adds a ![](docs/screens/button.png) button to HTMLEditorField for CMS users to insert Shortcodes in page content.
Shortcodes can optionally be represented in TinyMCE with a placeholder image. @@ -7,12 +10,36 @@ Shortcodes can optionally be represented in TinyMCE with a placeholder image. This module is a partial-to-largely rewrite of sheadawson/silverstripe-shortcodable.
It depends on [Silverstripe Simpler](https://github.com/restruct/silverstripe-simpler) for some non-react UI functionalities (mainly the modal dialog). +## Requirements + +* Silverstripe 5 or 6 (`silverstripe/framework ^5 || ^6`) +* PHP 8.1 or newer (Silverstripe 6 itself needs 8.3) +* [restruct/silverstripe-simpler](https://github.com/restruct/silverstripe-simpler), installed automatically: + its `0.x` line (`~0.2`) on Silverstripe 5; on Silverstripe 6 it requires restruct/silverstripe-simpler + 1.0.0 or later +* **Silverstripe 6 only:** `silverstripe/htmleditor-tinymce`. TinyMCE is a separate module on Silverstripe 6 + and `silverstripe/recipe-cms` does not include it. Without it the site still boots, but there is no + TinyMCE editor to add the shortcode button to. + +## Installation + +``` +composer require restruct/silverstripe-shortcodable +# Silverstripe 6, if your project does not have it yet: +composer require silverstripe/htmleditor-tinymce +``` + ## Version Compatibility -| Branch | Module Version | Silverstripe | PHP | -|---------|----------------|----------------|--------------| -| `main` | `5.x` | ^6.0 | ^8.2 | -| `ss4-5` | `4.x` | ^4.0 \|\| ^5.0 | ^7.4 \|\| ^8.0 | +| Branch | Module Version | Silverstripe | PHP | +|---------|----------------|----------------|----------------| +| `main` | `5.x` (from `5.1.0`) | `^5 \|\| ^6` | `^8.1` | +| `v4` | `4.x` | `^4 \|\| ^5` | `^7.4 \|\| ^8.0` | + +`main` is the maintained line and supports every Silverstripe version this module still targets. +Silverstripe 4 reached end of life in April 2025 and is no longer supported or tested here; projects +still on it can stay on the `4.x` tags, which remain available. The `5.0.x` tags declared Silverstripe 6 +only, and could not be installed from Packagist (see [CHANGELOG.md](CHANGELOG.md)); use `^5.1`. **Note:** `composer.json` is the source of truth for exact version constraints. @@ -33,6 +60,23 @@ Shortcodable\Shortcodable: --- ``` +### Configuration options + +| Option | Default | What it does | +|--------|---------|--------------| +| `Shortcodable\Shortcodable.shortcodable_classes` | `[]` | Classes to register with the shortcode parser and offer in the dialog. | +| `Shortcodable\Shortcodable.htmleditor_names` | `[cms]` | HTMLEditor configs that get the shortcode button. Only TinyMCE configs are changed; others are skipped. | +| `Shortcodable\Controllers\ShortcodableAdminController.default_placeholder` | see class | Size, font and colours of the default SVG placeholder (`width`, `height`, `full_width`, `full_height`, `font`, `fontsize`, `fg`, `bg`). | + +Per shortcodable class (as `private static` or YAML on that class): + +| Option | Default | What it does | +|--------|---------|--------------| +| `shortcode` | short class name | The shortcode tag, eg `currentyear` for `[currentyear]`. | +| `shortcode_callback` | `parse_shortcode` | Name of the parser method. | +| `shortcode_close_parent` | `false` | Block-level output: close the wrapping element before the shortcode and reopen it after (see below). Also gives the default placeholder the full size. | +| `placeholder_settings` | none | `width`/`height` overriding the default placeholder size for this shortcode. | + ## Required methods on shortcodable objects/classes @@ -44,7 +88,7 @@ Implement these methods on your shortcodable classes (may also be added via an E `public function MyCustomParser(...)` combined with:
`private static $shortcode_callback = 'MyCustomParser'`
-**Parser method arguments:** (see [shortcode documentation](https://docs.silverstripe.org/en/4/developer_guides/extending/shortcodes/#parameter-values))
+**Parser method arguments:** (see [shortcode documentation](https://docs.silverstripe.org/en/6/developer_guides/extending/shortcodes/#parameter-values))
`($attrs, $content=null, $parser=null, $shortcode, $info)` **NOTE: the parser method gets called on a singleton object instance.**
@@ -60,7 +104,7 @@ Implement these methods on your shortcodable classes (may also be added via an E **Wrapping (optional):** In this module, `$shortcodable_is_block` and `$disable_wrapper` have been replaced with `$shortcode_close_parent`:
-- `private static $shortcode_is_block` (optional, set to true if your shortcode output is a block-type html element) +- `private static $shortcode_close_parent` (optional, set to true if your shortcode output is a block-type html element) If you set `$shortcode_close_parent` to true, the parent node will be closed before your shortcode output and reopened after.
Eg `

Some [shortcode] content

` would become `

Some

[shortcode]

content

`; @@ -95,6 +139,31 @@ public function getShortcodePlaceHolder($attributes) } ``` +### Default placeholder image + +Without `getShortcodePlaceHolder()`, the editor shows an SVG generated by `admin/shortcodable/placehold.img` +(CMS access required). It takes `w` and `h` (a positive number of pixels, or `100%`), `bg` and `fg` +(3 or 6 hex digits, no `#`), `ff` (font family), `txtsize` and `txt`. Invalid values fall back to the +`default_placeholder` config. + +## Running the tests + +The module cannot be tested on its own: it needs a host Silverstripe project. Require it there +through a Composer **path repository with `symlink: true`** - `/tests` is `export-ignore`, so a dist +or mirrored install contains no tests - add `"Shortcodable\\Tests\\": "vendor/restruct/silverstripe-shortcodable/tests/"` +to the host's `autoload-dev`, then: + +```bash +# Silverstripe 5 (PHPUnit 9) - the path must come before flush=1 +vendor/bin/phpunit vendor/restruct/silverstripe-shortcodable/tests flush=1 + +# Silverstripe 6 (PHPUnit 11) - a flush=1 argument is ignored, use the env var +SS_PHPUNIT_FLUSH=1 vendor/bin/phpunit vendor/restruct/silverstripe-shortcodable/tests +``` + +On Silverstripe 6 the host also needs `silverstripe/htmleditor-tinymce`, or the TinyMCE button test is +skipped. CI runs the suite against Silverstripe 5 and 6 on every push; see `.github/workflows/ci.yml`. + ## Status - [x] TinyMCE button/plugin @@ -111,5 +180,5 @@ public function getShortcodePlaceHolder($attributes) ## Refs: -- [General shortcode documentation](https://docs.silverstripe.org/en/4/developer_guides/extending/shortcodes/) +- [General shortcode documentation](https://docs.silverstripe.org/en/6/developer_guides/extending/shortcodes/) - [Shortcodable V2 documentation](https://github.com/sheadawson/silverstripe-shortcodable/blob/e2e2f1a2fa981d56e3c8ba63808fbe05da3d20f0/README.md) diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..e6e59d4 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,34 @@ +# Upgrading + +## To 5.1 + +5.1 supports Silverstripe 5 and 6 from one line. The public API (`Shortcodable::register_class()`, +the `shortcodable_classes` config, and the methods a shortcodable class implements) is unchanged. + +### From 4.x on Silverstripe 5 + +Change the constraint from `^4` (or `~4.0`) to `^5.1`. Nothing else is required: simpler stays on its +`0.x` line (`~0.2`), and the dialog is loaded the same way as on 4.0.16. + +Differences you may notice: + +- The editor stylesheet `client/dist/styles/editor.css` is now added to TinyMCE. It keeps shortcode + placeholder images inline where a theme's editor styles make images block-level. +- The shortcode insert/replace code is the newer TinyMCE 6 selection handling from the 5.0.x line. +- `admin/shortcodable/placehold.img` now rejects invalid `w`, `h`, `bg`, `fg` values and uses the + `default_placeholder` values instead. If you call it yourself, pass sizes as whole pixels or `100%` + and colours as hex without `#`. + +### On Silverstripe 6 + +On Silverstripe 6 this module requires restruct/silverstripe-simpler 1.0.0 or later. + +Require TinyMCE, which Silverstripe 6 ships as a separate module and `recipe-cms` does not include: + +```bash +composer require silverstripe/htmleditor-tinymce +``` + +Without it the site boots, but no shortcode button is added because there is no TinyMCE editor. + +If you were on 5.0.x through a VCS or path repository, switch to `^5.1` from Packagist. diff --git a/_config.php b/_config.php index 6d5d074..b7b3605 100644 --- a/_config.php +++ b/_config.php @@ -3,7 +3,8 @@ use Shortcodable\Shortcodable; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Manifest\ModuleLoader; -use SilverStripe\TinyMCE\TinyMCEConfig; +use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; +//use SilverStripe\TinyMCE\TinyMCEConfig; // Register classes added via yml config. // NOTE: Shortcodes must be registered via YAML config (not programmatically in _config.php) @@ -21,7 +22,19 @@ $sc_placeholder_codes = array_keys($sc_placeholder_code_class_map); foreach ($htmlEditorNames as $htmlEditorName) { - TinyMCEConfig::get($htmlEditorName) + # HTMLEditorConfig::get() exists in framework on both Silverstripe 5 and 6 and returns whatever + # config class is configured for that editor. TinyMCEConfig itself moved on Silverstripe 6 + # (SilverStripe\Forms\HTMLEditor -> SilverStripe\TinyMCE, in the separate + # silverstripe/htmleditor-tinymce module), so naming it directly fatals on one major or the other. +// TinyMCEConfig::get($htmlEditorName) + $editorConfig = HTMLEditorConfig::get($htmlEditorName); + # Only a TinyMCE config has enablePlugins()/addButtonsToLine(). On Silverstripe 6 without + # silverstripe/htmleditor-tinymce installed there is no TinyMCE at all, and calling these on the + # framework's plain config would fatal every request - so skip rather than break the site. + if (!Shortcodable::is_tinymce_config($editorConfig)) { + continue; + } + $editorConfig ->enablePlugins([ 'shortcodable' => $shortcodableModuleResourceLoader->getResource('client/dist/js/editor_plugin.js'), ]) diff --git a/_config/config.yml b/_config/config.yml index 898746b..064087d 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -2,10 +2,18 @@ Name: shortcodable --- SilverStripe\Admin\LeftAndMain: + # Require AdminExtension for Vue import map + modal (simpler-silverstripe.js auto-loaded by module) + # The shortcode dialog is simpler's modal (simpler.modal), which simpler only loads when + # simpler_include_modal is set on its AdminExtension; simpler-silverstripe.js alone does not provide it. + # Same config on simpler 0.2+/0.3 (Silverstripe 5) and simpler main (Silverstripe 6). + extensions: + - Restruct\Silverstripe\Simpler\AdminExtension + simpler_include_modal: true extra_requirements_javascript: - 'restruct/silverstripe-shortcodable:client/dist/js/editor_plugin.js' - 'restruct/silverstripe-shortcodable:client/dist/js/shortcodable.js' # Require simpler object & jQuery/BootstrapJS/VueJS from SimplerSilverstripe module + # (simpler's own config requires this file too; Requirements de-duplicates by path, so listing it here is harmless) - 'restruct/silverstripe-simpler:client/dist/js/simpler-silverstripe.js' extra_requirements_css: - 'restruct/silverstripe-shortcodable:client/dist/styles/shortcodable.css' @@ -14,10 +22,6 @@ Shortcodable\Shortcodable: htmleditor_names: - cms -SilverStripe\TinyMCE\TinyMCEConfig: - editor_css: - - 'restruct/silverstripe-shortcodable:client/dist/styles/editor.css' - SilverStripe\Control\Director: rules: 'admin/shortcodable': Shortcodable\Controllers\ShortcodableAdminController @@ -26,3 +30,21 @@ SilverStripe\View\Parsers\ShortcodeParser: extensions: - Shortcodable\Extensions\ShortcodeParserExtension +# Placeholder styling inside the editor. TinyMCEConfig lives in a different namespace per Silverstripe +# major, so the editor_css entry is declared once per class, each guarded by classexists. +--- +Name: shortcodable-editorcss-ss6 +Only: + classexists: 'SilverStripe\TinyMCE\TinyMCEConfig' +--- +SilverStripe\TinyMCE\TinyMCEConfig: + editor_css: + - 'restruct/silverstripe-shortcodable:client/dist/styles/editor.css' +--- +Name: shortcodable-editorcss-ss5 +Only: + classexists: 'SilverStripe\Forms\HTMLEditor\TinyMCEConfig' +--- +SilverStripe\Forms\HTMLEditor\TinyMCEConfig: + editor_css: + - 'restruct/silverstripe-shortcodable:client/dist/styles/editor.css' diff --git a/composer.json b/composer.json index c526cd3..70cdecb 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,17 @@ } ], "require": { - "silverstripe/vendor-plugin": ">=1.0", - "silverstripe/framework": "~6", - "restruct/silverstripe-simpler": "~1.0" + "php": "^8.1", + "silverstripe/vendor-plugin": "^2 || ^3", + "silverstripe/framework": "^5 || ^6", + "silverstripe/admin": "^2 || ^3", + "restruct/silverstripe-simpler": "~0.2 || ^1" + }, + "require-dev": { + "silverstripe/recipe-testing": "^3 || ^4" + }, + "suggest": { + "silverstripe/htmleditor-tinymce": "Required on Silverstripe 6: TinyMCE is a separate module there, and without it the shortcode button is not added to any editor" }, "replace": { "sheadawson/silverstripe-shortcodable": "*" @@ -30,9 +38,20 @@ "Shortcodable\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Shortcodable\\Tests\\": "tests/" + } + }, "extra": { "expose": [ "client/dist" ] - } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/restruct" + } + ] } diff --git a/src/Controllers/ShortcodableAdminController.php b/src/Controllers/ShortcodableAdminController.php index 4d5dda5..162d61e 100644 --- a/src/Controllers/ShortcodableAdminController.php +++ b/src/Controllers/ShortcodableAdminController.php @@ -191,24 +191,55 @@ public function shortcodePlaceHolder($request) * adapted from https://gist.github.com/james2doyle/3aad1d22163c3c3e5cfd * Usage: * + * Every request variable is validated or escaped before it goes into the SVG: the image is served + * same-origin as image/svg+xml, so anything interpolated raw is script in a logged-in CMS session. + * Invalid values fall back to the `default_placeholder` config. + * * @param $request - * @return void + * @return HTTPResponse */ public function shortcodePlaceholderImage() { $req = $this->getRequest(); $defaults = self::config()->get('default_placeholder'); - $w = $req->getVar('w') ?: $defaults['width']; - $h = $req->getVar('h') ?: $defaults['height']; + $w = $this->sanitisePlaceholderSize($req->getVar('w'), $defaults['width']); + $h = $this->sanitisePlaceholderSize($req->getVar('h'), $defaults['height']); $render_w = ($w=='100%' ? $defaults['full_width'] : min($w, $defaults['full_width'])); $render_h = ($h=='100%' ? $defaults['full_height'] : min($h, $defaults['full_height'])); $txtsize = (int) $req->getVar('txtsize') ?: $defaults['fontsize']; - $bg = $req->getVar('bg') ?: $defaults['bg']; - $txtclr = $req->getVar('fg') ?: $defaults['fg']; - $font = str_replace('"', '\'', $req->getVar('ff') ?: $defaults['font'] ); - $txt = $req->getVar('txt') ? htmlentities((string) $req->getVar('txt')): "$w x $h"; + # a negative or zero font size is as invalid as a non-numeric one + if ($txtsize <= 0) { + $txtsize = (int) $defaults['fontsize']; + } + # and capped like w/h are capped at full_width/full_height: text taller than the tallest canvas the + # placeholder can ever be rendered at (full_height) cannot be displayed, so a larger value only + # bloats the SVG with a meaningless number + $txtsize = min($txtsize, (int) $defaults['full_height']); + $bg = $this->sanitisePlaceholderColour($req->getVar('bg'), $defaults['bg']); + $txtclr = $this->sanitisePlaceholderColour($req->getVar('fg'), $defaults['fg']); + # ff and txt are cast to string below: an array (?ff[]=x, ?txt[]=x) would raise 'Array to string + # conversion', so a non-scalar value is treated as invalid input and falls back to the default, + # the same way sanitisePlaceholderSize()/sanitisePlaceholderColour() treat w/h/bg/fg + $ff = $req->getVar('ff'); + if (!is_scalar($ff)) { + $ff = null; + } + $txtVar = $req->getVar('txt'); + if (!is_scalar($txtVar)) { + $txtVar = null; + } +// $font = str_replace('"', '\'', $req->getVar('ff') ?: $defaults['font'] ); + $font = str_replace('"', '\'', $ff ?: $defaults['font'] ); + # font-family is an attribute value: escape everything, quotes included (the default font list + # contains single quotes, which survive as ' and are decoded again by the XML parser) + $font = htmlspecialchars((string) $font, ENT_QUOTES | ENT_XML1, 'UTF-8'); +// $txt = $req->getVar('txt') ? htmlentities((string) $req->getVar('txt')): "$w x $h"; + # htmlspecialchars rather than htmlentities: named HTML entities such as é are not defined in + # XML, so htmlentities() turned any accented character into an SVG parse error +// $txt = htmlspecialchars($req->getVar('txt') ? (string) $req->getVar('txt') : "$w x $h", ENT_QUOTES | ENT_XML1, 'UTF-8'); + $txt = htmlspecialchars($txtVar ? (string) $txtVar : "$w x $h", ENT_QUOTES | ENT_XML1, 'UTF-8'); $svg = " @@ -218,10 +249,50 @@ public function shortcodePlaceholderImage() "; - $this->getResponse() + # Returned rather than output() directly: the framework sends the response, and returning it + # keeps the action usable from tests and from anything that post-processes the response. +// $this->getResponse() +// ->addHeader('Content-Type', 'image/svg+xml') +// ->setBody($svg) +// ->output(); + return $this->getResponse() ->addHeader('Content-Type', 'image/svg+xml') - ->setBody($svg) - ->output(); + ->setBody($svg); + } + + /** + * A placeholder dimension: a positive whole number of pixels, or '100%'. Anything else yields the default. + * + * @param mixed $value raw request variable + * @param int|string $default + * @return int|string + */ + protected function sanitisePlaceholderSize($value, $default) + { + if ($value === '100%') { + return $value; + } + if (is_scalar($value) && ctype_digit((string) $value) && (int) $value > 0) { + return (int) $value; + } + + return $default; + } + + /** + * A placeholder colour: 3 or 6 hex digits, without the leading '#'. Anything else yields the default. + * + * @param mixed $value raw request variable + * @param string $default + * @return string + */ + protected function sanitisePlaceholderColour($value, $default) + { + if (is_string($value) && preg_match('/^(?:[0-9a-f]{3}){1,2}$/i', $value)) { + return $value; + } + + return $default; } } diff --git a/src/Shortcodable.php b/src/Shortcodable.php index b3e1369..5db9502 100644 --- a/src/Shortcodable.php +++ b/src/Shortcodable.php @@ -109,6 +109,29 @@ public static function get_shortcodable_classes_with_placeholders() return $placeholderClasses; } + /** + * Whether an editor config is a TinyMCE config, i.e. one the shortcodable plugin and button can be added to. + * + * Checked by class NAME on purpose: TinyMCEConfig lives in framework on Silverstripe 5 + * (SilverStripe\Forms\HTMLEditor\TinyMCEConfig) and in the separate silverstripe/htmleditor-tinymce + * module on Silverstripe 6 (SilverStripe\TinyMCE\TinyMCEConfig), so at most one of the two exists in + * any install. `instanceof` against a class that does not exist is simply false - it does not autoload + * and does not error - which is what makes this safe on both majors. + * + * @param object|null $editorConfig typically the result of HTMLEditorConfig::get($identifier) + * @return bool + */ + public static function is_tinymce_config($editorConfig) + { + foreach (['SilverStripe\\TinyMCE\\TinyMCEConfig', 'SilverStripe\\Forms\\HTMLEditor\\TinyMCEConfig'] as $tinyMceConfigClass) { + if ($editorConfig instanceof $tinyMceConfigClass) { + return true; + } + } + + return false; + } + public static function get_shortcodable_sc_class_map() { $shortcodableInfo = static::shortcode_class_info(); diff --git a/tests/ShortcodableAdminControllerTest.php b/tests/ShortcodableAdminControllerTest.php new file mode 100644 index 0000000..f939676 --- /dev/null +++ b/tests/ShortcodableAdminControllerTest.php @@ -0,0 +1,323 @@ + SimpleShortcode::class, + 'CustomCallbackShortcode' => CustomCallbackShortcode::class, + 'sc_block' => BlockShortcode::class, + 'sc_placeholder' => PlaceholderShortcode::class, + 'sc_sized' => SizedShortcode::class, + 'sc_record' => ShortcodableRecord::class, + ]; + + protected function setUp(): void + { + parent::setUp(); + Config::modify()->set(Shortcodable::class, 'shortcodable_classes', array_values(self::SHORTCODABLE_CLASSES)); + // Mirror what _config.php does at boot for YAML-configured classes. It matters: the placeholder + // endpoint reads attributes through ShortcodeParser::extractTags(), which drops tags that have no + // handler registered. + Shortcodable::register_classes(array_values(self::SHORTCODABLE_CLASSES)); + } + + protected function tearDown(): void + { + foreach (array_keys(self::SHORTCODABLE_CLASSES) as $tag) { + ShortcodeParser::get()->unregister($tag); + } + parent::tearDown(); + } + + private function logInAsCmsUser(): void + { + $this->logInWithPermission('CMS_ACCESS_LeftAndMain'); + } + + /** + * The SVG action as a client receives it. Up to 5.0.x it echoed the response itself via output() + * and returned nothing, so the body can arrive either way; capture both so this helper does not + * depend on which. + */ + private function fetchPlaceholderImage(array $query): array + { + ob_start(); + $response = $this->get('admin/shortcodable/placehold.img?' . http_build_query($query)); + $echoed = ob_get_clean(); + + return [$response, (string) $response->getBody() . $echoed]; + } + + public function testEndpointsRequireCmsAccess() + { + // Not logged in: neither the dialog nor the placeholder image may be served + $this->autoFollowRedirection = false; + foreach (['admin/shortcodable', 'admin/shortcodable/placehold.img?txt=x', 'admin/shortcodable/placeholder/sc_simple'] as $url) { + ob_start(); + $response = $this->get($url); + $echoed = ob_get_clean(); + $this->assertNotSame(200, $response->getStatusCode(), "$url answered 200 without login"); + $this->assertStringNotContainsString('getBody(), $url); + } + } + + public function testDialogListsEveryShortcodeByLabel() + { + $this->logInAsCmsUser(); + $response = $this->post('admin/shortcodable', ['ShortcodeType' => '']); + + $this->assertSame(200, $response->getStatusCode()); + $body = $response->getBody(); + $this->assertStringContainsString('name="ShortcodeType"', $body); + $this->assertStringContainsString('Simple test shortcode', $body); + $this->assertStringContainsString('CustomCallbackShortcode', $body); + $this->assertStringContainsString('Test record', $body); + // No type chosen yet: nothing to insert + $this->assertStringNotContainsString('action_insert', $body); + } + + public function testDialogForAChosenShortcodeShowsItsAttributeFieldsAndInsertAction() + { + $this->logInAsCmsUser(); + $response = $this->post('admin/shortcodable', ['ShortcodeType' => 'sc_simple', 'colour' => 'teal']); + + $body = $response->getBody(); + $this->assertStringContainsString('Simple test shortcode', $body); + // getShortcodeFields() field, loaded from the posted values + $this->assertMatchesRegularExpression('/]+name="colour"[^>]+value="teal"/', $body); + $this->assertStringContainsString('action_insert', $body); + } + + public function testDialogForADataObjectShortcodeOffersItsRecords() + { + $record = ShortcodableRecord::create(['Title' => 'Pick me']); + $record->write(); + + $this->logInAsCmsUser(); + $response = $this->post('admin/shortcodable', ['ShortcodeType' => 'sc_record']); + + $body = $response->getBody(); + $this->assertStringContainsString('name="id"', $body); + $this->assertMatchesRegularExpression('/]+value="' . $record->ID . '"[^>]*>\s*Pick me/', $body); + } + + public function testPlaceholderUsesTheClassOwnPlaceholderWithParsedAttributes() + { + $this->logInAsCmsUser(); + $response = $this->get('admin/shortcodable/placeholder/sc_placeholder?' . http_build_query([ + 'sc' => '[sc_placeholder foo="bar"]', + ])); + + $this->assertSame('PH:{"foo":"bar"}', $response->getBody()); + } + + public function testPlaceholderDefaultsToAnSvgSizedToTheShortcodeText() + { + $this->logInAsCmsUser(); + $this->autoFollowRedirection = false; + $response = $this->get('admin/shortcodable/placeholder/sc_simple?sc=' . urlencode('[sc_simple]')); + + $this->assertSame(302, $response->getStatusCode()); + $query = $this->redirectQuery($response); + // 60 + floor(fontsize 16 / 1.8 * strlen('[sc_simple]') = 11) + $this->assertEquals(60 + floor(16 / 1.8 * 11), $query['w']); + $this->assertEquals(46, $query['h']); + $this->assertSame('[sc_simple]', $query['txt']); + } + + public function testBlockShortcodePlaceholderIsFullSize() + { + $this->logInAsCmsUser(); + $this->autoFollowRedirection = false; + $query = $this->redirectQuery($this->get('admin/shortcodable/placeholder/sc_block?sc=' . urlencode('[sc_block]'))); + + $this->assertEquals(1000, $query['w']); + $this->assertEquals(460, $query['h']); + } + + public function testPlaceholderSettingsConfigOverridesTheSize() + { + $this->logInAsCmsUser(); + $this->autoFollowRedirection = false; + $query = $this->redirectQuery($this->get('admin/shortcodable/placeholder/sc_sized?sc=' . urlencode('[sc_sized]'))); + + $this->assertEquals(321, $query['w']); + $this->assertEquals(123, $query['h']); + } + + public function testDefaultPlaceholderConfigChangesTheSize() + { + Config::modify()->merge(ShortcodableAdminController::class, 'default_placeholder', [ + 'height' => 77, + ]); + $this->logInAsCmsUser(); + $this->autoFollowRedirection = false; + $query = $this->redirectQuery($this->get('admin/shortcodable/placeholder/sc_simple?sc=' . urlencode('[sc_simple]'))); + + $this->assertEquals(77, $query['h']); + } + + public function testPlaceholderForAnUnknownShortcodeIsEmpty() + { + $this->logInAsCmsUser(); + $this->autoFollowRedirection = false; + $response = $this->get('admin/shortcodable/placeholder/not_registered?sc=' . urlencode('[not_registered]')); + + $this->assertNotSame(302, $response->getStatusCode()); + $this->assertSame('', (string) $response->getBody()); + } + + public function testPlaceholderImageRendersTheRequestedSvg() + { + $this->logInAsCmsUser(); + [$response, $svg] = $this->fetchPlaceholderImage(['w' => 300, 'h' => 50, 'txt' => '[sc_simple]', 'bg' => 'bada55']); + + $this->assertStringContainsString('assertStringContainsString('width="300"', $svg); + $this->assertStringContainsString('height="50"', $svg); + $this->assertStringContainsString('fill="#bada55"', $svg); + $this->assertStringContainsString('>[sc_simple]', $svg); + } + + public function testPlaceholderImageWithNonAsciiTextIsWellFormedXml() + { + // htmlentities() produced HTML named entities (é), which XML does not define, so the + // SVG failed to parse and the editor showed a broken image + $this->logInAsCmsUser(); + [, $svg] = $this->fetchPlaceholderImage(['txt' => "[caf\u{e9} title=\"\u{fc}ber\"]"]); + + $this->assertNotFalse(@simplexml_load_string($svg), 'placeholder SVG is not well-formed XML'); + $this->assertStringContainsString("caf\u{e9}", $svg); + } + + public function testPlaceholderImageCapsAndExpandsToTheConfiguredFullSize() + { + $this->logInAsCmsUser(); + [, $svg] = $this->fetchPlaceholderImage(['w' => '100%', 'h' => 99999, 'txt' => 'x']); + + $this->assertStringContainsString('width="1000"', $svg); + $this->assertStringContainsString('height="460"', $svg); + } + + public function testPlaceholderImageCapsAnOversizedWidthAtTheConfiguredFullWidth() + { + // the height cap is covered above; this pins the width cap (min($w, full_width)) on its own + $this->logInAsCmsUser(); + [, $svg] = $this->fetchPlaceholderImage(['w' => 5000, 'h' => 50, 'txt' => 'x']); + + $this->assertStringContainsString('width="1000"', $svg); + $this->assertStringNotContainsString('5000', $svg); + } + + /** + * Regression: every request variable of placehold.img was interpolated into the SVG unescaped + * (only `txt` went through htmlentities, and without ENT_QUOTES on PHP 8.0). The SVG is served + * same-origin as image/svg+xml, so opening a crafted link ran script in a logged-in CMS session. + */ + public function testPlaceholderImageEscapesEveryRequestVariable() + { + $this->logInAsCmsUser(); + $payload = '"/>assertStringContainsString('height="46"', $svg); + $this->assertStringContainsString('fill="#338dc1"', $svg); + $this->assertStringContainsString('fill="#ffffff"', $svg); + } + + public function testPlaceholderImageCapsTheFontSizeAtTheConfiguredFullHeight() + { + $this->logInAsCmsUser(); + [, $svg] = $this->fetchPlaceholderImage(['txtsize' => 999999, 'txt' => 'x']); + + $this->assertStringContainsString('font-size="460"', $svg); + $this->assertStringNotContainsString('999999', $svg); + } + + /** + * Regression: `ff` and `txt` were (string)-cast without a scalar check, so an array request + * variable (?ff[]=x, ?txt[]=x) raised 'Array to string conversion'. It must be treated like any + * other invalid value: fall back to the default. + */ + public function testPlaceholderImageFallsBackToTheDefaultFontForANonScalarFont() + { + $this->logInAsCmsUser(); + [$response, $svg] = $this->fetchPlaceholderImage(['ff' => ['b'], 'txt' => 'x']); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotFalse(@simplexml_load_string($svg), 'placeholder SVG is not well-formed XML'); + $this->assertStringContainsString('font-family="Consolas, ', $svg); + $this->assertStringNotContainsString('Array', $svg); + } + + public function testPlaceholderImageFallsBackToTheSizeTextForANonScalarText() + { + $this->logInAsCmsUser(); + [$response, $svg] = $this->fetchPlaceholderImage(['w' => 300, 'h' => 50, 'txt' => ['a']]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotFalse(@simplexml_load_string($svg), 'placeholder SVG is not well-formed XML'); + // the default text is " x " + $this->assertStringContainsString('>300 x 50', $svg); + $this->assertStringNotContainsString('Array', $svg); + } + + private function redirectQuery(HTTPResponse $response): array + { + $location = (string) $response->getHeader('Location'); + $this->assertStringContainsString('admin/shortcodable/placehold.img?', $location); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + return $query; + } +} diff --git a/tests/ShortcodableTest.php b/tests/ShortcodableTest.php new file mode 100644 index 0000000..6d92dff --- /dev/null +++ b/tests/ShortcodableTest.php @@ -0,0 +1,197 @@ +registeredTags as $tag) { + ShortcodeParser::get()->unregister($tag); + } + $this->registeredTags = []; + parent::tearDown(); + } + + private function register(string $class, string $expectedTag): void + { + $this->registeredTags[] = $expectedTag; + Shortcodable::register_class($class); + } + + public function testRegisterClassUsesConfiguredShortcodeAndDefaultParser() + { + $this->register(SimpleShortcode::class, 'sc_simple'); + + $this->assertArrayHasKey('sc_simple', ShortcodeParser::get()->getRegisteredShortcodes()); + $this->assertSame( + '

SIMPLE{"b":"2","colour":"red"}

', + ShortcodeParser::get()->parse('

[sc_simple colour="red" b="2"]

') + ); + } + + public function testRegisterClassFallsBackToShortClassNameAndUsesConfiguredCallback() + { + $this->register(CustomCallbackShortcode::class, 'CustomCallbackShortcode'); + + $this->assertArrayHasKey('CustomCallbackShortcode', ShortcodeParser::get()->getRegisteredShortcodes()); + // The configured $shortcode_callback, not parse_shortcode, renders it + $this->assertSame( + '

CUSTOM:CustomCallbackShortcode

', + ShortcodeParser::get()->parse('

[CustomCallbackShortcode]

') + ); + } + + public function testRegisterClassIgnoresClassesThatDoNotExist() + { + $before = ShortcodeParser::get()->getRegisteredShortcodes(); + Shortcodable::register_class('Shortcodable\\Tests\\Stub\\DoesNotExist'); + + $this->assertSame($before, ShortcodeParser::get()->getRegisteredShortcodes()); + } + + public function testRegisterClassesRegistersEachClass() + { + $this->registeredTags = ['sc_simple', 'sc_block']; + Shortcodable::register_classes([SimpleShortcode::class, BlockShortcode::class]); + + $registered = ShortcodeParser::get()->getRegisteredShortcodes(); + $this->assertArrayHasKey('sc_simple', $registered); + $this->assertArrayHasKey('sc_block', $registered); + } + + public function testShortcodeClassInfoMapsTagsToClassesAndLabels() + { + Config::modify()->set(Shortcodable::class, 'shortcodable_classes', [ + SimpleShortcode::class, + CustomCallbackShortcode::class, + ShortcodableRecord::class, + ]); + + $info = Shortcodable::shortcode_class_info(); + + $this->assertSame([ + 'sc_simple' => SimpleShortcode::class, + 'CustomCallbackShortcode' => CustomCallbackShortcode::class, + 'sc_record' => ShortcodableRecord::class, + ], $info['sc_class_map']); + + $this->assertSame([ + // getShortcodeLabel() wins + 'sc_simple' => 'Simple test shortcode', + // no label method and no singular_name(): short class name + 'CustomCallbackShortcode' => 'CustomCallbackShortcode', + // DataObject without getShortcodeLabel(): singular_name() + 'sc_record' => 'Test record', + ], $info['sc_label_map']); + } + + public function testShortcodeClassInfoIsEmptyWhenNothingIsConfigured() + { + Config::modify()->set(Shortcodable::class, 'shortcodable_classes', []); + + $this->assertSame( + ['sc_class_map' => [], 'sc_label_map' => []], + Shortcodable::shortcode_class_info() + ); + $this->assertSame([], Shortcodable::get_shortcodable_sc_class_map()); + $this->assertSame([], Shortcodable::get_shortcodable_classes_with_placeholders()); + } + + public function testEveryShortcodableClassGetsAPlaceholder() + { + // Since the default SVG placeholder exists, classes without getShortcodePlaceHolder() count too + Config::modify()->set(Shortcodable::class, 'shortcodable_classes', [ + SimpleShortcode::class, + BlockShortcode::class, + ]); + + $this->assertSame( + ['sc_simple' => SimpleShortcode::class, 'sc_block' => BlockShortcode::class], + Shortcodable::get_shortcodable_classes_with_placeholders() + ); + } + + public function testIsTinymceConfigRejectsAnythingElse() + { + $this->assertFalse(Shortcodable::is_tinymce_config(null)); + $this->assertFalse(Shortcodable::is_tinymce_config(new \stdClass())); + } + + public function testShortcodableButtonIsAddedToTheCmsEditor() + { + // _config.php runs at boot against the default `htmleditor_names` config (cms). This asserts + // the result on whichever TinyMCEConfig class this Silverstripe major provides. + // Skip on the presence of a TinyMCE class, NOT on is_tinymce_config(): skipping on the method + // under test would turn a broken detection into a silent skip instead of a failure. + if (!class_exists('SilverStripe\\TinyMCE\\TinyMCEConfig') && !class_exists('SilverStripe\\Forms\\HTMLEditor\\TinyMCEConfig')) { + $this->markTestSkipped('No TinyMCE installed (Silverstripe 6 without silverstripe/htmleditor-tinymce)'); + } + $config = HTMLEditorConfig::get('cms'); + $this->assertTrue(Shortcodable::is_tinymce_config($config), get_class($config) . ' not recognised as TinyMCE'); + + $this->assertArrayHasKey('shortcodable', $config->getPlugins()); + $this->assertStringContainsString( + 'client/dist/js/editor_plugin.js', + (string) $config->getPlugins()['shortcodable'] + ); + // addButtonsToLine() stores the string as given, separator included + $this->assertContains('| shortcodable', $config->getButtons()[1]); + $this->assertIsArray($config->getOption('shortcodable_placeholder_codes')); + } + + public function testEditorCssIsConfiguredOnThisMajorsTinymceConfig() + { + // _config/config.yml declares editor_css once per TinyMCEConfig namespace, each behind a + // classexists guard. Assert the entry landed on the class this major actually provides. + if (class_exists('SilverStripe\\TinyMCE\\TinyMCEConfig')) { + $class = 'SilverStripe\\TinyMCE\\TinyMCEConfig'; + } elseif (class_exists('SilverStripe\\Forms\\HTMLEditor\\TinyMCEConfig')) { + $class = 'SilverStripe\\Forms\\HTMLEditor\\TinyMCEConfig'; + } else { + $this->markTestSkipped('No TinyMCE installed (Silverstripe 6 without silverstripe/htmleditor-tinymce)'); + } + + $this->assertContains( + 'restruct/silverstripe-shortcodable:client/dist/styles/editor.css', + (array) Config::inst()->get($class, 'editor_css'), + "$class editor_css does not include the module's editor.css" + ); + } + + public function testLeftAndMainLoadsSimplerWithItsModal() + { + // The shortcode dialog is simpler's modal, which simpler only loads when its AdminExtension is + // applied AND simpler_include_modal is set + $this->assertTrue( + LeftAndMain::has_extension('Restruct\\Silverstripe\\Simpler\\AdminExtension'), + 'LeftAndMain does not have simpler\'s AdminExtension' + ); + $this->assertTrue(Config::inst()->get(LeftAndMain::class, 'simpler_include_modal')); + } +} diff --git a/tests/ShortcodeParserExtensionTest.php b/tests/ShortcodeParserExtensionTest.php new file mode 100644 index 0000000..e2b32e2 --- /dev/null +++ b/tests/ShortcodeParserExtensionTest.php @@ -0,0 +1,75 @@ +set(Shortcodable::class, 'shortcodable_classes', [ + SimpleShortcode::class, + BlockShortcode::class, + ]); + Shortcodable::register_classes([SimpleShortcode::class, BlockShortcode::class]); + } + + protected function tearDown(): void + { + ShortcodeParser::get()->unregister('sc_simple'); + ShortcodeParser::get()->unregister('sc_block'); + parent::tearDown(); + } + + public function testBlockShortcodeClosesAndReopensItsParentElement() + { + $this->assertSame( + '

Some

B

content

', + ShortcodeParser::get()->parse('

Some [sc_block] content

') + ); + } + + public function testEmptyParentElementsLeftBehindAreRemoved() + { + // Shortcode at the very start of the paragraph: no empty

may be left before it + $this->assertSame( + '
B

content

', + ShortcodeParser::get()->parse('

[sc_block] content

') + ); + } + + public function testInlineShortcodeStaysInsideItsParentElement() + { + $this->assertSame( + '

Some SIMPLE[] content

', + ShortcodeParser::get()->parse('

Some [sc_simple] content

') + ); + } + + public function testParserInstanceWithoutTheShortcodeRegisteredIsLeftAlone() + { + // A separate parser instance (like the 'regenerator' one) that does not know sc_block + $otherParser = ShortcodeParser::get('shortcodable-test-other'); + $this->assertArrayNotHasKey('sc_block', $otherParser->getRegisteredShortcodes()); + + $this->assertSame( + '

Some [sc_block] content

', + $otherParser->parse('

Some [sc_block] content

') + ); + } +} diff --git a/tests/Stub/BlockShortcode.php b/tests/Stub/BlockShortcode.php new file mode 100644 index 0000000..a214009 --- /dev/null +++ b/tests/Stub/BlockShortcode.php @@ -0,0 +1,28 @@ +B'; + } +} diff --git a/tests/Stub/CustomCallbackShortcode.php b/tests/Stub/CustomCallbackShortcode.php new file mode 100644 index 0000000..3ada1af --- /dev/null +++ b/tests/Stub/CustomCallbackShortcode.php @@ -0,0 +1,26 @@ + 'Varchar', + ]; + + public static function parse_shortcode($attrs, $content = null, $parser = null, $shortcode = null, $info = []) + { + return 'RECORD'; + } +} diff --git a/tests/Stub/SimpleShortcode.php b/tests/Stub/SimpleShortcode.php new file mode 100644 index 0000000..0a0c93e --- /dev/null +++ b/tests/Stub/SimpleShortcode.php @@ -0,0 +1,41 @@ + 321, + 'height' => 123, + ]; + + public static function parse_shortcode($attrs, $content = null, $parser = null, $shortcode = null, $info = []) + { + return 'SIZED'; + } +}