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 = <<
+
+
+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
+ // so theme button styles cascade via the standard core button classes.
+ $button_attributes = get_block_wrapper_attributes(
+ [
+ 'class' => 'wp-block-button__link wp-element-button wp-block-republication-tracker-tool-republish-button',
+ 'data-modal-trigger' => 'republish',
+ ]
+ );
+
+ // Three-layer button markup matches core/button so theme styles apply.
+ $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();
+ ?>
+
+
+
+ ',
+ 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( '