` echo) so they participate in dependency
* management like any other front-end script.
*/
final class IntegrationsOutput
{
private IntegrationsRepositoryInterface $integrations;
public function __construct( IntegrationsRepositoryInterface $integrations )
{
$this->integrations = $integrations;
}
public function register(): void
{
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_head_scripts' ) );
add_action( 'wp_head', array( $this, 'output_verification_tags' ), 1 );
// Runs on the 'wp_body_open' action (WP 5.2+) — themes that don't call
// wp_body_open() in header.php simply won't get the noscript fallback;
// this is a known, common limitation of GTM integrations in general,
// not something we can work around without editing theme files.
add_action( 'wp_body_open', array( $this, 'output_body_open' ) );
}
public function enqueue_head_scripts(): void
{
if ( $this->should_skip() )
{
return;
}
$settings = $this->integrations->get();
$this->enqueue_gtm_head( (string) $settings['gtm_container_id'] );
$this->enqueue_ga( (string) $settings['ga_measurement_id'] );
$this->enqueue_hotjar( (string) $settings['hotjar_site_id'] );
}
/**
* One `` per configured provider. Only fields
* the user actually filled in are printed — an empty verification tag is
* worse than none (Google reads it as a failed verification attempt).
*/
public function output_verification_tags(): void
{
if ( $this->should_skip() )
{
return;
}
$settings = $this->integrations->get();
foreach ( IntegrationsRepository::verification_meta_names() as $option_key => $meta_name )
{
$code = (string) ( $settings[ $option_key ] ?? '' );
if ( '' === $code )
{
continue;
}
printf( '' . "\n", esc_attr( $meta_name ), esc_attr( $code ) );
}
}
public function output_body_open(): void
{
if ( $this->should_skip() )
{
return;
}
$this->output_gtm_body( (string) $this->integrations->get()['gtm_container_id'] );
}
private function should_skip(): bool
{
$settings = $this->integrations->get();
return (bool) $settings['exclude_admins'] && current_user_can( 'manage_options' );
}
/** GTM's own loader snippet has no real external file — a handle-less registration is GTM/GA's own documented shape for wp_add_inline_script(). */
private function enqueue_gtm_head( string $container_id ): void
{
if ( '' === $container_id )
{
return;
}
$id = esc_js( $container_id );
$handle = 'acez-seo-gtm';
wp_register_script( $handle, '', array(), ACEZ_SEO_VERSION, false );
wp_enqueue_script( $handle );
wp_add_inline_script(
$handle,
"(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':" .
"new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0]," .
"j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=" .
"'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);" .
"})(window,document,'script','dataLayer','" . $id . "');"
);
}
private function output_gtm_body( string $container_id ): void
{
if ( '' === $container_id )
{
return;
}
printf(
"\n\n\n",
esc_attr( $container_id )
);
}
private function enqueue_ga( string $measurement_id ): void
{
if ( '' === $measurement_id )
{
return;
}
$id = esc_js( $measurement_id );
$handle = 'acez-seo-ga4';
$src = add_query_arg( 'id', rawurlencode( $measurement_id ), 'https://www.googletagmanager.com/gtag/js' );
// 'strategy' => 'async' (WP 6.3+ script-loading-strategy API) matches
// Google's own gtag.js snippet, which loads with the `async` attribute.
// Version stays null on purpose: gtag.js is Google-hosted and versioned
// by Google, so appending our own ?ver= would only bust their CDN cache.
wp_enqueue_script( $handle, $src, array(), null, array( 'strategy' => 'async' ) ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Google-hosted gtag.js carries Google's own versioning; a ?ver= of ours would only bust their CDN cache.
// MUST stay 'before', never 'after': WP_Scripts::filter_eligible_strategies()
// treats any handle carrying an 'after' inline script as undelayable, which
// silently drops the async attribute AND moves the whole gtag.js tag to the
// footer (WP_Scripts::do_item()'s "intended strategy delayed but actual one
// isn't" branch) — measurably later page-view hits. Defining dataLayer/gtag()
// ahead of the loader is safe by design: the queued calls are replayed once
// gtag.js finishes loading, which is how Google's own snippet works.
wp_add_inline_script(
$handle,
"window.dataLayer = window.dataLayer || [];\n" .
"function gtag(){dataLayer.push(arguments);}\n" .
"gtag('js', new Date());\n" .
"gtag('config', '" . $id . "');",
'before'
);
}
/** Hotjar's own loader snippet has no real external file upfront (it appends its own