['templating']` only. * 3. Takeover suppression: a feature-flagged, scoped, reversible * `fluent_cart/disable_auto_single_product_page` filter, added only for the * current request and only once a Breakdance template has demonstrably * taken over the request (see {@see self::maybeSuppressAutoSingleProductPage()}). * 4. Schema: this class emits no JSON-LD. See {@see self::SCHEMA_POLICY_NOTE}. */ final class TemplateRegistry { /** * Feature flag option. Default OFF (falsy/absent): the takeover suppression * filter is never added unless a site owner has explicitly opted in. */ public const TAKEOVER_OPTION = 'fcbd_takeover'; public const PRODUCT_POST_TYPE = 'fluent-products'; public const CATEGORY_TAXONOMY = 'product-categories'; public const BRAND_TAXONOMY = 'product-brands'; /** * Schema/JSON-LD policy for this fork (task instruction, backed by the G1K * negative-result probe `cap.fc.json_ld_absent`, docs/audit/g1k-probes.md * par. 5): FluentCart emits no product/offer JSON-LD anywhere in its own * code (exhaustive grep, zero hits). There is therefore nothing of * FluentCart's for a takeover template to preserve or duplicate. This * class deliberately emits NO JSON-LD of its own: schema.org markup for * FluentCart product pages is the responsibility of the site's SEO layer * (e.g. SEOPress), not of this plugin. If a future FluentCart release * starts emitting JSON-LD, this policy note is the place to revisit * (together with a new Contracts.php probe, since `cap.fc.json_ld_absent` * is a documented negative-result finding, not a live capability check). */ public const SCHEMA_POLICY_NOTE = 'FluentCart emits no JSON-LD (cap.fc.json_ld_absent); this plugin emits none either, schema is the site/SEOPress\'s responsibility.'; /** * Hooked on `breakdance_register_template_types_and_conditions` * (Plugin::hook()). Registers only the four templating conditions this * fork owns; see the class docblock for what is deliberately NOT * registered here and why. */ public static function register(): void { if (!function_exists('\\Breakdance\\Themeless\\registerCondition')) { return; } self::registerProductCategoryCondition(); self::registerProductBrandCondition(); self::registerFulfillmentTypeCondition(); self::registerPaymentTypeCondition(); self::registerFluentCartTemplateTypes(); } /** * Eersteklas FluentCart-templatetypes in de Add Custom Template-flow. * * Achtergrond (live geverifieerd op sparkcore-shop, 2026-07-15): de * Add Template-SNELLIJST in de templates-SPA is hardcoded in Breakdance's * gecompileerde bundel (builder/dist/js/manage-templates.*.js bevat de * letterlijke entries "Single Product"/category "Woo" met brandkleuren en * preset-settings; de Woo-opties verschijnen er zelfs zonder actieve * WooCommerce). Derden kunnen die snellijst niet uitbreiden. Wat wel kan, * en wat het Woo-pack zelf ook doet voor zijn archieven * (plugin/themeless/rules/archive/product-archives.php:21-56), is eigen * template-TYPES registreren: die verschijnen eersteklas, met eigen * categorie en preview-items, in de Add Custom Template-locatiekiezer. * * De generieke registratie (Single > Products via * registerSinglePostTypeRules) blijft bestaan; deze types voegen de * herkenbare FluentCart-groep toe met de shop-pagina-bewuste archive * (G1K: de CPT-archive 301-redirect naar de virtuele shop-pagina zodra * shop_page_id is ingesteld, dus een archive-type ZONDER shop-pagina-check * zou op een geconfigureerde shop nooit matchen). */ private static function registerFluentCartTemplateTypes(): void { if (!function_exists('\\Breakdance\\Themeless\\registerTemplateType')) { return; } if (function_exists('\\Breakdance\\Themeless\\registerTemplateTypeCategory')) { \Breakdance\Themeless\registerTemplateTypeCategory('FluentCart'); } $allArchivePriority = defined('TEMPLATE_PRIORITY_ALL_ARCHIVE_OR_ALL_SINGLE') ? TEMPLATE_PRIORITY_ALL_ARCHIVE_OR_ALL_SINGLE : 10; $specificPriority = defined('TEMPLATE_PRIORITY_SPECIFIC_SINGLE') ? TEMPLATE_PRIORITY_SPECIFIC_SINGLE : 20; \Breakdance\Themeless\registerTemplateType('FluentCart', [ 'slug' => 'fcbd-single-product', 'label' => __('Single Product (FluentCart)', 'sparkcore-commerce'), 'callback' => static function (): bool { return function_exists('is_singular') && is_singular(self::PRODUCT_POST_TYPE); }, 'templatePreviewableItems' => static function () { if (!function_exists('\\Breakdance\\Themeless\\get_posts_as_template_previewable_items')) { return []; } return \Breakdance\Themeless\get_posts_as_template_previewable_items(['post_type' => [self::PRODUCT_POST_TYPE]]); }, 'defaultPriority' => $specificPriority, ]); \Breakdance\Themeless\registerTemplateType('FluentCart', [ 'slug' => 'fcbd-shop-product-archives', 'label' => __('Shop Page & All Product Archives (FluentCart)', 'sparkcore-commerce'), 'callback' => static function (): bool { if (function_exists('is_post_type_archive') && is_post_type_archive(self::PRODUCT_POST_TYPE)) { return true; } if (function_exists('is_tax') && is_tax([self::CATEGORY_TAXONOMY, self::BRAND_TAXONOMY])) { return true; } // De shop-pagina is een gewone WP-pagina (virtueel, G1K par. 4); // FluentCarts eigen TemplateService herkent haar betrouwbaar. return class_exists('FluentCart\\App\\Services\\TemplateService') && method_exists('FluentCart\\App\\Services\\TemplateService', 'isFcPageType') && \FluentCart\App\Services\TemplateService::isFcPageType('shop'); }, 'templatePreviewableItems' => static function (): array { $items = self::productTermPreviewableItems(); if (function_exists('get_post_type_archive_link')) { $archiveLink = get_post_type_archive_link(self::PRODUCT_POST_TYPE); if (is_string($archiveLink) && $archiveLink !== '') { array_unshift($items, [ 'url' => $archiveLink, 'label' => __('Shop / product archive', 'sparkcore-commerce'), 'type' => __('FluentCart archive', 'sparkcore-commerce'), ]); } } return $items; }, 'defaultPriority' => $allArchivePriority, ]); \Breakdance\Themeless\registerTemplateType('FluentCart', [ 'slug' => 'fcbd-specific-product-archive', 'label' => __('Specific Product Archive (FluentCart)', 'sparkcore-commerce'), 'callback' => static function (): bool { return function_exists('is_tax') && is_tax([self::CATEGORY_TAXONOMY, self::BRAND_TAXONOMY]); }, 'templatePreviewableItems' => static function (): array { return self::productTermPreviewableItems(); }, 'defaultPriority' => defined('TEMPLATE_PRIORITY_SPECIFIC_ARCHIVE') ? TEMPLATE_PRIORITY_SPECIFIC_ARCHIVE : 20, ]); } /** * @return array */ private static function productTermPreviewableItems(): array { if (!function_exists('get_terms') || !function_exists('get_term_link')) { return []; } $items = []; foreach ([self::CATEGORY_TAXONOMY, self::BRAND_TAXONOMY] as $taxonomy) { if (function_exists('taxonomy_exists') && !taxonomy_exists($taxonomy)) { continue; } $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false]); if (!is_array($terms)) { continue; } foreach ($terms as $term) { $link = get_term_link($term); if (is_string($link)) { $items[] = [ 'url' => $link, 'label' => (string) $term->name, 'type' => __('FluentCart product term', 'sparkcore-commerce'), ]; } } } return $items; } /** * Evidence that Breakdance already registers single + archive template * types for `fluent-products` WITHOUT this plugin's help, so WP3 must not * register them a second time (task instruction: "verifieer dat * fluent-products daar al in meekomt en registreer NIET dubbel"): * * - Single: `Breakdance\Themeless\Rules\registerSinglePostTypeRules()` * (plugin/themeless/rules/single/post-types.php:7-72) loops * `Breakdance\Util\get_public_post_types_excluding_templates()` * (plugin/util/better-get-post-types.php:38-64), which is every * `public => true` post type minus Breakdance's own internal post * types. `fluent-products` is registered with `public => true` * (fluent-cart `app/CPT/FluentProducts.php:217`), so it is included and * gets its own `Single > fluent-products` template type plus the * `post-dropdown-fluent-products` "specific post" condition * (plugin/themeless/rules/conditions/singular.php:29-92) for free. * - Archive: `Breakdance\Themeless\Rules\registerArchivePostTypeRules()` * (plugin/themeless/rules/archive/post-type.php:10-37) registers a * single generic "Post Type Archive" template type, matched via * `is_post_type_archive()`, with a "Post Type" templating condition * (post-type.php:41-90) that lets a template be scoped to any archive * post type including `fluent-products`, which is registered with * `has_archive => true` (fluent-cart `app/CPT/FluentProducts.php:226`). * * Both are already contract-pinned by G1K (`cap.bd.template_type_registration`, * `cap.fc.product_archive` in Contracts.php); this method adds no new * runtime dependency, it only documents the decision for the test suite * and the integrator's audit trail. It intentionally never calls * `registerTemplateType()`. */ public static function singleAndArchiveAlreadyRegistered(): bool { return true; } /** * Explains, with the G1K shop evidence (docs/audit/g1k-probes.md par. 4), * why this class does NOT register a "FluentCart Shop" template type, * even though the task brief allows one "als de G1K-feiten zeggen dat de * shop (deels) virtueel is". * * G1K found the shop is BEIDE waar (both true): a real CPT archive * (`has_archive => true`) AND a designated virtual WP `page` post * (`StoreSettings::getShopPageId()`/`getShopPage()`, * fluent-cart `app/CPT/Pages.php:16-60`). Crucially, * `TemplateLoader.php:37-49` 301-redirects the CPT archive URL to that * page the moment `shop_page_id` is set, making the page the de facto * canonical shop. That means, at the point a real front-end request is * actually rendered, exactly one of two cases holds, and Breakdance * already has a generic template type for each: * * - `shop_page_id` IS set: the canonical request is a normal * `post_type = page` single request. It is covered by the SAME generic * `Single > page` template type from `registerSinglePostTypeRules()` * (see {@see self::singleAndArchiveAlreadyRegistered()}), and the * specific shop page can already be targeted individually via the * generic `post-dropdown-page` condition (same evidence trail, * plugin/themeless/rules/conditions/singular.php:29-92) without any * FluentCart-specific code. * - `shop_page_id` is NOT set: the canonical request is the real * `fluent-products` CPT archive, already covered by the generic * "Post Type Archive" template type (see * {@see self::singleAndArchiveAlreadyRegistered()}). * * A dedicated "FluentCart Shop" template type would therefore either * duplicate one of these two generic types, or require reimplementing * FluentCart's own `is_shop()`-equivalent redirect/virtual-page logic * (a second source of truth this plugin's architecture principle 14 * forbids). Registering nothing here is the considered outcome of * evaluating the exact condition the task set, not an omission. */ public static function explainWhyNoShopTemplateType(): bool { return true; } /** * Product category templating condition. Category and brand are real * public WordPress taxonomies registered by FluentCart directly on the * `fluent-products` CPT (`product-categories`/`product-brands`, * fluent-cart `app/CPT/FluentProducts.php:278-322`, both * `show_in_rest => true`/`query_var => true`), so this mirrors * Breakdance's own WooCommerce `product-categories.php` pattern * (`has_term()` against the current post) rather than going through * ProductContext/FluentCartAdapter. */ private static function registerProductCategoryCondition(): void { self::registerTaxonomyCondition( 'fcbd-templating-product-category', __('Product category', 'sparkcore-commerce'), self::CATEGORY_TAXONOMY ); } /** @see self::registerProductCategoryCondition() */ private static function registerProductBrandCondition(): void { self::registerTaxonomyCondition( 'fcbd-templating-product-brand', __('Product brand', 'sparkcore-commerce'), self::BRAND_TAXONOMY ); } private static function registerTaxonomyCondition(string $slug, string $label, string $taxonomy): void { \Breakdance\Themeless\registerCondition([ 'proOnly' => false, 'supports' => ['templating'], 'availableForType' => [self::PRODUCT_POST_TYPE], 'slug' => $slug, 'label' => $label, 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => self::multiValueOperands(), 'values' => static function () use ($taxonomy, $label): array { return [[ 'label' => $label, 'items' => self::taxonomyTermItems($taxonomy), ]]; }, 'callback' => static function ($operand, $value) use ($taxonomy): bool { return self::evaluateTaxonomyCondition($operand, $value, $taxonomy); }, 'templatePreviewableItems' => false, ]); } /** @return array */ private static function taxonomyTermItems(string $taxonomy): array { if (!function_exists('get_terms')) { return []; } $terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false]); if (!is_array($terms)) { return []; } $items = []; foreach ($terms as $term) { if (!($term instanceof \WP_Term)) { continue; } $items[] = ['text' => $term->name, 'value' => (string) $term->term_id]; } return $items; } /** * @param string $operand * @param array $value Selected term IDs (as strings). */ private static function evaluateTaxonomyCondition($operand, $value, string $taxonomy): bool { if (!$value || !function_exists('has_term')) { return false; } $postId = self::currentPostId(); if ($postId < 1) { return false; } $results = array_map( static fn($termId): bool => (bool) has_term((int) $termId, $taxonomy, $postId), (array) $value ); if (defined('OPERAND_ONE_OF') && $operand === OPERAND_ONE_OF) { return in_array(true, $results, true); } if (defined('OPERAND_NONE_OF') && $operand === OPERAND_NONE_OF) { return !in_array(true, $results, true); } if (defined('OPERAND_ALL_OF') && $operand === OPERAND_ALL_OF) { return !in_array(false, $results, true); } return false; } /** @return array */ private static function multiValueOperands(): array { $operands = []; foreach (['OPERAND_ONE_OF', 'OPERAND_NONE_OF', 'OPERAND_ALL_OF'] as $constant) { $operands[] = defined($constant) ? constant($constant) : strtolower(str_replace('OPERAND_', '', $constant)); } return $operands; } private static function currentPostId(): int { $post = function_exists('get_post') ? get_post() : null; return $post instanceof \WP_Post ? (int) $post->ID : 0; } /** * Fulfillment type and payment type are not taxonomies: fulfillment_type * lives on ProductDetail, payment_type on the resolved default * ProductVariation (ProductContext::defaultVariation()). These mirror * ConditionRegistry's element_display equivalents field-for-field * (same FluentCartAdapter helpers, same value vocabulary) but are * registered separately here with `supports => ['templating']` only, to * keep the templating/element_display ownership boundary explicit. */ private static function registerFulfillmentTypeCondition(): void { \Breakdance\Themeless\registerCondition([ 'proOnly' => false, 'supports' => ['templating'], 'availableForType' => [self::PRODUCT_POST_TYPE], 'slug' => 'fcbd-templating-fulfillment-type', 'label' => __('Fulfillment type', 'sparkcore-commerce'), 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => self::singleValueOperands(), 'values' => static fn(): array => [[ 'label' => __('Fulfillment type', 'sparkcore-commerce'), 'items' => [ ['text' => __('Physical', 'sparkcore-commerce'), 'value' => 'physical'], ['text' => __('Digital', 'sparkcore-commerce'), 'value' => 'digital'], ], ]], 'callback' => static function ($operand, $value): bool { $product = ProductContext::resolve(); if (!$product) { return false; } return self::compareSingleValue(FluentCartAdapter::fulfillmentType($product), (string) $value, $operand); }, 'templatePreviewableItems' => false, ]); } private static function registerPaymentTypeCondition(): void { \Breakdance\Themeless\registerCondition([ 'proOnly' => false, 'supports' => ['templating'], 'availableForType' => [self::PRODUCT_POST_TYPE], 'slug' => 'fcbd-templating-payment-type', 'label' => __('Payment type', 'sparkcore-commerce'), 'category' => __('FluentCart', 'sparkcore-commerce'), 'operands' => self::singleValueOperands(), 'values' => static fn(): array => [[ 'label' => __('Payment type', 'sparkcore-commerce'), 'items' => [ ['text' => __('One time', 'sparkcore-commerce'), 'value' => 'onetime'], ['text' => __('Subscription', 'sparkcore-commerce'), 'value' => 'subscription'], ], ]], 'callback' => static function ($operand, $value): bool { $product = ProductContext::resolve(); if (!$product) { return false; } return self::compareSingleValue(FluentCartAdapter::paymentType($product), (string) $value, $operand); }, 'templatePreviewableItems' => false, ]); } /** @return array */ private static function singleValueOperands(): array { $is = defined('OPERAND_IS') ? OPERAND_IS : 'is'; $isNot = defined('OPERAND_IS_NOT') ? OPERAND_IS_NOT : 'is not'; return [$is, $isNot]; } private static function compareSingleValue(string $actual, string $expected, string $operand): bool { $isNot = defined('OPERAND_IS_NOT') ? OPERAND_IS_NOT : 'is not'; return $operand === $isNot ? $actual !== $expected : $actual === $expected; } /** * Takeover suppression (task instruction 2). Hooked on `template_include` * at PHP_INT_MAX by Plugin::hook(), i.e. strictly AFTER Breakdance's own * `template_include` callback, which is pinned by G1K at priority * 1,000,000 (`cap.bd.template_include_priority`, * plugin/actions_filters/template_include.php:11). Running after that * priority means $template already reflects Breakdance's decision for * this request. * * Detection of "a Breakdance template demonstrably owns this request": * `maybe_override_the_theme_with_a_breakdance_template()` * (plugin/themeless/override_theme.php:13-111) returns exactly * `plugin_dir_path(__FILE__) . 'theme-simulator/breakdance-template.php'` * in the ONE branch where `getTemplateForRequest()` found a matching * Breakdance Template (override_theme.php:87-90); every other branch * (maintenance mode, blank canvas, no-template, broken permalinks, * password-required, or the original theme file) returns a different * path. Matching that exact trailing path is therefore a precise, * evidence-based way to detect real takeover without guessing at an * unknown/anonymous listener (architecture principle 5) and without * re-deriving FluentCart/Breakdance business logic (principle 14). * * NOTE for the integrator: this exact path fragment * (`themeless/theme-simulator/breakdance-template.php`) is new evidence * this fork needed but Contracts.php/VersionProfiles.php are read-only * dependencies for WP3 (G1K-pinned only). It is NOT yet a Contracts.php * entry. Flagged in this fork's final report; please add a proper * capability entry (e.g. `cap.bd.template_include_takeover_marker`) in a * follow-up integration commit so future forks don't have to * independently rediscover it. * * Scoped and reversible: the filter is only ever added (never removed * elsewhere, never global) for the current request, only when the * feature flag is on, only when the request is a `fluent-products` * singular, and only when takeover was demonstrated for THIS request. * `add_filter()` naturally does not persist across requests, so * disabling the `fcbd_takeover` option or removing the Breakdance * template instantly restores FluentCart's native output with no * separate rollback step. * * Per G1K par. 7 (`cap.fc.theme_support_gate`), the * `disable_auto_single_product_page` filter is only ever consulted by * FluentCart's own `TemplateActions::initSingleProductHooks()` when * `TemplateLoader::hasThemeSupport()` is false for the active theme * (not a block theme, no `add_theme_support('fluent_cart')`). Adding * this filter is a documented no-op on such a theme; that gate lives in * FluentCart's own theme-detection code and is out of this fork's * ownership to force open. * * G1K also found (par. 7) that `render_product_header`/related-products * do not run ungated outside that same gated path or explicit opt-in * shortcodes, and that the archive-to-shop-page redirect is ungated by * this filter but is a separate (URL-canonicalization, not * double-render) concern. No additional unhooking is added here as a * result: there is nothing further this filter needs to cover. * * @param mixed $template * @return mixed */ public static function maybeSuppressAutoSingleProductPage($template) { if (!self::takeoverEnabled()) { return $template; } if (!function_exists('is_singular') || !is_singular(self::PRODUCT_POST_TYPE)) { return $template; } if (!self::isBreakdanceTemplateTakeoverPath($template)) { return $template; } add_filter('fluent_cart/disable_auto_single_product_page', '__return_true'); return $template; } /** * Themeless-variant van {@see self::maybeSuppressAutoSingleProductPage()}. * * Gevonden op sparkcore-shop (2026-07-15, Breakdance 2.7.2 + themeless): * Breakdance's eigen `template_include`-callback (prio 1.000.000, * `plugin/actions_filters/template_include.php`) rendert de VOLLEDIGE * pagina al binnen die callback via `getWordPressHtmlOutput()` en geeft * daarna `render/relay-template.php` terug aan de filterketen. Gevolg: * (a) de theme-simulator-marker waar de PHP_INT_MAX-listener op test komt * nooit door het filter, en (b) zelfs een matchende listener zou te laat * zijn, want FluentCart's `filterSingleProductContent` (the_content 999, * gehookt omdat themeless `wp_is_block_theme()` false maakt) is dan al * gedraaid tijdens de render en injecteerde de native productpagina in * elke `the_content`-toepassing (concreet: dubbel `fct-single-product-page` * binnen het FC Product Description-atoom). * * Daarom zet deze variant de gate VOOR Breakdance's callback (prio * 999.999). Detectie is read-only en bewust smal: de feature-flag staat * aan, het is een fluent-products singular, en Breakdance themeless is * actief (dan bezit Breakdance elke singular-request, met of zonder * expliciete template-match; de blank-canvas/no-template-paden renderen * ook via Breakdance). Niet-themeless sites blijven op het bestaande * PHP_INT_MAX-pad; `add_filter()` persisteert niet voorbij de request, * dus uitzetten van `fcbd_takeover` is nog steeds de complete rollback. * * @param mixed $template * @return mixed */ public static function maybeSuppressAutoSingleProductPageThemeless($template) { if (!self::takeoverEnabled()) { return $template; } if (!function_exists('is_singular') || !is_singular(self::PRODUCT_POST_TYPE)) { return $template; } if (!self::isBreakdanceThemelessActive()) { return $template; } add_filter('fluent_cart/disable_auto_single_product_page', '__return_true'); return $template; } private static function isBreakdanceThemelessActive(): bool { return function_exists('\\Breakdance\\Themeless\\ThemeDisabler\\is_theme_disabled') && \Breakdance\Themeless\ThemeDisabler\is_theme_disabled(); } private static function takeoverEnabled(): bool { return function_exists('get_option') && (bool) get_option(self::TAKEOVER_OPTION, false); } /** @param mixed $template */ private static function isBreakdanceTemplateTakeoverPath($template): bool { if (!is_string($template) || $template === '') { return false; } $marker = 'themeless/theme-simulator/breakdance-template.php'; $normalized = str_replace('\\', '/', $template); return substr($normalized, -strlen($marker)) === $marker; } }