diff --git a/.distignore b/.distignore index d307c4b..0a301e0 100644 --- a/.distignore +++ b/.distignore @@ -35,3 +35,7 @@ contributing.md docs release.sh release +src/**/*.js +src/**/*.jsx +src/**/*.scss +webpack.config.js diff --git a/.gitignore b/.gitignore index bda5e68..ea83f92 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules/ release/ vendor .phpunit.result.cache +dist/ diff --git a/assets/widget.js b/assets/widget.js index 84c4b97..a7c120f 100644 --- a/assets/widget.js +++ b/assets/widget.js @@ -96,6 +96,14 @@ function show_modal( $modal, $close ) { initTabSwitching(); + // Bind copy button via data attribute (replaces inline onclick). + var $copyBtn = $modal.find( '[data-copy-active]' ); + $copyBtn.off( 'click.republish' ).on( 'click.republish', function() { + if ( window.ClipboardUtils ) { + ClipboardUtils.copyFromElement( getActiveTextarea(), this ); + } + } ); + trapFocus( $modal ); $close.focus(); } diff --git a/includes/class-republish-button-block.php b/includes/class-republish-button-block.php new file mode 100644 index 0000000..555fc7f --- /dev/null +++ b/includes/class-republish-button-block.php @@ -0,0 +1,193 @@ + [ __CLASS__, 'render_block' ], + ]; + + if ( function_exists( 'wp_is_block_theme' ) && ! wp_is_block_theme() ) { + $args['supports'] = [ + 'inserter' => false, + ]; + } + + register_block_type_from_metadata( + REPUBLICATION_TRACKER_TOOL_PATH . 'src/blocks/republish-button', + $args + ); + } + + /** + * Render the block on the frontend. + * + * @param array $attrs Block attributes. + * @return string Rendered block HTML. + */ + public static function render_block( $attrs ) { + global $post; + + // Guard: only render on singular views. + if ( ! is_singular() || ! $post instanceof \WP_Post ) { + return ''; + } + + // Guard: check allowed post types. + $allowed_post_types = (array) apply_filters( 'republication_tracker_tool_post_types', [ 'post' ] ); + if ( ! in_array( get_post_type( $post ), $allowed_post_types, true ) ) { + return ''; + } + + // Guard: check if widget is hidden for this post (same filter as the widget). + $hide = apply_filters( + 'hide_republication_widget', + get_post_meta( $post->ID, 'republication-tracker-tool-hide-widget', true ), + $post + ); + if ( true == $hide ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return ''; + } + + // Translated defaults (block.json defaults are not translatable). + $default_attrs = [ + 'buttonText' => __( 'Republish This Story', 'republication-tracker-tool' ), + 'message' => __( 'Republish our articles for free, online or in print, under a Creative Commons license.', 'republication-tracker-tool' ), + 'displayMode' => 'modal', + ]; + $attrs = wp_parse_args( $attrs, $default_attrs ); + + // Validate displayMode against allowed values. + if ( ! in_array( $attrs['displayMode'], [ 'modal', 'page' ], true ) ) { + $attrs['displayMode'] = 'modal'; + } + + // Fall back to translated default when attribute is empty string. + $button_text = '' === trim( (string) $attrs['buttonText'] ) ? $default_attrs['buttonText'] : $attrs['buttonText']; + $message_text = '' === trim( (string) $attrs['message'] ) ? $default_attrs['message'] : $attrs['message']; + $display_mode = $attrs['displayMode']; + + // License badge. + $license_key = get_option( 'republication_tracker_tool_license', REPUBLICATION_TRACKER_TOOL_DEFAULT_LICENSE ); + $using_license = isset( REPUBLICATION_TRACKER_TOOL_LICENSES[ $license_key ] ); + + // Block wrapper attributes go on the outer div (anchor, alignment, layout, block supports). + $wrapper_attributes = get_block_wrapper_attributes(); + + // Start building output. + $html = '
'; + + // Message. + $html .= '

' . wp_kses_post( $message_text ) . '

'; + + // Button or link (inherits colors from wrapper via CSS). + $button_class = 'wp-block-republication-tracker-tool-republish-button__button'; + if ( 'page' === $display_mode ) { + $endpoint = apply_filters( 'republication_tracker_tool_endpoint', 'republish' ); + $republish_url = home_url( '/' . $endpoint . wp_make_link_relative( get_permalink( $post->ID ) ) ); + $html .= '' . esc_html( $button_text ) . ''; + } else { + $html .= ''; + } + + // License badge. + if ( $using_license ) { + $html .= sprintf( + '

%s

', + esc_url( REPUBLICATION_TRACKER_TOOL_LICENSES[ $license_key ]['url'] ), + esc_html__( 'Creative Commons License', 'republication-tracker-tool' ), + esc_url( plugin_dir_url( __DIR__ ) . 'assets/img/' . $license_key . '.png' ) + ); + } + + $html .= '
'; + + // Modal (only for modal mode, only once per page). + if ( 'modal' === $display_mode ) { + self::enqueue_modal_assets(); + + if ( ! Republication_Tracker_Tool::$modal_rendered ) { + Republication_Tracker_Tool::$modal_rendered = true; + + $is_amp = false; // Block themes do not support AMP. + $modal_content_path = REPUBLICATION_TRACKER_TOOL_PATH . 'includes/shareable-content.php'; + + ob_start(); + ?> + + [], + 'version' => REPUBLICATION_TRACKER_TOOL_VERSION, + ]; + + wp_enqueue_script( + 'republication-tracker-tool-republish-button-view', + REPUBLICATION_TRACKER_TOOL_URL . 'dist/republish-button-view.js', + array_merge( $asset['dependencies'], [ 'republication-tracker-tool-clipboard-utils' ] ), + $asset['version'], + true + ); + } +} + +Republication_Tracker_Tool_Republish_Button_Block::init(); diff --git a/includes/class-widget.php b/includes/class-widget.php index 90056cb..1517bd9 100644 --- a/includes/class-widget.php +++ b/includes/class-widget.php @@ -13,13 +13,6 @@ */ class Republication_Tracker_Tool_Widget extends WP_Widget { - /** - * Whether the widget has been instantiated. - * - * @var bool - */ - public $has_instance = false; - /** * Sets up the widgets name etc. */ @@ -108,25 +101,27 @@ public function widget( $args, $instance ) { echo wp_kses_post( $args['after_widget'] ); - // if has_instance is false, we can continue with displaying the modal. - if ( isset( $this->has_instance ) && false === $this->has_instance ) { + // If the modal has not been rendered yet, include it. + if ( ! Republication_Tracker_Tool::$modal_rendered ) { - // update has_instance so the next time the widget is created on the same page, it does not create a second modal. - $this->has_instance = true; + // Mark the modal as rendered. + Republication_Tracker_Tool::$modal_rendered = true; // define our path to grab file content from. $modal_content_path = plugin_dir_path( __FILE__ ) . 'shareable-content.php'; + $is_amp = self::is_amp(); + if ( $is_amp ) { ?> - + - + =21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", - "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7339,20 +7404,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", - "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.2", - "@typescript-eslint/types": "^8.46.2", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7362,18 +7427,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", - "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7384,9 +7449,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", - "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -7397,7 +7462,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { @@ -7606,9 +7671,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", - "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -7620,22 +7685,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", - "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.2", - "@typescript-eslint/tsconfig-utils": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7645,13 +7709,52 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -7662,16 +7765,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", - "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7681,19 +7784,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", - "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -15190,13 +15293,13 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -32665,9 +32768,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d021fac..142bde4 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,17 @@ "main": "Gruntfile.js", "author": "Automattic", "scripts": { - "start": "npm ci", + "start": "npm ci && npm run watch", "cm": "newspack-scripts commit", - "build": "echo 'No build in this repository.'", + "build": "npm run clean && newspack-scripts wp-scripts build", + "watch": "npm run clean && newspack-scripts wp-scripts start", + "clean": "rm -rf dist", "i18n": "grunt i18n", - "lint:js": "echo 'No JS to lint in this repository.'", + "lint:js": "newspack-scripts wp-scripts lint-js 'src/**/*.{js,jsx}'", + "lint:scss": "newspack-scripts wp-scripts lint-style 'src/**/*.scss'", "readme": "grunt readme", "semantic-release": "newspack-scripts release --files=republication-tracker-tool.php", - "release:archive": "rm -rf release && mkdir -p release && rsync -r . ./release/republication-tracker-tool --exclude-from='./.distignore' && cd release && zip -r republication-tracker-tool.zip republication-tracker-tool", + "release:archive": "npm run build && rm -rf release && mkdir -p release && rsync -r . ./release/republication-tracker-tool --exclude-from='./.distignore' && cd release && zip -r republication-tracker-tool.zip republication-tracker-tool", "release": "npm run semantic-release" }, "repository": { @@ -23,6 +26,7 @@ "url": "https://github.com/Automattic/republication-tracker-tool/issues" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.58.0", "grunt": "~1.6.1", "grunt-wp-i18n": "~1.0.3", "grunt-wp-readme-to-markdown": "~2.1.0", diff --git a/readme.txt b/readme.txt index b4e4152..1d847ed 100644 --- a/readme.txt +++ b/readme.txt @@ -2,9 +2,9 @@ Contributors: innlabs Donate link: https://inn.org/donate Tags: publishers, news -Requires at least: 5.3 +Requires at least: 6.0 Requires PHP: 7.4 -Tested up to: 6.4.3 +Tested up to: 7.0 Stable tag: trunk License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html diff --git a/republication-tracker-tool.php b/republication-tracker-tool.php index 2266dde..824a70c 100644 --- a/republication-tracker-tool.php +++ b/republication-tracker-tool.php @@ -6,6 +6,7 @@ * Author URI: https://labs.inn.org * Text Domain: republication-tracker-tool * Domain Path: /languages + * Requires at least: 6.0 * Version: 2.8.1 * * @package Republication_Tracker_Tool @@ -32,6 +33,7 @@ function_exists( 'get_plugin_data' ) || require_once ABSPATH . 'wp-admin/include require plugin_dir_path( __FILE__ ) . 'includes/class-widget.php'; require plugin_dir_path( __FILE__ ) . 'includes/compatibility-co-authors-plus.php'; require plugin_dir_path( __FILE__ ) . 'includes/class-republication-rewrite.php'; +require plugin_dir_path( __FILE__ ) . 'includes/class-republish-button-block.php'; /** * Main initiation class. @@ -72,6 +74,13 @@ final class Republication_Tracker_Tool { */ protected static $single_instance = null; + /** + * Whether the modal has been rendered on this page. + * + * @var bool + */ + public static $modal_rendered = false; + /** * Instance of Republication_Tracker_Tool_Settings * diff --git a/src/blocks/republish-button/block.json b/src/blocks/republish-button/block.json new file mode 100644 index 0000000..b644c49 --- /dev/null +++ b/src/blocks/republish-button/block.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "republication-tracker-tool/republish-button", + "title": "Republish Button", + "category": "widgets", + "description": "A button that allows readers to republish your content under a Creative Commons license.", + "keywords": [ "republish", "creative commons", "syndication" ], + "textdomain": "republication-tracker-tool", + "attributes": { + "buttonText": { + "type": "string", + "default": "Republish This Story" + }, + "message": { + "type": "string", + "default": "Republish our articles for free, online or in print, under a Creative Commons license." + }, + "displayMode": { + "type": "string", + "default": "modal", + "enum": [ "modal", "page" ] + } + }, + "supports": { + "anchor": true, + "color": { + "background": true, + "text": true + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true + }, + "spacing": { + "padding": true + }, + "__experimentalBorder": { + "radius": true, + "color": true, + "style": true, + "width": true + }, + "shadow": true + }, + "editorScript": "file:../../../dist/republish-button.js", + "style": "file:../../../dist/republish-button.css", + "example": { + "attributes": { + "buttonText": "Republish This Story", + "message": "Republish our articles for free, online or in print, under a Creative Commons license.", + "displayMode": "modal" + } + } +} diff --git a/src/blocks/republish-button/edit.js b/src/blocks/republish-button/edit.js new file mode 100644 index 0000000..5ccb4bd --- /dev/null +++ b/src/blocks/republish-button/edit.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + InspectorControls, + RichText, + useBlockProps, +} from '@wordpress/block-editor'; +import { PanelBody, SelectControl } from '@wordpress/components'; + +function RepublishButtonEdit( { attributes, setAttributes } ) { + const { buttonText, message, displayMode } = attributes; + + // Block supports (color, typography, spacing, border, shadow) are applied + // to the wrapper div automatically by useBlockProps(). The inner button + // inherits colors via CSS. + const blockProps = useBlockProps(); + + return ( + <> + + + + setAttributes( { displayMode: val } ) + } + /> + + +
+ setAttributes( { message: val } ) } + placeholder={ __( + 'Add a description of the republish feature…', + 'republication-tracker-tool' + ) } + withoutInteractiveFormatting + /> + + + setAttributes( { buttonText: val } ) + } + placeholder={ __( + 'Republish This Story', + 'republication-tracker-tool' + ) } + allowedFormats={ [] } + aria-label={ __( + 'Button text', + 'republication-tracker-tool' + ) } + /> + +
+ + ); +} + +export default RepublishButtonEdit; diff --git a/src/blocks/republish-button/index.js b/src/blocks/republish-button/index.js new file mode 100644 index 0000000..213009b --- /dev/null +++ b/src/blocks/republish-button/index.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import Edit from './edit'; +import './style.scss'; + +registerBlockType( metadata, { + edit: Edit, + save: () => null, +} ); diff --git a/src/blocks/republish-button/style.scss b/src/blocks/republish-button/style.scss new file mode 100644 index 0000000..7eb96c3 --- /dev/null +++ b/src/blocks/republish-button/style.scss @@ -0,0 +1,18 @@ +.wp-block-republication-tracker-tool-republish-button { + + &__button { + display: inline-block; + cursor: pointer; + text-decoration: none; + border: none; + padding: 0; + background: none; + color: inherit; + font: inherit; + line-height: inherit; + } + + &__message { + margin-bottom: 0.5em; + } +} diff --git a/src/blocks/republish-button/view.js b/src/blocks/republish-button/view.js new file mode 100644 index 0000000..8509959 --- /dev/null +++ b/src/blocks/republish-button/view.js @@ -0,0 +1,270 @@ +/* global ClipboardUtils, DOMParser */ + +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; + +let initialized = false; +let currentTrigger = null; + +/** + * Get the textarea for the currently active tab. + * + * @return {HTMLElement|null} The active textarea element. + */ +function getActiveTextarea() { + const activeTextarea = document.querySelector( + '.republish-content.republish-content--active textarea' + ); + if ( activeTextarea ) { + return activeTextarea; + } + // Fallback to the original textarea if no tabs are present. + return document.querySelector( + '#republication-tracker-tool-shareable-content' + ); +} + +/** + * Initialize tab switching between HTML and Plain Text formats. + * + * @param {HTMLElement} modal The modal element. + */ +function initTabSwitching( modal ) { + const tabButtons = modal.querySelectorAll( + '.republish-format-tabs__button' + ); + const tabContents = modal.querySelectorAll( '.republish-content' ); + const mainCopyButton = modal.querySelector( + '.republication-tracker-tool__copy-button--main' + ); + + tabButtons.forEach( ( button ) => { + button.addEventListener( 'click', ( e ) => { + e.preventDefault(); + const targetTab = button.getAttribute( 'data-tab' ); + + // Update active states. + tabButtons.forEach( ( btn ) => + btn.classList.remove( 'republish-format-tabs__button--active' ) + ); + tabContents.forEach( ( content ) => + content.classList.remove( 'republish-content--active' ) + ); + + button.classList.add( 'republish-format-tabs__button--active' ); + const targetContent = modal.querySelector( + `[data-tab-content="${ targetTab }"]` + ); + if ( targetContent ) { + targetContent.classList.add( 'republish-content--active' ); + } + + // Show/hide main copy button based on active tab. + if ( mainCopyButton ) { + if ( targetTab === 'html' ) { + mainCopyButton.classList.add( 'show-for-html' ); + } else { + mainCopyButton.classList.remove( 'show-for-html' ); + } + } + } ); + } ); + + // Initialize copy buttons for individual plain text fields. + modal.querySelectorAll( '.plain-text-field__button' ).forEach( ( btn ) => { + btn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + const target = btn.getAttribute( 'data-target' ); + if ( window.ClipboardUtils && target ) { + ClipboardUtils.copyFromElement( target, btn ); + } + } ); + } ); + + // Bind main copy button via data attribute. + const copyActiveBtn = modal.querySelector( '[data-copy-active]' ); + if ( copyActiveBtn ) { + copyActiveBtn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + if ( window.ClipboardUtils ) { + ClipboardUtils.copyFromElement( + getActiveTextarea(), + copyActiveBtn + ); + } + } ); + } +} + +/** + * Strip captions from shareable content. + * + * @param {HTMLElement} modal The modal element. + */ +function stripCaptions( modal ) { + const shareable = modal.querySelector( + '#republication-tracker-tool-shareable-content' + ); + if ( ! shareable ) { + return; + } + const html = shareable.textContent; + const parser = new DOMParser(); + const doc = parser.parseFromString( html, 'text/html' ); + doc.querySelectorAll( '.wp-caption' ).forEach( ( el ) => el.remove() ); + shareable.innerHTML = doc.body.innerHTML; +} + +/** + * Trap focus within the modal for accessibility. + * + * @param {HTMLElement} modal The modal element. + */ +function trapFocus( modal ) { + const focusableSelector = + 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), select:not([disabled])'; + const focusableEls = modal.querySelectorAll( focusableSelector ); + if ( ! focusableEls.length ) { + return; + } + const firstFocusable = focusableEls[ 0 ]; + const lastFocusable = focusableEls[ focusableEls.length - 1 ]; + + modal.addEventListener( 'keydown', ( e ) => { + if ( e.key !== 'Tab' ) { + return; + } + const active = modal.ownerDocument.activeElement; + if ( e.shiftKey ) { + if ( active === firstFocusable ) { + lastFocusable.focus(); + e.preventDefault(); + } + } else if ( active === lastFocusable ) { + firstFocusable.focus(); + e.preventDefault(); + } + } ); +} + +/** + * Close the modal and return focus to the trigger button. + * + * @param {HTMLElement} modal The modal element. + */ +function closeModal( modal ) { + document.body.classList.remove( 'modal-open-disallow-scrolling' ); + modal.style.display = 'none'; + if ( currentTrigger ) { + currentTrigger.focus(); + } + currentTrigger = null; +} + +/** + * Initialize modal event listeners once. + * + * @param {HTMLElement} modal The modal element. + */ +function initModal( modal ) { + if ( initialized ) { + return; + } + initialized = true; + + const modalContent = modal.querySelector( + '#republication-tracker-tool-modal-content' + ); + const closeBtn = modal.querySelector( '.republication-tracker-tool-close' ); + + // Move modal to body once. + if ( modal.parentNode !== document.body ) { + document.body.appendChild( modal ); + } + + // Strip captions once (not per-open). + stripCaptions( modal ); + + // Tab switching (bind once). + initTabSwitching( modal ); + + // Focus trap (bind once). + trapFocus( modal ); + + // Prevent clicks inside modal content from closing modal. + if ( modalContent ) { + modalContent.addEventListener( 'click', ( e ) => e.stopPropagation() ); + } + + // Click outside modal content closes modal. + modal.addEventListener( 'click', () => closeModal( modal ) ); + + // Close button. + if ( closeBtn ) { + closeBtn.addEventListener( 'click', ( e ) => { + e.stopPropagation(); + closeModal( modal ); + } ); + } + + // Escape key. + document.addEventListener( 'keydown', ( e ) => { + if ( e.key === 'Escape' && modal.style.display !== 'none' ) { + closeModal( modal ); + } + } ); +} + +/** + * Show the modal. + * + * @param {HTMLElement} modal The modal element. + * @param {HTMLElement} triggerButton The button that triggered the modal. + */ +function showModal( modal, triggerButton ) { + initModal( modal ); + currentTrigger = triggerButton; + + const modalContent = modal.querySelector( + '#republication-tracker-tool-modal-content' + ); + + modal.style.display = ''; + if ( modalContent ) { + modalContent.style.display = ''; + } + document.body.classList.add( 'modal-open-disallow-scrolling' ); + + // Focus close button. + const closeBtn = modal.querySelector( '.republication-tracker-tool-close' ); + if ( closeBtn ) { + closeBtn.focus(); + } +} + +domReady( () => { + const modal = document.getElementById( 'republication-tracker-tool-modal' ); + if ( ! modal ) { + return; + } + + // Find all block trigger buttons. + document + .querySelectorAll( '[data-modal-trigger="republish"]' ) + .forEach( ( button ) => { + button.addEventListener( 'click', ( e ) => { + e.preventDefault(); + showModal( modal, button ); + } ); + } ); + + // Auto-open via URL hash. + if ( window.location.hash === '#show-republish' ) { + const firstTrigger = document.querySelector( + '[data-modal-trigger="republish"]' + ); + showModal( modal, firstTrigger ); + } +} ); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 123187e..deca9e2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,6 +5,9 @@ * @package Creative_Commons_Sharing */ +// Load the composer autoloader. +require_once __DIR__ . '/../vendor/autoload.php'; + $_tests_dir = getenv( 'WP_TESTS_DIR' ); if ( ! $_tests_dir ) { $_tests_dir = '/tmp/wordpress-tests-lib'; diff --git a/tests/test-republish-button-block.php b/tests/test-republish-button-block.php new file mode 100644 index 0000000..d1f7e3b --- /dev/null +++ b/tests/test-republish-button-block.php @@ -0,0 +1,209 @@ +is_registered( 'republication-tracker-tool/republish-button' ) ) { + register_block_type_from_metadata( + REPUBLICATION_TRACKER_TOOL_PATH . 'src/blocks/republish-button', + [ + 'render_callback' => [ 'Republication_Tracker_Tool_Republish_Button_Block', 'render_block' ], + ] + ); + } + + $this->test_post = $this->factory->post->create_and_get( + [ + 'post_title' => 'Test Post for Block', + 'post_content' => '

Test content for block display.

', + 'post_status' => 'publish', + ] + ); + } + + /** + * Clean up after tests. + */ + public function tear_down() { + wp_delete_post( $this->test_post->ID, true ); + Republication_Tracker_Tool::$modal_rendered = false; + parent::tear_down(); + } + + /** + * Helper to set up a singular post context. + */ + private function set_singular_context() { + global $post, $wp_query; + $post = $this->test_post; + $wp_query->is_singular = true; + $wp_query->is_single = true; + $wp_query->queried_object = $this->test_post; + $wp_query->queried_object_id = $this->test_post->ID; + } + + /** + * Helper to render the block through the standard pipeline so + * WP_Block_Supports context is set correctly. + * + * @param array $attrs Block attributes. + * @return string Rendered HTML. + */ + private function render_block( $attrs = [] ) { + $json = empty( $attrs ) ? '' : ' ' . wp_json_encode( (object) $attrs ); + $serialized = ''; + return do_blocks( $serialized ); + } + + /** + * Test block renders on singular post view. + */ + public function test_block_renders_on_singular_post() { + $this->set_singular_context(); + + $output = $this->render_block(); + + $this->assertStringContainsString( 'wp-block-republication-tracker-tool-republish-button', $output ); + $this->assertStringContainsString( 'Republish This Story', $output ); + $this->assertStringContainsString( 'data-modal-trigger="republish"', $output ); + } + + /** + * Test block returns empty on non-singular views. + */ + public function test_block_empty_on_non_singular() { + global $wp_query; + $wp_query->is_singular = false; + $wp_query->is_single = false; + + $output = Republication_Tracker_Tool_Republish_Button_Block::render_block( [] ); + + $this->assertEmpty( $output ); + } + + /** + * Test block respects post type filter. + */ + public function test_block_respects_post_type_filter() { + $this->set_singular_context(); + + add_filter( + 'republication_tracker_tool_post_types', + function () { + return [ 'page' ]; + } + ); + + $output = Republication_Tracker_Tool_Republish_Button_Block::render_block( [] ); + + $this->assertEmpty( $output ); + + remove_all_filters( 'republication_tracker_tool_post_types' ); + } + + /** + * Test block respects hide widget meta. + */ + public function test_block_respects_hide_meta() { + $this->set_singular_context(); + update_post_meta( $this->test_post->ID, 'republication-tracker-tool-hide-widget', true ); + + $output = Republication_Tracker_Tool_Republish_Button_Block::render_block( [] ); + + $this->assertEmpty( $output ); + + delete_post_meta( $this->test_post->ID, 'republication-tracker-tool-hide-widget' ); + } + + /** + * Test block respects hide_republication_widget filter. + */ + public function test_block_respects_hide_filter() { + $this->set_singular_context(); + add_filter( 'hide_republication_widget', '__return_true' ); + + $output = Republication_Tracker_Tool_Republish_Button_Block::render_block( [] ); + + $this->assertEmpty( $output ); + + remove_filter( 'hide_republication_widget', '__return_true' ); + } + + /** + * Test modal is only rendered once across multiple blocks. + */ + public function test_single_modal_across_multiple_blocks() { + $this->set_singular_context(); + + $output1 = $this->render_block(); + $output2 = $this->render_block(); + + $combined = $output1 . $output2; + + $this->assertEquals( 1, substr_count( $combined, 'id="republication-tracker-tool-modal"' ), 'Single modal in combined output.' ); + $this->assertEquals( 2, substr_count( $combined, 'data-modal-trigger="republish"' ), 'Two trigger buttons in combined output.' ); + } + + /** + * Test page mode renders a link instead of a button. + */ + public function test_page_mode_renders_link() { + $this->set_singular_context(); + + $output = $this->render_block( [ 'displayMode' => 'page' ] ); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'data-modal-trigger', $output ); + $this->assertStringNotContainsString( 'id="republication-tracker-tool-modal"', $output ); + } + + /** + * Test empty attributes fall back to translated defaults. + */ + public function test_empty_attributes_fallback() { + $this->set_singular_context(); + + $output = $this->render_block( + [ + 'buttonText' => '', + 'message' => '', + ] + ); + + $this->assertStringContainsString( 'Republish This Story', $output ); + $this->assertStringContainsString( 'Republish our articles for free', $output ); + } + + /** + * Test invalid displayMode falls back to modal. + */ + public function test_invalid_display_mode_fallback() { + $this->set_singular_context(); + + $output = $this->render_block( [ 'displayMode' => 'invalid' ] ); + + $this->assertStringContainsString( 'data-modal-trigger="republish"', $output ); + } +} diff --git a/tests/test-widget.php b/tests/test-widget.php index 043d61b..b0053b5 100644 --- a/tests/test-widget.php +++ b/tests/test-widget.php @@ -46,6 +46,7 @@ public function set_up() { */ public function tear_down() { wp_delete_post( $this->test_post->ID, true ); + Republication_Tracker_Tool::$modal_rendered = false; parent::tear_down(); } @@ -156,11 +157,11 @@ public function test_multiple_widget_instances() { $this->widget->widget( $args, $instance ); $this->widget->widget( $args, $instance ); $output = ob_get_clean(); - $this->assertStringContainsString( 'republication-tracker-tool-modal', $output ); + $this->assertStringContainsString( 'id="republication-tracker-tool-modal"', $output ); $this->assertStringContainsString( 'republication-tracker-tool-button', $output ); - // Check only one modal is present. - $this->assertEquals( substr_count( $output, 'republication-tracker-tool-modal' ), 1, 'Single modal found in output.' ); - $this->assertEquals( substr_count( $output, 'republication-tracker-tool-button' ), 3, 'Multiple buttons found in output.' ); + // Check only one modal wrapper is present. + $this->assertEquals( 1, substr_count( $output, 'id="republication-tracker-tool-modal"' ), 'Single modal found in output.' ); + $this->assertEquals( 3, substr_count( $output, 'republication-tracker-tool-button' ), 'Multiple buttons found in output.' ); } } diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..ff5770b --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +const path = require( 'path' ); +const getBaseWebpackConfig = require( 'newspack-scripts/config/getWebpackConfig' ); + +const entry = { + 'republish-button': path.join( __dirname, 'src', 'blocks', 'republish-button', 'index.js' ), + 'republish-button-view': path.join( __dirname, 'src', 'blocks', 'republish-button', 'view.js' ), +}; + +module.exports = getBaseWebpackConfig( { entry } );