From f62f461dbf16b1e03223f8a144370e4b098a75b4 Mon Sep 17 00:00:00 2001 From: Kevin Date: Sat, 31 Oct 2026 12:36:41 -0700 Subject: [PATCH 01/10] feat(migration): convert shortcodes to blocks or static HTML, with preview, batches, rollback and an assets companion Every supported shortcode now has a tested destination (docs/migration.md): grid -> Columns, standalone buttons -> Buttons, lead -> Paragraph, wells and alerts -> Custom HTML (self-contained) or Group (classes), collapse and tabs -> Details sections (name-grouped), labels/tooltips/inline buttons -> inline HTML, icons -> inline SVG extracted from the bundled Glyphicons font. Output is block markup (round-trips losslessly through core's parser so the editor never flags it) or plain HTML for classic posts; styling is self-contained by default or Bootstrap-classes-only. Unbalanced tags, escaped [[bs_x]] and unknown bs_ tags are left in place and listed; dropped behaviour (alert dismiss, tooltip placement, grid offsets, tabs -> sections) is reported as notes stored with the post. Tools page gains: per-post preview with a wp_text_diff() diff and a stale-check on apply; migrate one / selected / in AJAX batches (no-JS fallback); originals kept in _bs_migration_original (never overwritten) plus the usual revision; roll back selected; a migrated-posts list. Companion: companion/bootstrap-legacy-assets is a minimal separate plugin (CSS, JS, Glyphicons, tooltip init; three switches; no shortcodes) that stays idle while this plugin loads assets and skips files a theme already registers. The migration page builds its zip from this plugin's files and installs it through core's Plugin_Upgrader only after an explicit consent checkbox, or offers the zip for manual upload where PHP cannot write to the plugins directory. Verified end to end in specs/migration.spec.js: scan, preview, migrate, roll back (byte-identical), batch, re-scan clean, companion install, deactivate Bootstrap Shortcodes, then columns/buttons/wells/labels/svg icons/lead/details (click + keyboard, exclusive group)/tab sections/ native tooltips on desktop and a 390px viewport, with and without the companion. release-it now also bumps BS_SHORTCODES_VERSION. Co-Authored-By: Claude Fable 5.1 --- .release-it.json | 3 +- ROADMAP.md | 14 +- bootstrap-shortcodes.php | 6 +- .../bootstrap-legacy-assets.php | 138 ++++ companion/bootstrap-legacy-assets/readme.txt | 24 + docs/compatibility.md | 4 +- docs/migration.md | 122 +++ docs/privacy.md | 11 +- inc/class-bs-admin-migration.php | 437 +++++++++++ inc/class-bs-admin-tools.php | 14 +- inc/class-bs-companion.php | 126 ++++ inc/class-bs-migration.php | 149 ++++ inc/class-bs-migrator.php | 695 ++++++++++++++++++ js/admin-tools.js | 103 +++ readme.txt | 6 + specs/migration.spec.js | 222 ++++++ tests/phpunit/AdminMigrationTest.php | 52 ++ tests/phpunit/CompanionTest.php | 97 +++ tests/phpunit/MigrationApplyTest.php | 145 ++++ tests/phpunit/MigratorConvertTest.php | 283 +++++++ 20 files changed, 2634 insertions(+), 17 deletions(-) create mode 100644 companion/bootstrap-legacy-assets/bootstrap-legacy-assets.php create mode 100644 companion/bootstrap-legacy-assets/readme.txt create mode 100644 docs/migration.md create mode 100644 inc/class-bs-admin-migration.php create mode 100644 inc/class-bs-companion.php create mode 100644 inc/class-bs-migration.php create mode 100644 inc/class-bs-migrator.php create mode 100644 specs/migration.spec.js create mode 100644 tests/phpunit/AdminMigrationTest.php create mode 100644 tests/phpunit/CompanionTest.php create mode 100644 tests/phpunit/MigrationApplyTest.php create mode 100644 tests/phpunit/MigratorConvertTest.php diff --git a/.release-it.json b/.release-it.json index 527929b..dd5b888 100644 --- a/.release-it.json +++ b/.release-it.json @@ -20,8 +20,9 @@ "after:bump": [ "sed -i.bak -e \"s/Stable tag: .*/Stable tag: ${version}/\" readme.txt", "sed -i.bak -e \"s/Version: .*/Version: ${version}/\" bootstrap-shortcodes.php", + "sed -i.bak -e \"s/define( 'BS_SHORTCODES_VERSION', '[^']*' )/define( 'BS_SHORTCODES_VERSION', '${version}' )/\" bootstrap-shortcodes.php", "rm -f readme.txt.bak bootstrap-shortcodes.php.bak", "git add readme.txt bootstrap-shortcodes.php" ] } -} \ No newline at end of file +} diff --git a/ROADMAP.md b/ROADMAP.md index b43133f..2670e61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap: a safe landing for Bootstrap Shortcodes -Status: living document. Last updated 2026-10-31. +Status: living document. Last updated 2026-10-31 (all three phases implemented locally; PRs pending push approval). This plugin is in **maintenance mode**. It is kept safe and working for the sites that already depend on it; it is not being grown into a page builder or a Bootstrap @@ -89,12 +89,12 @@ string search, never unserialize-and-rewrite). Branch `feat/migration`. Acceptance criteria: -- [ ] Component-by-component matrix in `docs/migration.md` (destination, WP version, preserved content/interaction, visual differences, retained dependency, fallback, limitations). -- [ ] Converter: shortcode text to block markup or reviewed static HTML, preserving unrelated markup and third-party shortcodes; unknown/malformed/ambiguous occurrences are left in place and listed for manual review. -- [ ] Preview, migrate selected, migrate in batches, review outstanding; explicit confirmation, nonces, capability checks. -- [ ] Originals stored in post meta (not only revisions) with a tested rollback; stale-content protection via `post_modified_gmt` check; bounded, resumable, safe to retry. -- [ ] Legacy assets handoff: a separate, minimal companion plugin ("Bootstrap 3 Legacy Assets") shipped in this repo, installable from the migration page only with explicit consent; works with this plugin deactivated and deleted; avoids double-loading with theme Bootstrap; documents what retaining Bootstrap 3 means. -- [ ] Verified by deactivating this plugin in wp-env after migration: desktop/mobile layouts, keyboard, tabs, collapse, icons, tooltips. +- [x] Component-by-component matrix in `docs/migration.md` (destination, WP version, preserved content/interaction, visual differences, retained dependency, fallback, limitations). +- [x] Converter: shortcode text to block markup or reviewed static HTML, preserving unrelated markup and third-party shortcodes; unknown/malformed/ambiguous occurrences are left in place and listed for manual review. +- [x] Preview, migrate selected, migrate in batches, review outstanding; explicit confirmation, nonces, capability checks. +- [x] Originals stored in post meta (not only revisions) with a tested rollback; stale-content protection via `post_modified_gmt` check; bounded, resumable, safe to retry. +- [x] Legacy assets handoff: a separate, minimal companion plugin ("Bootstrap 3 Legacy Assets") shipped in this repo, installable from the migration page only with explicit consent; works with this plugin deactivated and deleted; avoids double-loading with theme Bootstrap; documents what retaining Bootstrap 3 means. +- [x] Verified by deactivating this plugin in wp-env after migration: desktop/mobile layouts, keyboard, tabs, collapse, icons, tooltips. ## Decisions diff --git a/bootstrap-shortcodes.php b/bootstrap-shortcodes.php index 5c5512d..8a218c9 100644 --- a/bootstrap-shortcodes.php +++ b/bootstrap-shortcodes.php @@ -26,7 +26,11 @@ require_once( 'inc/bs_lead.php' ); require_once( 'inc/bs_tooltip.php' ); require_once( 'inc/class-bs-scanner.php' ); +require_once( 'inc/class-bs-migrator.php' ); +require_once( 'inc/class-bs-migration.php' ); +require_once( 'inc/class-bs-companion.php' ); require_once( 'inc/class-bs-admin-tools.php' ); +require_once( 'inc/class-bs-admin-migration.php' ); require_once( 'inc/class-bs-cli.php' ); class BootstrapShortcodes{ @@ -302,5 +306,5 @@ function dw_render_form() { $bscodes = new BootstrapShortcodes(); if ( is_admin() ) { - $bs_admin_tools = new BS_Admin_Tools(); + $bs_admin_tools = new BS_Admin_Tools( new BS_Admin_Migration() ); } diff --git a/companion/bootstrap-legacy-assets/bootstrap-legacy-assets.php b/companion/bootstrap-legacy-assets/bootstrap-legacy-assets.php new file mode 100644 index 0000000..94f05e3 --- /dev/null +++ b/companion/bootstrap-legacy-assets/bootstrap-legacy-assets.php @@ -0,0 +1,138 @@ + '1', 'js' => '1', 'tooltips' => '1' ); + } + + public static function options() { + $o = get_option( self::OPTION ); + return is_array( $o ) ? $o : self::defaults(); + } + + public function __construct() { + register_activation_hook( __FILE__, array( __CLASS__, 'activate' ) ); + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ), 20 ); + add_action( 'admin_init', array( $this, 'register_settings' ) ); + add_action( 'admin_menu', array( $this, 'menu' ) ); + add_action( 'admin_notices', array( $this, 'notices' ) ); + } + + public static function activate() { + if ( false === get_option( self::OPTION ) ) { + add_option( self::OPTION, self::defaults() ); + } + } + + /** True when Bootstrap Shortcodes is active and still loading its own copy. */ + public static function original_plugin_loads_assets() { + if ( ! class_exists( 'BootstrapShortcodes' ) ) { + return false; + } + $o = get_option( 'bs_options' ); + return is_array( $o ) && ( ! empty( $o['chk_default_options_css'] ) || ! empty( $o['chk_default_options_js'] ) ); + } + + /** A theme or another plugin already registered a Bootstrap file of this kind. */ + public static function other_bootstrap( $kind ) { + $deps = 'css' === $kind ? wp_styles() : wp_scripts(); + foreach ( $deps->registered as $handle => $dep ) { + if ( 0 === strpos( $handle, 'bs-legacy' ) || empty( $dep->src ) ) { + continue; + } + if ( preg_match( '#bootstrap(\.bundle)?(\.min)?\.(css|js)(\?|$)#i', $dep->src ) ) { + return $handle; + } + } + return ''; + } + + public function enqueue() { + if ( self::original_plugin_loads_assets() ) { + return; // avoid double loading while the original is still active + } + $o = self::options(); + $base = plugins_url( '', __FILE__ ); + if ( ! empty( $o['css'] ) && ! self::other_bootstrap( 'css' ) ) { + wp_enqueue_style( 'bs-legacy-bootstrap', $base . '/css/bootstrap.css', array(), '3.4.1' ); + } + if ( ! empty( $o['js'] ) && ! self::other_bootstrap( 'js' ) ) { + wp_enqueue_script( 'bs-legacy-bootstrap', $base . '/js/bootstrap.js', array( 'jquery' ), '3.4.1' ); + } + if ( ! empty( $o['tooltips'] ) ) { + wp_enqueue_script( 'bs-legacy-init', $base . '/js/init.js', array( 'jquery' ), self::VERSION ); + } + } + + public function register_settings() { + register_setting( 'bs_legacy_assets', self::OPTION, array( 'sanitize_callback' => array( __CLASS__, 'sanitize' ) ) ); + } + + public static function sanitize( $input ) { + $clean = array(); + foreach ( array_keys( self::defaults() ) as $key ) { + if ( is_array( $input ) && ! empty( $input[ $key ] ) ) { + $clean[ $key ] = '1'; + } + } + return $clean; + } + + public function menu() { + add_options_page( 'Legacy Bootstrap', 'Legacy Bootstrap', 'manage_options', 'bs-legacy-assets', array( $this, 'page' ) ); + } + + public function notices() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + if ( self::original_plugin_loads_assets() ) { + echo '

