getWordPressFunctions(), $this->getWooCommerceFunctions() ); } /** * {@inheritdoc} */ public function getFilters(): array { return [ // Escaping filters (parent registers as functions only, templates use as |filter). new TwigFilter( 'esc_html', 'esc_html', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'esc_attr', 'esc_attr', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'esc_url', 'esc_url', [ 'is_safe' => [ 'html' ] ] ), // Slug/sanitize filters. new TwigFilter( 'sanitize_title', 'sanitize_title' ), // Text processing filters. new TwigFilter( 'wpautop', 'wpautop', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'wp_kses_post', 'wp_kses_post', [ 'is_safe' => [ 'html' ] ] ), new TwigFilter( 'wptexturize', 'wptexturize' ), new TwigFilter( 'do_shortcode', 'do_shortcode', [ 'is_safe' => [ 'html' ] ] ), ]; } /** * WordPress core functions not registered by the parent theme's TwigService. * * @return TwigFunction[] */ private function getWordPressFunctions(): array { return [ // Hook system. new TwigFunction( 'do_action', [ $this, 'doAction' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'apply_filters', [ $this, 'applyFilters' ] ), // Security. new TwigFunction( 'wp_nonce_field', function ( string $action = '-1', string $name = '_wpnonce', bool $referer = true ): string { return wp_nonce_field( $action, $name, $referer, false ); }, [ 'is_safe' => [ 'html' ] ] ), // Options and settings. new TwigFunction( 'get_option', 'get_option' ), // User functions. new TwigFunction( 'get_current_user_id', 'get_current_user_id' ), new TwigFunction( 'is_user_logged_in', 'is_user_logged_in' ), // URL helpers. new TwigFunction( 'wp_lostpassword_url', 'wp_lostpassword_url' ), new TwigFunction( 'get_permalink', 'get_permalink' ), new TwigFunction( 'get_term_link', 'get_term_link' ), new TwigFunction( 'wp_get_attachment_url', 'wp_get_attachment_url' ), // Content rendering (echo-based, wrapped in ob_start). new TwigFunction( 'get_avatar', 'get_avatar', [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'the_title', function (): string { ob_start(); the_title(); return ob_get_clean(); }, [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'the_content', function (): string { ob_start(); the_content(); return ob_get_clean(); }, [ 'is_safe' => [ 'html' ] ] ), // Taxonomy. new TwigFunction( 'term_description', 'term_description', [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'single_term_title', function ( string $prefix = '' ): string { return single_term_title( $prefix, false ); } ), // Date/time. new TwigFunction( 'date_i18n', 'date_i18n' ), // Text processing (as functions). new TwigFunction( 'wpautop', 'wpautop', [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'wptexturize', 'wptexturize' ), new TwigFunction( 'wp_parse_url', 'wp_parse_url' ), // Formatting. new TwigFunction( 'sprintf', 'sprintf' ), // Dynamic function calls. new TwigFunction( 'call_user_func', [ $this, 'callUserFunc' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'fn', [ $this, 'callFunction' ] ), // Product loop helpers (set global $product for WC hooks in Twig loops). new TwigFunction( 'wc_setup_product_data', [ $this, 'setupProductData' ] ), new TwigFunction( 'wp_reset_postdata', 'wp_reset_postdata' ), ]; } /** * WooCommerce-specific functions. * * @return TwigFunction[] */ private function getWooCommerceFunctions(): array { return [ // URL functions. new TwigFunction( 'wc_get_cart_url', 'wc_get_cart_url' ), new TwigFunction( 'wc_get_checkout_url', 'wc_get_checkout_url' ), new TwigFunction( 'wc_get_page_permalink', 'wc_get_page_permalink' ), new TwigFunction( 'wc_get_endpoint_url', 'wc_get_endpoint_url' ), new TwigFunction( 'wc_get_account_endpoint_url', 'wc_get_account_endpoint_url' ), new TwigFunction( 'wc_logout_url', 'wc_logout_url' ), // Boolean helpers. new TwigFunction( 'wc_coupons_enabled', 'wc_coupons_enabled' ), new TwigFunction( 'wc_shipping_enabled', 'wc_shipping_enabled' ), new TwigFunction( 'wc_ship_to_billing_address_only', 'wc_ship_to_billing_address_only' ), new TwigFunction( 'wc_terms_and_conditions_checkbox_enabled', 'wc_terms_and_conditions_checkbox_enabled' ), // Order functions. new TwigFunction( 'wc_get_order', 'wc_get_order' ), new TwigFunction( 'wc_get_order_status_name', 'wc_get_order_status_name' ), new TwigFunction( 'wc_format_datetime', 'wc_format_datetime' ), new TwigFunction( 'wc_date_format', 'wc_date_format' ), // Product functions. new TwigFunction( 'wc_attribute_label', 'wc_attribute_label' ), new TwigFunction( 'wc_placeholder_img_src', 'wc_placeholder_img_src' ), new TwigFunction( 'wc_get_image_size', 'wc_get_image_size' ), // Account functions. new TwigFunction( 'wc_get_account_menu_items', 'wc_get_account_menu_items' ), new TwigFunction( 'wc_get_account_menu_item_classes', 'wc_get_account_menu_item_classes' ), new TwigFunction( 'wc_get_account_orders_columns', 'wc_get_account_orders_columns' ), new TwigFunction( 'wc_get_account_orders_actions', 'wc_get_account_orders_actions' ), new TwigFunction( 'wc_get_account_payment_methods_columns', 'wc_get_account_payment_methods_columns' ), new TwigFunction( 'wc_get_account_formatted_address', 'wc_get_account_formatted_address', [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'wc_get_customer_saved_methods_list', 'wc_get_customer_saved_methods_list' ), new TwigFunction( 'wc_get_credit_card_type_label', 'wc_get_credit_card_type_label' ), // Content/form functions (echo-based, need output capture). new TwigFunction( 'wc_print_notices', [ $this, 'wcPrintNotices' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'wc_display_item_meta', [ $this, 'wcDisplayItemMeta' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'wc_query_string_form_fields', [ $this, 'wcQueryStringFormFields' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'woocommerce_form_field', [ $this, 'woocommerceFormField' ], [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'woocommerce_breadcrumb', function ( array $args = [] ): string { ob_start(); woocommerce_breadcrumb( $args ); return ob_get_clean(); }, [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'woocommerce_quantity_input', function ( array $args = [], $product = null ): string { ob_start(); woocommerce_quantity_input( $args, $product, true ); return ob_get_clean(); }, [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'woocommerce_product_loop_start', function (): string { return woocommerce_product_loop_start( false ); }, [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'woocommerce_product_loop_end', function (): string { return woocommerce_product_loop_end( false ); }, [ 'is_safe' => [ 'html' ] ] ), // Text/policy functions. new TwigFunction( 'wc_terms_and_conditions_checkbox_text', 'wc_terms_and_conditions_checkbox_text', [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'wc_replace_policy_page_link_placeholders', 'wc_replace_policy_page_link_placeholders', [ 'is_safe' => [ 'html' ] ] ), new TwigFunction( 'wc_get_privacy_policy_text', 'wc_get_privacy_policy_text', [ 'is_safe' => [ 'html' ] ] ), // Data helpers. new TwigFunction( 'wc_get_post_data_by_key', 'wc_get_post_data_by_key' ), new TwigFunction( 'wc_get_page_id', 'wc_get_page_id' ), // Template recursion (renders a WC template and returns HTML). new TwigFunction( 'wc_get_template', [ $this, 'wcGetTemplate' ], [ 'is_safe' => [ 'html' ] ] ), ]; } // ------------------------------------------------------------------------- // Method implementations for functions that need special handling. // ------------------------------------------------------------------------- /** * Execute a WordPress action hook and capture its output. * * Actions may echo HTML (e.g., rendering sub-templates). Output buffering * captures this so it can be returned as a Twig-safe string. * * @param string $tag Action hook name. * @param mixed ...$args Arguments to pass to the hook. * @return string Captured HTML output. */ public function doAction( string $tag, ...$args ): string { ob_start(); do_action( $tag, ...$args ); return ob_get_clean(); } /** * Apply WordPress filters. * * @param string $tag Filter hook name. * @param mixed ...$args Arguments (first is the value to filter). * @return mixed Filtered value. */ public function applyFilters( string $tag, ...$args ): mixed { return apply_filters( $tag, ...$args ); } /** * Call a user function and capture its output. * * Used for tab callbacks that echo content. * * @param callable $callback Function to call. * @param mixed ...$args Arguments. * @return string Captured output. */ public function callUserFunc( callable $callback, ...$args ): string { ob_start(); call_user_func( $callback, ...$args ); return ob_get_clean(); } /** * Allowlist of PHP functions that can be called via fn() in Twig templates. * * Prevents arbitrary function execution (e.g., exec, system) if template * context were ever compromised. Only functions actually used in templates * are permitted. */ private const ALLOWED_FUNCTIONS = [ 'WC', '_n', 'get_pagenum_link', 'wc_review_ratings_enabled', 'wc_get_product_category_list', 'wc_get_product_tag_list', 'woocommerce_page_title', 'wc_get_customer_available_downloads', ]; /** * Call a whitelisted PHP function by name and return its result. * * Enables `fn('WC')` in templates to access the WooCommerce singleton * and chain method calls via Twig's property accessor. * * Only functions in the ALLOWED_FUNCTIONS list can be called. This prevents * arbitrary code execution if template context were ever compromised. * * @param string $name Function name. * @param mixed ...$args Arguments. * @return mixed Function return value. * * @throws \RuntimeException If function is not allowed or does not exist. */ public function callFunction( string $name, ...$args ): mixed { if ( ! in_array( $name, self::ALLOWED_FUNCTIONS, true ) ) { throw new \RuntimeException( "Function {$name} is not allowed. Add it to ALLOWED_FUNCTIONS." ); } if ( ! function_exists( $name ) ) { throw new \RuntimeException( "Function {$name} does not exist." ); } return $name( ...$args ); } /** * Set up global product data for WC hook-based rendering in Twig loops. * * WooCommerce hooks (woocommerce_before_shop_loop_item, etc.) read from * the global $product. When iterating products in Twig (related, upsells), * the global must be updated before rendering each product card. * * @param \WC_Product $product Product object. * @return string Empty string (Twig requires a return value). */ public function setupProductData( \WC_Product $product ): string { $GLOBALS['product'] = $product; $post = get_post( $product->get_id() ); if ( $post ) { setup_postdata( $GLOBALS['post'] = $post ); } return ''; } /** * Capture wc_print_notices() output. * * @return string Rendered notices HTML. */ public function wcPrintNotices(): string { ob_start(); wc_print_notices(); return ob_get_clean(); } /** * Render item meta without echoing. * * @param mixed $item WC_Order_Item object. * @param array $args Display arguments. * @return string Rendered meta HTML. */ public function wcDisplayItemMeta( $item, array $args = [] ): string { $args['echo'] = false; return wc_display_item_meta( $item, $args ); } /** * Render query string form fields and return HTML. * * @param mixed $values Values array or null. * @param array $exclude Keys to exclude. * @param string $currentKey Current key prefix. * @return string Hidden input fields HTML. */ public function wcQueryStringFormFields( $values = null, array $exclude = [], string $currentKey = '' ): string { return wc_query_string_form_fields( $values, $exclude, $currentKey, true ); } /** * Render a WooCommerce form field and return HTML. * * @param string $key Field key. * @param array $args Field arguments. * @param mixed $value Field value. * @return string Rendered field HTML. */ public function woocommerceFormField( string $key, array $args, $value = null ): string { $args['return'] = true; return woocommerce_form_field( $key, $args, $value ); } /** * Render a WooCommerce template and return its HTML. * * Enables recursive template calls from within Twig templates. * The rendered template goes through the same interception pipeline, * so it will use the Twig version if one exists. * * @param string $templateName Template name (e.g., 'order/order-downloads.php'). * @param array $args Template context variables. * @return string Rendered HTML. */ public function wcGetTemplate( string $templateName, array $args = [] ): string { ob_start(); wc_get_template( $templateName, $args ); return ob_get_clean(); } }