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..ae578bd 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{ @@ -178,13 +182,19 @@ function enqueue_assets() { } $this->assets_enqueued = true; $init_deps = array( 'jquery' ); - if ( $this->option_on( 'chk_default_options_css' ) ) { + // The legacy-assets companion may already have queued the same files + // (conditional loading, late render-time load): never load Bootstrap twice. + if ( $this->option_on( 'chk_default_options_css' ) && ! wp_style_is( 'bs-legacy-bootstrap', 'enqueued' ) ) { wp_enqueue_style( 'bs_bootstrap', plugins_url( 'css/bootstrap.css', __FILE__ ) ); wp_enqueue_style( 'bs_shortcodes', plugins_url( 'css/shortcodes.css', __FILE__ ) ); } if ( $this->option_on( 'chk_default_options_js' ) ) { - wp_enqueue_script( 'bs_bootstrap', plugins_url( 'js/bootstrap.js', __FILE__ ), array( 'jquery' ) ); - $init_deps[] = 'bs_bootstrap'; + if ( wp_script_is( 'bs-legacy-bootstrap', 'enqueued' ) ) { + $init_deps[] = 'bs-legacy-bootstrap'; + } else { + wp_enqueue_script( 'bs_bootstrap', plugins_url( 'js/bootstrap.js', __FILE__ ), array( 'jquery' ) ); + $init_deps[] = 'bs_bootstrap'; + } } wp_enqueue_script( 'bs_init', plugins_url( 'js/init.js', __FILE__ ), $init_deps ); } @@ -302,5 +312,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..91a17ce --- /dev/null +++ b/companion/bootstrap-legacy-assets/bootstrap-legacy-assets.php @@ -0,0 +1,149 @@ + '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 loading its own copy on every + * page. With its conditional loading on it only loads where shortcodes + * remain, so this plugin covers the other pages; other_bootstrap() then sees + * the original's files on pages where it did load, and the original stands + * down on late (render-time) loads once this plugin's files are queued. + */ + public static function original_plugin_loads_assets() { + if ( ! class_exists( 'BootstrapShortcodes' ) ) { + return false; + } + $o = get_option( 'bs_options' ); + if ( ! is_array( $o ) || ! empty( $o['chk_conditional_assets'] ) ) { + return false; + } + return ! 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' ); + // The original loaded this alongside: icon-* sprites and small layout fixups. + wp_enqueue_style( 'bs-legacy-shortcodes', $base . '/css/shortcodes.css', array( 'bs-legacy-bootstrap' ), self::VERSION ); + } + 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 and loading its assets on every page, this plugin does nothing; with that plugin's conditional loading on, this plugin covers the pages it skips. If the theme registers its own bootstrap.css or bootstrap.js, the matching file here is skipped. + +The CSS switch also loads Bootstrap Shortcodes' small shortcodes.css (old icon-* sprite icons and layout fixups), as that plugin did. + +== 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..f4d36f1 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,134 @@ +# 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, `bs_` tags the plugin + never registered (typos, other plugins), and block-level components (row, column, + well, alert, lead, collapse, tabs) written *inside* a paragraph, heading or list + item of a block post, where a `
` would break the block. For a paragraph, + put the shortcode in its own paragraph (or a Shortcode block) and migrate again; + inside a list, move it out of the list block or convert that list by hand. 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 two ways: it round-trips through core's PHP block +parser/serializer losslessly (`MigratorConvertTest::test_block_mode_output_is_what_the_editor_would_save`, +which proves the delimiters and attributes are well formed), and the Playwright +run opens migrated posts in the block editor and asserts that no block shows the +"unexpected or invalid content" warning (`specs/migration.spec.js`), which is the +editor's own JavaScript validation of each block's saved HTML. + +## 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 %); a `bs_col` outside any `bs_row` gets its own Columns block (noted) | `
` unchanged (a custom `class` on `bs_row` replaces `row`, as the shortcode did) | 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 (noted when a `type` or `size` was set) | 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"` (the classes variant's Group block cannot store `role`, so it is dropped there, noted) | 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; old `icon-*` sprite names keep their `` and are noted | classes variant, or unknown glyphs, need the Glyphicons font (Bootstrap CSS); `icon-*` names need the plugin's `shortcodes.css` sprite, which the companion ships | +| `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; items with a `parent` (what the editor button writes) share `name` = that parent so one opens at a time (the block's `name` needs WP 6.8+; on 6.3 to 6.7 it is left out and the lost grouping is noted; below 6.3 each item is a Custom HTML block holding a `

`), items without one stay independent, as Bootstrap's `data-parent` worked; `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 (with this plugin's `shortcodes.css` and its + `icon-*` sprite), 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 and loading its assets on every + page, and skips its CSS or JS when the theme (or Bootstrap Shortcodes itself) + registers a `bootstrap.css` / `bootstrap.js`, so nothing loads twice. With + Bootstrap Shortcodes' conditional loading on, the companion covers the pages + without shortcodes and Bootstrap Shortcodes covers the rest; +- is installed only from the migration page after ticking an explicit statement, or + by uploading the downloaded zip yourself. Installed from the migration page, its + settings start as copies of this plugin's CSS/JS settings; uploaded by hand, it + starts with its own defaults (CSS, JS and tooltips on), so check Settings > Legacy + Bootstrap. + +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/docs/reporting-receiver.md b/docs/reporting-receiver.md index 6b1ac19..9c88326 100644 --- a/docs/reporting-receiver.md +++ b/docs/reporting-receiver.md @@ -1,9 +1,21 @@ # Follow-up: a receiver for shared usage summaries -Status: **decision recorded 2026-10-31, not built, not deployed.** Today the only -channel is the manual export on the Tools page (`docs/privacy.md`). This note is -the smallest design that would let a user send that same payload with one click, -so the deployment decision can be made in one sitting when it is wanted. +Status: **approved to build 2026-10-31 as a trial; not built, not deployed.** Today +the only channel is the manual export on the Tools page (`docs/privacy.md`). This +note is the design for a one-click send to a Cloudflare Worker, written so the next +session can build it without re-deriving the constraints. + +## Constraints from the maintainer (2026-10-31) + +- Hosting the tool for the community is welcome, and paying for it is fine, but it + must not be able to run up a bill: abuse by one party must cost nothing beyond a + fixed, known amount. Prefer the **Workers Free plan**, where exceeding a limit + fails requests instead of billing, over Workers Paid with usage billing. +- Heavy control and monitoring: a kill switch, per-IP and global rate limits, + payload size and schema caps, and a view of volume per day that the maintainer + checks, with alerts where Cloudflare offers them for free. +- This is a learning trial for future (possibly commercial) plugin services, so + keep it small, readable and fully documented rather than clever. ## What it must not change @@ -16,7 +28,7 @@ so the deployment decision can be made in one sitting when it is wanted. ## Smallest viable receiver One HTTPS endpoint, `POST /v1/summary`, accepting `application/json` bodies up to -8 KB with `schema === 1`, validating field names and types against an allowlist, +2 KB with `schema === 1`, validating field names and types against an allowlist, and appending the record to a store with a server-generated random id. Replies `202` with nothing but the id, which the plugin shows so the user can quote it when asking for deletion. @@ -37,7 +49,9 @@ Cloudflare's own request logs exist and are retained per their policy. - **Stored:** the payload, the server-generated id, and the received date (day). - **Not stored:** IP, user agent, headers, referrer. -- **Retention:** 24 months, then deleted; or sooner on request quoting the id. +- **Retention:** 12 months (KV TTL), then deleted; or sooner on request quoting the id. + The derived keys hold counts only and expire too: `count:YYYY-MM-DD` after 2 days, + `stats:YYYY-MM-DD` after 12 months (both set with a per-key TTL on write). - **Access:** the maintainer only; used to decide which components the migration tools must cover; aggregate counts may be published in the repository. - **Deletion:** the id shown after sending is the only key; an email or GitHub @@ -45,6 +59,25 @@ Cloudflare's own request logs exist and are retained per their policy. - **Re-identification:** unchanged from `docs/privacy.md`: not from the data, possibly from unusual counts to someone who already knows the site. +## Abuse and cost controls (required before deploy) + +| Control | Mechanism | Free-plan ceiling it relies on | +| --- | --- | --- | +| Account daily cap (shared) | Workers Free: 100,000 requests/day **per account**, then requests error; KV Free: 1,000 writes/day **per account**, then writes fail. Other Workers on the same account eat into these, so the receiver gets its own Cloudflare account (free) or the shared-quota risk is accepted knowingly | the cap is the bill: there is none | +| Per-IP rate limit | Workers Rate Limiting binding with a supported period (60 s; e.g. 3 requests / 60 s per IP). Counters are approximate and per Cloudflare location, so this is a brake, not a guarantee; one WAF rate-limiting rule (free plan allows one) backs it up at the edge | no cost | +| Kill switch | KV key `paused`; checked first; set from the dashboard or `wrangler kv`. KV is eventually consistent, so other Cloudflare locations can keep accepting for up to about 60 seconds after it is set; the immediate stop is `wrangler delete` (or deleting the Worker in the dashboard), because disabling one route leaves its version, preview and deployment URLs reachable | one KV read per request (100,000/day free) | +| Payload cap | reject bodies > 2 KB (a full payload with every component is about 1 KB), non-JSON, `schema !== 1`, unknown keys, non-integer counts, counts > 10^7 | Worker CPU only | +| Ingestion cap | about 300 accepted records per UTC day, read from a KV counter `count:YYYY-MM-DD` (2-day TTL); beyond that reply `429` without storing. KV is eventually consistent and allows one write per key per second, so under a concurrent burst this cap is **approximate** (it can overshoot, and counter writes can fail). The hard ceiling is the account's 1,000 KV writes/day, which bounds storage whatever the counter says. Write order: store the record first and increment the counter only if that succeeded; a failed record write returns `503` and leaves the counter alone, and `202` is returned only after both writes. A failed counter write after a stored record is logged and tolerated (it makes the cap looser, which the hard ceiling still bounds). If an exact cap, or both writes as one transaction, is wanted, use a Durable Object counter (SQLite-backed Durable Objects are on the Free plan), decided when the receiver is built | KV Free writes 1,000/day | +| Method/path allowlist | `POST /v1/summary` (public) and `GET /v1/stats` (bearer secret, see Monitoring); everything else 404 with no body | – | +| No egress | the Worker calls nothing (no email, no third parties) | – | +| Storage growth | one KV value per record, TTL 12 months, key = random id; a daily cron summarises counts into one `stats:YYYY-MM-DD` key. Worst case, using the hard 1,000 writes/day rather than the soft 300: 1,000 × 2 KB × 365 ≈ 730 MB, inside the 1 GB KV Free limit | KV Free 1 GB | +| Monitoring | `GET /v1/stats` behind a bearer secret (wrangler secret) returning per-day counts; Cloudflare dashboard Workers metrics; `wrangler tail` for live logs; Cloudflare Notifications for Worker error rate | no cost | +| Upgrade path | if the trial outgrows Free, move to Workers Paid only after adding a hard spend ceiling in the dashboard and re-reviewing this table | – | + +Explicitly rejected: Turnstile/CAPTCHA (the client is a server, not a browser), API +keys shipped in the plugin (public by definition), and storing IPs for abuse +analysis (the rate limiter needs them only in memory). + ## Plugin-side change when the receiver exists - A "Send this summary now" button on the same consent screen, next to diff --git a/inc/class-bs-admin-migration.php b/inc/class-bs-admin-migration.php new file mode 100644 index 0000000..ed0d506 --- /dev/null +++ b/inc/class-bs-admin-migration.php @@ -0,0 +1,441 @@ + 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. + ?> +
+ + +
+
+ + + + + + +

+ + +

+
+ + +

+ __( 'opening/closing tags do not match', 'bsshortcodes' ), + 'inline-context' => __( 'a block-level component written inside a paragraph or heading; move it onto its own line, then migrate again', 'bsshortcodes' ), + ); + $parts[] = sprintf( '[%s] (%s)', $s['tag'], isset( $reasons[ $s['reason'] ] ) ? $reasons[ $s['reason'] ] : $s['reason'] ); + } + echo esc_html( implode( ', ', $parts ) ); + ?> +

+ + +
    + +
  • + +
+ + +

+ + +

+ __( '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..b255c48 --- /dev/null +++ b/inc/class-bs-companion.php @@ -0,0 +1,129 @@ + 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', + // Loaded with bootstrap.css by the original: legacy icon-* sprites and layout fixups. + self::SLUG . '/css/shortcodes.css' => $plugin . '/css/shortcodes.css', + self::SLUG . '/images/glyphicons-halflings.png' => $plugin . '/images/glyphicons-halflings.png', + 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' => '1', // the original always loads init.js, whatever the JS setting + ) ) ); + $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..88ef2e4 --- /dev/null +++ b/inc/class-bs-migration.php @@ -0,0 +1,203 @@ + $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' ) ); + } + // Users without unfiltered_html have their content passed through kses on + // save, which strips the inline SVG icons (and, before WordPress 6.9, the + //
grouping). The preview shows the unfiltered markup, so + // refuse before writing anything rather than save a different post. + if ( self::would_be_filtered( $preview['converted'] ) ) { + return self::filtered_error(); + } + $backup_created = false; + if ( ! $preview['already'] ) { + // add_post_meta() and wp_update_post() both wp_unslash() their input, so + // slash first or every backslash in the content is lost. + $kept = add_post_meta( $post_id, self::META_ORIGINAL, wp_slash( 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' ) ); + } + $backup_created = true; + } + $updated = wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $preview['converted'] ) ), true ); + if ( is_wp_error( $updated ) ) { + if ( $backup_created ) { + delete_post_meta( $post_id, self::META_ORIGINAL ); + } + return $updated; + } + // Backstop for any other content_save_pre filter: never leave a silently + // different version behind. Put the original back, and keep the stored + // copy unless the restore is verified exact. + $saved = get_post_field( 'post_content', $post_id, 'raw' ); + if ( $saved !== $preview['converted'] ) { + wp_update_post( wp_slash( array( 'ID' => $post_id, 'post_content' => $preview['original'] ) ) ); + if ( get_post_field( 'post_content', $post_id, 'raw' ) !== $preview['original'] ) { + // The stored original (created above, or from an earlier migration) stays. + return new WP_Error( 'bs_restore_failed', __( 'A filter on this site changed the post on save and the original could not be written back exactly. The original is kept in the custom field _bs_migration_original; roll back from the Migrated posts list, or restore it from the revisions.', 'bsshortcodes' ) ); + } + if ( $backup_created ) { + delete_post_meta( $post_id, self::META_ORIGINAL ); + } + return new WP_Error( 'bs_filtered', __( 'A filter on this site changed the converted markup on save, so the post would not match the preview. The original was put back.', 'bsshortcodes' ) ); + } + 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' ) ); + } + // Restoring as a user whose HTML is filtered would strip whatever the + // original held that kses disallows (an embed, a script): refuse instead. + if ( self::would_be_filtered( $backup['content'] ) ) { + return self::filtered_error(); + } + $updated = wp_update_post( wp_slash( array( 'ID' => $post->ID, 'post_content' => $backup['content'] ) ), true ); + if ( is_wp_error( $updated ) ) { + return $updated; + } + if ( get_post_field( 'post_content', $post->ID, 'raw' ) !== $backup['content'] ) { + // Some other filter changed it: keep the stored original so nothing is lost. + return new WP_Error( 'bs_restore_failed', __( 'A filter on this site changed the content on save, so the original was not restored exactly. The stored original is kept; restore it from the revisions or the custom field _bs_migration_original.', 'bsshortcodes' ) ); + } + delete_post_meta( $post->ID, self::META_ORIGINAL ); + delete_post_meta( $post->ID, self::META_RESULT ); + return true; + } + + /** Would saving $content as the current user change it? (kses runs for users without unfiltered_html.) */ + private static function would_be_filtered( $content ) { + if ( false === has_filter( 'content_save_pre', 'wp_filter_post_kses' ) ) { + return false; + } + return wp_unslash( wp_filter_post_kses( wp_slash( $content ) ) ) !== $content; + } + + private static function filtered_error() { + return new WP_Error( 'bs_filtered', __( 'Your account cannot save this markup unchanged: WordPress filters HTML for users without the "unfiltered_html" capability, so the post would not match the preview. Nothing was changed. Run this as a user who has that capability; if no account on this site has it, this post cannot be converted automatically.', 'bsshortcodes' ) ); + } + + /** + * 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). Same types and + * statuses as the scan: 'any' would leave out reusable blocks, templates + * and trashed posts, which could then be migrated but never rolled back. + */ + public static function migrated_posts( $limit = 500 ) { + return get_posts( array( + 'post_type' => BS_Scanner::post_types(), + 'post_status' => BS_Scanner::post_statuses(), + '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..347ab30 --- /dev/null +++ b/inc/class-bs-migrator.php @@ -0,0 +1,757 @@ + '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; + self::$row_depth = 0; + self::$inline_only = false; + + $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 ( has_blocks( $content ) ) { + // A block document: convert inside the blocks that hold shortcodes + // (Shortcode, Classic, Paragraph, Custom HTML ...) without breaking + // their structure. In html mode the replacements are plain HTML. + $result['content'] = serialize_blocks( self::convert_blocks( parse_blocks( $content ) ) ); + } else { + $result['content'] = self::process( $content ); + 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::replacement_block( $html ); + } + $out[] = $block; + continue; + } + if ( 'core/shortcode' === $block['blockName'] && $has ) { + $out[] = self::replacement_block( $html ); + 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] ) ) { + $out[] = self::replacement_block( $m[1] ); + continue; + } + if ( $has ) { + // Components inside this block's own HTML (paragraph, heading, list, + // classic, custom HTML ...) are converted in place as static HTML. Only + // Classic and Custom HTML blocks may take block-level markup; inside a + // paragraph or heading a
would break the block (and the page), so + // block-level components there are left in place and listed. + $saved_mode = self::$options['mode']; + self::$options['mode'] = 'html'; + self::$inline_only = ! in_array( $block['blockName'], self::$block_containers, true ); + $block['innerHTML'] = self::process( $html ); + $counted = self::$converted; + $skipped = self::$skipped; + foreach ( $block['innerContent'] as $i => $chunk ) { + if ( is_string( $chunk ) && false !== strpos( $chunk, '[bs_' ) ) { + $block['innerContent'][ $i ] = self::process( $chunk ); + } + } + // innerContent repeats innerHTML: count and list each occurrence once. + self::$converted = $counted; + self::$skipped = $skipped; + self::$inline_only = false; + 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 ); + } + + /** A whole block (Shortcode, Classic text, lone-shortcode paragraph) replaced by its conversion. */ + private static function replacement_block( $html ) { + $converted = self::process( $html ); + return self::raw_block( self::blocks() ? self::inner_blocks( $converted ) : $converted ); + } + + 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 ) { + 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; + } + if ( self::$inline_only && in_array( $tag, self::$block_level, true ) ) { + self::$skipped[] = array( 'tag' => $tag, 'reason' => 'inline-context', '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 ); + 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 ) { + switch ( $tag ) { + case 'bs_row': + return self::row( $attrs, $content ); + case 'bs_col': + return self::col( $attrs, $content ); + 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 ); + 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 ); + } + + /* ------------------------------------------------------------------ */ + /* Components */ + /* ------------------------------------------------------------------ */ + + private static function row( $attrs, $content ) { + // The handler used `class` instead of "row" (shortcode_atts default), never in addition. + $classes = isset( $attrs['class'] ) ? array_values( array_filter( preg_split( '/\s+/', trim( $attrs['class'] ) ) ) ) : array( 'row' ); + $extra = array_values( array_diff( $classes, array( 'row' ) ) ); + if ( ! self::blocks() ) { + self::$deps[] = 'bootstrap-css'; + return '
' . self::inner_html( $content ) . '
'; + } + self::$row_depth++; + $inner = self::process( $content ); + self::$row_depth--; + // 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 ) { + $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 ) ); + $column = self::wrap_block( 'column', $block_attrs, '
' . $inner . '
' ); + if ( 0 === self::$row_depth ) { + // A Column block is only valid inside Columns. + self::$notes[] = 'A [bs_col] outside a [bs_row] was given its own Columns block.'; + return self::wrap_block( 'columns', array(), '
' . $column . '
' ); + } + return $column; + } + + 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 ); + $html = '
' . self::bake_paragraphs( $inner ) . '
'; + return self::wrap_block( 'html', array(), $html ); + } + self::$deps[] = 'bootstrap-css'; + if ( '' !== $extra_attrs ) { + // The Group block's save() writes no role attribute; keeping it would make the block invalid. + self::$notes[] = 'The alert role is not kept: the Group block cannot store it. The message and colours are kept.'; + } + return self::wrap_block( 'group', array( 'className' => $class ), '
' . self::inner_blocks( self::process( $content ) ) . '
' ); + } + $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 ); + 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 ) { + $dropped = array(); + if ( 'default' !== $a['type'] && '' !== $a['type'] ) { + $dropped[] = 'btn-' . $a['type']; + } + if ( '' !== $a['size'] && $a['size'] !== $a['type'] ) { + $dropped[] = 'btn-' . $a['size']; + } + if ( $dropped ) { + self::$notes[] = sprintf( 'Button style "%s" is not carried over: the Button block uses your theme\'s button style. Restyle it in the editor if the colour or size mattered.', implode( ' ', $dropped ) ); + } + $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 ); + 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 ( ! $glyph && preg_match( '/(^|\s)icon-[a-z0-9-]+/', $name ) ) { + // Bootstrap 2 style icon-* classes: a PNG sprite styled by the plugin's + // shortcodes.css, which loads with the Bootstrap CSS (and the companion's). + self::$deps[] = 'bootstrap-css'; + self::$notes[] = sprintf( 'Icon "%s" uses the old icon-* sprite; its markup was kept and still needs the Bootstrap 3 CSS bundle (theme or companion).', $name ); + return $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'] ) : ''; + $inner = self::process( $content ); + if ( self::blocks() ) { + return trim( $inner ); + } + return '
' . $inner . '
'; + } + + private static function citem( $attrs, $content, $unused ) { + // Bootstrap made items exclusive only through data-parent (the `parent` + // attribute); without it each item toggled on its own, so no group name. + $group = isset( $attrs['parent'] ) ? sanitize_html_class( ltrim( trim( $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() ) { + // Match what this WordPress's Details block can save, or the editor + // flags the block as invalid: no block before 6.3, no `name` before 6.8. + $type = WP_Block_Type_Registry::get_instance()->get_registered( 'core/details' ); + if ( ! $type ) { + self::$notes[] = 'Collapsible sections are saved as Custom HTML blocks: this WordPress version has no Details block (it arrived in 6.3).'; + $html = '' . $title_html . '' . self::bake_paragraphs( self::process( $content ) ) . '
'; + return self::wrap_block( 'html', array(), $html ); + } + if ( $group && ! isset( $type->attributes['name'] ) ) { + self::$notes[] = 'Collapse items no longer close each other when one opens: the Details block\'s grouping needs WordPress 6.8 or later. Re-migrate after updating WordPress to keep that behaviour.'; + $group = ''; + } + $block_attrs = array(); + if ( $open ) { + $block_attrs['showContent'] = true; + } + if ( $group ) { + $block_attrs['name'] = $group; + } + $html = '
' . $title_html . '' . self::inner_blocks( self::process( $content ) ) . '
'; + 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 ); + 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..23a770e 100644 --- a/readme.txt +++ b/readme.txt @@ -14,7 +14,7 @@ WordPress plugin to add shortcodes for Bootstrap 3. In maintenance mode: kept sa ## Maintenance mode -This plugin was written in 2013 for Bootstrap 3 and the TinyMCE editor. It is still maintained for **security and compatibility** so the sites that use it keep working, but no new components will be added. If you are starting a new site, use the block editor's own Columns, Buttons, Details, Accordion and Tabs blocks instead. If you already use it, a component-by-component migration guide and tooling ship in the next release; the [compatibility policy](https://github.com/TheWebShop/bootstrap-shortcodes/blob/master/docs/compatibility.md) says what is tested today. +This plugin was written in 2013 for Bootstrap 3 and the TinyMCE editor. It is still maintained for **security and compatibility** so the sites that use it keep working, but no new components will be added. If you are starting a new site, use the block editor's own Columns, Buttons, Details, Accordion and Tabs blocks instead. If you already use it, Tools > Bootstrap Shortcodes finds where it is used and migrates that content to blocks, with a preview and rollback (see the [migration guide](https://github.com/TheWebShop/bootstrap-shortcodes/blob/master/docs/migration.md)); the [compatibility policy](https://github.com/TheWebShop/bootstrap-shortcodes/blob/master/docs/compatibility.md) says what is tested today. Bootstrap Shortcodes allow quick and easy implementation of Bootstrap 3 components through the TinyMCE rich-editor (Classic Editor). In the block editor the shortcodes still render; the toolbar buttons do not appear. @@ -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..26a6f66 --- /dev/null +++ b/specs/migration.spec.js @@ -0,0 +1,264 @@ +/** + * 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

p

\n"; + // wp_insert_post() expects slashed input (the factory passes args straight through). + $id = self::factory()->post->create( array( 'post_content' => wp_slash( $original ) ) ); + $this->assertSame( $original, get_post( $id )->post_content ); + + $r = BS_Migration::apply( $id, array( 'mode' => 'blocks', 'style' => 'inline' ) ); + + $this->assertNotWPError( $r ); + $migrated = get_post( $id )->post_content; + $this->assertStringContainsString( 'C:\\Users\\kev', $migrated ); + $this->assertStringContainsString( '\\u002d\\u002d\\u003c', $migrated ); + $this->assertStringContainsString( 'keep \\ this', $migrated ); + $this->assertSame( $original, get_post_meta( $id, BS_Migration::META_ORIGINAL, true )['content'] ); + $this->assertTrue( BS_Migration::rollback( $id ) ); + $this->assertSame( $original, get_post( $id )->post_content ); + } + + public function test_users_whose_markup_would_be_filtered_are_refused_and_nothing_changes() { + $author = self::factory()->user->create( array( 'role' => 'author' ) ); + $original = '[bs_icon name="glyphicon glyphicon-ok"] done [bs_collapse id="g"][bs_citem id="a" parent="g" title="Q"]A[/bs_citem][/bs_collapse]'; + $id = self::factory()->post->create( array( 'post_content' => $original, 'post_author' => $author ) ); + wp_set_current_user( $author ); + $this->assertFalse( current_user_can( 'unfiltered_html' ) ); + + $r = BS_Migration::apply( $id, array( 'mode' => 'html', 'style' => 'inline' ) ); + + $this->assertWPError( $r ); + $this->assertSame( 'bs_filtered', $r->get_error_code() ); + $this->assertSame( $original, get_post( $id )->post_content ); + $this->assertFalse( BS_Migration::is_migrated( $id ) ); + } + + public function test_migrated_list_includes_every_type_and_status_the_scan_covers() { + $block = self::factory()->post->create( array( 'post_type' => 'wp_block', 'post_content' => '[bs_well]reusable[/bs_well]' ) ); + $trash = self::factory()->post->create( array( 'post_status' => 'trash', 'post_content' => '[bs_well]binned[/bs_well]' ) ); + $this->assertIsArray( BS_Migration::apply( $block, $this->opts ) ); + $this->assertIsArray( BS_Migration::apply( $trash, $this->opts ) ); + $listed = BS_Migration::migrated_posts(); + $this->assertContains( $block, $listed ); + $this->assertContains( $trash, $listed ); + } + + public function test_a_filtered_migration_writes_nothing_at_all() { + $author = self::factory()->user->create( array( 'role' => 'author' ) ); + $original = 'Done [bs_icon name="glyphicon glyphicon-ok"]'; + $id = self::factory()->post->create( array( 'post_content' => $original, 'post_author' => $author ) ); + $before = get_post( $id ); + $revs = count( wp_get_post_revisions( $id ) ); + wp_set_current_user( $author ); + + $r = BS_Migration::apply( $id, array( 'mode' => 'html', 'style' => 'inline' ) ); + + $this->assertSame( 'bs_filtered', $r->get_error_code() ); + $this->assertStringNotContainsString( 'administrator', $r->get_error_message() ); + $this->assertSame( $revs, count( wp_get_post_revisions( $id ) ) ); + $this->assertSame( $before->post_modified_gmt, get_post( $id )->post_modified_gmt ); + } + + public function test_rollback_refuses_rather_than_strip_markup_the_user_cannot_save() { + $author = self::factory()->user->create( array( 'role' => 'author' ) ); + $original = "\n\n[bs_well]x[/bs_well]"; + $id = self::factory()->post->create( array( 'post_content' => $original, 'post_author' => $author ) ); + $this->assertIsArray( BS_Migration::apply( $id, $this->opts ) ); // as the administrator + $migrated = get_post( $id )->post_content; + + wp_set_current_user( $author ); + $r = BS_Migration::rollback( $id ); + + $this->assertWPError( $r ); + $this->assertSame( 'bs_filtered', $r->get_error_code() ); + $this->assertSame( $migrated, get_post( $id )->post_content ); + $this->assertTrue( BS_Migration::is_migrated( $id ) ); + + wp_set_current_user( $this->admin ); + $this->assertTrue( BS_Migration::rollback( $id ) ); + $this->assertSame( $original, get_post( $id )->post_content ); + } +} diff --git a/tests/phpunit/MigratorConvertTest.php b/tests/phpunit/MigratorConvertTest.php new file mode 100644 index 0000000..fa36621 --- /dev/null +++ b/tests/phpunit/MigratorConvertTest.php @@ -0,0 +1,408 @@ + 'blocks', 'style' => $style ) ); + } + + private function html( $content, $style = 'inline' ) { + return BS_Migrator::convert( $content, array( 'mode' => 'html', 'style' => $style ) ); + } + + /* ---- untouched things ------------------------------------------------ */ + + public function test_content_without_our_shortcodes_is_returned_byte_for_byte() { + $content = "

Hello

\n[gallery ids=\"1,2\"]\n\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( '