Source: includes/course-theme/class-sensei-course-theme.php

<?php
/**
 * File containing Sensei_Course_Theme class.
 *
 * @package sensei-lms
 * @since   3.13.4
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Load the 'Sensei Course Theme' theme for the /learn subsite.
 *
 * @since 3.15.0
 */
class Sensei_Course_Theme {
	/**
	 * URL prefix for loading the course theme.
	 */
	const QUERY_VAR = 'learn';

	/**
	 * Update when rewrite rules change to make sure they are flushed.
	 */
	const REWRITE_VERSION = '3';

	/**
	 * Course theme preview query var.
	 */
	const PREVIEW_QUERY_VAR = 'sensei_theme_preview';

	/**
	 * Directory for the course theme.
	 */
	const THEME_NAME = 'sensei-course-theme';

	/**
	 * Instance of class.
	 *
	 * @var self
	 */
	private static $instance;

	/**
	 * Active theme on the site before override.
	 *
	 * @var string
	 */
	private $original_theme;

	/**
	 * Sensei_Course_Theme constructor. Prevents other instances from being created outside of `self::instance()`.
	 */
	private function __construct() {
	}

	/**
	 * Fetches an instance of the class.
	 *
	 * @return self
	 */
	public static function instance() {
		if ( ! self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Initializes the Course Theme.
	 */
	public function init() {
		Sensei_Course_Theme_Templates::instance()->init();
		Sensei_Course_Theme_Template_Selection::instance()->init();

		// The following actions add '/learn' route. The '/learn' route is used only when the theme is overridden.
		add_action( 'setup_theme', [ $this, 'add_query_var' ], 1 );
		add_action( 'registered_post_type', [ $this, 'add_post_type_rewrite_rules' ], 10, 2 );
		add_action( 'setup_theme', [ $this, 'maybe_override_theme' ], 2 );
		add_action( 'shutdown', [ $this, 'maybe_flush_rewrite_rules' ] );

		// Initialize quiz and lesson specific functionality.
		add_action( 'template_redirect', [ $this, 'redirect_modules_to_first_lesson' ], 9 );
		add_action( 'template_redirect', [ Sensei_Course_Theme_Lesson::instance(), 'init' ] );
		add_filter( 'sensei_notice', [ Sensei_Course_Theme_Lesson::instance(), 'intercept_notice' ], 10, 1 );
		add_action( 'template_redirect', [ Sensei_Course_Theme_Quiz::instance(), 'init' ] );
		add_filter( 'the_content', [ $this, 'add_lesson_video_to_content' ], 80, 1 );

		// Load learning mode assets and add hooks.
		add_action( 'template_redirect', [ $this, 'load_theme' ] );

		// Prevent module links in learning mode.
		add_filter( 'sensei_do_link_to_module', [ $this, 'prevent_link_to_module' ] );

		// Add custom body class.
		add_filter( 'body_class', [ $this, 'add_body_class' ] );
	}

	/**
	 * Checks if the theme is overridden which currently is not done by default.
	 *
	 * @deprecated
	 *
	 * @return bool
	 */
	public function is_active() {
		_deprecated_function( __METHOD__, '4.7.0' );

		return self::THEME_NAME === get_stylesheet();
	}

	/**
	 * Add the URL prefix the theme is active under.
	 *
	 * @param string $path Optional path to prefix.
	 *
	 * @return string|void
	 */
	public function get_theme_redirect_url( $path = '' ) {

		if ( '' === get_option( 'permalink_structure' ) || get_query_var( 'preview' ) ) {
			return home_url( add_query_arg( [ self::QUERY_VAR => 1 ], $path ) );
		}

		return home_url( '/' . self::QUERY_VAR . '/' . $path );
	}

	/**
	 * Replace theme for the current request if the '/learn' route is used.
	 */
	public function maybe_override_theme() {

		// Do a cheaper preliminary check first.
		$uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification
		if ( ! preg_match( '#' . preg_quote( '/' . self::QUERY_VAR . '/', '#' ) . '#i', $uri ) && ! isset( $_GET[ self::QUERY_VAR ] ) ) {
			return;
		}

		// Then parse the request and make sure the query var is correct.
		wp_load_translations_early();
		wp();

		if ( get_query_var( self::QUERY_VAR ) ) {
			$this->override_theme();
		}
	}

	/**
	 * Load course theme styles and add related filters.
	 *
	 * @return void
	 */
	public function load_theme() {

		if ( ! Sensei_Course_Theme_Option::should_use_learning_mode() ) {
			return;
		}

		Sensei_Course_Theme_Compat::instance()->load_theme();
		Sensei_Course_Theme_Styles::init();

		add_filter( 'sensei_use_sensei_template', '__return_false' );
		add_filter( 'body_class', [ $this, 'add_sensei_theme_body_class' ] );
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] );

		add_action( 'template_redirect', [ $this, 'admin_menu_init' ], 20 );
		add_action( 'admin_init', [ $this, 'admin_menu_init' ], 20 );

		/**
		 * Fires when learning mode is loaded for a page.
		 *
		 * @since 4.0.2
		 *
		 * @hook  sensei_course_learning_mode_load_theme
		 */
		do_action( 'sensei_course_learning_mode_load_theme' );
	}

