gocodebox/lifterlms

View on GitHub
includes/class-llms-block-templates.php

Summary

Maintainability
C
1 day
Test Coverage
F
28%
<?php
/**
 * LLMS_Block_Templates class file
 *
 * @package LifterLMS/Classes
 *
 * @since 5.8.0
 * @version 7.5.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Handles the block templates.
 *
 * @since 5.8.0
 */
class LLMS_Block_Templates {

    use LLMS_Trait_Singleton;

    /**
     * Directory name of the block templates.
     *
     * @var string
     */
    const LLMS_BLOCK_TEMPLATES_DIRECTORY_NAME = 'block-templates';

    /**
     * Block Template namespace.
     *
     * This is used to save templates to the DB which are stored against this value in the wp_terms table.
     *
     * @var string
     */
    const LLMS_BLOCK_TEMPLATES_NAMESPACE = 'lifterlms/lifterlms';

    /**
     * Block Template slug prefix.
     *
     * @var string
     */
    const LLMS_BLOCK_TEMPLATES_PREFIX = 'llms_';

    /**
     * Block templates configuration.
     *
     * @var array
     */
    private $block_templates_config;

    /**
     * Private Constructor.
     *
     * @since 5.8.0
     *
     * @return void
     */
    private function __construct() {

        $this->configure_block_templates();

        add_filter( 'get_block_templates', array( $this, 'add_llms_block_templates' ), 10, 3 );
        add_filter( 'pre_get_block_file_template', array( $this, 'maybe_return_blocks_template' ), 10, 3 );
        add_action( 'admin_enqueue_scripts', array( $this, 'localize_blocks' ), 9999 );
    }

    /**
     * Configure block templates.
     *
     * @since 5.8.0
     *
     * @return void
     */
    public function configure_block_templates() {

        $block_templates_config = array(
            llms()->plugin_path() . '/templates/' . self::LLMS_BLOCK_TEMPLATES_DIRECTORY_NAME => array(
                'slug_prefix'       => self::LLMS_BLOCK_TEMPLATES_PREFIX,
                'namespace'         => self::LLMS_BLOCK_TEMPLATES_NAMESPACE,
                'blocks_dir'        => self::LLMS_BLOCK_TEMPLATES_DIRECTORY_NAME, // Relative to the plugin's templates directory.
                'admin_blocks_l10n' => $this->block_editor_l10n(),
                'template_titles'   => $this->template_titles(),
            ),
        );

        /**
         * Filters the block templates configuration.
         *
         * @since 5.8.0
         *
         * @param array $block_templates_config Block templates configuration array.
         */
        $this->block_templates_config = apply_filters( 'llms_block_templates_config', $block_templates_config );

    }

    /**
     * This function checks if there's a blocks template to return to pre_get_posts short-circuiting the query in Gutenberg.
     *
     * Ultimately it resolves either a saved blocks template from the
     * database or a template file in `lifterlms/templates/block-templates/`.
     * Without this it won't be possible to save llms templates customizations in the DB.
     *
     * @since 5.8.0
     *
     * @param WP_Block_Template|null $template      Return a block template object to short-circuit the default query,
     *                                              or null to allow WP to run its normal queries.
     * @param string                 $id            Template unique identifier (example: theme_slug//template_slug).
     * @param array                  $template_type wp_template or wp_template_part.
     * @return mixed|WP_Block_Template|WP_Error
     */
    public function maybe_return_blocks_template( $template, $id, $template_type ) {

        // Bail if 'get_block_template' (introduced in WP 5.9.) doesn't exist, or the requested template is not a 'wp_template' type.
        if ( ! function_exists( 'get_block_template' ) || 'wp_template' !== $template_type ) {
            return $template;
        }

        $template_name_parts = explode( '//', $id );
        if ( count( $template_name_parts ) < 2 ) {
            return $template;
        }

        list( , $slug ) = $template_name_parts;

        // Remove the filter at this point because if we don't then this function will infinite loop.
        remove_filter( 'pre_get_block_file_template', array( $this, 'maybe_return_blocks_template' ), 10, 3 );

        // Check if the theme has a saved version of this template before falling back to the llms one.
        $maybe_template = get_block_template( $id, $template_type );

        if ( null !== $maybe_template ) {
            add_filter( 'pre_get_block_file_template', array( $this, 'maybe_return_blocks_template' ), 10, 3 );
            return $maybe_template;
        }

        // Theme-based template didn't exist, try switching the theme to lifterlms and try again. This function has
        // been unhooked so won't run again.
        add_filter( 'get_block_file_template', array( $this, 'get_single_block_template' ), 10, 3 );
        $maybe_template = get_block_template( $id, $template_type );

        // Re-hook this function, it was only unhooked to stop recursion.
        add_filter( 'pre_get_block_file_template', array( $this, 'maybe_return_blocks_template' ), 10, 3 );
        remove_filter( 'get_block_file_template', array( $this, 'get_single_block_template' ), 10, 3 );
        if ( null !== $maybe_template ) {
            return $maybe_template;
        }

        // At this point we haven't had any luck finding a template. Give up and let Gutenberg take control again.
        return $template;

    }


