diff --git a/functions.php b/functions.php index 51ef884..0f3fa67 100644 --- a/functions.php +++ b/functions.php @@ -43,16 +43,42 @@ function wc_bootstrap_setup(): void { add_action( 'after_setup_theme', 'wc_bootstrap_setup' ); /** - * Register plugin template overrides. + * Initialize the WooCommerce-to-Twig template bridge. * - * Prepends the child theme's templates/ directory to the plugin's Twig loader, - * so child theme templates take priority over plugin templates. + * Registers the WooCommerce Twig extension with the parent theme's TwigService + * and sets up template interception hooks so the child theme's Twig templates + * are rendered instead of WooCommerce's PHP templates. + * + * Runs at 'init' priority 20 to ensure WooCommerce is fully loaded (it + * initializes at 'init' priority 0). */ -function wc_bootstrap_register_template_override(): void { +function wc_bootstrap_init_twig_bridge(): void { + // Guard: require parent TwigService and WooCommerce. + if ( ! class_exists( '\WPBootstrap\Twig\TwigService' ) || ! function_exists( 'WC' ) ) { + return; + } + + $twig = \WPBootstrap\Twig\TwigService::getInstance(); + $env = $twig->getEnvironment(); + + // Add child theme templates directory to the Twig loader so {% include %} + // directives in Twig templates can find other child theme templates. + $loader = $env->getLoader(); + if ( $loader instanceof \Twig\Loader\FilesystemLoader ) { + $template_dir = WC_BOOTSTRAP_PATH . 'templates'; + if ( is_dir( $template_dir ) ) { + $loader->prependPath( $template_dir ); + } + } + + // Register WooCommerce functions and filters as Twig extensions. + $env->addExtension( new \WcBootstrap\Twig\WooCommerceExtension() ); + + // Register template interception hooks. $override = new \WcBootstrap\TemplateOverride(); $override->register(); } -add_action( 'after_setup_theme', 'wc_bootstrap_register_template_override' ); +add_action( 'init', 'wc_bootstrap_init_twig_bridge', 20 ); /** * Enqueue child theme styles. diff --git a/inc/TemplateOverride.php b/inc/TemplateOverride.php index 624a53a..20ab7ee 100644 --- a/inc/TemplateOverride.php +++ b/inc/TemplateOverride.php @@ -1,9 +1,12 @@ template_path = WC_BOOTSTRAP_PATH . 'templates'; + $this->templatePath = WC_BOOTSTRAP_PATH . 'templates/'; } /** - * Register the template override with WordPress hooks. + * Register the template override hooks. * - * Must be called after the plugin's Template singleton is initialized - * (plugin inits at 'init' priority 0). + * Only registers if the parent theme's TwigService is available. * * @return void */ public function register(): void { - add_action( 'init', [ $this, 'override_template_paths' ], 20 ); + if ( ! class_exists( TwigService::class ) ) { + return; + } + + add_action( 'woocommerce_before_template_part', [ $this, 'beforeTemplatePart' ], 10, 4 ); + add_action( 'woocommerce_after_template_part', [ $this, 'afterTemplatePart' ], 10, 4 ); } /** - * Prepend the child theme's templates directory to the Twig loader. + * Before WooCommerce includes a PHP template. * - * This makes Twig look in the child theme's templates/ first, - * falling back to the plugin's templates/ if not found. + * If a matching Twig template exists, renders it and starts output + * buffering to capture (and later discard) the PHP template output. * + * @param string $templateName Template name (e.g., 'cart/cart.php'). + * @param string $templatePath Template path override. + * @param string $located Full path to the located PHP template. + * @param array $args Template context variables. * @return void */ - public function override_template_paths(): void { - if ( ! class_exists( Template::class ) ) { - return; - } + public function beforeTemplatePart( string $templateName, string $templatePath, string $located, array $args ): void { + $twigTemplate = $this->resolveTwigTemplate( $templateName ); - if ( ! is_dir( $this->template_path ) ) { - return; + if ( null === $twigTemplate ) { + return; // No Twig override — let PHP render normally. } try { - $twig = Template::get_instance()->get_twig(); - $loader = $twig->getLoader(); + $twig = TwigService::getInstance(); + echo $twig->render( $twigTemplate, $args ); - if ( $loader instanceof FilesystemLoader ) { - $loader->prependPath( $this->template_path ); - } - } catch ( \Exception $e ) { + // Buffer the upcoming PHP include so we can discard it. + ob_start(); + $this->bufferStack[] = $templateName; + } catch ( \Throwable $e ) { + // Twig render failed — let PHP render as fallback. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( 'WooCommerce Bootstrap: Failed to register template override - ' . $e->getMessage() ); + error_log( sprintf( + 'WC Bootstrap: Twig render failed for %s — %s', + $templateName, + $e->getMessage() + ) ); } } } + + /** + * After WooCommerce includes a PHP template. + * + * If we started buffering for this template, discards the PHP output. + * + * @param string $templateName Template name. + * @param string $templatePath Template path override. + * @param string $located Full path to the located PHP template. + * @param array $args Template context variables. + * @return void + */ + public function afterTemplatePart( string $templateName, string $templatePath, string $located, array $args ): void { + if ( ! empty( $this->bufferStack ) && end( $this->bufferStack ) === $templateName ) { + ob_end_clean(); // Discard PHP template output. + array_pop( $this->bufferStack ); + } + } + + /** + * Resolve a WooCommerce template name to a Twig template path. + * + * Maps 'cart/cart.php' to 'cart/cart.html.twig' and checks that + * the file exists in the child theme's templates/ directory. + * + * @param string $templateName WooCommerce template name. + * @return string|null Twig template path relative to templates/, or null if not found. + */ + private function resolveTwigTemplate( string $templateName ): ?string { + $twigName = preg_replace( '/\.php$/', '.html.twig', $templateName ); + $fullPath = $this->templatePath . $twigName; + + if ( file_exists( $fullPath ) ) { + return $twigName; + } + + return null; + } } diff --git a/inc/Twig/WooCommerceExtension.php b/inc/Twig/WooCommerceExtension.php new file mode 100644 index 0000000..c9a8069 --- /dev/null +++ b/inc/Twig/WooCommerceExtension.php @@ -0,0 +1,320 @@ +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' ] ] ), + + // 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' ] ), + ]; + } + + /** + * 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(); + } + + /** + * Call a 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. + * + * @param string $name Function name. + * @param mixed ...$args Arguments. + * @return mixed Function return value. + * + * @throws \RuntimeException If function does not exist. + */ + public function callFunction( string $name, ...$args ): mixed { + if ( ! function_exists( $name ) ) { + throw new \RuntimeException( "Function {$name} does not exist." ); + } + return $name( ...$args ); + } + + /** + * 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(); + } +} diff --git a/templates/order/order-details-item.html.twig b/templates/order/order-details-item.html.twig index e37c18b..eec0ee9 100644 --- a/templates/order/order-details-item.html.twig +++ b/templates/order/order-details-item.html.twig @@ -18,46 +18,44 @@ # @since 0.1.0 #} -{% if not apply_filters('woocommerce_order_item_visible', true, item) %} - {% do return() %} -{% endif %} +{% if apply_filters('woocommerce_order_item_visible', true, item) %} + {% set is_visible = product and product.is_visible() %} + {% set product_permalink = apply_filters('woocommerce_order_item_permalink', is_visible ? product.get_permalink(item) : '', item, order) %} -{% set is_visible = product and product.is_visible() %} -{% set product_permalink = apply_filters('woocommerce_order_item_permalink', is_visible ? product.get_permalink(item) : '', item, order) %} - - - - {% if product_permalink %} - {{ item.get_name()|esc_html }} - {% else %} - {{ item.get_name()|esc_html }} - {% endif %} - - {% set qty = item.get_quantity() %} - {% set refunded_qty = order.get_qty_refunded_for_item(item_id) %} - - - {% if refunded_qty %} - × {{ qty }} {{ qty - (refunded_qty * -1) }} + + + {% if product_permalink %} + {{ item.get_name()|esc_html }} {% else %} - × {{ qty }} + {{ item.get_name()|esc_html }} {% endif %} - - {{ do_action('woocommerce_order_item_meta_start', item_id, item, order, false) }} - {{ wc_display_item_meta(item) }} - {{ do_action('woocommerce_order_item_meta_end', item_id, item, order, false) }} - + {% set qty = item.get_quantity() %} + {% set refunded_qty = order.get_qty_refunded_for_item(item_id) %} - - {{ order.get_formatted_line_subtotal(item)|raw }} - - + + {% if refunded_qty %} + × {{ qty }} {{ qty - (refunded_qty * -1) }} + {% else %} + × {{ qty }} + {% endif %} + -{% if show_purchase_note and purchase_note %} - - - {{ purchase_note|wp_kses_post|wpautop }} + {{ do_action('woocommerce_order_item_meta_start', item_id, item, order, false) }} + {{ wc_display_item_meta(item) }} + {{ do_action('woocommerce_order_item_meta_end', item_id, item, order, false) }} + + + + {{ order.get_formatted_line_subtotal(item)|raw }} + + {% if show_purchase_note and purchase_note %} + + + {{ purchase_note|wp_kses_post|wpautop }} + + + {% endif %} {% endif %} diff --git a/templates/order/order-details.html.twig b/templates/order/order-details.html.twig index 235fcd5..635c433 100644 --- a/templates/order/order-details.html.twig +++ b/templates/order/order-details.html.twig @@ -16,76 +16,74 @@ {% set order = wc_get_order(order_id) %} -{% if not order %} - {% do return() %} -{% endif %} +{% if order %} + {% set order_items = order.get_items(apply_filters('woocommerce_purchase_order_item_types', 'line_item')) %} + {% set show_purchase_note = order.has_status(apply_filters('woocommerce_purchase_note_order_statuses', ['completed', 'processing'])) %} + {% set show_customer_details = order.get_user_id() == get_current_user_id() %} -{% set order_items = order.get_items(apply_filters('woocommerce_purchase_order_item_types', 'line_item')) %} -{% set show_purchase_note = order.has_status(apply_filters('woocommerce_purchase_note_order_statuses', ['completed', 'processing'])) %} -{% set show_customer_details = order.get_user_id() == get_current_user_id() %} + {% if show_downloads is defined and show_downloads %} + {% set downloads = order.get_downloadable_items() %} + {% if downloads is not empty %} + {{ wc_get_template('order/order-downloads.php', { downloads: downloads, show_title: true }) }} + {% endif %} + {% endif %} -{% if show_downloads is defined and show_downloads %} - {% set downloads = order.get_downloadable_items() %} - {% if downloads is not empty %} - {{ wc_get_template('order/order-downloads.php', { downloads: downloads, show_title: true }) }} +
+ {{ do_action('woocommerce_order_details_before_order_table', order) }} + +

{{ __('Order details') }}

+ +
+ + + + + + + + + + {{ do_action('woocommerce_order_details_before_order_table_items', order) }} + + {% for item_id, item in order_items %} + {% set product = item.get_product() %} + {% include 'order/order-details-item.html.twig' with { + order: order, + item_id: item_id, + item: item, + show_purchase_note: show_purchase_note, + purchase_note: product ? product.get_purchase_note() : '', + product: product + } %} + {% endfor %} + + {{ do_action('woocommerce_order_details_after_order_table_items', order) }} + + + + {% for key, total in order.get_order_item_totals() %} + + + + + {% endfor %} + + {% if order.get_customer_note() %} + + + + + {% endif %} + +
{{ __('Product') }}{{ __('Total') }}
{{ total.label|esc_html }}{{ total.value|wp_kses_post }}
{{ __('Note:') }}{{ order.get_customer_note()|nl2br|esc_html }}
+
+ + {{ do_action('woocommerce_order_details_after_order_table', order) }} +
+ + {{ do_action('woocommerce_after_order_details', order) }} + + {% if show_customer_details %} + {% include 'order/order-details-customer.html.twig' with { order: order } %} {% endif %} {% endif %} - -
- {{ do_action('woocommerce_order_details_before_order_table', order) }} - -

{{ __('Order details') }}

- -
- - - - - - - - - - {{ do_action('woocommerce_order_details_before_order_table_items', order) }} - - {% for item_id, item in order_items %} - {% set product = item.get_product() %} - {% include 'order/order-details-item.html.twig' with { - order: order, - item_id: item_id, - item: item, - show_purchase_note: show_purchase_note, - purchase_note: product ? product.get_purchase_note() : '', - product: product - } %} - {% endfor %} - - {{ do_action('woocommerce_order_details_after_order_table_items', order) }} - - - - {% for key, total in order.get_order_item_totals() %} - - - - - {% endfor %} - - {% if order.get_customer_note() %} - - - - - {% endif %} - -
{{ __('Product') }}{{ __('Total') }}
{{ total.label|esc_html }}{{ total.value|wp_kses_post }}
{{ __('Note:') }}{{ order.get_customer_note()|nl2br|esc_html }}
-
- - {{ do_action('woocommerce_order_details_after_order_table', order) }} -
- -{{ do_action('woocommerce_after_order_details', order) }} - -{% if show_customer_details %} - {% include 'order/order-details-customer.html.twig' with { order: order } %} -{% endif %}