* @static */ protected static array $params = []; /** @var array */ private static array $frontendProfiles = []; /** @var string[] */ private static array $frontendScripts = []; /** @var string[] */ private static array $frontendStyles = []; private static bool $frontendMedia = false; /** * Constructor */ public function __construct() { // Plugin Profile $this->profile = Profile::getInstance(); } public function init(): void { // Include needed assets (CSS, JavaScript etc.) in the WordPress backend add_action('admin_enqueue_scripts', [$this, 'backend'], 0); // Include needed assets (CSS, JavaScript etc.) in the WordPress frontend add_action('wp_enqueue_scripts', [$this, 'frontend'], 0); add_action('wp_enqueue_scripts', [$this, 'adminBar'], 1); add_action('wp_footer', [$this, 'lateFrontendAssets'], 19); add_action('wp_footer', [$this, 'lateFrontendAssets'], 10000); add_filter('script_loader_tag', [$this, 'scriptAttributes'], 10, 2); // Register DayTidy function to be called in WordPress footer hook if (is_admin()) add_action('admin_footer', [$this, 'loadFooter'], 9999); else add_action('wp_footer', [$this, 'loadFooter'], 9999); } public function frontend(): void { if (!$this->allowed()) return; self::enqueueFrontend($this->detectedProfiles() ?: ['base']); } /** * Renderers and programmatic integrations declare their actual requirements. * An explicit filter override can still opt out of product frontend assets. * * @param string[] $profiles */ public static function enqueueFrontend(array $profiles): void { if (is_admin() && !wp_doing_ajax()) return; if (!apply_filters('DayTidy/F__DayTidy/Assets__allowed/frontend', true)) return; $assets = new self(); $definitions = [ 'base' => ['layout', 'surfaces', 'buttons', 'icons'], 'booking' => ['forms', 'modal', 'notice-dismiss', 'busy', 'badge'], 'payment' => ['forms', 'notice-dismiss', 'busy'], 'portal' => ['forms', 'notice-dismiss', 'busy', 'badge'], 'views' => ['notice-dismiss', 'busy', 'badge'], 'calendar' => ['notice-dismiss', 'busy'], 'single-offering' => ['badge'], 'dashboard-manage' => ['forms', 'notice-dismiss', 'busy', 'badge'], 'dashboard-editor' => ['forms', 'modal', 'notice-dismiss', 'busy', 'badge'], 'dashboard-resources' => ['forms', 'modal', 'notice-dismiss', 'busy', 'badge'], ]; $profiles = array_values(array_unique(array_merge(['base'], $profiles))); foreach ($profiles as $profile) { if (!isset($definitions[$profile]) || isset(self::$frontendProfiles[$profile])) continue; self::$frontendProfiles[$profile] = true; $scripts = wp_scripts()->queue; $styles = wp_styles()->queue; UIAssets::enqueue($definitions[$profile], Engagement::uiUrl()); self::theme(); $assets->icons(); if ($profile === 'dashboard-editor') $assets->colorpicker(); wp_enqueue_script('daytidy-frontend', self::url('dist/js/frontend.min.js'), ['jquery'], $assets->version(), true); wp_enqueue_style('daytidy-frontend', self::url('dist/css/frontend.css'), ['daytidy-ui'], $assets->version()); if ($profile === 'base') { $assets->localize(); do_action('DayTidy/A__DayTidy/Assets__frontend/assets'); } else if ($profile !== 'single-offering') { $entry = 'frontend-' . $profile; $dependencies = ['daytidy-frontend', 'webilia-ui-v1-notice', 'webilia-ui-v1-busy']; if ($profile === 'dashboard-editor') $dependencies[] = 'wp-color-picker'; if (in_array($profile, ['booking', 'dashboard-editor', 'dashboard-resources'], true)) { $dependencies[] = 'webilia-ui-v1-modal'; wp_enqueue_script('daytidy-frontend-forms', self::url('dist/js/frontend-forms.min.js'), ['daytidy-frontend', 'webilia-ui-v1-busy', 'webilia-ui-v1-modal', 'webilia-ui-v1-notice'], $assets->version(), true); wp_enqueue_style('daytidy-frontend-forms', self::url('dist/css/frontend-forms.css'), ['daytidy-frontend', 'webilia-ui-v1-forms'], $assets->version()); $dependencies[] = 'daytidy-frontend-forms'; } // The resource screen shares the editor entrypoint for its modal validation. if ($profile === 'dashboard-resources') $entry = 'frontend-dashboard-editor'; wp_enqueue_script('daytidy-' . $entry, self::url('dist/js/' . $entry . '.min.js'), $dependencies, $assets->version(), true); } if (in_array($profile, ['booking', 'portal', 'views', 'calendar', 'single-offering', 'dashboard-manage', 'dashboard-editor', 'dashboard-resources'], true)) { $style_profiles = ['calendar' => 'views', 'dashboard-resources' => 'dashboard-editor']; $style = $style_profiles[$profile] ?? $profile; wp_enqueue_style('daytidy-frontend-' . $style, self::url('dist/css/frontend-' . $style . '.css'), ['daytidy-frontend'], $assets->version()); } if (in_array($profile, ['dashboard-editor', 'dashboard-resources'], true)) $assets->media(); if ($profile === 'dashboard-editor') { $assets->editor(); $assets->fontawesome(); } self::$frontendScripts = array_values(array_unique(array_merge(self::$frontendScripts, array_diff(wp_scripts()->queue, $scripts), ['daytidy-frontend']))); self::$frontendStyles = array_values(array_unique(array_merge(self::$frontendStyles, array_diff(wp_styles()->queue, $styles), ['daytidy-frontend']))); } } /** * AJAX fragments carry the registered dependency graph, including WordPress * localization/inline configuration. No script tags are embedded in form HTML. * * @return array */ public static function frontendManifest(): array { if (!self::$frontendProfiles) return []; self::captureFrontendAssets(); $manifest = [ 'styles' => self::dependencyManifest(wp_styles(), self::$frontendStyles), 'scripts' => self::dependencyManifest(wp_scripts(), self::$frontendScripts), ]; if (self::$frontendMedia) { // AJAX has no footer in which WordPress can print its inert media // templates. Carry them alongside the dependency graph for mounting. ob_start(); wp_print_media_templates(); $manifest['media_templates'] = (string) ob_get_clean(); } return $manifest; } /** Keep extension-owned enqueues in the AJAX graph, regardless of handle prefix. */ public static function captureFrontendEnqueues(callable $enqueue): void { $scripts = wp_scripts()->queue; $styles = wp_styles()->queue; $enqueue(); if (!self::$frontendProfiles) return; self::$frontendScripts = array_values(array_unique(array_merge(self::$frontendScripts, array_diff(wp_scripts()->queue, $scripts)))); self::$frontendStyles = array_values(array_unique(array_merge(self::$frontendStyles, array_diff(wp_styles()->queue, $styles)))); } /** * @param WP_Scripts|WP_Styles $registry * @param string[] $handles * @return array> */ private static function dependencyManifest($registry, array $handles): array { $items = []; $visited = []; $collect = static function (string $handle) use (&$collect, &$items, &$visited, $registry): void { if (isset($visited[$handle]) || !isset($registry->registered[$handle])) return; $visited[$handle] = true; $asset = $registry->registered[$handle]; foreach ($asset->deps as $dependency) $collect($dependency); $src = $asset->src; if ($src && !preg_match('#^(https?:)?//#', $src)) $src = $registry->base_url . $src; $version = $asset->ver === false ? $registry->default_version : $asset->ver; if ($src && $version !== null) $src = add_query_arg('ver', $version, $src); $item = ['handle' => $handle, 'src' => $src ?: '', 'deps' => $asset->deps]; if ($registry instanceof WP_Scripts) { $translations = $registry->print_translations($handle, false); $item['before'] = array_merge(isset($asset->extra['data']) ? [$asset->extra['data']] : [], $translations ? [$translations] : [], (array) ($asset->extra['before'] ?? [])); $item['after'] = (array) ($asset->extra['after'] ?? []); $item['attributes'] = (array) ($asset->extra['daytidy_attributes'] ?? []); } else $item['media'] = $asset->args ?: 'all'; $items[] = $item; }; foreach ($handles as $handle) $collect($handle); return $items; } private static function captureFrontendAssets(): void { foreach (wp_scripts()->queue as $handle) { if (strpos($handle, 'daytidy-') === 0 || strpos($handle, 'daytidy-paypal-') === 0 || in_array($handle, ['stripe', 'wp-color-picker', 'editor', 'quicktags', 'wp-tinymce'], true)) self::$frontendScripts[] = $handle; } foreach (wp_styles()->queue as $handle) { if (strpos($handle, 'daytidy-') === 0 || in_array($handle, ['wp-color-picker', 'media-views', 'editor-buttons', 'dashicons'], true)) self::$frontendStyles[] = $handle; } self::$frontendScripts = array_values(array_unique(self::$frontendScripts)); self::$frontendStyles = array_values(array_unique(self::$frontendStyles)); } public function lateFrontendAssets(): void { if (!self::$frontendProfiles) return; // wp_head may already have printed the initial styles. WP handles prevent // duplicate output while dependencies added by late renderers still print. self::captureFrontendAssets(); wp_print_styles(self::$frontendStyles); if (did_action('wp_print_footer_scripts')) wp_scripts()->do_items(self::$frontendScripts, 1); } /** @return string[] */ private function detectedProfiles(): array { $profiles = []; if (is_singular(Offering::PT) && Modules::enabled(SingleConfig::MODULE) && (new PageResolver())->isGenerated((int) get_queried_object_id())) $profiles[] = 'single-offering'; $query = $GLOBALS['wp_query'] ?? null; foreach (($query->posts ?? []) as $post) { if ($post instanceof WP_Post && !post_password_required($post)) { $profiles = array_merge($profiles, $this->contentProfiles($post->post_content)); } } return array_values(array_unique($profiles)); } /** * Parse only; never render shortcode/block callbacks during early detection. * Referenced synced blocks use a visited set to prevent cyclic traversal. * * @param int[] $visited * @return string[] */ private function contentProfiles(string $content, array $visited = []): array { $profiles = []; global $shortcode_tags; $tags = array_values(array_filter(array_keys($shortcode_tags), static function (string $tag): bool { return $tag === 'daytidy' || strpos($tag, 'daytidy-') === 0; })); if (!$tags) return []; preg_match_all('/' . get_shortcode_regex($tags) . '/s', $content, $matches, PREG_SET_ORDER); foreach ($matches as $match) { if ($match[1] === '[' && $match[6] === ']') continue; if (!shortcode_exists($match[2])) continue; if ($match[2] === 'daytidy-customer-portal') $profiles[] = 'portal'; else if ($match[2] === 'daytidy-dashboard') { $mode = filter_input(INPUT_GET, 'mode', FILTER_UNSAFE_RAW); $modes = ['editor' => 'dashboard-editor', 'resources' => 'dashboard-resources']; $profiles[] = $modes[$mode] ?? 'dashboard-manage'; } else if (in_array($match[2], ['daytidy-booking', 'daytidy-seg-booking'], true)) $profiles[] = 'booking'; else if ($match[2] !== 'daytidy') $profiles[] = 'single-offering'; else { $atts = shortcode_parse_atts($match[3]); $id = is_array($atts) ? absint($atts['id'] ?? 0) : 0; if ($id && get_post_status($id) === 'publish' && get_post_type($id) === 'daytidy-view') { $config = ViewConfig::get($id); $profiles[] = $config['type'] === 'full_calendar' ? 'calendar' : 'views'; } } } $blocks = parse_blocks($content); $walk = function (array $blocks) use (&$walk, &$profiles, $visited): void { foreach ($blocks as $block) { $ref = absint($block['attrs']['ref'] ?? 0); if ($block['blockName'] === 'core/block' && $ref && !in_array($ref, $visited, true)) { $post = get_post($ref); if ($post instanceof WP_Post) $profiles = array_merge($profiles, $this->contentProfiles($post->post_content, array_merge($visited, [$ref]))); } if (!empty($block['innerBlocks'])) $walk($block['innerBlocks']); } }; $walk($blocks); return $profiles; } public function scriptAttributes(string $tag, string $handle): string { $attributes = wp_scripts()->get_data($handle, 'daytidy_attributes'); if (!is_array($attributes)) return $tag; foreach ($attributes as $name => $value) { if (strpos($name, 'data-') !== 0) continue; $tag = str_replace('