* widget.php the form itself * preview.png optional: the picture on the skins screen (.jpg/.webp too) * * Adding a skin is adding a folder; nothing in the plugin changes. The plugin * ships its own under `skins/`, and a theme or add-on can offer more through the * `rahkar_wp_login_skin_dirs` filter — a Woodmart child theme can carry a skin * that matches its header exactly. * * Every skin drives the same script, so a skin decides how the form looks and * never how it behaves: its markup only has to carry the `data-rahkar-*` hooks * the script looks for. The classic skin's widget.php is the reference. * * A folder missing a required file is ignored rather than offered, so a * half-copied skin can never be selected and break the login form. */ final class SkinRegistry { public const DEFAULT_SKIN = 'classic'; public const DEFAULT_ACCENT = '#1fab55'; private const REQUIRED = [ 'skin.json', 'skin.css', 'widget.php' ]; private const PREVIEWS = [ 'preview.png', 'preview.jpg', 'preview.webp' ]; /** * Skins found on disk, once per request. * * @var array|null */ private static ?array $skins = null; /** * Every usable skin, in display order. * * @return array */ public static function all(): array { if ( null !== self::$skins ) { return self::$skins; } $skins = []; foreach ( self::directories() as $base ) { foreach ( glob( $base['path'] . '*', GLOB_ONLYDIR ) ?: [] as $path ) { $id = basename( $path ); // The id ends up in a file path and a CSS class, so it is held to a // strict alphabet rather than trusted because it came from disk. if ( 1 !== preg_match( '/^[a-z0-9_-]{1,40}$/', $id ) || isset( $skins[ $id ] ) ) { continue; } foreach ( self::REQUIRED as $file ) { if ( ! is_file( $path . '/' . $file ) ) { continue 2; } } $meta = json_decode( (string) file_get_contents( $path . '/skin.json' ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- A local file shipped with the skin. $meta = is_array( $meta ) ? $meta : []; $url = trailingslashit( $base['url'] ) . $id . '/'; $preview = ''; foreach ( self::PREVIEWS as $image ) { if ( is_file( $path . '/' . $image ) ) { $preview = $url . $image; break; } } $skins[ $id ] = [ 'id' => $id, 'path' => trailingslashit( $path ), 'url' => $url, 'order' => (int) ( $meta['order'] ?? 100 ), 'name' => (string) ( $meta['name'] ?? $id ), 'description' => (string) ( $meta['description'] ?? '' ), 'preview' => $preview, ]; } } uasort( $skins, static fn( array $a, array $b ): int => [ $a['order'], $a['id'] ] <=> [ $b['order'], $b['id'] ] ); self::$skins = $skins; return $skins; } /** * Is there a usable skin with this id? * * @param string $id Skin id. */ public static function has( string $id ): bool { return isset( self::all()[ $id ] ); } /** * The skin in use. Falls back to the default when the saved one has since * been removed, so deleting a skin folder cannot break the login form. */ public static function current(): string { $id = (string) Settings::get( OtpAuthModule::SLUG . '.skin', self::DEFAULT_SKIN ); return self::has( $id ) ? $id : self::DEFAULT_SKIN; } /** * One skin, or the default one, or the first there is. Null only when no * skin folder is usable at all. * * @param string $id Skin id. * * @return array{id: string, path: string, url: string, order: int, name: string, description: string, preview: string}|null */ public static function get( string $id ): ?array { $all = self::all(); return $all[ $id ] ?? $all[ self::DEFAULT_SKIN ] ?? ( [] !== $all ? reset( $all ) : null ); } /** * The button colour, always a safe `#rrggbb`: it is printed into a style * attribute, so anything else stored there is ignored. */ public static function accent(): string { $value = strtolower( trim( (string) Settings::get( OtpAuthModule::SLUG . '.skin_accent', self::DEFAULT_ACCENT ) ) ); return self::isColor( $value ) ? $value : self::DEFAULT_ACCENT; } /** * Is this a `#rrggbb` colour? * * @param string $value Candidate. */ public static function isColor( string $value ): bool { return 1 === preg_match( '/^#[0-9a-f]{6}$/i', $value ); } /** * Where skins are looked for: the plugin's own, then any a theme or add-on * registers. The first folder to offer an id wins, so a theme cannot replace * a shipped skin by accident — it has to use a new id. * * @return list */ private static function directories(): array { $dirs = [ [ 'path' => RAHKAR_WP_DIR . 'skins/', 'url' => RAHKAR_WP_URL . 'skins/', ], ]; /** * Filters the folders login skins are loaded from. * * Each entry is `['path' => '/abs/path/', 'url' => 'https://…/']`; every * sub-folder holding a skin.json, skin.css and widget.php becomes a skin. * * @param list $dirs Skin folders. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- Inline type assertion for phpstan: a filter can return anything. /** @var mixed $filtered */ $filtered = apply_filters( 'rahkar_wp_login_skin_dirs', $dirs ); $clean = []; foreach ( is_array( $filtered ) ? $filtered : $dirs as $dir ) { if ( is_array( $dir ) && is_string( $dir['path'] ?? null ) && is_string( $dir['url'] ?? null ) && is_dir( $dir['path'] ) ) { $clean[] = [ 'path' => trailingslashit( $dir['path'] ), 'url' => $dir['url'], ]; } } return $clean; } }