$atts Shortcode attributes. * @return string HTML output. */ public function render( array $atts = [] ): string { $atts = shortcode_atts( [ 'view' => 'month', 'category' => '', 'user' => '', 'users' => '', 'show_categories' => 'true', 'show_filters' => 'true', 'initial_month' => '', 'list_start' => 'month', 'list_mode' => 'pages', 'list_height' => '', ], $atts, 'webcalendar' ); // Enqueue frontend assets wp_enqueue_script( 'webcal-frontend' ); wp_enqueue_style( 'webcal-frontend' ); $initial_month = preg_match( '/^\d{4}-\d{2}$/', $atts['initial_month'] ) ? $atts['initial_month'] : ''; // Where List view begins: the 1st of the month (default) or today. $list_start = 'today' === $atts['list_start'] ? 'today' : 'month'; // Continuous: List view keeps loading upcoming events as you scroll. $list_mode = 'continuous' === $atts['list_mode'] ? 'continuous' : 'pages'; $list_height = self::list_height( $atts['list_height'] ); [ $category, $unknown_categories ] = $this->resolve_categories( $atts['category'] ); // Pass attributes to JS wp_localize_script( 'webcal-frontend', 'webcalData', [ 'view' => $atts['view'], 'category' => $category, 'user' => $atts['user'], 'users' => sanitize_text_field( $atts['users'] ), 'showCategories' => $atts['show_categories'] !== 'false', 'showFilters' => $atts['show_filters'] !== 'false', // Venue and organizer filtering is Starter+. The server ignores // those parameters without a licence, so this only decides // whether the controls are worth rendering at all. 'canFilterByPlace' => \webcal_feature_available( 'filter_by_place' ), 'initialMonth' => $initial_month, 'listStart' => $list_start, 'listMode' => $list_mode, 'listHeight' => $list_height, 'nonce' => wp_create_nonce( 'wp_rest' ), 'apiUrl' => get_rest_url( null, 'webcal/v1' ), ] ); // Every substituted value is esc_attr()'d; wp_kses() is a defensive // boundary on the returned markup so the shortcode contract is // verifiably safe even if a future edit forgets an inner escape (WPORG-13.2). $notice = ''; if ( [] !== $unknown_categories && current_user_can( 'edit_posts' ) ) { $notice = '

' . esc_html( sprintf( /* translators: %s: comma-separated category names from the shortcode. */ __( 'WebCalendar: no category named %s. Only editors see this message.', 'agenticdaisy-calendar' ), implode( ', ', $unknown_categories ) ) ) . '

'; } return wp_kses( $notice . sprintf( '
', esc_attr( $atts['view'] ), esc_attr( $category ), esc_attr( $initial_month ), esc_attr( sanitize_text_field( $atts['users'] ) ), esc_attr( $atts['show_categories'] !== 'false' ? 'true' : 'false' ), esc_attr( $atts['show_filters'] !== 'false' ? 'true' : 'false' ), esc_attr( $list_start ), esc_attr( $list_mode ), esc_attr( $list_height ) ), [ 'p' => [ 'class' => [], ], 'div' => [ 'class' => [], 'data-view' => [], 'data-category' => [], 'data-initial-month' => [], 'data-users' => [], 'data-show-categories' => [], 'data-show-filters' => [], 'data-list-start' => [], 'data-list-mode' => [], 'data-list-height' => [], ], ] ); } /** * Turn the category attribute into the id list the REST API takes. * * Each comma-separated entry is a category id or a category name. Names * match the site's global categories, ignoring case and slug form, so * "team-training" finds "Team Training". Tags share the categories table * and are skipped. Personal categories are not considered: a page is the * same for every visitor, and those differ per viewer. * * When entries were given but none resolved, the result is "0", which * the API reads as a filter matching nothing. An empty result would mean * "every category" and turn a typo into the whole calendar. * * @param string $raw The shortcode's category attribute. * @return array{0: string, 1: list} The id list, and the names that matched nothing. */ private function resolve_categories( string $raw ): array { $entries = array_values( array_filter( array_map( 'trim', explode( ',', $raw ) ), static fn( string $e ): bool => '' !== $e ) ); if ( [] === $entries ) { return [ '', [] ]; } $ids = []; $unknown = []; $by_name = null; foreach ( $entries as $entry ) { if ( ctype_digit( $entry ) ) { $ids[] = (int) $entry; continue; } $by_name ??= $this->global_categories_by_name(); $key = sanitize_title( $entry ); if ( isset( $by_name[ $key ] ) ) { $ids[] = $by_name[ $key ]; } else { $unknown[] = $entry; } } $ids = array_values( array_unique( array_filter( $ids, static fn( int $id ): bool => $id > 0 ) ) ); return [ [] === $ids ? '0' : implode( ',', $ids ), $unknown ]; } /** * Global (non-tag) categories keyed by the slug form of their name. * * @return array */ private function global_categories_by_name(): array { $map = []; foreach ( Container::get_instance()->get_category_repository()->findAllGlobal() as $cat ) { if ( $cat->isTag() ) { continue; } $map[ sanitize_title( $cat->name() ) ] ??= $cat->id(); } return $map; } /** * A list height the browser can use as-is ("500px"), or '' for none. * * The value becomes inline CSS, so only a positive number (at most four * digits, two decimals) with px, em, rem or vh is kept; the same rule as * src/listFeed.js. Anything else could carry a second declaration. */ private static function list_height( string $raw ): string { $value = strtolower( trim( $raw ) ); if ( 1 !== preg_match( '/^(\d{1,4}(?:\.\d{1,2})?)(px|em|rem|vh)$/', $value, $m ) || (float) $m[1] <= 0 ) { return ''; } return $value; } }