/(page/N/) {brand}//(page/N/) * {collection}//(page/N/) {budget}//(page/N/) {delivery}/ {shop}// ← product rule always last * * Listing filters (query string, read in current()): `?price=` (must equal a bucket from the ping — * else dropped) and `?sort=` (AooShop_Settings::SORTS · 'manual' only on collections). Dropped values never reach * aoo nor the cache key, so `?price=junk` can't fan out into endless cached pages. Budget pages ARE a price range, * so they take `sort` only. * * Rules carry only our query vars; the `request` filter points them at the template page (page_id) — the per-type * template of that route type when one is set (AooShop_Template_Page::for_type()) — so re-creating or * re-assigning a page never needs a flush. Rules are flushed automatically whenever their hash changes * (prefix/mode change, WooCommerce turned on/off → preview toggles). * * Extension: `aooshop_route_rules` filter — add routes (e.g. W3 cart `{shop}/cart/`) before the product rule. * * @package AooShop */ defined( 'ABSPATH' ) || exit; class AooShop_Router { const HASH_OPTION = 'aooshop_rules_hash'; const FLUSH_OPTION = 'aooshop_flush_rules'; /** Route types that list products (take `?price=` / `?sort=`). */ const LISTING_TYPES = array( 'index', 'category', 'brand', 'collection', 'budget', 'search' ); /** @var AooShop_Settings */ private $settings; /** @var AooShop_Template_Page */ private $template; /** @var array|null regex => query */ private $rules = null; /** @var array|null|false */ private $current = false; public function __construct( AooShop_Settings $settings, AooShop_Template_Page $template ) { $this->settings = $settings; $this->template = $template; } public function hooks() { add_action( 'init', array( $this, 'register' ), 20 ); add_action( 'init', array( $this, 'maybe_flush' ), 99 ); add_filter( 'query_vars', array( $this, 'query_vars' ) ); add_filter( 'request', array( $this, 'request' ), 5 ); add_filter( 'redirect_canonical', array( $this, 'redirect_canonical' ), 10, 2 ); // Polylang / other plugins must not translate or redirect our URLs. add_filter( 'pll_check_canonical_url', array( $this, 'redirect_canonical' ), 10, 2 ); } public function query_vars( $vars ) { $vars[] = 'aooshop_route'; $vars[] = 'aooshop_slug'; $vars[] = 'aooshop_page'; return $vars; } private static function q( $path ) { return preg_quote( ltrim( $path, '/' ), '#' ); } /** @return array regex => rewrite target (in match order). */ public function rules() { if ( null !== $this->rules ) { return $this->rules; } $p = $this->settings->paths(); $shop = self::q( $p['prefix_shop'] ); $cat = self::q( $p['prefix_category'] ); $brand = self::q( $p['prefix_brand'] ); $coll = self::q( $p['prefix_collection'] ); $budg = self::q( $p['prefix_budget'] ); $deliv = self::q( $p['path_delivery'] ); $base = 'index.php?aooshop_route='; $rules = array( "^{$cat}/([^/]+)/page/([0-9]{1,6})/?$" => $base . 'category&aooshop_slug=$matches[1]&aooshop_page=$matches[2]', "^{$cat}/([^/]+)/?$" => $base . 'category&aooshop_slug=$matches[1]', "^{$cat}/?$" => $base . 'root', "^{$brand}/([^/]+)/page/([0-9]{1,6})/?$" => $base . 'brand&aooshop_slug=$matches[1]&aooshop_page=$matches[2]', "^{$brand}/([^/]+)/?$" => $base . 'brand&aooshop_slug=$matches[1]', "^{$brand}/?$" => $base . 'root', "^{$coll}/([^/]+)/page/([0-9]{1,6})/?$" => $base . 'collection&aooshop_slug=$matches[1]&aooshop_page=$matches[2]', "^{$coll}/([^/]+)/?$" => $base . 'collection&aooshop_slug=$matches[1]', "^{$coll}/?$" => $base . 'root', "^{$budg}/([^/]+)/page/([0-9]{1,6})/?$" => $base . 'budget&aooshop_slug=$matches[1]&aooshop_page=$matches[2]', "^{$budg}/([^/]+)/?$" => $base . 'budget&aooshop_slug=$matches[1]', "^{$budg}/?$" => $base . 'root', "^{$shop}/search/?$" => $base . 'search', "^{$shop}/page/([0-9]{1,6})/?$" => $base . 'index&aooshop_page=$matches[1]', "^{$shop}/?$" => $base . 'index', "^{$deliv}/?$" => $base . 'delivery', ); $rules = (array) apply_filters( 'aooshop_route_rules', $rules, $p ); // Product slug catch-all — must stay last. $rules[ "^{$shop}/([^/]+)/?$" ] = $base . 'product&aooshop_slug=$matches[1]'; $this->rules = $rules; return $rules; } public function register() { foreach ( $this->rules() as $regex => $target ) { add_rewrite_rule( $regex, $target, 'top' ); } } public function request_flush() { update_option( self::FLUSH_OPTION, 1, true ); } public function maybe_flush() { $hash = md5( wp_json_encode( $this->rules() ) . '|' . get_option( 'permalink_structure' ) ); if ( get_option( self::FLUSH_OPTION ) || get_option( self::HASH_OPTION ) !== $hash ) { flush_rewrite_rules( false ); update_option( self::HASH_OPTION, $hash, true ); delete_option( self::FLUSH_OPTION ); } } public function remove_rules_and_flush() { global $wp_rewrite; foreach ( array_keys( $this->rules() ) as $regex ) { unset( $wp_rewrite->extra_rules_top[ $regex ] ); } remove_action( 'init', array( $this, 'register' ), 20 ); flush_rewrite_rules( false ); delete_option( self::HASH_OPTION ); delete_option( self::FLUSH_OPTION ); } /** Point aoo routes at the template page (per-type template when set, else the central page). */ public function request( $qv ) { if ( empty( $qv['aooshop_route'] ) ) { return $qv; } $tpl = $this->template->for_type( sanitize_key( (string) $qv['aooshop_route'] ) ); if ( ! $tpl ) { return $qv; } // A page query only — drop anything that would turn it into something else. foreach ( array( 'pagename', 'name', 'p', 'page', 'paged', 'post_type', 'error' ) as $k ) { unset( $qv[ $k ] ); } $qv['page_id'] = $tpl; return $qv; } public function redirect_canonical( $redirect, $requested = null ) { return $this->current() ? false : $redirect; } /** * Route of the current main request (after parse_request). * * @return array{type:string, slug:string, page:int, q:string, price:string, sort:string}|null */ public function current() { if ( false !== $this->current ) { return $this->current; } global $wp; if ( ! did_action( 'parse_request' ) || ! isset( $wp->query_vars ) ) { return null; // Not known yet — don't memoize. } $type = isset( $wp->query_vars['aooshop_route'] ) ? sanitize_key( $wp->query_vars['aooshop_route'] ) : ''; if ( '' === $type ) { $this->current = null; return null; } $slug = isset( $wp->query_vars['aooshop_slug'] ) ? (string) $wp->query_vars['aooshop_slug'] : ''; $slug = rawurldecode( $slug ); // Thai slugs arrive percent-encoded (or already decoded — harmless). $slug = trim( wp_check_invalid_utf8( $slug ), "/ \t\n\r\0\x0B" ); $page = isset( $wp->query_vars['aooshop_page'] ) ? (int) $wp->query_vars['aooshop_page'] : 1; $q = ''; if ( 'search' === $type ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- public search form. $q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; $page = isset( $_GET['page'] ) ? (int) $_GET['page'] : 1; // phpcs:enable } $filters = $this->filters( $type ); $this->current = array( 'type' => $type, 'slug' => $slug, 'page' => max( 1, $page ), 'q' => $q, 'price' => $filters['price'], 'sort' => $filters['sort'], 'on_sale' => $filters['on_sale'], ); return $this->current; } /** * Whitelisted `?price=` / `?sort=` / `?on_sale=1` of the current request for a route type ('' = not set / not allowed). * * @return array{price:string, sort:string, on_sale:string} */ public function filters( $type ) { $out = array( 'price' => '', 'sort' => '', 'on_sale' => '' ); if ( ! in_array( $type, self::LISTING_TYPES, true ) ) { return $out; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- public listing filters, whitelisted. if ( 'budget' !== $type && isset( $_GET['price'] ) && is_scalar( $_GET['price'] ) ) { $out['price'] = $this->settings->valid_price( sanitize_text_field( wp_unslash( $_GET['price'] ) ) ); } if ( isset( $_GET['sort'] ) && is_scalar( $_GET['sort'] ) ) { $out['sort'] = AooShop_Settings::valid_sort( sanitize_key( wp_unslash( $_GET['sort'] ) ), $type ); } // "ลดราคา" shortcut — only the exact value '1' (anything else must not split the cache). if ( isset( $_GET['on_sale'] ) && '1' === $_GET['on_sale'] ) { $out['on_sale'] = '1'; } // phpcs:enable return $out; } /** * Local URL of a route (current effective prefixes). * * @param string $type product | category | brand | collection | budget | search | delivery | index. * @param string $slug Slug (search: the query). * @param int $page Page number (listings). * @param array $query Listing filters {price?, sort?} — appended as-is (callers pass whitelisted values). */ public function url( $type, $slug = '', $page = 1, array $query = array() ) { return $this->build_url( $this->settings->paths(), $type, $slug, $page, $query ); } /** * Same as url() but with the configured (live-mode) prefixes even while in preview — for things that outlive * the preview phase, e.g. nav menu items converted from WooCommerce (class-menu-convert.php). */ public function live_url( $type, $slug = '', $page = 1, array $query = array() ) { return $this->build_url( $this->settings->configured_paths(), $type, $slug, $page, $query ); } /** Active-route URL (preview prefix in preview mode) → the same URL under the live prefixes. Other URLs unchanged. */ public function to_live( $url ) { $home = untrailingslashit( home_url() ); $from = $this->settings->paths(); $to = $this->settings->configured_paths(); $map = array(); foreach ( AooShop_Settings::PATH_KEYS as $k ) { if ( $from[ $k ] !== $to[ $k ] ) { $map[ $home . $from[ $k ] . '/' ] = $home . $to[ $k ] . '/'; } } return $map ? strtr( (string) $url, $map ) : (string) $url; } private function build_url( array $p, $type, $slug = '', $page = 1, array $query = array() ) { $home = untrailingslashit( home_url() ); $paged = $page > 1 ? 'page/' . (int) $page . '/' : ''; $map = array( 'category' => 'prefix_category', 'brand' => 'prefix_brand', 'collection' => 'prefix_collection', 'budget' => 'prefix_budget', ); switch ( $type ) { case 'product': return $home . $p['prefix_shop'] . '/' . rawurlencode( $slug ) . '/'; case 'category': case 'brand': case 'collection': case 'budget': $url = $home . $p[ $map[ $type ] ] . '/' . rawurlencode( $slug ) . '/' . $paged; break; case 'search': $url = add_query_arg( 'q', rawurlencode( $slug ), $home . $p['prefix_shop'] . '/search/' ); if ( $page > 1 ) { $url = add_query_arg( 'page', (int) $page, $url ); } break; case 'delivery': return $home . $p['path_delivery'] . '/'; default: $url = $home . $p['prefix_shop'] . '/' . $paged; } $args = array(); foreach ( array( 'price', 'sort' ) as $k ) { if ( ! empty( $query[ $k ] ) && is_scalar( $query[ $k ] ) ) { $args[ $k ] = rawurlencode( (string) $query[ $k ] ); } } return $args ? add_query_arg( $args, $url ) : $url; } }