get_rules_for_page();
if ( empty( $rules ) ) {
return;
}
// Enqueue CTC lib core library (CopyEngine, CapabilityDetector).
wp_enqueue_script(
'ctc-lib-core',
CTC_URI . 'assets/frontend/js/lib/ctc.js',
[],
CTC_VER,
true
);
// Enqueue Global Injector frontend script.
wp_enqueue_script(
'ctc-global-injector-frontend',
CTC_URI . 'assets/frontend/js/global-injector.js',
[ 'ctc-lib-core' ],
CTC_VER,
true
);
// Inline CSS: only for needed styles/positions, minified, filterable. No separate CSS file.
$needed = $this->get_needed_styles_and_positions( $rules );
$raw_css = Inline_CSS::build( $needed['styles'], $needed['positions'] );
$min_css = Helper::minify_css( $raw_css );
$inline_css = (string) apply_filters( 'ctc/global_injector/inline_css', $min_css );
if ( $inline_css !== '' ) {
wp_register_style( 'ctc-global-injector-inline', false, [], CTC_VER );
wp_enqueue_style( 'ctc-global-injector-inline' );
wp_add_inline_style( 'ctc-global-injector-inline', $inline_css );
}
// Localize script with rules and page context.
$localize_data = $this->get_localize_data( $rules );
/**
* Filter frontend localize data.
*
* Pro can use this to add eventsUrl for analytics tracking.
*
* @since 5.3.0
* @param array $data Localized data array.
* @param array $rules Active rules for the current page.
*/
$localize_data = apply_filters( 'ctc/global_injector/frontend_localize_data', $localize_data, $rules );
wp_localize_script(
'ctc-global-injector-frontend',
'ctcGlobalInjector',
$localize_data
);
}
/**
* Get localize data for frontend script
*
* @since 5.0.0
* @param array $rules Active rules.
* @return array
*/
private function get_localize_data( $rules ) {
return [
'rules' => $rules,
'apiUrl' => rest_url( 'ctc/v1/rules' ),
'eventsUrl' => rest_url( 'ctc/v1/analytics/events' ),
'isPro' => Helper::is_pro(),
'postId' => get_the_ID() ? get_the_ID() : null,
'postType' => get_post_type() ? get_post_type() : null,
'pageUrl' => $this->get_current_page_url(),
'pageContext' => Display_Conditions::get_page_context(),
'debug' => (bool) apply_filters( 'ctc/global_injector/debug', defined( 'WP_DEBUG' ) && WP_DEBUG ),
'styles' => [
'button' => ButtonStyle::get_localize_data(),
'icon' => IconStyle::get_localize_data(),
'cover' => CoverStyle::get_localize_data(),
],
];
}
/**
* Get rules that should load on the current page (single source of truth).
*
* Used for enqueue_assets, admin bar, and localized data. No scripts or CSS
* are enqueued when this returns an empty array.
*
* @since 5.0.0
* @return array List of rule data arrays for rules that pass display conditions.
*/
public function get_rules_for_page() {
$rules = $this->get_active_rules();
return apply_filters( 'ctc/global_injector/rules_for_page', $rules );
}
/**
* Get which visual styles and positions are used by the given rules.
*
* Used to inject only the CSS needed for the current page.
*
* @since 5.0.2
* @param array $rules Rule data arrays (from get_rules_for_page).
* @return array{styles: string[], positions: string[]}
*/
private function get_needed_styles_and_positions( array $rules ) {
$styles = [];
$positions = [];
$valid_positions = [
'inside_top_left',
'inside_top_right',
'inside_bottom_left',
'inside_bottom_right',
'outside_before_left',
'outside_before_right',
'outside_after_left',
'outside_after_right',
];
foreach ( $rules as $rule ) {
$style = isset( $rule['visual_style'] ) ? $rule['visual_style'] : 'button';
if ( ! in_array( $style, $styles, true ) ) {
$styles[] = $style;
}
$pos = isset( $rule['button_position'] ) ? $rule['button_position'] : 'inside_top_right';
if ( in_array( $pos, $valid_positions, true ) && ! in_array( $pos, $positions, true ) ) {
$positions[] = $pos;
}
}
if ( empty( $positions ) ) {
$positions = [ 'inside_top_right' ];
}
return [
'styles' => array_values( $styles ),
'positions' => array_values( $positions ),
];
}
/**
* Get current page URL
*
* @since 5.0.0
* @return string|null
*/
private function get_current_page_url() {
if ( is_singular() ) {
return get_permalink();
}
if ( is_front_page() ) {
return home_url( '/' );
}
// Fallback to current URL.
global $wp;
return home_url( add_query_arg( [], $wp->request ) );
}
/**
* Add admin bar links to edit Global Injector rules that apply on this page.
*
* Shows clipboard icon + "CTC" parent with "Edit: [Rule name]" children for each active rule.
*
* @since 5.0.0
* @param \WP_Admin_Bar $wp_admin_bar Admin bar object.
* @return void
*/
public function add_admin_bar_links( $wp_admin_bar ) {
if ( is_admin() || ! current_user_can( 'manage_options' ) ) {
return;
}
$rules = $this->get_rules_for_page();
if ( empty( $rules ) ) {
return;
}
$base_url = admin_url( 'options-general.php?page=ctc-global-injector' );
$parent_id = 'ctc';
$title = __( 'CTC', 'ctc' );
$wp_admin_bar->add_node(
[
'id' => $parent_id,
'title' => '' . $title . '',
'href' => $base_url,
'meta' => [
'title' => __( 'Copy the Code – edit rules on this page', 'ctc' ),
],
]
);
foreach ( $rules as $rule ) {
$rule_id = isset( $rule['id'] ) ? (int) $rule['id'] : 0;
$rule_title = $rule_id ? get_the_title( $rule_id ) : __( 'Untitled Rule', 'ctc' );
$edit_url = add_query_arg( 'rule', $rule_id, $base_url );
$wp_admin_bar->add_node(
[
'parent' => $parent_id,
'id' => $parent_id . '-rule-' . $rule_id,
'title' => sprintf( /* translators: %s: rule name */ __( 'Edit: %s', 'ctc' ), $rule_title ),
'href' => $edit_url,
'meta' => [
'title' => sprintf( /* translators: %s: rule name */ __( 'Edit rule "%s" in Global Injector', 'ctc' ), $rule_title ),
],
]
);
}
}
/**
* Get active rules for frontend
*
* @since 5.0.0
* @return array
*/
private function get_active_rules() {
$posts = get_posts(
[
'post_type' => 'copy-to-clipboard',
'posts_per_page' => -1,
'post_status' => 'any',
'orderby' => 'date',
'order' => 'DESC',
]
);
$rules = [];
foreach ( $posts as $post ) {
// Check if rule is active.
$is_active = get_post_meta( $post->ID, 'is_active', true );
if ( '' === $is_active ) {
$is_active = 'publish' === $post->post_status;
} else {
$is_active = '0' !== $is_active && false !== $is_active;
}
// Skip inactive rules.
if ( ! $is_active ) {
continue;
}
// Get display conditions.
$display_conditions = get_post_meta( $post->ID, 'display_conditions', true );
// Evaluate display conditions.
if ( ! empty( $display_conditions ) && is_array( $display_conditions ) ) {
$should_display = Display_Conditions::evaluate( $display_conditions );
if ( ! $should_display ) {
continue;
}
}
// Build rule data.
$rule = $this->build_rule_data( $post );
if ( $rule ) {
/**
* Filter the frontend rule data before adding to the rules array.
*
* Pro add-on can use this to add additional fields like custom_css_class.
*
* @since 5.0.0
* @param array $rule Rule data array.
* @param \WP_Post $post Post object.
*/
$rules[] = apply_filters( 'ctc/global_injector/frontend_rule_data', $rule, $post );
}
}
return $rules;
}
/**
* Build rule data for frontend
*
* @since 5.0.0
* @param \WP_Post $post Post object.
* @return array|null
*/
private function build_rule_data( $post ) {
$selector = get_post_meta( $post->ID, 'selector', true );
// Skip rules without selectors.
if ( empty( $selector ) ) {
return null;
}
$visual_style_raw = get_post_meta( $post->ID, 'visual_style', true );
$visual_style = ! empty( $visual_style_raw ) ? $visual_style_raw : 'button';
// Get style configuration based on visual style.
$style_config = $this->get_style_config( $post->ID, $visual_style );
return [
'id' => $post->ID,
'selector' => $selector,
'exclude_selector' => get_post_meta( $post->ID, 'exclude_selector', true ),
'visual_style' => $visual_style,
'button_text' => $this->get_meta_with_default( $post->ID, 'button-text', __( 'Copy', 'ctc' ) ),
'success_text' => $this->get_meta_with_default( $post->ID, 'button-copy-text', __( 'Copied!', 'ctc' ) ),
'tooltip_text' => $this->get_meta_with_default( $post->ID, 'tooltip-text', __( 'Copy to clipboard', 'ctc' ) ),
'button_position' => $this->migrate_position( $this->get_meta_with_default( $post->ID, 'button-position', 'inside_top_right' ) ),
'copy_format' => $this->get_meta_with_default( $post->ID, 'copy-format', 'text' ),
'copy_as' => $this->get_copy_as_for_rule( $post->ID ),
'analytics_enabled' => $this->get_bool_meta( $post->ID, 'analytics_enabled', true ),
'icon_enabled' => $this->get_bool_meta( $post->ID, 'icon_enabled', true ),
'icon_position' => $this->get_meta_with_default( $post->ID, 'icon_position', 'left' ),
'icon_key' => $this->get_meta_with_default( $post->ID, 'icon_key', 'clipboard' ),
'style' => $style_config,
];
}
/**
* Get style configuration for a rule
*
* @since 5.0.0
* @param int $post_id Post ID.
* @param string $visual_style Visual style type.
* @return array
*/
private function get_style_config( $post_id, $visual_style ) {
switch ( $visual_style ) {
case 'button':
return [
'text_color' => $this->get_meta_with_default( $post_id, 'text_color', '#ffffff' ),
'bg_color' => $this->get_meta_with_default( $post_id, 'bg_color', '#4f46e5' ),
'border_radius' => $this->get_int_meta_with_default( $post_id, 'border_radius', 6 ),
'font_size' => $this->get_int_meta_with_default( $post_id, 'font_size', 13 ),
'padding_x' => $this->get_int_meta_with_default( $post_id, 'padding_x', 16 ),
'padding_y' => $this->get_int_meta_with_default( $post_id, 'padding_y', 8 ),
];
case 'icon':
return [
'icon_color' => $this->get_meta_with_default( $post_id, 'icon_color', '#6b7280' ),
'icon_hover_color' => $this->get_meta_with_default( $post_id, 'icon_hover_color', '#4f46e5' ),
'bg_color' => $this->get_meta_with_default( $post_id, 'bg_color', 'transparent' ),
'border_color' => $this->get_meta_with_default( $post_id, 'border_color', '#e5e7eb' ),
'icon_size' => $this->get_int_meta_with_default( $post_id, 'icon_size', 16 ),
'padding' => $this->get_int_meta_with_default( $post_id, 'padding', 8 ),
'border_radius' => $this->get_int_meta_with_default( $post_id, 'border_radius', 6 ),
'border_width' => $this->get_int_meta_with_default( $post_id, 'border_width', 1 ),
];
case 'cover':
return [
'overlay_color' => $this->get_meta_with_default( $post_id, 'overlay_color', '#0f172a' ),
'hover_overlay_color' => $this->get_meta_with_default( $post_id, 'hover_overlay_color', '#4f46e5' ),
'overlay_opacity' => $this->get_int_meta_with_default( $post_id, 'overlay_opacity', 20 ),
'blur' => $this->get_int_meta_with_default( $post_id, 'blur', 2 ),
'cover_text_color' => $this->get_meta_with_default( $post_id, 'cover_text_color', '#0f172a' ),
'cover_bg_color' => $this->get_meta_with_default( $post_id, 'cover_bg_color', '#ffffff' ),
'cover_hover_bg_color' => $this->get_meta_with_default( $post_id, 'cover_hover_bg_color', '#f1f5f9' ),
'cover_border_radius' => $this->get_int_meta_with_default( $post_id, 'cover_border_radius', 9999 ),
'cover_font_size' => $this->get_int_meta_with_default( $post_id, 'cover_font_size', 10 ),
'cover_padding_x' => $this->get_int_meta_with_default( $post_id, 'cover_padding_x', 12 ),
'cover_padding_y' => $this->get_int_meta_with_default( $post_id, 'cover_padding_y', 6 ),
];
default:
return [];
}
}
/**
* Migrate legacy position values to new format
*
* Old wp.org plugin used 'inside' and 'outside' values.
* New format uses specific positions like 'inside_top_right'.
*
* @since 5.0.0
* @param string $position Position value (may be legacy or new).
* @return string Migrated position value.
*/
private function migrate_position( $position ) {
// Legacy to new position mapping.
$legacy_map = [
'inside' => 'inside_top_right',
'outside' => 'outside_after_right',
];
// All valid new position values.
$valid_positions = [
'inside_top_left',
'inside_top_right',
'inside_bottom_left',
'inside_bottom_right',
'outside_before_left',
'outside_before_right',
'outside_after_left',
'outside_after_right',
];
// If already a valid new position, return as-is.
if ( in_array( $position, $valid_positions, true ) ) {
return $position;
}
// If legacy value, migrate it.
if ( isset( $legacy_map[ $position ] ) ) {
return $legacy_map[ $position ];
}
// Default fallback.
return 'inside_top_right';
}
/**
* Get clipboard as for a rule (for Copy as: text, html, image, etc.).
* Uses copy-as meta when set; otherwise derives from legacy copy-format.
*
* @since 5.0.0
* @param int $post_id Post ID.
* @return string One of: text, html, text_and_html, image, json, svg.
*/
private function get_copy_as_for_rule( $post_id ) {
$copy_as = get_post_meta( $post_id, 'copy-as', true );
$valid_types = [ 'text', 'html', 'text_and_html', 'image', 'json', 'svg' ];
if ( ! empty( $copy_as ) && in_array( $copy_as, $valid_types, true ) ) {
return $copy_as;
}
$copy_format = get_post_meta( $post_id, 'copy-format', true );
if ( 'html' === $copy_format || 'innerHTML' === $copy_format ) {
return 'html';
}
if ( 'text' === $copy_format ) {
return 'text';
}
return 'text_and_html';
}
/**
* Get post meta with default value
*
* @since 5.0.0
* @param int $post_id Post ID.
* @param string $key Meta key.
* @param mixed $fallback Default value.
* @return mixed
*/
private function get_meta_with_default( $post_id, $key, $fallback ) {
$value = get_post_meta( $post_id, $key, true );
return ! empty( $value ) ? $value : $fallback;
}
/**
* Get integer post meta with default value
*
* @since 5.0.0
* @param int $post_id Post ID.
* @param string $key Meta key.
* @param int $fallback Default value.
* @return int
*/
private function get_int_meta_with_default( $post_id, $key, $fallback ) {
$value = get_post_meta( $post_id, $key, true );
return ! empty( $value ) ? (int) $value : $fallback;
}
/**
* Get boolean meta value
*
* @since 5.0.0
* @param int $post_id Post ID.
* @param string $key Meta key.
* @param bool $fallback Default value.
* @return bool
*/
private function get_bool_meta( $post_id, $key, $fallback = false ) {
$value = get_post_meta( $post_id, $key, true );
if ( '' === $value ) {
return $fallback;
}
return '1' === $value || true === $value;
}
}