' . esc_html__( 'Bootstrap 3 Legacy Assets is waiting: the Bootstrap Shortcodes plugin is still loading its own Bootstrap files. Deactivate Bootstrap Shortcodes (or turn off its two asset settings) and this plugin takes over.', 'bs-legacy-assets' ) . '

'; + } + } + + public function page() { + $o = self::options(); + ?> +
+

Bootstrap 3 Legacy Assets

+

+

+
+ + + + + +
+

+ +
+
+ Legacy Bootstrap turn the CSS, the JavaScript and the tooltip initialiser on or off. + +**Bootstrap 3 is end-of-life.** 3.4.1 (February 2019) is its last release. Keeping it running is a deliberate choice made for content that still depends on it; the plan should be to migrate that content and deactivate this plugin. + +It avoids double loading: while Bootstrap Shortcodes is active with its asset settings on, this plugin does nothing; if the theme registers its own bootstrap.css or bootstrap.js, the matching file here is skipped. + +== Changelog == + += 1.0.0 = +* First release, built from Bootstrap Shortcodes' bundled assets. diff --git a/docs/compatibility.md b/docs/compatibility.md index d69f939..ca78305 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -20,8 +20,8 @@ itself still supports. | WordPress | PHP | What ran | Result | | --- | --- | --- | --- | -| 7.1.4 | 8.3 | PHPUnit (81 tests), Playwright (17 tests: Classic Editor dialogs, frontend rendering and interaction under plugin-/theme-/no-Bootstrap-JS setups, conditional loading, block-editor Shortcode block) | green, 2026-10-31 | -| 6.4.7 | 7.4 | PHPUnit (81 tests); every component rendered on the frontend with assets present; deactivate/reactivate keeps settings | green, 2026-10-31 | +| 7.1.4 | 8.3 | PHPUnit (151 tests), Playwright (20 tests: Classic Editor dialogs; frontend rendering and interaction under plugin-/theme-/no-Bootstrap-JS setups; conditional loading; block-editor Shortcode block; the Tools report; the full migration run ending with the plugin deactivated and the migrated pages checked on desktop and phone viewports) | green, 2026-10-31 | +| 6.4.7 | 7.4 | PHPUnit (151 tests, including converter, scanner and migration); every component rendered on the frontend with assets present; deactivate/reactivate keeps settings | green, 2026-10-31 | | latest (wp-env default) | 8.2 | PHPUnit + Playwright in GitHub Actions on every PR | see CI | Not tested: multisite, PHP 8.4 specifically (nothing in the plugin is version diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..749d8ce --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,122 @@ +# Migrating off Bootstrap Shortcodes + +The goal is a site that renders the same content with this plugin deactivated and +deleted. There are three steps, all on **Tools > Bootstrap Shortcodes**: + +1. **Scan** to learn what depends on the plugin (see the report's "If you deactivated + this plugin today" and "Not scanned" sections; a clean post scan is not proof). +2. **Migrate** post content: preview one post with a diff, migrate it, or select + posts and migrate in batches. Originals are kept for rollback. +3. **Decide about Bootstrap itself**: either nothing else needs it (self-contained + styling, theme has its own), or install the legacy-assets companion. + +Then deactivate the plugin, check the pages the report listed, and delete it. + +## What the migration does and does not touch + +- Rewrites **post content** only (every post type and status the scan covers). + Widgets, options, custom fields, term descriptions and theme files are reported, + not rewritten: edit those by hand using the same mappings below. +- Reads each post fresh at migration time, so a post edited after the scan is + converted from its current content. A single-post preview is refused if the post + changed after the preview was made. +- Stores the original content in the custom field `_bs_migration_original` (first + migration only; later migrations of the same post never overwrite it), and + `wp_update_post()` records a revision as usual. **Roll back** restores the stored + original and removes the custom field. Rollback does not depend on revisions. +- Leaves in place, and lists: occurrences whose opening and closing tags do not match + (an unclosed `[bs_well]`), escaped `[[bs_x]]` examples, and `bs_` tags the plugin + never registered (typos, other plugins). Nothing in a post is dropped silently; + every approximation is reported as a note in the preview and stored with the post. +- Never runs on activation, update or a schedule. Needs `manage_options`, a nonce + and a button press (or `edit_post` for each post). + +## Output options + +| Option | Values | Meaning | +| --- | --- | --- | +| Output | auto / blocks / HTML | `blocks` writes core block markup (Columns, Buttons, Details, Paragraph, Group, Custom HTML, Classic). `HTML` writes plain markup with no block comments, for classic-editor sites. `auto` picks blocks for posts that already contain blocks, otherwise HTML. | +| Styling | self-contained / Bootstrap classes | `self-contained` adds inline styles (wells, alerts, labels, lead, tooltips) and inline SVG icons so nothing needs Bootstrap CSS. `Bootstrap classes` keeps only the original class names, for sites that keep Bootstrap 3 via the theme or the companion. | + +Block output is checked to round-trip through core's block parser/serializer +losslessly (`MigratorConvertTest::test_block_mode_output_is_what_the_editor_would_save`), +so the editor opens migrated posts without "invalid block" prompts. + +## Component matrix + +WP = minimum WordPress version for the block destination; the HTML destination works +everywhere. "Keeps" lists what is preserved; "Changes" what is visibly or +behaviourally different; "Needs" what the output still depends on. + +| Shortcode | Blocks destination | HTML destination | WP | Keeps | Changes | Needs | +| --- | --- | --- | --- | --- | --- | --- | +| `bs_row` + `bs_col` | Columns > Column, width from the first `col-*-N` class (N/12 as %) | `
` unchanged | 5.0 | content, nesting, column proportions, extra classes (as block className) | responsive breakpoints become the Columns block's own (stack on mobile); `offset`/`push`/`pull` dropped and noted; theme spacing | HTML output needs Bootstrap CSS | +| `bs_button` standalone | Buttons > Button with url/text/target (`rel="noopener noreferrer"` for `_blank`) | `` identical to today | 5.4 | text, link, target | theme button style instead of Bootstrap colours/sizes | HTML output needs Bootstrap CSS | +| `bs_button` inline in a sentence | stays an inline `` (noted) | same | – | everything | – | Bootstrap CSS | +| `bs_well` | self-contained: Custom HTML block with inline well styling; classes: Group block with `well` class | `
` or `
` | 5.0 | content (paragraphs baked in for the HTML block) | editable as HTML rather than blocks (self-contained); small sizing differences | classes variant needs Bootstrap CSS | +| `bs_notification` | same shape as wells with alert colours and `role="alert"` | same | 5.0 | message, type colour | **dismiss (×) button removed** (needs Bootstrap JS), noted | classes variant needs Bootstrap CSS | +| `bs_lead` | Paragraph with `lead` class and the theme's `large` font size | `

` | 5.0 | text | exact size follows the theme | classes variant needs Bootstrap CSS | +| `bs_label` | inline `` with inline colours | same | – | text, type colour | – | classes variant needs Bootstrap CSS | +| `bs_icon` | inline SVG built from the bundled Glyphicons font (``, `currentColor`, 1em) | same | – | the glyph, size relative to text, extra classes | none visible; unknown glyph names keep `` and are noted | classes variant, or unknown glyphs, need the Glyphicons font (Bootstrap CSS) | +| `bs_tooltip` | `` (dotted underline when self-contained) | same | – | the text and the tooltip text (they were always identical) | native browser tooltip: no placement/trigger, none on touch devices; noted | – | +| `bs_collapse` + `bs_citem` | one Details block per item, `name` = group id so one opens at a time (WP 6.6+; ignored earlier), `open` for `open="true"`, element `id` kept so `#anchor` links work | `

