array_merge( $b, [
* 'name' => 'Agency SEO Insights',
* 'logo_url' => 'https://agency.example/logo.svg', // square, shown at 18–40 px
* 'site_url' => 'https://agency.example',
* 'docs_url' => 'https://agency.example/help/',
* 'show_upsell' => false, // hide "See Pro", upgrade boxes and PRO badges
* 'show_vendor' => false, // hide links to clusterlane.app
* ] ) );
*
* What follows the brand: the admin menu name and icon, page titles, the
* dock and Dashboard logo, notices, the support report, drafts created from
* briefs, and — when the name differs — every translated text that says
* "Clusterlane" (via the gettext filter for this text domain). JavaScript gets
* the same values as window.clusterlaneBrand.
*
* Internal names are not branded: the text domain, the clusterlane_ prefix, REST
* routes and the AI prompts stay as they are.
*/
final class Brand {
const DEFAULTS = [
'name' => 'Clusterlane',
'logo_url' => '', // '' = the built-in station mark
'site_url' => 'https://clusterlane.app',
'docs_url' => 'https://clusterlane.app/docs/',
'guides_url' => 'https://clusterlane.app/guides/',
'pricing_url' => 'https://clusterlane.app/pricing/',
'support_url' => 'https://clusterlane.app/',
'show_upsell' => true,
'show_vendor' => true,
];
/** Line and ring colours of the mark (the plugin's Pillar orange and ink). */
const ORANGE = '#E76C2E';
const INK = '#16223A';
private static ?array $brand = null;
/** The brand, filtered and sanitized once per request. */
public static function get(): array {
if ( self::$brand !== null ) return self::$brand;
$b = (array) apply_filters( 'clusterlane_brand', self::DEFAULTS );
$b = array_merge( self::DEFAULTS, array_intersect_key( $b, self::DEFAULTS ) );
foreach ( [ 'logo_url', 'site_url', 'docs_url', 'guides_url', 'pricing_url', 'support_url' ] as $k ) {
$b[ $k ] = $b[ $k ] !== '' ? esc_url_raw( (string) $b[ $k ] ) : '';
}
$b['name'] = trim( wp_strip_all_tags( (string) $b['name'] ) ) ?: self::DEFAULTS['name'];
$b['show_upsell'] = (bool) $b['show_upsell'];
$b['show_vendor'] = (bool) $b['show_vendor'];
return self::$brand = $b;
}
/** Forget the cached brand (tests, or after a settings change in the same request). */
public static function reset(): void {
self::$brand = null;
}
public static function name(): string {
return self::get()['name'];
}
/** True when the plugin is shown under another name than Clusterlane. */
public static function is_white_label(): bool {
return self::name() !== self::DEFAULTS['name'];
}
/** site | docs | guides | pricing | support — '' when vendor links are hidden. */
public static function url( string $which ): string {
$b = self::get();
$url = (string) ( $b[ $which . '_url' ] ?? '' );
// Pricing keeps its older filter (Freemius hooks in here).
if ( $which === 'pricing' ) $url = (string) apply_filters( 'clusterlane_upgrade_url', $url );
if ( ! $b['show_vendor'] && str_starts_with( $url, self::DEFAULTS['site_url'] ) ) return '';
return $url;
}
public static function show_upsell(): bool {
return self::get()['show_upsell'];
}
/**
* The mark: a station (ring) on a line — the same mark as on clusterlane.app.
* $ring is the ring's stroke, $fill its centre (the background it sits on).
* With a custom logo_url, an
of that logo is returned instead.
*/
public static function mark( int $size = 24, string $ring = self::INK, string $fill = '#fff' ): string {
$logo = self::get()['logo_url'];
if ( $logo !== '' ) {
return '
';
}
return '';
}
/** Allowed markup for mark() — for wp_kses(). */
public static function kses(): array {
return [
'svg' => [ 'class' => true, 'xmlns' => true, 'width' => true, 'height' => true, 'viewbox' => true, 'aria-hidden' => true, 'focusable' => true ],
'path' => [ 'd' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'fill' => true ],
'circle' => [ 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true ],
'img' => [ 'class' => true, 'src' => true, 'width' => true, 'height' => true, 'alt' => true, 'aria-hidden' => true ],
];
}
/** Print the mark, escaped. */
public static function mark_e( int $size = 24, string $ring = self::INK, string $fill = '#fff' ): void {
echo wp_kses( self::mark( $size, $ring, $fill ), self::kses() );
}
/**
* Admin menu icon: the mark in one colour ("black" is the WordPress
* convention — the admin colour scheme tints it). A custom logo is used
* as is.
*/
public static function menu_icon(): string {
$logo = self::get()['logo_url'];
if ( $logo !== '' ) return $logo;
$svg = '';
return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- data URI for the menu icon
}
/** For JavaScript (window.clusterlaneBrand). */
public static function to_js(): array {
return [
'name' => self::name(),
'whiteLabel' => self::is_white_label(),
'showUpsell' => self::show_upsell(),
'urls' => [
'site' => self::url( 'site' ),
'docs' => self::url( 'docs' ),
'guides' => self::url( 'guides' ),
'pricing' => self::url( 'pricing' ),
'support' => self::url( 'support' ),
],
];
}
/**
* Under another name, translated texts that say "Clusterlane" say that
* name instead — one filter for this text domain, no per-string work.
*/
public static function register_hooks(): void {
if ( ! self::is_white_label() ) return;
$swap = fn( $text ) => str_replace( self::DEFAULTS['name'], self::name(), (string) $text );
add_filter( 'gettext_clusterlane', $swap, 20 );
add_filter( 'ngettext_clusterlane', $swap, 20 );
add_filter( 'gettext_with_context_clusterlane', $swap, 20 );
// The Plugins screen shows the brand too (the support report keeps the
// real plugin name on purpose — it is for support).
add_filter( 'all_plugins', function ( $plugins ) {
$file = plugin_basename( CLUSTERLANE_FILE );
if ( isset( $plugins[ $file ] ) ) {
$plugins[ $file ]['Name'] = self::name();
$plugins[ $file ]['Title'] = self::name();
$plugins[ $file ]['PluginURI'] = self::url( 'site' );
if ( ! self::get()['show_vendor'] ) { $plugins[ $file ]['Author'] = ''; $plugins[ $file ]['AuthorURI'] = ''; $plugins[ $file ]['AuthorName'] = ''; }
}
return $plugins;
} );
}
}