category pages (direct children) > articles (grandchildren). * Direct children without children of their own are listed under "General". * * @package 3Task_Glossary * @since 2.4.0 */ if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'AZGL_KB', false ) ) { /** * Knowledge base mode. */ final class AZGL_KB { /** * Plugin URL and version of the plugin that booted the KB (Free or Pro). * * @var array */ private static $boot = array(); /** * Register hooks once. Called by the frontend of Free or Pro. * * @param string $plugin_url Plugin base URL (with trailing slash). * @param string $version Plugin version for asset URLs. */ public static function boot( $plugin_url, $version ) { if ( self::$boot ) { return; } self::$boot = array( 'url' => $plugin_url, 'version' => $version ); add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); } /** * Styles and rating script for KB pages. Called by the frontend after its own stylesheet. * * @param string $after_style Handle of the stylesheet with the color variables. */ public static function enqueue_assets( $after_style ) { if ( ! self::$boot ) { return; } wp_enqueue_style( 'azgl-kb', self::$boot['url'] . 'assets/css/kb.css', array( $after_style ), self::$boot['version'] ); if ( self::is_article( get_queried_object_id() ) ) { wp_enqueue_script( 'azgl-kb', self::$boot['url'] . 'assets/js/kb.js', array(), self::$boot['version'], true ); wp_localize_script( 'azgl-kb', 'azglKb', array( 'url' => rest_url( 'azgl/v1/kb-vote' ), 'thanks' => __( 'Thank you for your feedback!', '3task-glossary' ), ) ); } } /** * REST route for the "helpful" rating. */ public static function register_routes() { register_rest_route( 'azgl/v1', '/kb-vote', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'rest_vote' ), 'permission_callback' => '__return_true', // Public, anonymous rating without personal data. 'args' => array( 'article' => array( 'required' => true, 'type' => 'integer', 'minimum' => 1 ), 'vote' => array( 'required' => true, 'type' => 'string', 'enum' => array( 'yes', 'no' ) ), ), ) ); } /** * Count one vote. Stores only two counters per article; no IP, no user agent, no cookie. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function rest_vote( $request ) { $id = absint( $request['article'] ); if ( ! self::is_article( $id ) ) { return new WP_Error( 'azgl_kb_not_found', __( 'Article not found.', '3task-glossary' ), array( 'status' => 404 ) ); } $key = 'yes' === $request['vote'] ? '_azgl_kb_yes' : '_azgl_kb_no'; update_post_meta( $id, $key, absint( get_post_meta( $id, $key, true ) ) + 1 ); $response = new WP_REST_Response( array( 'ok' => true ) ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Save mode and breadcrumb switch of one glossary. Caller checks nonce and capability. * * @param int $page_id Glossary page ID. * @param string $mode glossary or kb. * @param bool $breadcrumbs Show breadcrumbs in KB mode. * @return bool */ public static function save_settings( $page_id, $mode, $breadcrumbs ) { $options = get_option( 'azgl_options', array() ); foreach ( (array) ( $options['glossaries'] ?? array() ) as $key => $glossary ) { if ( isset( $glossary['page_id'] ) && absint( $glossary['page_id'] ) === absint( $page_id ) ) { $options['glossaries'][ $key ]['mode'] = 'kb' === $mode ? 'kb' : 'glossary'; $options['glossaries'][ $key ]['kb_breadcrumbs'] = (bool) $breadcrumbs; update_option( 'azgl_options', $options ); return true; } } return false; } /** * Admin table cell: mode switch for one glossary (posts azgl_action=save_glossary_mode). * * @param array $glossary Glossary settings. * @param string $nonce_html Nonce field of the calling admin page. * @return string */ public static function admin_cell( $glossary, $nonce_html ) { $id = absint( $glossary['page_id'] ); $is_kb = 'kb' === ( $glossary['mode'] ?? '' ); $out = '
'; } /** * Counts of a KB for the admin overview. * * @param int $root_id KB home page ID. * @return array categories, articles, yes, no. */ public static function stats( $root_id ) { $stats = array( 'categories' => 0, 'articles' => 0, 'yes' => 0, 'no' => 0 ); foreach ( self::tree( $root_id ) as $category ) { $stats['categories'] += $category['id'] ? 1 : 0; foreach ( $category['articles'] as $article ) { $rating = self::rating( $article->ID ); ++$stats['articles']; $stats['yes'] += $rating['yes']; $stats['no'] += $rating['no']; } } return $stats; } /** * Vote counters of an article. * * @param int $post_id Article ID. * @return array yes and no. */ public static function rating( $post_id ) { return array( 'yes' => absint( get_post_meta( $post_id, '_azgl_kb_yes', true ) ), 'no' => absint( get_post_meta( $post_id, '_azgl_kb_no', true ) ), ); } /** * Whether a page is a public KB article (not the home page, not a category). * * @param int $post_id Page ID. * @return bool */ public static function is_article( $post_id ) { $post = get_post( $post_id ); $root = $post && 'page' === $post->post_type && AZGL_Content::public_page( $post ) ? self::root_of( $post->ID ) : 0; if ( ! $root || $root === $post->ID ) { return false; } return $post->post_parent !== $root || ! self::children( $post->ID ); } /** * Glossary settings for a page, or null. * * @param int $page_id Page ID. * @return array|null */ private static function glossary( $page_id ) { $options = get_option( 'azgl_options', array() ); foreach ( (array) ( $options['glossaries'] ?? array() ) as $glossary ) { if ( isset( $glossary['page_id'] ) && absint( $glossary['page_id'] ) === absint( $page_id ) ) { return $glossary; } } return null; } /** * Whether a glossary page runs in knowledge base mode. * * @param int $page_id Page ID. * @return bool */ public static function is_kb( $page_id ) { $glossary = self::glossary( $page_id ); return $glossary && ! empty( $glossary['active'] ) && 'kb' === ( $glossary['mode'] ?? '' ); } /** * KB home page ID for a page inside a knowledge base (home, category or article), else 0. * * @param int $post_id Page ID. * @return int */ public static function root_of( $post_id ) { $post = get_post( $post_id ); for ( $depth = 0; $post && $depth < 3; ++$depth ) { if ( self::is_kb( $post->ID ) ) { return AZGL_Content::public_page( $post->ID ) ? $post->ID : 0; } $post = $post->post_parent ? get_post( $post->post_parent ) : null; } return 0; } /** * Render any KB page: home, category or article. * * @param int $post_id Page ID. * @param string $content Page content. * @return string */ public static function render( $post_id, $content ) { $root = self::root_of( $post_id ); if ( ! $root ) { return $content; } if ( $root === (int) $post_id ) { return self::render_home( $root, $content ); } $crumbs = self::breadcrumbs_on( $root ) ? self::render_breadcrumbs( $post_id ) : ''; $post = get_post( $post_id ); $kids = $post->post_parent === $root ? self::children( $post_id ) : array(); if ( $kids ) { return $crumbs . $content . self::render_list( $kids ); } $toc = self::toc( $content ); $out = $crumbs; if ( count( $toc['items'] ) >= 2 ) { $out .= ''; } $out .= $toc['html']; $related = array_filter( self::children( $post->post_parent ), static function ( $page ) use ( $post, $root ) { return $page->ID !== $post->ID && ( $post->post_parent !== $root || ! self::children( $page->ID ) ); } ); if ( $related ) { $out .= ''; } $out .= ''; return $out; } /** * Whether breadcrumbs are switched on for a KB (default on). * * @param int $root KB home page ID. * @return bool */ private static function breadcrumbs_on( $root ) { $glossary = self::glossary( $root ); return ! isset( $glossary['kb_breadcrumbs'] ) || ! empty( $glossary['kb_breadcrumbs'] ); } /** * Whether an SEO plugin that prints its own breadcrumb schema is active. * * @return bool */ private static function seo_plugin_active() { return defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath', false ) || defined( 'AIOSEO_VERSION' ); } /** * Breadcrumb trail from the KB home down to the page. * * @param int $post_id Page ID. * @return array List of title and url. */ private static function trail( $post_id ) { $root = self::root_of( $post_id ); $trail = array(); for ( $post = get_post( $post_id ); $post; $post = $post->post_parent ? get_post( $post->post_parent ) : null ) { array_unshift( $trail, array( 'title' => get_the_title( $post ), 'url' => get_permalink( $post ) ) ); if ( $post->ID === $root ) { break; } } return $trail; } /** * Visible breadcrumbs. * * @param int $post_id Page ID. * @return string */ private static function render_breadcrumbs( $post_id ) { $trail = self::trail( $post_id ); $last = count( $trail ) - 1; $out = ''; } /** * Print BreadcrumbList JSON-LD for a KB page (not on the home page, not next to an SEO plugin). * * @param int $post_id Page ID. */ public static function print_schema( $post_id ) { $root = self::root_of( $post_id ); if ( ! $root || $root === (int) $post_id || ! self::breadcrumbs_on( $root ) || self::seo_plugin_active() ) { return; } $trail = self::trail( $post_id ); $items = array(); foreach ( $trail as $i => $crumb ) { $item = array( '@type' => 'ListItem', 'position' => $i + 1, 'name' => $crumb['title'] ); if ( $i < count( $trail ) - 1 ) { $item['item'] = $crumb['url']; } $items[] = $item; } $schema = array( '@context' => 'https://schema.org', '@type' => 'BreadcrumbList', 'itemListElement' => $items ); echo '' . "\n"; } /** * Article list of a category page. * * @param WP_Post[] $articles Articles. * @return string */ private static function render_list( $articles ) { $out = '' . esc_html__( 'No articles found.', '3task-glossary' ) . '
'; } $out .= '