	/**
	 * Load a bundled theme for the request.
	 */
	public function override_theme() {

		$this->original_theme = get_stylesheet();

		add_filter( 'theme_root', [ $this, 'get_plugin_themes_root' ] );
		add_filter( 'pre_option_stylesheet_root', [ $this, 'get_plugin_themes_root' ] );
		add_filter( 'pre_option_template_root', [ $this, 'get_plugin_themes_root' ] );
		add_filter( 'pre_option_template', [ $this, 'theme_template' ] );
		add_filter( 'pre_option_stylesheet', [ $this, 'theme_stylesheet' ] );
		add_filter( 'theme_root_uri', [ $this, 'theme_root_uri' ] );

		/**
		 * Fires when the theme is override is added for learning mode.
		 *
		 * @since 4.0.2
		 *
		 * @hook  sensei_course_learning_mode_override_theme
		 */
		do_action( 'sensei_course_learning_mode_override_theme' );

		$this->load_theme();

		remove_action( 'template_redirect', 'redirect_canonical' );
	}

	/**
	 * Add learning mode prefix as query var.
	 *
	 * @access private
	 */
	public function add_query_var() {
		global $wp;

		$wp->add_query_var( self::QUERY_VAR );
	}


	/**
	 * Flush rewrite rules if changed.
	 *
	 * @access private
	 */
	public function maybe_flush_rewrite_rules() {

		if ( self::REWRITE_VERSION !== get_option( 'sensei_course_theme_query_var_flushed' ) ) {
			flush_rewrite_rules( false );
			update_option( 'sensei_course_theme_query_var_flushed', self::REWRITE_VERSION );
		}
	}


	/**
	 * Add a route with a /learn prefix for using course theme for a post type.
	 *
	 * @access private
	 *
	 * @param string       $post_type Post type name.
	 * @param WP_Post_Type $args      Post type object.
	 */
	public function add_post_type_rewrite_rules( $post_type, $args ) {

		if ( ! in_array( $post_type, [ 'lesson', 'quiz' ], true ) ) {
			return;
		}

		$slug = preg_quote( $args->rewrite['slug'] ?? $post_type, '/' );

		add_rewrite_rule( '^' . self::QUERY_VAR . '/' . $slug . '/([^/]+)(?:/([0-9]+))?\??(.*)', 'index.php?' . self::QUERY_VAR . '=1&' . $post_type . '=$matches[1]&page=$matches[2]&$matches[3]', 'top' );
		add_rewrite_tag( '%' . self::QUERY_VAR . '%', '([^?]+)' );

	}

	/**
	 * Get course theme name.
	 *
	 * @access private
	 *
	 * @return string
	 */
	public function theme_template() {
		return self::THEME_NAME;
	}

	/**
	 * Get course theme name.
	 *
	 * @access private
	 *
	 * @return string
	 */
	public function theme_stylesheet() {
		return self::THEME_NAME;
	}

	/**
	 * Root URL for bundled themes.
	 *
	 * @access private
	 *
	 * @return string
	 */
	public function theme_root_uri() {
		return Sensei()->plugin_url . '/themes';
	}

	/**
	 * Root directory for bundled themes.
	 *
	 * @access private
	 *
	 * @return string
	 */
	public function get_plugin_themes_root() {
		return Sensei()->plugin_path() . 'themes';
	}

	/**
	 * Directory for course theme.
	 *
	 * @access private
	 *
	 * @return string
	 */
	public function get_course_theme_root() {
		return $this->get_plugin_themes_root() . '/' . self::THEME_NAME;
	}

	/**
	 * Root URL for course theme.
	 *
	 * @access private
	 *
	 * @return string
	 */
	public function get_course_theme_root_url() {
		return $this->theme_root_uri() . '/' . self::THEME_NAME;
	}

	/**
	 * Add Sensei theme body class.
	 *
	 * @access private
	 *
	 * @param string[] $classes The html classess to be added.
	 *
	 * @return string[] $classes
	 */
	public function add_sensei_theme_body_class( $classes ) {
		return array_merge( $classes, [ self::THEME_NAME, 'sensei-' . Sensei_Course_Theme_Template_Selection::get_active_template_name() ] );
	}

