entry).
*/
protected static $themes = array();
/**
* @var array Raw nx_res_themes map (id => entry).
*/
protected static $res_themes = array();
/**
* @var array Raw nx_themes_trigger map (theme id => trigger list).
*/
protected static $triggers = array();
/**
* @var bool Whether the theme-trigger map has been primed this request.
*/
protected static $triggers_booted = false;
/**
* Prime the extension builder filters once (same trigger the admin metabox
* uses), then cache the theme maps for the rest of the request.
*
* @return void
*/
public static function boot() {
if ( self::$booted ) {
return;
}
self::$booted = true;
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- consuming core NotificationX (nx_) hooks.
do_action( 'nx_before_metabox_load' );
$themes = apply_filters( 'nx_themes', array() );
$res = apply_filters( 'nx_res_themes', array() );
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
self::$themes = is_array( $themes ) ? $themes : array();
self::$res_themes = is_array( $res ) ? $res : array();
}
/**
* The raw nx_themes map.
*
* @return array
*/
public static function all_themes() {
self::boot();
return self::$themes;
}
/**
* The raw nx_res_themes map.
*
* @return array
*/
public static function all_res_themes() {
self::boot();
return self::$res_themes;
}
/**
* Registered source (extension) objects keyed by id.
*
* @return array
*/
public static function sources() {
$exts = ExtensionFactory::get_instance()->get_all();
return is_array( $exts ) ? $exts : array();
}
/**
* Whether a source id is registered.
*
* @param string $source Source id.
* @return bool
*/
public static function source_exists( $source ) {
$exts = self::sources();
return $source && isset( $exts[ $source ] );
}
/**
* The notification type a source belongs to (e.g. press_bar → notification_bar).
*
* @param string $source Source id.
* @return string
*/
public static function type_for_source( $source ) {
$exts = self::sources();
return isset( $exts[ $source ]->types ) ? (string) $exts[ $source ]->types : '';
}
/**
* Whether a source's module is enabled (sources with no module are always on).
*
* @param string $source Source id.
* @return bool
*/
public static function source_enabled( $source ) {
$exts = self::sources();
if ( ! isset( $exts[ $source ] ) ) {
return false;
}
$module = isset( $exts[ $source ]->module ) ? $exts[ $source ]->module : '';
return $module ? (bool) Modules::get_instance()->is_enabled( $module ) : true;
}
/**
* Whether a source is Pro-only.
*
* @param string $source Source id.
* @return bool
*/
public static function source_is_pro( $source ) {
$exts = self::sources();
return isset( $exts[ $source ] ) && ! empty( $exts[ $source ]->is_pro );
}
/**
* Valid theme ids for a source (desktop themes).
*
* @param string $source Source id.
* @return string[]
*/
public static function theme_ids_for_source( $source ) {
return self::filter_ids( self::all_themes(), $source );
}
/**
* Valid responsive theme ids for a source.
*
* @param string $source Source id.
* @return string[]
*/
public static function res_theme_ids_for_source( $source ) {
return self::filter_ids( self::all_res_themes(), $source );
}
/**
* Whether a theme id is valid for a source.
*
* @param string $source Source id.
* @param string $theme Theme id.
* @return bool
*/
public static function is_valid_theme( $source, $theme ) {
return $theme && in_array( $theme, self::theme_ids_for_source( $source ), true );
}
/**
* A theme id that is guaranteed to exist for the source: the type's declared
* default if valid, otherwise the first available theme.
*
* @param string $source Source id.
* @return string
*/
public static function effective_default_theme( $source ) {
$ids = self::theme_ids_for_source( $source );
$exts = self::sources();
$type_id = self::type_for_source( $source );
$types = TypeFactory::get_instance()->get_all();
$declared = '';
if ( isset( $exts[ $source ]->default_theme ) && $exts[ $source ]->default_theme ) {
$declared = $exts[ $source ]->default_theme;
} elseif ( $type_id && isset( $types[ $type_id ]->default_theme ) ) {
$declared = $types[ $type_id ]->default_theme;
}
if ( $declared && in_array( $declared, $ids, true ) ) {
return $declared;
}
return isset( $ids[0] ) ? $ids[0] : '';
}
/**
* The default notification-template (content-slot → data-tag map) the admin
* builder applies when a theme is selected.
*
* Data-driven types (comments, download stats, reviews, sales, forms) render
* their text entirely through this template; static-content types (bar,
* cookie notice, announcement, exit intent) carry their own literal content
* and have no template here. The builder writes it from the selected theme's
* `template` array via the nx_themes_trigger system (@notification-template.
* : entries). A headless create/update (MCP, REST, WP-CLI) never
* fires those UI triggers, so without backfilling this the record saves and
* lists fine but renders blank for data-driven types. Reconstructing it from
* the exact same trigger data guarantees it can never diverge from the admin.
*
* @param string $theme Theme id (stored value, e.g. download_stats_today-download).
* @return array Param => tag/value map (first_param, custom_first_param, ...); empty when the theme has no template.
*/
public static function default_template_for_theme( $theme ) {
$list = self::theme_triggers( $theme );
$template = array();
$prefix = '@notification-template.';
$len = strlen( $prefix );
foreach ( $list as $entry ) {
if ( ! is_string( $entry ) || 0 !== strpos( $entry, $prefix ) ) {
continue;
}
// Entry shape: "@notification-template.:". Split on the
// FIRST colon only — a value may itself contain colons (e.g. custom
// text like "in last {{day:7}}").
$rest = substr( $entry, $len );
$pos = strpos( $rest, ':' );
if ( false === $pos ) {
continue;
}
$param = substr( $rest, 0, $pos );
$value = substr( $rest, $pos + 1 );
if ( '' !== $param ) {
$template[ $param ] = $value;
}
}
return $template;
}
/**
* The default inline display locations (hook names) the admin builder
* applies when an inline theme is selected.
*
* Inline themes declare `inline_location` in their theme config; the
* builder copies it into the notification through the nx_themes_trigger
* system. A headless create never fires that trigger, so without this the
* record stores no location and the notification renders nowhere.
*
* @param string $theme Theme id (stored value, e.g. woo_inline_conv-theme-seven).
* @return array Hook names; empty when the theme declares none.
*/
public static function default_inline_location_for_theme( $theme ) {
$list = self::theme_triggers( $theme );
if ( empty( $list['inline_location'] ) ) {
return array();
}
return array_values( array_filter( (array) $list['inline_location'], 'is_string' ) );
}
/**
* The nx_themes_trigger entries for one theme, booting the trigger map once.
*
* @param string $theme Theme id.
* @return array
*/
protected static function theme_triggers( $theme ) {
if ( ! $theme || ! is_string( $theme ) ) {
return array();
}
if ( ! self::$triggers_booted ) {
self::boot(); // Ensures nx_before_metabox_load fired so extensions registered nx_themes_trigger.
self::$triggers_booted = true;
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- consuming core NotificationX (nx_) hooks.
$triggers = apply_filters( 'nx_themes_trigger', array() );
// phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
self::$triggers = is_array( $triggers ) ? $triggers : array();
}
return isset( self::$triggers[ $theme ] ) && is_array( self::$triggers[ $theme ] ) ? self::$triggers[ $theme ] : array();
}
/**
* Filter a raw theme map down to the ids belonging to a source. Prefers each
* entry's own source rule (["includes","source",[...]]); falls back to the
* "_" id prefix.
*
* @param array $themes Raw theme map.
* @param string $source Source id.
* @return string[]
*/
protected static function filter_ids( $themes, $source ) {
$out = array();
foreach ( $themes as $id => $theme ) {
$value = is_array( $theme ) && isset( $theme['value'] ) ? (string) $theme['value'] : (string) $id;
$match = false;
// The entry's `rules` can be a NotificationX\Core\Rule object, a plain
// array, or a nested mix (e.g. GDPR uses ["and", , ] where
// the source rule is nested). JSON round-trip the WHOLE tree so every
// nested Rule object becomes an array and the recursive source lookup
// below can reach it.
$rules = null;
if ( is_array( $theme ) && isset( $theme['rules'] ) ) {
$decoded = json_decode( wp_json_encode( $theme['rules'] ), true );
$rules = is_array( $decoded ) ? $decoded : null;
}
if ( is_array( $rules ) && ! empty( $rules ) ) {
$sources = self::rule_sources( $rules );
if ( ! empty( $sources ) ) {
$match = in_array( $source, $sources, true );
} else {
$match = 0 === strpos( $value, $source . '_' );
}
} else {
$match = 0 === strpos( $value, $source . '_' );
}
if ( $match ) {
$out[] = $value;
}
}
return $out;
}
/**
* Extract source ids from a (possibly nested) rules array shaped like
* ["includes","source",[...]].
*
* @param array $rules Rules array.
* @return string[]
*/
protected static function rule_sources( $rules ) {
$found = array();
if ( isset( $rules[0], $rules[1], $rules[2] ) && 'includes' === $rules[0] && 'source' === $rules[1] && is_array( $rules[2] ) ) {
foreach ( $rules[2] as $s ) {
$found[] = (string) $s;
}
return $found;
}
foreach ( $rules as $r ) {
if ( is_array( $r ) ) {
$found = array_merge( $found, self::rule_sources( $r ) );
}
}
return $found;
}
}