    /**
     * Runs on the get_block_template hook.
     *
     * If a template is already found and passed to this function, then return it and don't run.
     * If a template is *not* passed, try to look for one that matches the ID in the database, if that's not found defer
     * to Blocks templates files. Priority goes: DB-Theme, DB-Blocks, Filesystem-Theme, Filesystem-Blocks.
     *
     * @since 5.8.0
     *
     * @param WP_Block_Template $template      The found block template.
     * @param string            $id            Template unique identifier (example: theme_slug//template_slug).
     * @param array             $template_type wp_template or wp_template_part.
     *
     * @return mixed|null
     */
    public function get_single_block_template( $template, $id, $template_type ) {

        // The template was already found before the filter runs, or the requested template is not a 'wp_template' type, just return it immediately.
        if ( null !== $template || 'wp_template' !== $template_type ) {
            return $template;
        }

        $template_name_parts = explode( '//', $id );
        if ( count( $template_name_parts ) < 2 ) {
            return $template;
        }
        list( , $slug ) = $template_name_parts;

        // Get available llms templates from the filesystem.
        $available_templates = $this->block_templates( array( $slug ), '', true );

        // If this blocks template doesn't exist then we should just skip the function and let Gutenberg handle it.
        if ( ! in_array( $slug, wp_list_pluck( $available_templates, 'slug' ), true ) ) {
            return $template;
        }

        $template = ( is_array( $available_templates ) && count( $available_templates ) > 0 ) ?
            $available_templates[0] : $template;

        return $template;

    }

    /**
     * Gets the templates.
     *
     * @since 5.8.0
     * @since 5.9.0 Filter template slugs array before checking if it's empty.
     *
     * @param array  $slugs     An array of slugs to retrieve templates for.
     * @param string $post_type Post Type.
     * @param bool   $fs_only   Retrieve templates from the filesystem ony.
     * @return WP_Block_Template[] Templates.
     */
    private function block_templates( $slugs = array(), $post_type = '', $fs_only = false ) {

        // Get paths where to look for block templates.
        $block_templates_paths = $this->block_templates_paths();

        // Get all the slugs.
        $template_slugs = array_map( array( $this, 'generate_template_slug_from_path' ), $block_templates_paths );
        // If specific slugs are required, filter them only.
        $template_slugs = empty( array_filter( $slugs ) ) ? $template_slugs : array_intersect( $slugs, $template_slugs );

        if ( empty( $template_slugs ) ) {
            return array();
        }

        $templates = $fs_only
            ?
            $this->block_templates_from_fs( $block_templates_paths, $template_slugs )
            :
            array_merge(
                $this->block_templates_from_db( $template_slugs ),
                $this->block_templates_from_fs( $block_templates_paths, $template_slugs )
            );

        // DB wins over fs, exclude not allowed post types.
        $templates = array_values(
            array_filter(
                $templates,
                function( $template, $key ) use ( $templates, $post_type ) {
                    return ( ! ( $post_type && isset( $template->post_types ) && ! in_array( $post_type, $template->post_types, true ) ) ) &&
                        array_search( $template->slug, array_unique( wp_list_pluck( $templates, 'slug' ) ), true ) === $key;
                },
                ARRAY_FILTER_USE_BOTH
            )
        );

        return $templates;

    }