	/**
	 * Get the version of the active Learning Mode template.
	 *
	 * @return string|null Version string in the format of 4-0-2
	 */
	private function get_template_version() {
		global $_wp_current_template_content;

		preg_match( '/sensei-version--(\d+-\d+-\d+)/', $_wp_current_template_content ?? '', $version_matches );

		$version = $version_matches[1] ?? null;

		// Versions before 4.0.2 didn't have a version tag, check for Ui blocks instead.
		if ( ! $version && ! preg_match( '/wp:sensei-lms\/ui/', $_wp_current_template_content ) ) {
			$version = '4-0-2';
		}

		return $version;
	}

	/**
	 * Enqueue styles.
	 *
	 * @access private
	 */
	public function enqueue_styles() {

		$version         = $this->get_template_version();
		$css_file        = 'css/learning-mode.' . $version . '.css';
		$compat_css_file = 'css/learning-mode-compat.' . $version . '.css';

		if ( ! $version || ! file_exists( Sensei()->assets->dist_path( $css_file ) ) ) {
			$css_file = 'css/learning-mode.css';
		}

		if ( ! $version || ! file_exists( Sensei()->assets->dist_path( $compat_css_file ) ) ) {
			$compat_css_file = 'css/learning-mode-compat.css';
		}

		Sensei()->assets->enqueue( self::THEME_NAME . '-style', $css_file );

		if ( ! current_theme_supports( 'sensei-learning-mode' ) ) {
			Sensei()->assets->enqueue( self::THEME_NAME . 'compatibility-style', $compat_css_file );
		}

		Sensei()->assets->enqueue( self::THEME_NAME . '-script', 'course-theme/learning-mode.js' );
		Sensei()->assets->enqueue_script( 'sensei-blocks-frontend' );

		$check_circle_icon = Sensei()->assets->get_icon( 'check-circle' );
		wp_add_inline_script( self::THEME_NAME . '-script', "window.sensei = window.sensei || {}; window.sensei.checkCircleIcon = '$check_circle_icon';", 'before' );

		$this->enqueue_fonts();

		if ( Sensei_Course_Theme_Option::should_override_theme() ) {

			Sensei()->assets->enqueue( self::THEME_NAME . '-theme-style', 'css/learning-mode.theme.css' );

			/**
			 * Fires when the override theme styles are loaded for learning mode.
			 *
			 * @since 4.0.2
			 *
			 * @hook  sensei_course_learning_mode_load_override_styles
			 */
			do_action( 'sensei_course_learning_mode_load_override_styles' );
		}

	}

	/**
	 * Enqueue Google fonts.
	 *
	 * @access private
	 */
	public function enqueue_fonts() {
		$font_families = [ 'family=Inter:wght@300;400;500;600;700', 'family=Source+Serif+Pro:ital,wght@0,200;0,300;0,400;0,600;0,700;0,900;1,200;1,300;1,400;1,600;1,700;1,900' ];

		$fonts_url = esc_url_raw( 'https://fonts.googleapis.com/css2?' . implode( '&', array_unique( $font_families ) ) . '&display=swap' );

		//phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- External resource.
		wp_enqueue_style( 'sensei-course-theme-fonts', $fonts_url, [], null );
	}

