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-pattern.php b/includes/class-republish-pattern.php new file mode 100644 index 0000000..ab6b969 --- /dev/null +++ b/includes/class-republish-pattern.php @@ -0,0 +1,85 @@ + esc_html__( 'Republication', 'republication-tracker-tool' ) ] + ); + + $description = esc_html__( 'Republish our articles for free, online or in print, under a Creative Commons license.', 'republication-tracker-tool' ); + + // Plain __() (not esc_html__): buttonText is JSON-encoded into a block + // attribute and escaped again by the block's render_callback, so escaping + // here would double-encode entities in translations (apostrophes, etc.). + $button_text = __( 'Republish This Story', 'republication-tracker-tool' ); + $button_text_enc = wp_json_encode( $button_text ); + + $content = << +
+ +

{$description}

+ + + +
+ +HTML; + + // Hide the pattern from the inserter on classic themes (matches the + // republish-button block's gating). Stays registered so existing + // instances and inter-block references keep working. + $inserter = ! function_exists( 'wp_is_block_theme' ) || wp_is_block_theme(); + + register_block_pattern( + self::PATTERN_NAME, + [ + 'title' => esc_html__( 'Republish Section', 'republication-tracker-tool' ), + 'description' => esc_html__( 'A paragraph, republish button, and Creative Commons license badge grouped together.', 'republication-tracker-tool' ), + 'categories' => [ self::CATEGORY_SLUG ], + 'content' => $content, + 'inserter' => $inserter, + ] + ); + } +} + +Republication_Tracker_Tool_Republish_Pattern::init(); diff --git a/includes/class-settings.php b/includes/class-settings.php index def8a9b..8e4ac96 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -227,7 +227,7 @@ public function republication_tracker_tool_display_attribution_callback() { checked /> -