    /**
     * Get block templates from the file system.
     *
     * @since 5.8.0
     *
     * @param string[] $block_templates_paths Array of block templates paths to look for templates.
     * @param string[] $slugs                 Arrray of template slugs to be retrieved.
     * @return void
     */
    private function block_templates_from_fs( $block_templates_paths, $slugs = array() ) {

        $templates = array();

        foreach ( $block_templates_paths as $template_file ) {
            $template_slug = $this->generate_template_slug_from_path( $template_file );
            if ( ! empty( $slugs ) && ! in_array( $template_slug, $slugs, true ) ) {
                continue;
            }
            $templates[] = $this->build_template_result_from_file( $template_file, $template_slug );
        }

        return $templates;

    }

    /**
     * Gets the templates saved in the database.
     *
     * @since 5.8.0
     *
     * @param array $slugs An array of slugs to retrieve templates for.
     * @return int[]|WP_Post[] An array of found templates.
     */
    private function block_templates_from_db( $slugs = array() ) {

        $query_args = array(
            'post_status'    => array( 'auto-draft', 'draft', 'publish' ),
            'post_type'      => 'wp_template',
            'posts_per_page' => -1,
            'no_found_rows'  => true,
            'tax_query'      => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
                array(
                    'taxonomy' => 'wp_theme',
                    'field'    => 'name',
                    'terms'    => array_merge(
                        array( get_stylesheet(), get_template() ),
                        array_column( $this->block_templates_config, 'namespace' )
                    ),
                ),
            ),
        );

        if ( is_array( $slugs ) && count( $slugs ) > 0 ) {
            $query_args['post_name__in'] = $slugs;
        }

        /**
         * Filters the query arguments to retrieve the templates saved in the db.
         *
         * @since 5.8.0
         *
         * @param array $query_args WQ_Query argiments to retrieve the templates saved in the db.
         */
        $query_args = apply_filters( 'llms_block_templates_from_db_query_args', $query_args );

        $templates = ( new WP_Query( $query_args ) )->posts;