	/**
	 * Tells if sensei theme is in preview mode.
	 *
	 * @param int $course_id The id of the course.
	 *
	 * @return bool
	 */
	public static function is_preview_mode( $course_id ) {
		// Do not allow sensei preview if not an administrator.
		if ( ! current_user_can( 'manage_sensei' ) ) {
			return false;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The user is administrator at this point. No need.
		$query_var = isset( $_GET[ self::PREVIEW_QUERY_VAR ] ) ? intval( $_GET[ self::PREVIEW_QUERY_VAR ] ) : 0;

		// Do not allow sensei preview if requested course id does not match.
		if ( $query_var !== $course_id ) {
			return false;
		}

		return true;
	}

	/**
	 * Returns the url for sensei theme customization.
	 *
	 * @param string|null $post_type The post type to customize.
	 *
	 * @return string The customization url.
	 */
	public static function get_learning_mode_fse_url( string $post_type = null ) : string {
		// Get the post type manually if not provided.
		if ( ! $post_type ) {
			$post_type = get_post_type();
		}

		// Fallback the post type to lesson if not determined.
		if ( ! $post_type ) {
			$post_type = 'lesson';
		}

		return admin_url( 'site-editor.php?postType=wp_template&postId=' . self::get_learning_mode_template_id( $post_type ) );
	}

	/**
	 * Returns the template ID of the post type for Learning Mode.
	 *
	 * @param string|null $post_type The post type to generate the template ID for.
	 *
	 * @return string The template ID.
	 */
	public static function get_learning_mode_template_id( $post_type = null ) : string {
		return self::THEME_NAME . '//' . $post_type;
	}

	/**
	 * Returns the url for customizing Learning Mode template colors.
	 */
	public static function get_learning_mode_customizer_url(): string {
			// Get the last modified lesson.
		$result = get_posts(
			[
				'posts_per_page' => 1,
				'post_type'      => 'lesson',
				'orderby'        => 'modified',
				'meta'           => [
					'key'     => '_lesson_course',
					'compare' => 'EXISTS',
				],
			]
		);
		if ( empty( $result ) ) {
			return '';
		}

		$lesson      = $result[0];
		$course_id   = get_post_meta( $lesson->ID, '_lesson_course', true );
		$preview_url = '/?p=' . $lesson->ID;

		if ( ! $course_id ) {
			return '';
		}

		if ( ! Sensei_Course_Theme_Option::has_learning_mode_enabled( $course_id ) ) {
			$preview_url .= '&' . self::PREVIEW_QUERY_VAR . '=' . $course_id;
		}

		return '/wp-admin/customize.php?autofocus[section]=sensei-course-theme&url=' . rawurlencode( $preview_url );
	}

	/**
	 * Replace 'Edit site' in admin bar to point to the current theme template.
	 *
	 * @access private
	 */
	public function admin_menu_init() {

		if ( ! function_exists( 'wp_is_block_theme' ) ) {
			return;
		}

		remove_action( 'admin_bar_menu', 'wp_admin_bar_edit_site_menu', 40 );
		add_action( 'admin_bar_menu', [ $this, 'add_admin_bar_edit_site_menu' ], 39 );

	}

	/**
	 * Add 'Edit site' in admin bar opening the current theme template.
	 *
	 * @access private
	 *
	 * @param WP_Admin_Bar $wp_admin_bar The WordPress Admin Bar object.
	 *
	 * @return void
	 */
	public function add_admin_bar_edit_site_menu( $wp_admin_bar ) {

		if ( ! current_user_can( 'edit_theme_options' ) || is_admin() ) {
			return;
		}

		$wp_admin_bar->add_node(
			array(
				'id'    => 'site-editor',
				'title' => __( 'Edit Site', 'sensei-lms' ),
				'href'  => self::get_learning_mode_fse_url( get_post_type() ),
			)
		);
	}

	/**
	 * Get the original active site theme.
	 *
	 * @return mixed
	 */
	public function get_original_theme() {
		return $this->original_theme;
	}

	/**
	 * Filter the post content when using learning mode to add the video
	 * added through the legacy video embed meta box.
	 *
	 * @param string $content The post content.
	 *
	 * @return string The post content with the video.
	 */
	public function add_lesson_video_to_content( $content ) {
		$course_id = \Sensei_Utils::get_current_course();

		if ( is_admin() || ! is_single() || 'lesson' !== get_post_type() || ! Sensei_Course_Theme_Option::has_learning_mode_enabled( $course_id ) ) {
			return $content;
		}

		ob_start();
		Sensei()->frontend->sensei_lesson_video( get_the_ID() );
		$video = ob_get_clean();

		// Checks if video is already added in the content to avoid it duplicated when `the_content`
		// filter is called more than once.
		if ( ! empty( $video ) && false === strpos( $content, Sensei_Frontend::VIDEO_EMBED_CLASS ) ) {
			return $video . $content;
		}

		return $content;
	}

	/**
	 * Prevent modules to be linked in learning mode.
	 *
	 * @since 4.7.0
	 *
	 * @param bool $do_link_to_module True if module should be linked to.
	 *
	 * @return bool
	 */
	public function prevent_link_to_module( bool $do_link_to_module ): bool {
		if ( ! Sensei_Course_Theme_Option::should_use_learning_mode() ) {
			return $do_link_to_module;
		}

		return false;
	}

	/**
	 * Redirect all module pages to the first module lesson.
	 *
	 * @since 4.7.0
	 */
	public function redirect_modules_to_first_lesson(): void {
		if ( ! Sensei_Course_Theme_Option::should_use_learning_mode() || ! is_tax( 'module' ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- No action based on input.
		$course_id = ! empty( $_GET['course_id'] ) ? (int) $_GET['course_id'] : Sensei_Utils::get_current_course();
		if ( ! $course_id ) {
			return;
		}

		$module_lessons = Sensei()->modules->get_lessons( $course_id, get_queried_object_id() );
		if ( $module_lessons ) {
			wp_safe_redirect( get_permalink( $module_lessons[0] ) );
			die();
		}
	}

	/**
	 * Add current theme's text domain to body class.
	 *
	 * @since 4.17.0
	 * @internal
	 *
	 * @param  array $classes Existing body classes.
	 * @return array          Body classes with theme slug added.
	 */
	public function add_body_class( $classes ) {
		$theme       = wp_get_theme();
		$text_domain = $theme->get( 'TextDomain' );

		if ( ! empty( $text_domain ) ) {
			$classes[] = 'sensei-' . $theme->get( 'TextDomain' );
		}

		return $classes;
	}
}