+

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 ) { ?> - + - + REPUBLICATION_TRACKER_TOOL_LICENSES[ $license_key ]['url'], + 'badge' => REPUBLICATION_TRACKER_TOOL_LICENSES[ $license_key ]['badge'], + 'description' => REPUBLICATION_TRACKER_TOOL_LICENSES[ $license_key ]['description'], + ] + : null; + + wp_add_inline_script( + 'republication-tracker-tool-republish-button-editor-script', + 'window.republicationTrackerToolEditor = ' . wp_json_encode( [ 'license' => $license ] ) . ';', + 'before' + ); + } + + /** + * Register the block. + * + * On block themes the block is fully available. On classic themes it is + * still registered (so existing content does not become an "unknown block") + * but hidden from the inserter. + */ + public static function register_block(): void { + // register_block_type_from_metadata() is only available on WP 5.5+. + // Bail gracefully on older installs instead of fataling on init. + if ( ! function_exists( 'register_block_type_from_metadata' ) ) { + return; + } + + $block_dir = REPUBLICATION_TRACKER_TOOL_PATH . 'src/blocks/republish-button'; + $args = [ + 'render_callback' => [ __CLASS__, 'render_block' ], + ]; + + if ( function_exists( 'wp_is_block_theme' ) && ! wp_is_block_theme() ) { + // Deep-merge into the declared supports rather than replacing them. + // register_block_type_from_metadata() shallow-merges $args over the + // block.json metadata, so a bare supports override would drop the + // block's color/typography/spacing/border support and strip saved + // styles from existing instances rendered on a classic theme. + $metadata = wp_json_file_decode( $block_dir . '/block.json', [ 'associative' => true ] ); + $supports = ( is_array( $metadata ) && isset( $metadata['supports'] ) && is_array( $metadata['supports'] ) ) + ? $metadata['supports'] + : []; + + $supports['inserter'] = false; + $args['supports'] = $supports; + } + + register_block_type_from_metadata( $block_dir, $args ); + } + + /** + * Render the block on the frontend. + * + * @param array $attrs Block attributes. + * @return string Rendered block HTML. + */ + public static function render_block( array $attrs ): string { + 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' ), + 'showLicense' => true, + ]; + $attrs = wp_parse_args( $attrs, $default_attrs ); + + // Fall back to translated default when attribute is empty string. + $button_text = '' === trim( (string) $attrs['buttonText'] ) ? $default_attrs['buttonText'] : $attrs['buttonText']; + $show_license = (bool) $attrs['showLicense']; + + // Block supports (color, typography, spacing, border, shadow) apply to the inner + // '; + $html .= ''; + $html .= ''; + + // Optional Creative Commons license badge, sourced from the site setting. + // Rendered as a sibling outside the buttons container so the flex layout + // doesn't affect the image. + if ( $show_license ) { + $html .= self::render_license_badge(); + } + + // Modal markup — only rendered once per page across all block instances. + self::enqueue_modal_assets(); + + if ( ! Republication_Tracker_Tool::$modal_rendered ) { + Republication_Tracker_Tool::$modal_rendered = true; + + $is_amp = false; // Used by shareable-content.php; block themes do not support AMP. + $modal_content_path = REPUBLICATION_TRACKER_TOOL_PATH . 'includes/shareable-content.php'; + + ob_start(); + ?> + + %2$s', + esc_url( $license['url'] ), + esc_attr( $license['description'] ), + esc_url( $license['badge'] ) + ); + } + + /** + * Enqueue modal-specific assets. + */ + private static function enqueue_modal_assets(): void { + // Modal styles (same handle as widget for deduplication). + wp_enqueue_style( + 'republication-tracker-tool-css', + REPUBLICATION_TRACKER_TOOL_URL . 'assets/widget.css', + [], + REPUBLICATION_TRACKER_TOOL_VERSION + ); + + // Clipboard utilities (shared with widget). + wp_enqueue_script( + 'republication-tracker-tool-clipboard-utils', + REPUBLICATION_TRACKER_TOOL_URL . 'assets/clipboard-utils.js', + [], + REPUBLICATION_TRACKER_TOOL_VERSION, + true + ); + + // Block frontend script. + $asset_file = REPUBLICATION_TRACKER_TOOL_PATH . 'dist/republish-button-view.asset.php'; + $asset = file_exists( $asset_file ) + ? include $asset_file + : [ + 'dependencies' => [], + '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/src/blocks/republish-button/edit.js b/src/blocks/republish-button/edit.js new file mode 100644 index 0000000..da74e9a --- /dev/null +++ b/src/blocks/republish-button/edit.js @@ -0,0 +1,117 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + InspectorControls, + RichText, + useBlockProps, + /* eslint-disable @wordpress/no-unsafe-wp-apis */ + __experimentalUseBorderProps as useBorderProps, + __experimentalUseColorProps as useColorProps, + __experimentalGetSpacingClassesAndStyles as useSpacingProps, + /* eslint-enable @wordpress/no-unsafe-wp-apis */ +} from '@wordpress/block-editor'; +import { PanelBody, ToggleControl } from '@wordpress/components'; + +function RepublishButtonEdit( { attributes, setAttributes } ) { + const { buttonText, showLicense } = attributes; + const borderProps = useBorderProps( attributes ); + const colorProps = useColorProps( attributes ); + const spacingProps = useSpacingProps( attributes ); + + // License data is injected by the server via wp_add_inline_script + // (see Republication_Tracker_Tool_Republish_Button_Block::enqueue_editor_data). + // Null when no recognizable license is configured for the site. + const licenseData = window.republicationTrackerToolEditor?.license || null; + + const innerClassNames = [ + 'wp-block-button__link', + 'wp-element-button', + 'wp-block-republication-tracker-tool-republish-button', + colorProps.className, + borderProps.className, + ] + .filter( Boolean ) + .join( ' ' ); + + const blockProps = useBlockProps( { + className: innerClassNames, + style: { + ...borderProps.style, + ...colorProps.style, + ...spacingProps.style, + }, + } ); + + return ( + <> + + + + setAttributes( { showLicense: val } ) + } + help={ __( + 'Display the site’s configured Creative Commons license badge below the button.', + 'republication-tracker-tool' + ) } + /> + + +
+
+
+ + setAttributes( { buttonText: val } ) + } + placeholder={ __( + 'Republish This Story', + 'republication-tracker-tool' + ) } + allowedFormats={ [] } + aria-label={ __( + 'Button text', + 'republication-tracker-tool' + ) } + /> +
+
+
+ { showLicense && ( +
+ { licenseData ? ( + { + ) : ( + + { __( + 'No Creative Commons license is configured.', + '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..9b1f488 --- /dev/null +++ b/src/blocks/republish-button/index.js @@ -0,0 +1,18 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; +import { button as icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import Edit from './edit'; +import './style.scss'; + +registerBlockType( metadata, { + edit: Edit, + icon, + save: () => null, +} ); diff --git a/src/blocks/republish-button/style.scss b/src/blocks/republish-button/style.scss new file mode 100644 index 0000000..9f611e1 --- /dev/null +++ b/src/blocks/republish-button/style.scss @@ -0,0 +1,15 @@ +// The republish button renders with the standard `wp-block-button` / `wp-block-button__link` +// markup, so theme button styles cascade automatically. Plugin-specific overrides go on the +// `wp-block-republication-tracker-tool-republish-button` class (attached to the inner button). +.wp-block-republication-tracker-tool-republish-button { + cursor: pointer; +} + +.wp-block-republication-tracker-tool-republish-button__license { + margin-top: 0.5em; + + img { + max-width: 100%; + height: auto; + } +} diff --git a/src/blocks/republish-button/view.js b/src/blocks/republish-button/view.js new file mode 100644 index 0000000..8189e20 --- /dev/null +++ b/src/blocks/republish-button/view.js @@ -0,0 +1,275 @@ +/* global ClipboardUtils */ + +/** + * 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])'; + + modal.addEventListener( 'keydown', ( e ) => { + if ( e.key !== 'Tab' ) { + return; + } + // Recompute visible focusable elements on each Tab so tab switching + // (which hides controls like the main copy button) doesn't leave + // stale references that let focus escape the modal. + const focusableEls = Array.from( + modal.querySelectorAll( focusableSelector ) + ).filter( ( el ) => el.offsetParent !== null ); + if ( ! focusableEls.length ) { + return; + } + const firstFocusable = focusableEls[ 0 ]; + const lastFocusable = focusableEls[ focusableEls.length - 1 ]; + 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..0ee326d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,6 +5,14 @@ * @package Creative_Commons_Sharing */ +// Load the composer autoloader. +$rtt_autoload = __DIR__ . '/../vendor/autoload.php'; +if ( ! file_exists( $rtt_autoload ) ) { + fwrite( STDERR, "Composer autoloader not found. Run `composer install` before running the test suite.\n" ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite + exit( 1 ); +} +require_once $rtt_autoload; + $_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..3cf35ac --- /dev/null +++ b/tests/test-republish-button-block.php @@ -0,0 +1,244 @@ +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( 'wp-block-buttons', $output ); + $this->assertStringContainsString( 'wp-block-button__link', $output ); + $this->assertStringContainsString( 'wp-element-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 empty buttonText falls back to translated default. + */ + public function test_empty_attributes_fallback() { + $this->set_singular_context(); + + $output = $this->render_block( + [ + 'buttonText' => '', + ] + ); + + $this->assertStringContainsString( 'Republish This Story', $output ); + } + + /** + * Block no longer emits the message paragraph. + */ + public function test_block_does_not_emit_message_paragraph() { + $this->set_singular_context(); + + $output = $this->render_block(); + + $this->assertStringNotContainsString( 'wp-block-republication-tracker-tool-republish-button__message', $output ); + } + + /** + * Block emits the license badge by default when a license is configured. + */ + public function test_block_emits_license_badge_by_default() { + update_option( 'republication_tracker_tool_license', 'cc-by-nd-4.0' ); + + $this->set_singular_context(); + + $output = $this->render_block(); + + $this->assertStringContainsString( '__license', $output ); + $this->assertStringContainsString( 'rel="noreferrer license"', $output ); + $this->assertStringContainsString( REPUBLICATION_TRACKER_TOOL_LICENSES['cc-by-nd-4.0']['url'], $output ); + + delete_option( 'republication_tracker_tool_license' ); + } + + /** + * Block omits the license badge when showLicense is false. + */ + public function test_block_omits_license_when_disabled() { + update_option( 'republication_tracker_tool_license', 'cc-by-nd-4.0' ); + + $this->set_singular_context(); + + $output = $this->render_block( [ 'showLicense' => false ] ); + + $this->assertStringNotContainsString( '__license', $output ); + + delete_option( 'republication_tracker_tool_license' ); + } + + /** + * Block omits the license badge when no recognizable license is set, + * even if showLicense is true. + */ + public function test_block_omits_license_when_no_license_set() { + update_option( 'republication_tracker_tool_license', 'not-a-real-license' ); + + $this->set_singular_context(); + + $output = $this->render_block(); + + $this->assertStringNotContainsString( '__license', $output ); + + delete_option( 'republication_tracker_tool_license' ); + } +} diff --git a/tests/test-republish-pattern.php b/tests/test-republish-pattern.php new file mode 100644 index 0000000..b9ce303 --- /dev/null +++ b/tests/test-republish-pattern.php @@ -0,0 +1,62 @@ +assertTrue( + $registry->is_registered( 'republication-tracker-tool/republish-section' ), + 'Republish section pattern should be registered.' + ); + + $pattern = $registry->get_registered( 'republication-tracker-tool/republish-section' ); + + $this->assertContains( 'republication-tracker-tool', $pattern['categories'] ); + } + + /** + * The pattern category is registered with a translatable label. + */ + public function test_pattern_category_is_registered() { + $registry = WP_Block_Pattern_Categories_Registry::get_instance(); + $categories = $registry->get_all_registered(); + + $found = false; + foreach ( $categories as $category ) { + if ( 'republication-tracker-tool' === $category['name'] ) { + $found = true; + $this->assertNotEmpty( $category['label'] ); + break; + } + } + + $this->assertTrue( $found, 'Republication pattern category should be registered.' ); + } + + /** + * The pattern content references the expected child blocks. + */ + public function test_pattern_content_includes_expected_blocks() { + $registry = WP_Block_Patterns_Registry::get_instance(); + $pattern = $registry->get_registered( 'republication-tracker-tool/republish-section' ); + + $content = $pattern['content']; + + $this->assertStringContainsString( '