        return array_map(
            function( $template ) {
                return $this->build_template_result_from_post( $template );
            },
            $templates
        );

    }

    /**
     * Retrieve the block templates directory paths.
     *
     * @since 5.8.0
     *
     * @return string[]
     */
    private function block_templates_paths() {

        $block_template_paths = array();

        $block_templates_base_paths = array_keys( $this->block_templates_config );

        foreach ( $block_templates_base_paths as $block_template_base_path ) {
            $block_template_paths = array_merge(
                _get_block_templates_paths( $block_template_base_path ),
                $block_template_paths
            );
        }

        return $block_template_paths;

    }

    /**
     * Build a wp template from file.
     *
     * @since 5.8.0
     * @since 5.9.0 Allow template directory override when the block template comes from an add-on.
     * @since 7.5.0 Use `traverse_and_serialize_blocks` in place of deprecated (since wp 6.4.0) `_inject_theme_attribute_in_block_template_content`
     *
     * @param string $template_file Template file path.
     * @param string $template_slug Template slug.
     * @return WP_Block_Template
     */
    private function build_template_result_from_file( $template_file, $template_slug = '' ) {

        $template_slug = empty( $template_slug ) ? $this->generate_template_slug_from_path( $template_file ) : $template_slug;
        $namespace     = $this->generate_template_namespace_from_path( $template_file );  // Looks like 'lifterlms/lifterlms' or 'lifterlms-groups/lifterlms-groups', etc.
        $template_file = $this->get_maybe_overridden_block_template_file_path( $template_file );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        $template_content = file_get_contents( $template_file );

        // Is the template from the theme/child-theme.
        $theme = false !== strpos( $template_file, get_template_directory() ) ? get_template() : get_stylesheet();
        $theme = false !== strpos( $template_file, get_stylesheet_directory() ) ? $theme : false;

        $template                 = new WP_Block_Template();
        $template->id             = $theme ? $theme . '//' . $template_slug : $namespace . '//' . $template_slug;
        $template->theme          = $theme ? $theme : $namespace;
        $template->content        = function_exists( 'traverse_and_serialize_blocks' ) ?
            traverse_and_serialize_blocks( parse_blocks( $template_content ), '_inject_theme_attribute_in_template_part_block' ) :
            _inject_theme_attribute_in_block_template_content( $template_content );
        $template->source         = $theme ? 'theme' : 'plugin'; // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909.
        $template->slug           = $template_slug;
        $template->type           = 'wp_template';
        $template->title          = $this->convert_slug_to_title( $template_slug );
        $template->status         = 'publish';
        $template->has_theme_file = true;
        $template->origin         = $theme ? 'theme' : 'plugin';
        $template->is_custom      = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
        $template->post_types     = array(); // Don't appear in any Edit Post template selector dropdown.

        return $template;
    }

    /**
     * Build a unified template object based on a WP_Post object.
     *
     * @since 5.8.0
     *
     * @param WP_Post $post Template post.
     * @return WP_Block_Template|WP_Error Template.
     */
    private function build_template_result_from_post( $post ) {

        $terms = get_the_terms( $post, 'wp_theme' );

        if ( is_wp_error( $terms ) ) {
            return $terms;
        }

        if ( ! $terms ) {
            return new \WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'lifterlms' ) );
        }

        $theme = $terms[0]->name;

        $template                 = new WP_Block_Template();
        $template->wp_id          = $post->ID;
        $template->id             = $theme . '//' . $post->post_name;
        $template->theme          = $theme;
        $template->content        = $post->post_content;
        $template->slug           = $post->post_name;
        $template->source         = 'custom';
        $template->type           = $post->post_type;
        $template->description    = $post->post_excerpt;
        $template->title          = $post->post_title;
        $template->status         = $post->post_status;
        $template->has_theme_file = true;
        $template->is_custom      = false;
        $template->post_types     = array(); // Don't appear in any Edit Post template selector dropdown.

        /**
         * Set the 'plugin' origin
         * if it doesn't come from from the current theme (or its parent).
         */
        if ( ! in_array( $theme, array( get_template(), get_stylesheet() ), true ) ) {
            $template->origin = 'plugin';
        }

        return $template;

    }

    /**
     * Retrieve the actual template file path, maybe overridden in the theme.
     *
     * @since 5.9.0
     *
     * @param string $template_file The template's path.
     * @return string
     */
    private function get_maybe_overridden_block_template_file_path( $template_file ) {

        $template_path_info  = pathinfo( $template_file );
        $template_file_name  = $template_path_info['filename'];
        $template_blocks_dir = untrailingslashit( $this->generate_template_blocks_dir_from_path( $template_file ) ); // Looks like 'block-templates'.

        /**
         * Does this come from LifterLMS or from an add-on? In the latter case use the absolute path.
         *
         * $template_path_info['dirname'] looks like 'ABSPATH/wp-content/plugins/lifterlms/templates/block-templates' or
         * 'ABSPATH/wp-content/plugins/lifterlms-groups/templates/block-templates' for an add-on.
         */
        return false !== strpos( $template_path_info['dirname'], trailingslashit( llms()->plugin_path() ) )
            ?
            llms_template_file_path(
                $template_blocks_dir . '/' . $template_file_name . '.html'
            )
            :
            llms_template_file_path(
                $template_blocks_dir . '/' . $template_file_name . '.html', // Looks like 'block-templates/single-llms_group.html'.
                substr( $template_path_info['dirname'], 0, -1 * strlen( $template_blocks_dir ) ), // Looks like 'ABSPATH/wp-content/plugins/lifterlms-groups/templates/'.
                true
            );

    }

    /**
     * Convert the template paths into a slug.
     *
     * @since 5.8.0
     * @since 5.9.0 Return empty string if the passed path is not in the configuration.
     * @since 5.10.0 Use '/' in favor of DIRECTORY_SEPARATOR to avoid issues on Windows.
     * @since 7.2.0 Retrieve the slug by using `basename()` which also fixes issues on Windows filesystems.
     *
     * @param string $path The template's path.
     * @return string
     */
    private function generate_template_slug_from_path( $path ) {

        $prefix  = $this->block_template_config_property_from_path( $path, 'slug_prefix' );

        return $prefix . basename( $path, '.html' );

    }

    /**
     * Generate the template namespace from the template path.
     *
     * @since 5.8.0
     *
     * @param string $path The template's path.
     * @return string
     */
    private function generate_template_namespace_from_path( $path ) {

        return $this->block_template_config_property_from_path( $path, 'namespace' );

    }

    /**
     * Generate the template slug prefix from the template path.
     *
     * @since 5.8.0
     * @since 5.9.0 Fix property name.
     *
     * @param string $path The template's path.
     * @return string
     */
    private function generate_template_prefix_from_path( $path ) {

        return $this->block_template_config_property_from_path( $path, 'slug_prefix' );

    }

    /**
     * Generate the block template directory (relative to the templates direcotry) from the template path.
     *
     * @since 5.9.0
     *
     * @param string $path The template's path.
     * @return string
     */
    private function generate_template_blocks_dir_from_path( $path ) {

        return $this->block_template_config_property_from_path( $path, 'blocks_dir' );

    }

    /**
     * Retrieve a template config property from path.
     *
     * @since 5.8.0
     * @since 5.9.0 Return an empty string if requesting a non existing property.
     *               Also removed unused var `$dirname`.
     *
     * @param string $path     The template's path.
     * @param string $property The template's config property to retrieve.
     * @return string
     */
    private function block_template_config_property_from_path( $path, $property ) {

        $prop_value = '';
        foreach ( $this->block_templates_config as $block_templates_base_path => $config ) {
            if ( false !== strpos( $path, $block_templates_base_path ) ) {
                $prop_value = $config[ $property ] ?? $prop_value;
                break;
            }
        }
        return $prop_value;

    }

    /**
     * Converts template slugs into readable titles.
     *
     * @since 5.8.0
     *
     * @param string $template_slug The templates slug (e.g. single-product).
     * @return string Human friendly title converted from the slug.
     */
    private function convert_slug_to_title( $template_slug ) {

        $template_titles = array_merge( ...array_column( $this->block_templates_config, 'template_titles' ) );

        return array_key_exists( $template_slug, $template_titles ) ?
            $template_titles[ $template_slug ]
            :
            // Replace all hyphens and underscores with spaces.
            ucwords( preg_replace( '/[\-_]/', ' ', $template_slug ) );

    }

    /**
     * Add lifterlms blocks templates.
     *
     * @since 5.8.0
     * @since 6.0.0 Use `llms_is_block_theme()` in favor of `wp_is_block_theme()`.
     *
     * @param WP_Block_Template[] $query_result Array of found block templates.
     * @param array               $query        {
     *     Optional. Arguments to retrieve templates.
     *
     *     @type array  $slug__in List of slugs to include.
     *     @type int    $wp_id    Post ID of customized template.
     * }
     * @param array               $template_type wp_template or wp_template_part.
     * @return WP_Block_Template[] Templates.
     */
    public function add_llms_block_templates( $query_result, $query, $template_type = 'wp_template' ) {

        // Bail it's not a block theme, or is being retrieved a non wp_template type requested.
        if ( ! llms_is_block_theme() || 'wp_template' !== $template_type ) {
            return $query_result;
        }

        $post_type = $query['post_type'] ?? '';
        $slugs     = $query['slug__in'] ?? array();

        // Retrieve templates.
        $templates = $this->block_templates( $slugs, $post_type );

        /**
         * Remove theme override templates who have a customization in the db from $query_result:
         * those template blocks will be already retrieved by our LLMS_Block_Templates::block_templates_from_db().
         */
        $query_result = array_values(
            array_filter(
                $query_result,
                function( $template ) use ( $templates ) {
                    $slugs = wp_list_pluck( $templates, 'slug' );
                    return ( ! in_array( $template->slug, $slugs, true ) );
                }
            )
        );

        return array_merge( $query_result, $templates );

    }

    /**
     * Returns an associative array of template titles.
     *
     * Keys are template slugs.
     * Values are template titles in a human readable form.
     *
     * @since 5.8.0
     *
     * @return array
     */
    private function template_titles() {

        $template_titles = array(
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'archive-course'             => esc_html__( 'Course Catalog', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'archive-llms_membership'    => esc_html__( 'Membership Catalog', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'single-certificate'         => esc_html__( 'Single Certificate', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'single-no-access'           => esc_html__( 'Single Access Restricted', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'taxonomy-course_cat'        => esc_html__( 'Taxonomy Course Category', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'taxonomy-course_difficulty' => esc_html__( 'Taxonomy Course Difficulty', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'taxonomy-course_tag'        => esc_html__( 'Taxonomy Course Tag', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'taxonomy-course_track'      => esc_html__( 'Taxonomy Course Track', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'taxonomy-membership_cat'    => esc_html__( 'Taxonomy Membership Category', 'lifterlms' ),
            self::LLMS_BLOCK_TEMPLATES_PREFIX . 'taxonomy-membership_tag'    => esc_html__( 'Taxonomy Membership Tag', 'lifterlms' ),
        );

        /**
         * Filters the block template titles.
         *
         * @since 5.8.0
         *
         * @param array $template_titles  {
         *     Associative array of template titles.
         *
         *     @type string $slug  The template slug.
         *     @type string $title The template readable titles.
         * }
         */
        return apply_filters( 'lifterlms_block_templates_titles', $template_titles );

    }

    /**
     * Block Templates admin js strings.
     *
     * @since 5.8.0
     *
     * @return string[]
     */
    private function block_editor_l10n() {

        return array(
            'archive-course'             => esc_html__( 'LifterLMS Course Catalog Template', 'lifterlms' ),
            'archive-llms_membership'    => esc_html__( 'LifterLMS Membership Catalog Template', 'lifterlms' ),
            'single-certificate'         => esc_html__( 'LifterLMS Certificate Template', 'lifterlms' ),
            'single-no-access'           => esc_html__( 'LifterLMS Single Template Access Restricted', 'lifterlms' ),
            'taxonomy-course_cat'        => esc_html__( 'LifterLMS Course Category Taxonomy Template', 'lifterlms' ),
            'taxonomy-course_difficulty' => esc_html__( 'LifterLMS Course Difficulty Taxonomy Template', 'lifterlms' ),
            'taxonomy-course_tag'        => esc_html__( 'LifterLMS Course Tag Taxonomy Template', 'lifterlms' ),
            'taxonomy-course_track'      => esc_html__( 'LifterLMS Course Track Taxonomy Template', 'lifterlms' ),
            'taxonomy-membership_cat'    => esc_html__( 'LifterLMS Membership Tag Taxonomy Template', 'lifterlms' ),
            'taxonomy-membership_tag'    => esc_html__( 'LifterLMS Membership Tag Taxonomy Template', 'lifterlms' ),
        );

    }

    /**
     * Localize block templates.
     *
     * @since 5.8.0
     * @since 5.9.0 Retuns the `wp_localize_script()` return value.
     *
     * @return bool
     */
    public function localize_blocks() {
        return wp_localize_script(
            'llms-blocks-editor',
            'llmsBlockTemplatesL10n',
            array_merge( ...array_column( $this->block_templates_config, 'admin_blocks_l10n' ) )
        );
    }

}