` | 6.3 (Details block); HTML works everywhere | titles, content, initial open state, one-at-a-time behaviour, anchor ids | Bootstrap panel chrome gone (theme styles the Details block); keyboard: native `` | – | +| `bs_tabs` + `bs_thead` + `bs_tab` + `bs_dropdown` + `bs_tcontents` + `bs_tcontent` | one Details block per pane, titled by the matching tab (`href="#id"` ↔ `id`), active pane open; dropdown tabs flattened as "Dropdown: Tab" | `
` per pane | 6.3 / any | every pane's content, titles, order, initial pane, anchor ids | **tabs become stacked collapsible sections** (semantic alternative, noted). On WordPress 7.1+ the editor's Tabs block can be built from them by hand if tabs are wanted back | – | + +Attributes the plugin never used (anything not in the table above, such as a +`color=` on a well) are dropped with a note; they never rendered anything. + +Why not the core Accordion (6.9) and Tabs (7.1) blocks directly? Both are +Interactivity-API blocks whose saved markup must match the editor's exactly; hand +written markup that drifts makes the editor flag every migrated post as invalid. +Details is a static block with a stable save format and a `name` attribute, so it is +the reliable automatic destination. Converting Details to Accordion or Tabs is a +manual, per-post editor action. + +## Keeping Bootstrap 3 after removal: the companion + +If the report shows "Still needs: Bootstrap 3 CSS" on migrated posts, or your theme or +hand-written HTML uses Bootstrap 3 classes, or you use `[bs_icon]` names that are not +in the bundled set, the site still needs Bootstrap 3 after this plugin is gone. + +**Bootstrap 3 Legacy Assets** is a separate, minimal plugin (`companion/` in this +repository; the migration page builds its zip from this plugin's own files). It: + +- loads the same Bootstrap 3.4.1 CSS, JS, Glyphicons font and tooltip initialiser + this plugin loads, with three on/off switches (Settings > Legacy Bootstrap); +- registers **no** shortcodes and has no other behaviour; +- stays idle while Bootstrap Shortcodes is active with its asset settings on, and + skips its CSS or JS when the theme registers its own `bootstrap.css` / `bootstrap.js`, + so nothing loads twice; +- is installed only from the migration page after ticking an explicit statement, or + by uploading the downloaded zip yourself. Its settings start as copies of this + plugin's CSS/JS settings. + +Be honest with yourself about what that means: the site no longer depends on +Bootstrap Shortcodes, but it still depends on **end-of-life Bootstrap 3** (see +`docs/security.md`). Treat the companion as a bridge to be removed once the remaining +markup is migrated. + +## Verifying before you delete + +The repository's Playwright suite (`specs/migration.spec.js`) does this end to end +in a test site: scan, preview, migrate, roll back, migrate again in a batch, install +the companion, **deactivate Bootstrap Shortcodes**, and then check on the frontend +that columns, buttons, wells, labels, icons, lead text, collapse sections (open, +close, keyboard), tab sections and tooltips still render and work, on a desktop and +a phone-sized viewport. Do the same on your site with the pages the report listed. + +Checklist: + +- [ ] Scan shows no remaining post content, or only occurrences you have reviewed. +- [ ] "Other surfaces" and "Not scanned" items reviewed by hand. +- [ ] Decided about Bootstrap 3: theme-provided, not needed, or companion installed. +- [ ] Plugin deactivated; listed pages checked on desktop and mobile. +- [ ] Plugin deleted. The custom fields `_bs_migration_original` / `_bs_migration_result` + stay with the posts; delete them when you no longer want rollback. + +## Manual mappings for non-post content + +For widgets, options, custom fields and theme files, use the HTML destinations above +(the plugin's output for each shortcode is in `inc/bs_*.php`). A theme calling +`do_shortcode()` on a custom field will render whatever that field holds; migrate +the field's text with the same rules, or replace the call with the markup directly. diff --git a/docs/privacy.md b/docs/privacy.md index 0695d45..979b4ce 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -76,9 +76,10 @@ shared summaries answer well enough. If that changes, it will be a separate, documented decision, with the same "off by default, inspect before sending" rules, and never enabled by an update. -## Migration (planned, phase 3) +## Migration -Not part of this release. When it ships, migrating content will rewrite your own -posts in your own database after explicit confirmation, keep the original in post -meta (`_bs_migration_original`) for rollback, and send nothing anywhere. Its -documentation (`docs/migration.md`) arrives with it. +Migrating content rewrites your own posts in your own database after explicit +confirmation, and keeps the original in post meta (`_bs_migration_original`, plus a +`_bs_migration_result` summary) for rollback. Nothing is sent anywhere. Those two +custom fields stay with the posts after uninstall so rollback remains possible; delete +them when you no longer want it. See `docs/migration.md`. diff --git a/inc/class-bs-admin-migration.php b/inc/class-bs-admin-migration.php new file mode 100644 index 0000000..65f6e2c --- /dev/null +++ b/inc/class-bs-admin-migration.php @@ -0,0 +1,437 @@ + Bootstrap Shortcodes: options, per-post + * preview with a diff, migrate one / selected / in batches, roll back, and + * the legacy-assets companion hand-off. Every write needs manage_options, a + * nonce, and a click; nothing runs on activation, update or cron. + */ +class BS_Admin_Migration { + + const ACTION_PREVIEW = 'bs_migrate_preview'; + const ACTION_APPLY = 'bs_migrate_apply'; + const ACTION_BATCH = 'bs_migrate_batch'; + const ACTION_ROLLBACK = 'bs_migrate_rollback'; + const ACTION_COMPANION = 'bs_companion_install'; + const ACTION_COMPANION_ZIP = 'bs_companion_zip'; + const BATCH = 10; + + public function __construct() { + add_action( 'admin_post_' . self::ACTION_APPLY, array( $this, 'handle_apply' ) ); + add_action( 'admin_post_' . self::ACTION_BATCH, array( $this, 'handle_batch_form' ) ); + add_action( 'admin_post_' . self::ACTION_ROLLBACK, array( $this, 'handle_rollback' ) ); + add_action( 'admin_post_' . self::ACTION_COMPANION, array( $this, 'handle_companion' ) ); + add_action( 'admin_post_' . self::ACTION_COMPANION_ZIP, array( $this, 'handle_companion_zip' ) ); + add_action( 'wp_ajax_' . self::ACTION_BATCH, array( $this, 'handle_batch_ajax' ) ); + } + + private function authorise( $action ) { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do that.', 'bsshortcodes' ), '', array( 'response' => 403 ) ); + } + check_admin_referer( $action ); + } + + private function back( $args = array() ) { + wp_safe_redirect( BS_Admin_Tools::url( $args ) ); + exit; + } + + private function options_from_request() { + return BS_Migration::options_from( array( + 'mode' => isset( $_REQUEST['mode'] ) ? sanitize_key( $_REQUEST['mode'] ) : 'auto', + 'style' => isset( $_REQUEST['style'] ) ? sanitize_key( $_REQUEST['style'] ) : 'inline', + ) ); + } + + /* ---------------------------------------------------------------- */ + /* Handlers */ + /* ---------------------------------------------------------------- */ + + public function handle_apply() { + $this->authorise( self::ACTION_APPLY ); + $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; + $expected = isset( $_POST['modified_gmt'] ) ? sanitize_text_field( wp_unslash( $_POST['modified_gmt'] ) ) : null; + $r = BS_Migration::apply( $post_id, $this->options_from_request(), $expected ); + if ( is_wp_error( $r ) ) { + $this->back( array( 'bs_msg' => 'error', 'bs_text' => rawurlencode( $r->get_error_message() ), 'bs_preview' => $post_id, 'mode' => $this->options_from_request()['mode'], 'style' => $this->options_from_request()['style'] ) ); + } + $this->back( array( 'bs_msg' => 'migrated', 'bs_count' => 1, 'bs_post' => $post_id ) ); + } + + /** No-JS batch: up to MAX_BATCH posts in one request. */ + public function handle_batch_form() { + $this->authorise( self::ACTION_BATCH ); + $ids = isset( $_POST['post_ids'] ) ? array_map( 'intval', (array) $_POST['post_ids'] ) : array(); + $out = BS_Migration::batch( $ids, $this->options_from_request() ); + $ok = count( array_filter( $out, function ( $r ) { return $r['ok']; } ) ); + $this->back( array( 'bs_msg' => 'migrated', 'bs_count' => $ok, 'bs_failed' => count( $out ) - $ok ) ); + } + + public function handle_batch_ajax() { + list( $status, $data ) = $this->batch_response(); + if ( 200 === $status ) { + wp_send_json_success( $data ); + } + wp_send_json_error( $data, $status ); + } + + public function batch_response() { + if ( ! current_user_can( 'manage_options' ) ) { + return array( 403, array( 'message' => 'forbidden' ) ); + } + if ( ! check_ajax_referer( self::ACTION_BATCH, false, false ) ) { + return array( 403, array( 'message' => 'bad nonce' ) ); + } + $ids = isset( $_POST['post_ids'] ) ? array_map( 'intval', (array) $_POST['post_ids'] ) : array(); + $ids = array_slice( $ids, 0, self::BATCH ); + return array( 200, array( 'results' => BS_Migration::batch( $ids, $this->options_from_request() ) ) ); + } + + public function handle_rollback() { + $this->authorise( self::ACTION_ROLLBACK ); + $ids = isset( $_POST['post_ids'] ) ? array_map( 'intval', (array) $_POST['post_ids'] ) : array(); + if ( isset( $_POST['post_id'] ) ) { + $ids[] = (int) $_POST['post_id']; + } + $ok = 0; + foreach ( array_slice( array_unique( $ids ), 0, BS_Migration::MAX_BATCH ) as $id ) { + if ( true === BS_Migration::rollback( $id ) ) { + $ok++; + } + } + $this->back( array( 'bs_msg' => 'rolled_back', 'bs_count' => $ok ) ); + } + + public function handle_companion() { + $this->authorise( self::ACTION_COMPANION ); + if ( empty( $_POST['bs_companion_consent'] ) ) { + $this->back( array( 'bs_msg' => 'error', 'bs_text' => rawurlencode( __( 'Tick the confirmation box to install the companion.', 'bsshortcodes' ) ) ) ); + } + $r = BS_Companion::install_and_activate(); + if ( is_wp_error( $r ) ) { + $this->back( array( 'bs_msg' => 'error', 'bs_text' => rawurlencode( $r->get_error_message() ) ) ); + } + $this->back( array( 'bs_msg' => 'companion' ) ); + } + + public function handle_companion_zip() { + $this->authorise( self::ACTION_COMPANION_ZIP ); + $zip = BS_Companion::build_zip(); + if ( is_wp_error( $zip ) ) { + wp_die( esc_html( $zip->get_error_message() ) ); + } + nocache_headers(); + header( 'Content-Type: application/zip' ); + header( 'Content-Disposition: attachment; filename="' . BS_Companion::SLUG . '.zip"' ); + header( 'Content-Length: ' . filesize( $zip ) ); + readfile( $zip ); + @unlink( $zip ); + exit; + } + + /* ---------------------------------------------------------------- */ + /* Rendering (called from BS_Admin_Tools::render) */ + /* ---------------------------------------------------------------- */ + + public function render_notices() { + if ( empty( $_GET['bs_msg'] ) ) { + return; + } + $msg = sanitize_key( $_GET['bs_msg'] ); + $count = isset( $_GET['bs_count'] ) ? (int) $_GET['bs_count'] : 0; + $class = 'notice-success'; + switch ( $msg ) { + case 'migrated': + $failed = isset( $_GET['bs_failed'] ) ? (int) $_GET['bs_failed'] : 0; + /* translators: %d: count */ + $text = sprintf( _n( '%d post migrated.', '%d posts migrated.', $count, 'bsshortcodes' ), $count ); + if ( $failed ) { + /* translators: %d: count */ + $text .= ' ' . sprintf( __( '%d could not be migrated (nothing convertible, or skipped occurrences); see the list.', 'bsshortcodes' ), $failed ); + $class = 'notice-warning'; + } + $text .= ' ' . __( 'Re-run the scan to refresh the report.', 'bsshortcodes' ); + break; + case 'rolled_back': + /* translators: %d: count */ + $text = sprintf( _n( '%d post rolled back to its original content.', '%d posts rolled back to their original content.', $count, 'bsshortcodes' ), $count ); + break; + case 'companion': + $text = __( 'Bootstrap 3 Legacy Assets is installed and active. It stays idle until Bootstrap Shortcodes stops loading its own files.', 'bsshortcodes' ); + break; + case 'error': + $class = 'notice-error'; + $text = isset( $_GET['bs_text'] ) ? sanitize_text_field( rawurldecode( wp_unslash( $_GET['bs_text'] ) ) ) : __( 'Something went wrong.', 'bsshortcodes' ); + break; + default: + return; + } + echo '

' . esc_html( $text ) . '

'; + } + + public function current_options() { + return $this->options_from_request(); + } + + /** The per-post preview page (diff + migrate button). Returns true if it rendered. */ + public function render_preview() { + if ( empty( $_GET['bs_preview'] ) ) { + return false; + } + $post_id = (int) $_GET['bs_preview']; + $options = $this->options_from_request(); + $preview = BS_Migration::preview( $post_id, $options ); + echo '

← ' . esc_html__( 'Back to the report', 'bsshortcodes' ) . '

'; + if ( is_wp_error( $preview ) ) { + echo '

' . esc_html( $preview->get_error_message() ) . '

'; + return true; + } + $r = $preview['result']; + ?> +

+

+ + options_switcher( $options, array( 'bs_preview' => $post_id ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +

+ +

+ + render_result_lists( $r ); ?> + +

+ + __( 'Current content', 'bsshortcodes' ), 'title_right' => __( 'After migration', 'bsshortcodes' ) ) ); + echo $diff ? $diff : '

' . esc_html__( 'No visible difference.', 'bsshortcodes' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_text_diff escapes. + ?> +
+ + +
+
+ + + + + + +

+ + +

+
+ + +

+ +

+ + +
    + +
  • + +
+ + +

+ + +

+ __( 'Bootstrap 3 CSS', 'bsshortcodes' ), + 'glyphicons' => __( 'Glyphicons font (part of Bootstrap 3 CSS)', 'bsshortcodes' ), + ); + return isset( $labels[ $dep ] ) ? $labels[ $dep ] : $dep; + } + + private function options_switcher( $options, $extra_args ) { + $links = array(); + foreach ( array( 'auto' => __( 'auto', 'bsshortcodes' ), 'blocks' => __( 'blocks', 'bsshortcodes' ), 'html' => __( 'HTML', 'bsshortcodes' ) ) as $mode => $label ) { + $links[] = $mode === $options['mode'] ? '' . esc_html( $label ) . '' : '
' . esc_html( $label ) . ''; + } + $out = esc_html__( 'Output:', 'bsshortcodes' ) . ' ' . implode( ' | ', $links ); + $links = array(); + foreach ( array( 'inline' => __( 'self-contained', 'bsshortcodes' ), 'classes' => __( 'Bootstrap classes', 'bsshortcodes' ) ) as $style => $label ) { + $links[] = $style === $options['style'] ? '' . esc_html( $label ) . '' : '' . esc_html( $label ) . ''; + } + return '' . $out . '   ' . esc_html__( 'Styling:', 'bsshortcodes' ) . ' ' . implode( ' | ', $links ) . ''; + } + + /** Migration controls shown under the report's affected-content table. */ + public function render_migration_section( $results ) { + $options = $this->options_from_request(); + $migrated = BS_Migration::migrated_posts(); + $pending = array(); + foreach ( $results['posts'] as $p ) { + if ( ! in_array( 'malformed', $p['flags'], true ) || $p['counts'] ) { + $pending[] = $p['id']; + } + } + ?> +

+

+ +

+

options_switcher( $options, array() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>

+
    +
  • +
  • +
  • +
+ + +
+ + + + + + + + + + + + + + + $p['id'], 'mode' => $options['mode'], 'style' => $options['style'] ) ); + ?> + + + + + + + + + + +
$n ) { + $parts[] = $tag . ' ×' . (int) $n; + } + echo esc_html( implode( ', ', $parts ) ); + ?> + + + + +
+

+ + + +

+
+ +

+ + +

+ +

+ +
+ + + + + + + + + + + + + + +
+

+
+ + + render_companion_section( $results ); ?> + +

+

+
    +
  • +
  • +
+

+ +

+ +

+ +
+ + +

+

+ + + +

+
+ +

+ +

+ migration = $migration; add_action( 'admin_menu', array( $this, 'register_page' ) ); add_action( 'admin_post_' . self::ACTION, array( $this, 'handle_form_step' ) ); add_action( 'admin_post_bs_scan_reset', array( $this, 'handle_reset' ) ); @@ -46,6 +50,11 @@ public function enqueue( $hook ) { 'nonce' => wp_create_nonce( self::ACTION ), 'batch' => self::BATCH, 'reload' => self::url(), + 'i18n' => array( + 'migrated' => __( 'migrated', 'bsshortcodes' ), + 'failed' => __( 'not migrated', 'bsshortcodes' ), + 'confirm' => __( 'Migrate the selected posts now? Originals are kept for rollback.', 'bsshortcodes' ), + ), ) ); } @@ -146,6 +155,8 @@ public function render() { ?>

+ migration ) { $this->migration->render_notices(); } ?> + migration && $this->migration->render_preview() ) { echo '
'; return; } ?>

render_controls( $state, $done ); ?> @@ -156,6 +167,7 @@ public function render() { render_posts( $results ); ?> render_surfaces( $results ); ?> render_not_scanned( $results ); ?> + migration ) { $this->migration->render_migration_section( $results ); } ?> render_share( $results ); ?>
diff --git a/inc/class-bs-companion.php b/inc/class-bs-companion.php new file mode 100644 index 0000000..cccba1c --- /dev/null +++ b/inc/class-bs-companion.php @@ -0,0 +1,126 @@ + absolute source]. */ + public static function manifest() { + $plugin = dirname( BS_SHORTCODES_FILE ); + $files = array( + self::SLUG . '/bootstrap-legacy-assets.php' => self::source_dir() . '/bootstrap-legacy-assets.php', + self::SLUG . '/readme.txt' => self::source_dir() . '/readme.txt', + self::SLUG . '/css/bootstrap.css' => $plugin . '/css/bootstrap.css', + self::SLUG . '/js/bootstrap.js' => $plugin . '/js/bootstrap.js', + self::SLUG . '/js/init.js' => $plugin . '/js/init.js', + ); + foreach ( glob( $plugin . '/fonts/glyphicons-halflings-regular.*' ) as $font ) { + $files[ self::SLUG . '/fonts/' . basename( $font ) ] = $font; + } + return $files; + } + + /** + * Write the zip into the uploads dir (private name) and return its path. + */ + public static function build_zip() { + $upload = wp_upload_dir(); + if ( ! empty( $upload['error'] ) ) { + return new WP_Error( 'bs_zip', $upload['error'] ); + } + $path = trailingslashit( $upload['basedir'] ) . self::SLUG . '-' . wp_generate_password( 8, false ) . '.zip'; + $files = self::manifest(); + foreach ( $files as $src ) { + if ( ! is_readable( $src ) ) { + return new WP_Error( 'bs_zip', sprintf( 'Missing source file %s', basename( $src ) ) ); + } + } + if ( class_exists( 'ZipArchive' ) ) { + $zip = new ZipArchive(); + if ( true !== $zip->open( $path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { + return new WP_Error( 'bs_zip', 'Could not create the zip file.' ); + } + foreach ( $files as $inside => $src ) { + $zip->addFile( $src, $inside ); + } + $zip->close(); + } else { + require_once ABSPATH . 'wp-admin/includes/class-pclzip.php'; + $zip = new PclZip( $path ); + foreach ( $files as $inside => $src ) { + $res = $zip->add( $src, PCLZIP_OPT_REMOVE_ALL_PATH, PCLZIP_OPT_ADD_PATH, dirname( $inside ) ); + if ( 0 === $res ) { + return new WP_Error( 'bs_zip', $zip->errorInfo( true ) ); + } + } + } + return $path; + } + + public static function is_installed() { + return file_exists( WP_PLUGIN_DIR . '/' . self::FILE ); + } + + public static function is_active() { + return function_exists( 'is_plugin_active' ) ? is_plugin_active( self::FILE ) : in_array( self::FILE, (array) get_option( 'active_plugins', array() ), true ); + } + + /** + * Install (if needed) and activate through core's upgrader. Only works when + * WordPress can write to the plugins directory itself; otherwise the caller + * should offer the zip for manual upload. + */ + public static function install_and_activate() { + if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) { + return new WP_Error( 'bs_forbidden', __( 'You cannot install plugins on this site.', 'bsshortcodes' ) ); + } + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + if ( ! self::is_installed() ) { + if ( 'direct' !== get_filesystem_method() || ! wp_is_writable( WP_PLUGIN_DIR ) ) { + return new WP_Error( 'bs_filesystem', __( 'WordPress cannot write to the plugins directory on this host. Download the zip and install it through Plugins > Add New > Upload Plugin.', 'bsshortcodes' ) ); + } + $zip = self::build_zip(); + if ( is_wp_error( $zip ) ) { + return $zip; + } + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + $skin = new WP_Ajax_Upgrader_Skin(); + $upgrader = new Plugin_Upgrader( $skin ); + $result = $upgrader->install( $zip ); + @unlink( $zip ); + if ( is_wp_error( $result ) ) { + return $result; + } + if ( ! $result ) { + $errors = $skin->get_errors(); + return new WP_Error( 'bs_install', $errors && $errors->has_errors() ? $errors->get_error_message() : __( 'Install failed.', 'bsshortcodes' ) ); + } + if ( ! self::is_installed() ) { + return new WP_Error( 'bs_install', __( 'The companion did not land in the plugins directory.', 'bsshortcodes' ) ); + } + } + // Mirror the original plugin's asset choices so the site looks the same. + $o = get_option( 'bs_options' ); + $o = is_array( $o ) ? $o : array(); + update_option( 'bs_legacy_assets', array_filter( array( + 'css' => empty( $o['chk_default_options_css'] ) ? '' : '1', + 'js' => empty( $o['chk_default_options_js'] ) ? '' : '1', + 'tooltips' => empty( $o['chk_default_options_js'] ) ? '' : '1', + ) ) ); + $activated = activate_plugin( self::FILE ); + if ( is_wp_error( $activated ) ) { + return $activated; + } + return true; + } +} diff --git a/inc/class-bs-migration.php b/inc/class-bs-migration.php new file mode 100644 index 0000000..0497dee --- /dev/null +++ b/inc/class-bs-migration.php @@ -0,0 +1,149 @@ + $mode, 'style' => $style ); + } + + /** + * Convert without writing. Includes what the apply step will check against. + */ + public static function preview( $post_id, $options ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( 'bs_not_found', __( 'Post not found.', 'bsshortcodes' ) ); + } + if ( ! current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( 'bs_forbidden', __( 'You cannot edit this post.', 'bsshortcodes' ) ); + } + $result = BS_Migrator::convert( $post->post_content, $options ); + return array( + 'post_id' => $post->ID, + 'title' => $post->post_title, + 'status' => $post->post_status, + 'modified_gmt' => $post->post_modified_gmt, + 'original' => $post->post_content, + 'converted' => $result['content'], + 'result' => $result, + 'already' => (bool) get_post_meta( $post->ID, self::META_ORIGINAL, true ), + ); + } + + /** + * Write the conversion. Refuses if the post changed since $expected_modified_gmt + * (when given), keeps the original in post meta on first migration, and lets + * wp_update_post() add a revision as usual. + */ + public static function apply( $post_id, $options, $expected_modified_gmt = null ) { + $preview = self::preview( $post_id, $options ); + if ( is_wp_error( $preview ) ) { + return $preview; + } + if ( null !== $expected_modified_gmt && $expected_modified_gmt !== $preview['modified_gmt'] ) { + return new WP_Error( 'bs_stale', __( 'This post was edited after the preview was made. Preview it again before migrating.', 'bsshortcodes' ) ); + } + $result = $preview['result']; + if ( 0 === $result['converted'] ) { + return new WP_Error( 'bs_nothing', $result['skipped'] ? __( 'Nothing could be converted automatically; see the skipped list.', 'bsshortcodes' ) : __( 'No shortcodes to convert in this post.', 'bsshortcodes' ) ); + } + if ( ! $preview['already'] ) { + $kept = add_post_meta( $post_id, self::META_ORIGINAL, array( + 'content' => $preview['original'], + 'modified_gmt' => $preview['modified_gmt'], + 'migrated_at' => time(), + 'version' => BS_Migrator::VERSION, + ), true ); + if ( ! $kept ) { + return new WP_Error( 'bs_backup', __( 'Could not store the original content; nothing was changed.', 'bsshortcodes' ) ); + } + } + $updated = wp_update_post( array( 'ID' => $post_id, 'post_content' => $preview['converted'] ), true ); + if ( is_wp_error( $updated ) ) { + return $updated; + } + update_post_meta( $post_id, self::META_RESULT, array( + 'converted' => $result['converted'], + 'skipped' => count( $result['skipped'] ), + 'notes' => $result['notes'], + 'dependencies' => $result['dependencies'], + 'mode' => $result['mode'], + 'style' => $result['style'], + 'migrated_at' => time(), + ) ); + return array( + 'post_id' => $post_id, + 'converted' => $result['converted'], + 'skipped' => $result['skipped'], + 'notes' => $result['notes'], + 'dependencies' => $result['dependencies'], + ); + } + + /** Put the stored original back. The migrated version stays in revisions. */ + public static function rollback( $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( 'bs_not_found', __( 'Post not found.', 'bsshortcodes' ) ); + } + if ( ! current_user_can( 'edit_post', $post->ID ) ) { + return new WP_Error( 'bs_forbidden', __( 'You cannot edit this post.', 'bsshortcodes' ) ); + } + $backup = get_post_meta( $post->ID, self::META_ORIGINAL, true ); + if ( ! is_array( $backup ) || ! isset( $backup['content'] ) ) { + return new WP_Error( 'bs_no_backup', __( 'No stored original for this post.', 'bsshortcodes' ) ); + } + $updated = wp_update_post( array( 'ID' => $post->ID, 'post_content' => $backup['content'] ), true ); + if ( is_wp_error( $updated ) ) { + return $updated; + } + delete_post_meta( $post->ID, self::META_ORIGINAL ); + delete_post_meta( $post->ID, self::META_RESULT ); + return true; + } + + /** + * Apply to several posts. Each post is read fresh and converted from its + * current content, so a post edited after the scan is converted as it is + * now, never from a stale copy. Bounded to MAX_BATCH per call. + */ + public static function batch( $post_ids, $options ) { + $post_ids = array_slice( array_values( array_unique( array_map( 'intval', (array) $post_ids ) ) ), 0, self::MAX_BATCH ); + $out = array(); + foreach ( $post_ids as $id ) { + $r = self::apply( $id, $options ); + $out[ $id ] = is_wp_error( $r ) + ? array( 'ok' => false, 'code' => $r->get_error_code(), 'message' => $r->get_error_message() ) + : array( 'ok' => true, 'converted' => $r['converted'], 'skipped' => count( $r['skipped'] ), 'dependencies' => $r['dependencies'] ); + } + return $out; + } + + /** Posts that hold a stored original, newest first (bounded). */ + public static function migrated_posts( $limit = 500 ) { + return get_posts( array( + 'post_type' => 'any', + 'post_status' => 'any', + 'posts_per_page' => $limit, + 'meta_key' => self::META_ORIGINAL, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ) ); + } + + public static function is_migrated( $post_id ) { + return (bool) get_post_meta( $post_id, self::META_ORIGINAL, true ); + } +} diff --git a/inc/class-bs-migrator.php b/inc/class-bs-migrator.php new file mode 100644 index 0000000..07e1372 --- /dev/null +++ b/inc/class-bs-migrator.php @@ -0,0 +1,695 @@ + 'auto', 'style' => 'inline' ) ); + if ( 'auto' === $options['mode'] ) { + $options['mode'] = ( is_string( $content ) && has_blocks( $content ) ) ? 'blocks' : 'html'; + } + self::$options = $options; + self::$skipped = array(); + self::$notes = array(); + self::$deps = array(); + self::$converted = 0; + self::$tab_titles = array(); + self::$section_counter = 0; + + $result = array( + 'content' => $content, + 'converted' => 0, + 'skipped' => array(), + 'notes' => array(), + 'dependencies' => array(), + 'mode' => $options['mode'], + 'style' => $options['style'], + 'version' => self::VERSION, + ); + if ( ! is_string( $content ) || false === strpos( $content, '[bs_' ) ) { + return $result; + } + + $scan = BS_Scanner::scan_text( $content ); + self::$unbalanced = $scan['unbalanced']; + + if ( 'blocks' === $options['mode'] && has_blocks( $content ) ) { + // A block document: convert inside the blocks that hold shortcodes + // (Shortcode, Classic, Paragraph, Custom HTML ...) without breaking + // their structure. + $result['content'] = serialize_blocks( self::convert_blocks( parse_blocks( $content ) ) ); + } else { + $result['content'] = self::process( $content, true ); + if ( 'blocks' === $options['mode'] ) { + // Loose text between blocks becomes paragraphs (or a Classic block), as the editor would. + $result['content'] = self::inner_blocks( $result['content'] ); + } + } + $result['converted'] = self::$converted; + $result['skipped'] = self::$skipped; + $result['notes'] = array_values( array_unique( self::$notes ) ); + $result['dependencies'] = array_values( array_unique( self::$deps ) ); + return $result; + } + + /* ------------------------------------------------------------------ */ + /* Block documents */ + /* ------------------------------------------------------------------ */ + + /** + * Walk parsed blocks. Shortcode blocks and standalone-shortcode paragraphs + * are replaced by the converted blocks; Classic, Custom HTML and other + * blocks get their inner HTML converted as static HTML; inner blocks recurse. + */ + private static function convert_blocks( $blocks ) { + $out = array(); + foreach ( $blocks as $block ) { + $html = isset( $block['innerHTML'] ) ? $block['innerHTML'] : ''; + $has = false !== strpos( $html, '[bs_' ); + if ( null === $block['blockName'] ) { + if ( $has ) { + $block = self::raw_block( self::inner_blocks( self::process( $html, true ) ) ); + } + $out[] = $block; + continue; + } + if ( 'core/shortcode' === $block['blockName'] && $has ) { + $converted = self::inner_blocks( self::process( $html, true ) ); + $out[] = self::raw_block( $converted ); + continue; + } + if ( 'core/paragraph' === $block['blockName'] && $has && preg_match( '/^\s*]*>\s*(\[bs_[\s\S]*\])\s*<\/p>\s*$/', $html, $m ) && self::is_single_shortcode( $m[1] ) ) { + $converted = self::inner_blocks( self::process( $m[1], true ) ); + $out[] = self::raw_block( $converted ); + continue; + } + if ( $has ) { + // Inline components inside this block's own HTML (paragraph, heading, + // list, classic, custom HTML ...): static HTML keeps the block valid. + $saved_mode = self::$options['mode']; + self::$options['mode'] = 'html'; + $block['innerHTML'] = self::process( $html, false ); + $counted = self::$converted; + foreach ( $block['innerContent'] as $i => $chunk ) { + if ( is_string( $chunk ) && false !== strpos( $chunk, '[bs_' ) ) { + $block['innerContent'][ $i ] = self::process( $chunk, false ); + } + } + self::$converted = $counted; // innerContent repeats innerHTML; count once + self::$options['mode'] = $saved_mode; + } + if ( ! empty( $block['innerBlocks'] ) ) { + $block['innerBlocks'] = self::convert_blocks( $block['innerBlocks'] ); + } + $out[] = $block; + } + return $out; + } + + /** One of our shortcodes (possibly enclosing), and nothing else. */ + private static function is_single_shortcode( $text ) { + if ( ! preg_match_all( '/' . get_shortcode_regex( BS_Scanner::tags() ) . '/', $text, $m, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { + return false; + } + return 1 === count( $m ) && 0 === $m[0][0][1] && strlen( $m[0][0][0] ) === strlen( $text ); + } + + private static function raw_block( $html ) { + $html = "\n" . trim( $html ) . "\n"; + return array( 'blockName' => null, 'attrs' => array(), 'innerBlocks' => array(), 'innerHTML' => $html, 'innerContent' => array( $html ) ); + } + + /* ------------------------------------------------------------------ */ + /* Walking */ + /* ------------------------------------------------------------------ */ + + private static function process( $text, $top_level ) { + if ( false === strpos( $text, '[bs_' ) ) { + return $text; + } + $pattern = '/' . get_shortcode_regex( BS_Scanner::tags() ) . '/'; + $out = ''; + $last = 0; + if ( ! preg_match_all( $pattern, $text, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { + return $text; + } + foreach ( $matches as $m ) { + $start = $m[0][1]; + $whole = $m[0][0]; + $out .= substr( $text, $last, $start - $last ); + $last = $start + strlen( $whole ); + + if ( '[' === $m[1][0] && ']' === $m[6][0] ) { + $out .= $whole; // escaped: literal text by design + continue; + } + $tag = $m[2][0]; + if ( in_array( $tag, self::$unbalanced, true ) ) { + self::$skipped[] = array( 'tag' => $tag, 'reason' => 'unbalanced', 'offset' => $start ); + $out .= $whole; + continue; + } + $attrs = shortcode_parse_atts( $m[3][0] ); + $attrs = is_array( $attrs ) ? $attrs : array(); + $content = isset( $m[5][0] ) ? $m[5][0] : ''; + // The editor's own line-break markers ("
", or without the + // space before TinyMCE normalises it) were never meant to render. + $content = preg_replace( '/
/', '', $content ); + self::note_unknown_attrs( $tag, $attrs ); + + $before = substr( $text, 0, $start ); + $after = substr( $text, $last ); + $standalone = (bool) preg_match( '/(^|\n)[ \t]*$/', $before ) && (bool) preg_match( '/^[ \t]*(\n|$)/', $after ); + + $replacement = self::convert_one( $tag, $attrs, $content, $standalone, $top_level ); + if ( null === $replacement ) { + $out .= $whole; + continue; + } + self::$converted++; + $out .= $replacement; + } + $out .= substr( $text, $last ); + return $out; + } + + private static function note_unknown_attrs( $tag, $attrs ) { + $known = BS_Scanner::$known_attrs[ $tag ]; + foreach ( $attrs as $k => $v ) { + if ( is_string( $k ) && ! in_array( $k, $known, true ) ) { + /* translators: 1: attribute, 2: shortcode */ + self::$notes[] = sprintf( 'Attribute "%1$s" on [%2$s] was never used by the plugin and is dropped.', $k, $tag ); + } + } + } + + private static function blocks() { + return 'blocks' === self::$options['mode']; + } + + private static function inline_style() { + return 'inline' === self::$options['style']; + } + + private static function attr( $value ) { + return esc_attr( (string) $value ); + } + + private static function convert_one( $tag, $attrs, $content, $standalone, $top_level ) { + switch ( $tag ) { + case 'bs_row': + return self::row( $attrs, $content ); + case 'bs_col': + return self::col( $attrs, $content, $top_level ); + case 'bs_well': + return self::well( $attrs, $content ); + case 'bs_notification': + return self::alert( $attrs, $content ); + case 'bs_lead': + return self::lead( $content ); + case 'bs_button': + return self::button( $attrs, $standalone ); + case 'bs_label': + return self::label( $attrs, $content ); + case 'bs_icon': + return self::icon( $attrs ); + case 'bs_tooltip': + return self::tooltip( $attrs, $content ); + case 'bs_collapse': + return self::collapse( $attrs, $content ); + case 'bs_citem': + return self::citem( $attrs, $content, '' ); + case 'bs_tabs': + return self::tabs( $content ); + case 'bs_thead': + case 'bs_dropdown': + case 'bs_tab': + // Only meaningful inside [bs_tabs]; stray ones have nothing to show. + self::$notes[] = sprintf( 'A stray [%s] outside [bs_tabs] was removed (it rendered an empty tab strip).', $tag ); + return ''; + case 'bs_tcontents': + return self::process( $content, false ); + case 'bs_tcontent': + return self::tcontent( $attrs, $content ); + } + return null; + } + + /* ------------------------------------------------------------------ */ + /* Inner content helpers */ + /* ------------------------------------------------------------------ */ + + /** + * Turn already-processed inner content into inner blocks: existing blocks + * pass through, plain text becomes paragraphs, anything else becomes a + * Classic block (which keeps wpautop behaviour for mixed HTML). + */ + private static function inner_blocks( $html ) { + $html = trim( $html ); + if ( '' === $html ) { + return ''; + } + $parts = array(); + foreach ( parse_blocks( $html ) as $block ) { + if ( null !== $block['blockName'] ) { + $parts[] = trim( serialize_block( $block ) ); + continue; + } + $chunk = trim( $block['innerHTML'] ); + if ( '' === $chunk ) { + continue; + } + if ( $chunk === wp_strip_all_tags( $chunk ) ) { + foreach ( preg_split( '/\n\s*\n/', $chunk ) as $para ) { + $para = trim( $para ); + if ( '' !== $para ) { + $parts[] = "\n

" . str_replace( "\n", "
\n", $para ) . "

\n"; + } + } + continue; + } + if ( self::is_simple_inline_html( $chunk ) ) { + $parts[] = "\n

" . $chunk . "

\n"; + continue; + } + $parts[] = "\n" . $chunk . "\n"; + } + return implode( "\n\n", $parts ); + } + + /** One line of text with only inline elements (span, a, svg, i, strong ...). */ + private static function is_simple_inline_html( $chunk ) { + if ( false !== strpos( $chunk, "\n" ) ) { + return false; + } + $stripped = preg_replace( '#<(/?)(a|abbr|b|strong|i|em|span|svg|path|code|small|sub|sup|br)\b[^>]*>#i', '', $chunk ); + return false === strpos( $stripped, '<' ); + } + + private static function wrap_block( $name, $attrs, $html ) { + $json = $attrs ? ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES ) : ''; + return "\n{$html}\n"; + } + + /** Static HTML keeps classic behaviour: wpautop runs later, as it always did. */ + private static function inner_html( $html ) { + return self::process( $html, false ); + } + + /* ------------------------------------------------------------------ */ + /* Components */ + /* ------------------------------------------------------------------ */ + + private static function row( $attrs, $content ) { + $classes = isset( $attrs['class'] ) ? preg_split( '/\s+/', trim( $attrs['class'] ) ) : array( 'row' ); + $extra = array_values( array_diff( array_filter( $classes ), array( 'row' ) ) ); + if ( ! self::blocks() ) { + self::$deps[] = 'bootstrap-css'; + return '
' . self::inner_html( $content ) . '
'; + } + $inner = self::process( $content, false ); + // Inner must be columns only; stray text between columns is kept as its own column. + $columns = array(); + foreach ( parse_blocks( trim( $inner ) ) as $block ) { + if ( 'core/column' === $block['blockName'] ) { + $columns[] = trim( serialize_block( $block ) ); + } elseif ( '' !== trim( $block['innerHTML'] ) || null !== $block['blockName'] ) { + $columns[] = self::wrap_block( 'column', array(), '
' . self::inner_blocks( null !== $block['blockName'] ? serialize_block( $block ) : $block['innerHTML'] ) . '
' ); + self::$notes[] = 'Content inside a [bs_row] that was not in a [bs_col] was placed in its own column.'; + } + } + $block_attrs = $extra ? array( 'className' => implode( ' ', $extra ) ) : array(); + $class = 'wp-block-columns' . ( $extra ? ' ' . implode( ' ', $extra ) : '' ); + return self::wrap_block( 'columns', $block_attrs, '
' . implode( '', $columns ) . '
' ); + } + + private static function col( $attrs, $content, $top_level ) { + $classes = isset( $attrs['class'] ) ? preg_split( '/\s+/', trim( $attrs['class'] ) ) : array( 'col-sm-1' ); + $classes = array_values( array_filter( $classes ) ); + if ( ! self::blocks() ) { + self::$deps[] = 'bootstrap-css'; + return '
' . self::inner_html( $content ) . '
'; + } + $width = null; + $extra = array(); + foreach ( $classes as $class ) { + if ( preg_match( '/^col-(xs|sm|md|lg)-(\d{1,2})$/', $class, $mm ) ) { + if ( null === $width ) { + $width = (int) $mm[2]; + } + } elseif ( preg_match( '/^col-(xs|sm|md|lg)-(offset|push|pull)-\d{1,2}$/', $class ) ) { + self::$notes[] = sprintf( 'Grid %s class "%s" has no Columns-block equivalent and was dropped.', 'offset/push/pull', $class ); + } else { + $extra[] = $class; + } + } + $block_attrs = array(); + $style = ''; + if ( $width && $width < 12 ) { + $pct = rtrim( rtrim( number_format( $width / 12 * 100, 2, '.', '' ), '0' ), '.' ) . '%'; + $block_attrs['width'] = $pct; + $style = ' style="flex-basis:' . $pct . '"'; + } + if ( $extra ) { + $block_attrs['className'] = implode( ' ', $extra ); + } + $class = 'wp-block-column' . ( $extra ? ' ' . implode( ' ', $extra ) : '' ); + $inner = self::inner_blocks( self::process( $content, false ) ); + return self::wrap_block( 'column', $block_attrs, '
' . $inner . '
' ); + } + + private static function well( $attrs, $content ) { + $size = isset( $attrs['size'] ) && in_array( $attrs['size'], array( 'sm', 'lg' ), true ) ? $attrs['size'] : ''; + $class = 'well' . ( $size ? ' well-' . $size : '' ); + $pad = 'lg' === $size ? '24px' : ( 'sm' === $size ? '9px' : '19px' ); + $style = 'background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:' . ( 'lg' === $size ? '6px' : ( 'sm' === $size ? '3px' : '4px' ) ) . ';padding:' . $pad . ';margin-bottom:20px'; + return self::box( $class, $style, $content, '' ); + } + + private static function alert( $attrs, $content ) { + $type = isset( $attrs['type'] ) ? sanitize_html_class( $attrs['type'], 'unknown' ) : 'unknown'; + $colors = array( + 'success' => array( '#dff0d8', '#d6e9c6', '#3c763d' ), + 'info' => array( '#d9edf7', '#bce8f1', '#31708f' ), + 'warning' => array( '#fcf8e3', '#faebcc', '#8a6d3b' ), + 'danger' => array( '#f2dede', '#ebccd1', '#a94442' ), + ); + $c = isset( $colors[ $type ] ) ? $colors[ $type ] : array( '#f5f5f5', '#e3e3e3', '#333333' ); + $style = 'background-color:' . $c[0] . ';border:1px solid ' . $c[1] . ';color:' . $c[2] . ';border-radius:4px;padding:15px;margin-bottom:20px'; + if ( ! isset( $attrs['dismissible'] ) || 'true' === $attrs['dismissible'] ) { + self::$notes[] = 'Alerts lose their dismiss (×) button: it needs Bootstrap JavaScript. The message itself is kept.'; + } + return self::box( 'alert alert-' . $type, $style, $content, ' role="alert"' ); + } + + /** Shared by wells and alerts: a styled container. */ + private static function box( $class, $style, $content, $extra_attrs ) { + if ( self::blocks() ) { + if ( self::inline_style() ) { + $inner = self::process( $content, false ); + $html = '
' . self::bake_paragraphs( $inner ) . '
'; + return self::wrap_block( 'html', array(), $html ); + } + self::$deps[] = 'bootstrap-css'; + return self::wrap_block( 'group', array( 'className' => $class ), '
' . self::inner_blocks( self::process( $content, false ) ) . '
' ); + } + $inner = self::inner_html( $content ); + if ( self::inline_style() ) { + return '
' . $inner . '
'; + } + self::$deps[] = 'bootstrap-css'; + return '
' . $inner . '
'; + } + + /** + * Inside a Custom HTML block wpautop no longer runs, so bake the paragraphs + * classic content relied on. Block markup inside is rendered to HTML. + */ + private static function bake_paragraphs( $html ) { + $html = trim( $html ); + if ( '' === $html ) { + return ''; + } + if ( has_blocks( $html ) ) { + $html = do_blocks( $html ); + } + return wpautop( $html ); + } + + private static function lead( $content ) { + $inner = self::process( $content, false ); + if ( self::blocks() ) { + if ( trim( $inner ) === wp_strip_all_tags( trim( $inner ) ) || self::is_simple_inline_html( trim( $inner ) ) ) { + return self::wrap_block( 'paragraph', array( 'className' => 'lead', 'fontSize' => 'large' ), '

' . trim( $inner ) . '

' ); + } + return self::wrap_block( 'group', array( 'className' => 'lead', 'fontSize' => 'large' ), '
' . self::inner_blocks( $inner ) . '
' ); + } + $style = self::inline_style() ? ' style="font-size:1.3em;font-weight:300"' : ''; + if ( ! self::inline_style() ) { + self::$deps[] = 'bootstrap-css'; + } + $tag = ( trim( $inner ) === wp_strip_all_tags( trim( $inner ) ) || self::is_simple_inline_html( trim( $inner ) ) ) ? 'p' : 'div'; + return '<' . $tag . ' class="lead"' . $style . '>' . trim( $inner ) . ''; + } + + private static function button( $attrs, $standalone ) { + $a = shortcode_atts( array( 'size' => '', 'type' => 'default', 'value' => 'button', 'href' => '#', 'target' => '' ), $attrs ); + $target = strtolower( trim( $a['target'] ) ); + $target_attr = ''; + if ( in_array( $target, array( '_blank', '_self', '_parent', '_top' ), true ) ) { + $target_attr = ' target="' . self::attr( $target ) . '"' . ( '_blank' === $target ? ' rel="noopener noreferrer"' : '' ); + } + if ( self::blocks() && $standalone ) { + $link = '' . esc_html( $a['value'] ) . ''; + $inner = self::wrap_block( 'button', array(), '
' . $link . '
' ); + return self::wrap_block( 'buttons', array(), '
' . $inner . '
' ); + } + // Inline (or html mode): identical to what the shortcode rendered. + $size_class = ( $a['size'] && $a['size'] !== $a['type'] ) ? ' btn-' . self::attr( $a['size'] ) : ''; + if ( self::inline_style() ) { + self::$deps[] = 'bootstrap-css'; + self::$notes[] = 'Inline buttons keep their Bootstrap "btn" classes: a button in the middle of a sentence has no block equivalent.'; + } else { + self::$deps[] = 'bootstrap-css'; + } + return '' . esc_html( $a['value'] ) . ''; + } + + private static function label( $attrs, $content ) { + $type = isset( $attrs['type'] ) ? sanitize_html_class( $attrs['type'], 'default' ) : 'default'; + $colors = array( 'default' => '#777', 'primary' => '#337ab7', 'success' => '#5cb85c', 'info' => '#5bc0de', 'warning' => '#f0ad4e', 'danger' => '#d9534f' ); + $bg = isset( $colors[ $type ] ) ? $colors[ $type ] : '#777'; + $inner = self::process( $content, false ); + if ( self::inline_style() ) { + return '' . $inner . ''; + } + self::$deps[] = 'bootstrap-css'; + return '' . $inner . ''; + } + + private static function icon( $attrs ) { + $name = isset( $attrs['name'] ) ? trim( $attrs['name'] ) : 'default'; + $classes = preg_split( '/\s+/', $name ); + $glyph = null; + foreach ( $classes as $class ) { + if ( preg_match( '/^glyphicon-([a-z0-9-]+)$/', $class, $mm ) ) { + $glyph = $mm[1]; + } + } + $fallback = ''; + if ( ! self::inline_style() || ! $glyph ) { + self::$deps[] = 'glyphicons'; + return $fallback; + } + $path = BS_Glyphs::path( $glyph ); + if ( ! $path ) { + self::$deps[] = 'glyphicons'; + self::$notes[] = sprintf( 'Icon "%s" is not in the bundled Glyphicons set; its markup was kept and still needs the font.', $glyph ); + return $fallback; + } + $extra = array_values( array_diff( $classes, array( 'glyphicon', 'glyphicon-' . $glyph ) ) ); + $class = 'bs-icon bs-icon-' . $glyph . ( $extra ? ' ' . implode( ' ', $extra ) : '' ); + return ''; + } + + private static function tooltip( $attrs, $content ) { + $text = trim( $content ); + self::$notes[] = 'Tooltips become native browser tooltips (title attribute): no placement or trigger control, and no tooltip on touch devices. The text is shown inline as before.'; + $style = self::inline_style() ? ' style="text-decoration:underline dotted;cursor:help"' : ''; + return '' . esc_html( $text ) . ''; + } + + private static function collapse( $attrs, $content ) { + $group = isset( $attrs['id'] ) ? sanitize_html_class( $attrs['id'] ) : ''; + // Children need the group name; pass it through a static so citem() sees it. + $inner = self::process_with_group( $content, $group ); + if ( self::blocks() ) { + return trim( $inner ); + } + return '
' . $inner . '
'; + } + + private static $group_stack = array(); + + private static function process_with_group( $content, $group ) { + self::$group_stack[] = $group; + $out = self::process( $content, false ); + array_pop( self::$group_stack ); + return $out; + } + + private static function citem( $attrs, $content, $unused ) { + $group = self::$group_stack ? end( self::$group_stack ) : ( isset( $attrs['parent'] ) ? sanitize_html_class( $attrs['parent'] ) : '' ); + $id = isset( $attrs['id'] ) ? sanitize_html_class( $attrs['id'] ) : ''; + $title = isset( $attrs['title'] ) ? $attrs['title'] : 'Collapse title'; + $open = isset( $attrs['open'] ) && 'true' === $attrs['open']; + return self::details( $title, $content, $open, $id, $group ); + } + + private static function details( $title, $content, $open, $id, $group ) { + $title_html = esc_html( wp_strip_all_tags( $title ) ); + if ( self::blocks() ) { + $block_attrs = array(); + if ( $open ) { + $block_attrs['showContent'] = true; + } + if ( $group ) { + $block_attrs['name'] = $group; + } + $html = '
' . $title_html . '' . self::inner_blocks( self::process( $content, false ) ) . '
'; + return self::wrap_block( 'details', $block_attrs, $html ); + } + return '' . $title_html . '' . self::inner_html( $content ) . '
'; + } + + /** + * Tabs: collect the tab strip (titles by href) first, then render each pane + * as a collapsible section titled by its tab. A disclosed semantic change. + */ + private static function tabs( $content ) { + self::$notes[] = 'Tabs become collapsible sections (one
per tab, in order). Dropdown tabs are flattened as "Dropdown: Tab". On WordPress 7.1+ you can convert them to the Tabs block in the editor afterwards.'; + $titles = array(); + if ( preg_match_all( '/' . get_shortcode_regex( array( 'bs_thead' ) ) . '/', $content, $heads, PREG_SET_ORDER ) ) { + foreach ( $heads as $head ) { + self::collect_tab_titles( isset( $head[5] ) ? $head[5] : '', '', $titles ); + } + } + $previous = self::$tab_titles; + self::$tab_titles = $titles; + self::$section_counter = 0; + // Strip the head: its tabs are now titles. Convert the panes. + $panes = preg_replace( '/' . get_shortcode_regex( array( 'bs_thead' ) ) . '/', '', $content ); + $panes = self::process( $panes, false ); + self::$tab_titles = $previous; + if ( self::blocks() ) { + return trim( $panes ); + } + return '
' . trim( $panes ) . '
'; + } + + private static function collect_tab_titles( $head, $prefix, &$titles ) { + if ( ! preg_match_all( '/' . get_shortcode_regex( array( 'bs_tab', 'bs_dropdown' ) ) . '/', $head, $items, PREG_SET_ORDER ) ) { + return; + } + foreach ( $items as $item ) { + $attrs = shortcode_parse_atts( $item[3] ); + $attrs = is_array( $attrs ) ? $attrs : array(); + if ( 'bs_dropdown' === $item[2] ) { + $title = isset( $attrs['title'] ) ? $attrs['title'] : ''; + self::collect_tab_titles( isset( $item[5] ) ? $item[5] : '', $title ? $title . ': ' : '', $titles ); + continue; + } + $href = isset( $attrs['href'] ) ? ltrim( trim( $attrs['href'] ), '#' ) : ''; + if ( '' !== $href && ! isset( $titles[ $href ] ) ) { + $titles[ $href ] = $prefix . ( isset( $attrs['title'] ) ? $attrs['title'] : $href ); + } + } + } + + private static function tcontent( $attrs, $content ) { + $id = isset( $attrs['id'] ) ? sanitize_html_class( $attrs['id'] ) : ''; + $open = isset( $attrs['class'] ) && 'active' === trim( $attrs['class'] ); + self::$section_counter++; + $title = ( $id && isset( self::$tab_titles[ $id ] ) ) ? self::$tab_titles[ $id ] : sprintf( 'Section %d', self::$section_counter ); + return self::details( $title, $content, $open, $id, '' ); + } +} + +/** + * Glyphicons Halflings paths, read from the SVG font this plugin already ships + * (the same glyphs the CSS maps to .glyphicon-* classes), so migrated icons + * need no font or CSS at all. + */ +class BS_Glyphs { + + private static $map; + + public static function path( $name ) { + self::load(); + return isset( self::$map[ $name ] ) ? self::$map[ $name ] : null; + } + + public static function names() { + self::load(); + return array_keys( self::$map ); + } + + private static function load() { + if ( null !== self::$map ) { + return; + } + self::$map = array(); + $dir = dirname( BS_SHORTCODES_FILE ); + $css = @file_get_contents( $dir . '/css/bootstrap.css' ); + $font = @file_get_contents( $dir . '/fonts/glyphicons-halflings-regular.svg' ); + if ( ! $css || ! $font ) { + return; + } + // class -> codepoint + $codes = array(); + if ( preg_match_all( '/\.glyphicon-([a-z0-9-]+):before\{content:"\\\\([0-9a-f]+)"\}/i', $css, $m, PREG_SET_ORDER ) ) { + foreach ( $m as $mm ) { + $codes[ $mm[1] ] = hexdec( $mm[2] ); + } + } + // codepoint -> path + $paths = array(); + if ( preg_match_all( '/]*)\/>/', $font, $g, PREG_SET_ORDER ) ) { + foreach ( $g as $gg ) { + if ( ! preg_match( '/unicode="([^"]*)"/', $gg[1], $u ) || ! preg_match( '/\sd="([^"]*)"/', $gg[1], $d ) ) { + continue; + } + $uni = html_entity_decode( $u[1], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + $cp = self::codepoint( $uni ); + if ( null === $cp ) { + continue; + } + $adv = preg_match( '/horiz-adv-x="(\d+)"/', $gg[1], $a ) ? (int) $a[1] : 1200; + $paths[ $cp ] = array( 'd' => $d[1], 'adv' => $adv ); + } + } + foreach ( $codes as $name => $cp ) { + if ( isset( $paths[ $cp ] ) ) { + self::$map[ $name ] = $paths[ $cp ]; + } + } + } + + private static function codepoint( $char ) { + if ( '' === $char ) { + return null; + } + $ord = function_exists( 'mb_ord' ) ? mb_ord( $char, 'UTF-8' ) : null; + if ( null === $ord || false === $ord ) { + $u = unpack( 'N', mb_convert_encoding( $char, 'UCS-4BE', 'UTF-8' ) ); + $ord = $u ? $u[1] : null; + } + return $ord; + } +} diff --git a/js/admin-tools.js b/js/admin-tools.js index dbf4340..d1d6efb 100644 --- a/js/admin-tools.js +++ b/js/admin-tools.js @@ -63,3 +63,106 @@ run(false); } })(); + +/* Migration: select-all boxes and batched "Migrate selected". */ +(function () { + if (typeof bsTools === 'undefined') { + return; + } + var checkAlls = document.querySelectorAll('.bs-check-all'); + for (var i = 0; i < checkAlls.length; i++) { + checkAlls[i].addEventListener('change', function (e) { + var boxes = e.target.closest('form').querySelectorAll('input[name="post_ids[]"]'); + for (var j = 0; j < boxes.length; j++) { + boxes[j].checked = e.target.checked; + } + sync(); + }); + } + var form = document.querySelector('.bs-migrate-batch'); + var button = form ? form.querySelector('.bs-migrate-selected') : null; + var progress = form ? form.querySelector('.bs-migrate-progress') : null; + + function selected() { + if (!form) { + return []; + } + var boxes = form.querySelectorAll('input[name="post_ids[]"]:checked'); + var ids = []; + for (var i = 0; i < boxes.length; i++) { + ids.push(boxes[i].value); + } + return ids; + } + function sync() { + if (button) { + button.disabled = selected().length === 0; + } + } + if (!form) { + return; + } + form.addEventListener('change', sync); + sync(); + + function mark(id, text, ok) { + var row = form.querySelector('tr[data-post-id="' + id + '"] .bs-migrate-status'); + if (row) { + var strong = document.createElement('strong'); + strong.style.color = ok ? '#00a32a' : '#d63638'; + strong.textContent = text; + row.textContent = ''; + row.appendChild(strong); + } + } + + function sendBatch(ids) { + var body = new FormData(); + body.append('action', form.getAttribute('data-action')); + body.append('_ajax_nonce', form.getAttribute('data-nonce')); + body.append('mode', form.querySelector('input[name="mode"]').value); + body.append('style', form.querySelector('input[name="style"]').value); + for (var i = 0; i < ids.length; i++) { + body.append('post_ids[]', ids[i]); + } + return fetch(bsTools.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body }) + .then(function (r) { return r.json(); }) + .then(function (json) { + if (!json || !json.success) { + throw new Error(json && json.data && json.data.message ? json.data.message : 'request failed'); + } + var results = json.data.results || {}; + Object.keys(results).forEach(function (id) { + var r = results[id]; + mark(id, r.ok ? bsTools.i18n.migrated + ' (' + r.converted + ')' : bsTools.i18n.failed + ': ' + r.message, r.ok); + }); + }); + } + + form.addEventListener('submit', function (e) { + e.preventDefault(); + var ids = selected(); + if (!ids.length || !window.confirm(bsTools.i18n.confirm)) { + return; + } + button.disabled = true; + var size = parseInt(form.getAttribute('data-batch'), 10) || 10; + var done = 0; + function next() { + if (!ids.length) { + progress.textContent = ' ' + done + ' done. Re-run the scan to refresh the report.'; + return; + } + var chunk = ids.splice(0, size); + return sendBatch(chunk).then(function () { + done += chunk.length; + progress.textContent = ' ' + done + ' processed…'; + return next(); + }); + } + next().catch(function (err) { + progress.textContent = ' stopped: ' + err.message; + button.disabled = false; + }); + }); +})(); diff --git a/readme.txt b/readme.txt index 640f0a9..7945132 100644 --- a/readme.txt +++ b/readme.txt @@ -70,6 +70,8 @@ Please report issues directly to our [Github repository](https://github.com/TheW * New Tools > Bootstrap Shortcodes page: scans the site in batches and reports which shortcodes are used where (published vs drafts, widgets, options, custom fields, term descriptions, theme files), what a text scan cannot see, and what removing the plugin would change * New WP-CLI command: wp bs-shortcodes scan * Optional, manual "share a summary with the author" export: nothing is ever sent automatically (see docs/privacy.md) +* Migration: preview (with a diff), migrate one post or a batch, and roll back. Every component has a destination (Columns, Buttons, Details, Paragraph, Group/Custom HTML blocks, or plain HTML); originals are kept in a custom field; nothing runs automatically (see docs/migration.md) +* "Bootstrap 3 Legacy Assets" companion plugin, built and installed from the migration page with explicit consent, for sites that must keep Bootstrap 3 after removing this plugin = 3.5.0 = * Add optional target attribute to the button shortcode ("_blank" also adds rel="noopener noreferrer") @@ -146,6 +148,10 @@ Please report issues directly to our [Github repository](https://github.com/TheW No. It makes no external requests and has no tracking. The dependency report under Tools can produce a summary of shortcode usage counts that you may choose to send to the author yourself; nothing is sent by the plugin. Details: https://github.com/TheWebShop/bootstrap-shortcodes/blob/master/docs/privacy.md += How do I stop using this plugin? = + +Tools > Bootstrap Shortcodes: scan, preview, migrate (per post or in batches, with rollback), then decide whether anything still needs Bootstrap 3 itself. The full component-by-component guide: https://github.com/TheWebShop/bootstrap-shortcodes/blob/master/docs/migration.md + = How do I find out what on my site uses these shortcodes? = Tools > Bootstrap Shortcodes scans posts, pages, custom post types, widgets, options, custom fields, term descriptions and your theme's PHP files, and lists what it found and what it could not check. On the command line: `wp bs-shortcodes scan`. diff --git a/specs/migration.spec.js b/specs/migration.spec.js new file mode 100644 index 0000000..e876c86 --- /dev/null +++ b/specs/migration.spec.js @@ -0,0 +1,222 @@ +/** + * The whole exit path in a browser: scan, preview, migrate one post, roll it + * back, migrate in a batch, install the legacy-assets companion, deactivate + * Bootstrap Shortcodes, and check the migrated content still renders and works + * on desktop and phone viewports. Finally reactivate so other specs are unaffected. + */ +const { test, expect } = require('@wordpress/e2e-test-utils-playwright'); + +const CLASSIC = [ + '[bs_row class="row"][bs_col class="col-sm-8"]Main column text[/bs_col][bs_col class="col-sm-4"]Side column text[/bs_col][/bs_row]', + '', + '[bs_button type="primary" value="Go somewhere" href="https://example.com/"]', + '', + '[bs_well size="sm"]A well of text[/bs_well]', + '[bs_notification type="info"]An info notice[/bs_notification]', + '[bs_lead]Lead text here[/bs_lead]', + 'Inline: [bs_label type="success"]Label text[/bs_label] and [bs_icon name="glyphicon glyphicon-star"] star and [bs_tooltip]tooltip words[/bs_tooltip].', + '', + '[bs_collapse id="faq"][bs_citem id="q1" parent="faq" title="First question"]First answer[/bs_citem][bs_citem id="q2" parent="faq" title="Second question" open="true"]Second answer[/bs_citem][/bs_collapse]', + '', + '[bs_tabs][bs_thead][bs_tab class="active" href="#t1" title="Tab one"][bs_tab href="#t2" title="Tab two"][/bs_thead][bs_tcontents][bs_tcontent class="active" id="t1"]Pane one text[/bs_tcontent][bs_tcontent id="t2"]Pane two text[/bs_tcontent][/bs_tcontents][/bs_tabs]', +].join('\n'); + +const BLOCKS = [ + '', + '

Block intro

', + '', + '', + '', + '[bs_row][bs_col class="col-sm-6"]Block left[/bs_col][bs_col class="col-sm-6"]Block right[/bs_col][/bs_row]', + '', +].join('\n'); + +const TOOLS = { path: 'tools.php', query: 'page=bs-shortcodes-tools' }; + +async function scan(admin, page) { + await admin.visitAdminPage(TOOLS.path, TOOLS.query); + await page.getByRole('button', { name: /Scan this site|Scan again/ }).click(); + await expect(page.getByRole('heading', { name: 'Migrate', exact: true })).toBeVisible({ timeout: 30000 }); +} + +async function expectMigratedPageWorks(page, link) { + await page.goto(link); + // Grid + await expect(page.locator('.row .col-sm-8')).toContainText('Main column text'); + // Button + const button = page.locator('a.btn.btn-primary'); + await expect(button).toHaveAttribute('href', 'https://example.com/'); + await expect(button).toBeVisible(); + // Well, alert, lead, label with self-contained styling + await expect(page.locator('div.well')).toHaveCSS('background-color', 'rgb(245, 245, 245)'); + await expect(page.locator('div.alert[role="alert"]')).toContainText('An info notice'); + await expect(page.locator('.lead')).toContainText('Lead text here'); + await expect(page.locator('span.label-success')).toHaveCSS('background-color', 'rgb(92, 184, 92)'); + // Icon as inline SVG, tooltip as native title + await expect(page.locator('svg.bs-icon-star path')).toHaveCount(1); + await expect(page.locator('span.bs-tooltip')).toHaveAttribute('title', 'tooltip words'); + // Collapse: second open, first toggles by click and by keyboard + await expect(page.locator('details#q2')).toHaveAttribute('open', ''); + await expect(page.locator('details#q1')).not.toHaveAttribute('open', ''); + await page.locator('details#q1 summary').click(); + await expect(page.locator('details#q1')).toHaveAttribute('open', ''); + await expect(page.locator('details#q1')).toContainText('First answer'); + // Exclusive group: opening q1 closed q2 (name attribute); keyboard toggles q1 back. + await expect(page.locator('details#q2')).not.toHaveAttribute('open', ''); + await page.locator('details#q1 summary').focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('details#q1')).not.toHaveAttribute('open', ''); + // Tabs: sections, first open + await expect(page.locator('details#t1')).toHaveAttribute('open', ''); + await expect(page.locator('details#t1 summary')).toHaveText('Tab one'); + await page.locator('details#t2 summary').click(); + await expect(page.locator('details#t2')).toContainText('Pane two text'); + // No literal shortcodes anywhere + await expect(page.locator('body')).not.toContainText('[bs_'); +} + +const COMPANION = 'bootstrap-legacy-assets/bootstrap-legacy-assets'; + +// Start and finish without the companion, whatever an earlier run left behind. +async function removeCompanion(requestUtils) { + try { + await requestUtils.rest({ method: 'PUT', path: `/wp/v2/plugins/${COMPANION}`, data: { status: 'inactive' } }); + } catch (e) { /* not installed */ } + try { + await requestUtils.rest({ method: 'DELETE', path: `/wp/v2/plugins/${COMPANION}` }); + } catch (e) { /* not installed or not deletable here */ } +} + +test.describe('Migration end to end', () => { + let classic; + let blocks; + + test.beforeAll(async ({ requestUtils }) => { + await removeCompanion(requestUtils); + await requestUtils.deleteAllPosts(); + classic = await requestUtils.createPost({ title: 'Classic migration', content: CLASSIC, status: 'publish' }); + blocks = await requestUtils.createPost({ title: 'Block migration', content: BLOCKS, status: 'publish' }); + }); + + test('preview, migrate, roll back, batch-migrate, hand off assets, deactivate and verify', async ({ admin, page, requestUtils }) => { + test.setTimeout(180000); + + // Default asset settings so the pre-migration page is the normal one. + await admin.visitAdminPage('options-general.php', 'page=bootstrap-shortcodes/bootstrap-shortcodes.php'); + await page.locator('input[name="bs_options[chk_default_options_css]"]').setChecked(true); + await page.locator('input[name="bs_options[chk_default_options_js]"]').setChecked(true); + await page.locator('input[name="bs_options[chk_conditional_assets]"]').setChecked(false); + await page.locator('input[type="submit"]').click(); + + await scan(admin, page); + const row = page.locator('table.bs-migrate-table tr', { hasText: 'Classic migration' }); + await expect(row).toContainText('bs_row'); + + // Preview shows a diff, notes, and no write has happened. + await row.getByRole('link', { name: 'Preview' }).click(); + await expect(page.getByRole('heading', { name: 'Preview: Classic migration' })).toBeVisible(); + await expect(page.locator('table.diff')).toBeVisible(); + await expect(page.locator('.bs-notes')).toContainText('Tabs become collapsible sections'); + await expect(page.locator('.bs-notes')).toContainText('dismiss'); + const before = await requestUtils.rest({ path: `/wp/v2/posts/${classic.id}`, params: { context: 'edit' } }); + expect(before.content.raw).toContain('[bs_row'); + + // Migrate this post. + await page.getByRole('button', { name: 'Migrate this post' }).click(); + await expect(page.locator('.notice.is-dismissible.notice-success')).toContainText('1 post migrated'); + const after = await requestUtils.rest({ path: `/wp/v2/posts/${classic.id}`, params: { context: 'edit' } }); + expect(after.content.raw).not.toContain('[bs_row'); + expect(after.content.raw).toContain(' d.accept()); + await page.getByRole('button', { name: 'Roll back selected' }).click(); + await expect(page.locator('.notice.is-dismissible.notice-success')).toContainText('1 post rolled back'); + const restored = await requestUtils.rest({ path: `/wp/v2/posts/${classic.id}`, params: { context: 'edit' } }); + expect(restored.content.raw).toBe(CLASSIC); + + // Batch-migrate both posts through the AJAX path. + await scan(admin, page); + await page.locator('table.bs-migrate-table .bs-check-all').check(); + page.once('dialog', (d) => d.accept()); + await page.getByRole('button', { name: 'Migrate selected' }).click(); + await expect(page.locator('.bs-migrate-progress')).toContainText('done', { timeout: 30000 }); + await expect(page.locator('table.bs-migrate-table tr', { hasText: 'Classic migration' })).toContainText('migrated'); + await expect(page.locator('table.bs-migrate-table tr', { hasText: 'Block migration' })).toContainText('migrated'); + const migratedBlocks = await requestUtils.rest({ path: `/wp/v2/posts/${blocks.id}`, params: { context: 'edit' } }); + expect(migratedBlocks.content.raw).toContain('\n

Block

\n"; + $r = $this->blocks( $content ); + $this->assertSame( $content, $r['content'] ); + $this->assertSame( 0, $r['converted'] ); + } + + public function test_escaped_shortcodes_and_unknown_bs_tags_are_left_alone() { + $content = 'Docs: [[bs_well]x[/bs_well]] and [bs_bogus a="1"] and [bs_well]real[/bs_well]'; + $r = $this->html( $content ); + $this->assertStringContainsString( '[[bs_well]x[/bs_well]]', $r['content'] ); + $this->assertStringContainsString( '[bs_bogus a="1"]', $r['content'] ); + $this->assertStringNotContainsString( '[bs_well]real', $r['content'] ); + $this->assertSame( 1, $r['converted'] ); + } + + public function test_unbalanced_tags_are_skipped_and_explained() { + $content = '[bs_well]never closed [bs_button value="ok"]'; + $r = $this->html( $content ); + $this->assertStringStartsWith( '[bs_well]never closed', $r['content'] ); + $this->assertStringNotContainsString( '[bs_button', $r['content'] ); + $this->assertCount( 1, $r['skipped'] ); + $this->assertSame( 'bs_well', $r['skipped'][0]['tag'] ); + $this->assertSame( 'unbalanced', $r['skipped'][0]['reason'] ); + } + + public function test_unknown_attributes_are_dropped_with_a_note() { + $r = $this->html( '[bs_well size="sm" color="red"]x[/bs_well]' ); + $this->assertStringNotContainsString( 'red', $r['content'] ); + $this->assertStringContainsString( 'Attribute "color" on [bs_well]', implode( "\n", $r['notes'] ) ); + } + + public function test_editor_line_break_markers_are_removed_like_the_handlers_do() { + $r = $this->html( '[bs_well]
Text
[/bs_well]' ); + $this->assertStringNotContainsString( 'class="nc"', $r['content'] ); + $this->assertStringContainsString( 'Text', $r['content'] ); + } + + /* ---- grid ------------------------------------------------------------- */ + + public function test_grid_becomes_columns_blocks_with_widths() { + $r = $this->blocks( '[bs_row][bs_col class="col-sm-8"]Main[/bs_col][bs_col class="col-sm-4"]Side[/bs_col][/bs_row]' ); + $c = $r['content']; + $this->assertStringContainsString( '', $c ); + $this->assertStringContainsString( '', $c ); + $this->assertStringContainsString( '
', $c ); + $this->assertStringContainsString( '', $c ); + $this->assertStringContainsString( '' . "\n" . '

Main

' . "\n" . '', $c ); + $this->assertSame( 3, $r['converted'] ); + $blocks = parse_blocks( $c ); + $this->assertSame( 'core/columns', $blocks[0]['blockName'] ); + $this->assertCount( 2, $blocks[0]['innerBlocks'] ); + $this->assertSame( 'core/paragraph', $blocks[0]['innerBlocks'][0]['innerBlocks'][0]['blockName'] ); + } + + public function test_grid_custom_classes_and_offsets_are_kept_or_noted() { + $r = $this->blocks( '[bs_row class="row fancy"][bs_col class="col-md-6 col-md-offset-3 hero"]x[/bs_col][/bs_row]' ); + $this->assertStringContainsString( '', $r['content'] ); + $this->assertStringContainsString( '
', $r['content'] ); + $this->assertStringContainsString( '{"width":"50%","className":"hero"}', $r['content'] ); + $this->assertStringContainsString( 'offset', wp_json_encode( $r['notes'] ) ); + } + + public function test_grid_in_html_mode_keeps_bootstrap_markup() { + $r = $this->html( '[bs_row][bs_col class="col-sm-6"]A[/bs_col][/bs_row]' ); + $this->assertSame( '
A
', $r['content'] ); + $this->assertContains( 'bootstrap-css', $r['dependencies'] ); + } + + /* ---- buttons ---------------------------------------------------------- */ + + public function test_standalone_button_becomes_a_buttons_block() { + $r = $this->blocks( "Before\n\n[bs_button type=\"primary\" value=\"Go\" href=\"https://x.test/\" target=\"_blank\"]\n\nAfter" ); + $c = $r['content']; + $this->assertStringContainsString( '', $c ); + $this->assertStringContainsString( 'Go', $c ); + $blocks = array_values( array_filter( parse_blocks( $c ), function ( $b ) { return null !== $b['blockName']; } ) ); + $this->assertSame( array( 'core/paragraph', 'core/buttons', 'core/paragraph' ), wp_list_pluck( $blocks, 'blockName' ) ); + } + + public function test_inline_button_stays_inline_html() { + $r = $this->blocks( 'Click [bs_button value="here" href="/x"] to continue.' ); + $this->assertStringContainsString( '

Click here to continue.

', $r['content'] ); + $this->assertStringNotContainsString( 'wp:buttons', $r['content'] ); + } + + public function test_button_html_mode_is_identical_to_the_shortcode_output() { + $shortcode = '[bs_button size="lg" type="primary" value="Click Me" href="https://example.com" target="_blank"]'; + $this->assertSame( do_shortcode( $shortcode ), $this->html( $shortcode )['content'] ); + } + + /* ---- wells, alerts, lead ---------------------------------------------- */ + + public function test_well_inline_style_is_self_contained() { + $r = $this->html( '[bs_well size="lg"]Text[/bs_well]' ); + $this->assertStringContainsString( '
Text
', $r['content'] ); + $this->assertContains( 'bootstrap-css', $r['dependencies'] ); + } + + public function test_well_in_blocks_mode_becomes_a_group_block_with_inner_paragraph() { + $r = $this->blocks( '[bs_well]One
[/bs_well]', 'classes' ); + $this->assertStringContainsString( '', $r['content'] ); + $this->assertStringContainsString( '
', $r['content'] ); + $html = $this->blocks( '[bs_well]One[/bs_well]', 'inline' ); + $this->assertStringContainsString( '', $html['content'] ); + $this->assertStringContainsString( '
assertStringContainsString( '