true`. * * The same map embedded twice used to produce a duplicate DOM id and a * second settings entry silently overwriting the first — only one * instance booted. Suffixing repeats fixes both at once. * * @var array */ private array $used_container_ids = array(); /** * Constructor. * * @param Client $api API client. * @param MapConfigRepository $repository Config repository. */ public function __construct( Client $api, MapConfigRepository $repository ) { $this->api = $api; $this->repository = $repository; } /** * Renders a map embed from a `mapsemble_map` post id. * * @param int $config_id Post id of the map. * @param array $args Optional: height, scroll_capture, scroll_offset_top, * scroll_offset_bottom, fill_viewport, filters. */ public function render( int $config_id, array $args = array() ): string { $config = $this->repository->get( $config_id ); if ( null === $config ) { return current_user_can( Capabilities::EDIT_MAPS ) ? '

' . esc_html__( 'Mapsemble: unknown map. Check the block or shortcode id.', 'mapsemble' ) . '

' : ''; } if ( '' === $config->map_id ) { // Nothing downstream can work without this, and a widget booted // against an empty map id fails silently on the visitor's page. return current_user_can( Capabilities::EDIT_MAPS ) ? '

' . esc_html__( 'Mapsemble: this map is not connected to a map on Mapsemble yet.', 'mapsemble' ) . '

' : ''; } return $this->boot( $config->map_id, $config, $args ); } /** * Renders a map embed from a Mapsemble map id. * * Accepts `'sample'` for the published sample map. No credentials and * no API call are needed: embedding a public map is a map id in a * script tag, and this method does not pretend otherwise. * * @param string $map_id The Mapsemble map id, or 'sample'. * @param array $args Optional display args, as for render(). */ public function render_by_map_id( string $map_id, array $args = array() ): string { $map_id = trim( $map_id ); if ( 'sample' === $map_id ) { $map_id = self::SAMPLE_MAP_ID; } if ( ! MapId::is_valid( $map_id ) ) { return current_user_can( Capabilities::EDIT_MAPS ) ? '

' . esc_html__( 'Mapsemble: that is not a Mapsemble map id. Use the id shown on Mapsemble, or "sample".', 'mapsemble' ) . '

' : ''; } // The resolver: "is this map fed by WordPress?" is a property of // the map, not of whoever placed the embed. A binding to a site-fed // configuration upgrades the embed to this site's feed; anything // else — no binding, or a legacy hosted one — stays bare. $config = $this->repository->find_by_map_id( $map_id ); return $this->boot( $map_id, $config, $args ); } /** * The one boot path both entries share. * * @param string $map_id The Mapsemble map id. * @param MapConfig|null $config The bound config, when one exists. * @param array $args Display args. */ private function boot( string $map_id, ?MapConfig $config, array $args ): string { $height = $this->sanitize_height( (string) ( $args['height'] ?? '' ) ); $app_base = self::SAMPLE_MAP_ID === $map_id ? $this->sample_app_base() : $this->api->get_app_base(); $ids = $this->unique_container_id( $map_id ); $language = strtolower( (string) strtok( determine_locale(), '_' ) ); // One handle on purpose: WordPress keeps the first-registered src, so a // page mixing the sample and a real map under an app-base override // loads widget.js from the first host. A second handle would load two // bundles both assigning window.Mapsemble, which is worse; each widget // still talks to its own embedHost. wp_enqueue_script( 'mapsemble-widget', $app_base . '/widget.js', array(), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- remote service asset, versioned upstream. wp_enqueue_script( 'mapsemble', plugins_url( 'assets/js/mapsemble.js', MAPSEMBLE_PLUGIN_FILE ), array(), MAPSEMBLE_VERSION, true ); $settings = array( 'containerId' => $ids['container_id'], 'mapId' => $map_id, 'embedHost' => $app_base, 'language' => $language, // Rides the widget's requestContext channel, which both the // widget and the Mapsemble service append as flat query params // on the features/feature endpoints. 'requestContext' => array( 'language' => $language ), ); // Said beside the (then hidden) container by the boot script when // the map cannot be shown: Mapsemble reports it unavailable, or // widget.js never arrives. Editors only — a visitor is better served // by the section quietly collapsing. Logged-in editors normally // bypass page caches; one serving editor HTML to visitors would show // these, as it would the other editor-only messages. $notices = current_user_can( Capabilities::EDIT_MAPS ) ? self::editor_notices() : array_fill_keys( array_keys( self::editor_notices() ), '' ); $settings = array_merge( $settings, $notices ); // These keys describe how this site's feed is fetched, which only a // site-fed configuration provides. A hosted or unbound map must not // get them: the public endpoints refuse it outright, so setting them // would point the widget at URLs that deliberately 404; omitting // them lets it fall through to the map's own data source. if ( null !== $config && ! $config->is_hosted() ) { $settings['featuresUrl'] = add_query_arg( 'mapId', rawurlencode( $map_id ), rest_url( 'mapsemble/v1/features' ) ); $settings['featureUrl'] = add_query_arg( 'mapId', rawurlencode( $map_id ), rest_url( 'mapsemble/v1/feature' ) ); // The SaaS backend's route in, as opposed to the browser's two // above: built with public_rest_url() so MAPSEMBLE_REMOTE_BASE_URL // (or its filter) wins, and computed fresh every render so a // data-source URL gone stale on Mapsemble heals without a re-save. // Without an override it equals featuresUrl — correct, not a bug. // Page caches pin the host in cached HTML; acceptable, they vary // by URL. $settings['endpoint'] = add_query_arg( 'mapId', rawurlencode( $map_id ), $this->api->public_rest_url( 'mapsemble/v1/features' ) ); // The widget and the card list both load this feed; seeding the // first configured sort keeps them requesting the same order. $order_by = $config->initial_order_by(); if ( null !== $order_by ) { $settings['orderBy'] = $order_by; } } $settings = $this->apply_display_args( $settings, $args ); /** * Filters the boot settings handed to the Mapsemble widget. * * @param array $settings Widget init settings. * @param MapConfig|null $config The bound map config, or null for an * embed of a map this site has no * configuration for. */ $settings = (array) apply_filters( 'mapsemble_widget_settings', $settings, $config ); wp_add_inline_script( 'mapsemble', 'window.mapsembleSettings = window.mapsembleSettings || {};' . ' window.mapsembleSettings.maps = window.mapsembleSettings.maps || {};' . ' window.mapsembleSettings.maps[' . wp_json_encode( $ids['settings_key'] ) . '] = ' . wp_json_encode( $settings ) . ';', 'before' ); // While the widget fills the visible viewport it sets the container's // height itself, and ours is the floor it may not shrink below. With // the fill off nothing else sizes the container, so the height is // pinned: `height="300px"` then makes a 300px map. Read from the // filtered settings — what the widget is actually told — so a site // turning the fill off through the filter gets the exact height too. // The placeholder fills a pinned box exactly rather than repeating // the length, which it would resolve against its own smaller font // for em. // Read loosely: a filter may hand back 'false' or 0 for off. Should // the widget fill regardless, it writes the inline height itself, // over this one. $fixed = false === filter_var( $settings['fillVisibleViewport'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); return sprintf( '
' . '
%3$s
' . '
', esc_attr( $ids['container_id'] ), esc_attr( $height ), esc_html__( 'Loading map...', 'mapsemble' ), $fixed ? 'height:' . esc_attr( $height ) . ';' : '', $fixed ? 'height:100%' : 'min-height:' . esc_attr( $height ) ); } /** * The editor-facing words for a map Mapsemble reports unavailable. * * Keyed as the boot settings carry them: `unavailableText` for a map * Mapsemble does not know (a typo, a deleted map, another account's), * `unreachableText` for anything else. One source for every surface * that addresses an editor — the embed itself and the block preview. * * @return array{unavailableText: string, unreachableText: string} */ public static function editor_notices(): array { return array( 'unavailableText' => __( 'Mapsemble: this map could not be loaded. Check the map id in the block or shortcode — the map may have been deleted, or belong to another account.', 'mapsemble' ), 'unreachableText' => __( 'Mapsemble: the map could not be loaded right now — reload the page once Mapsemble is reachable again.', 'mapsemble' ), ); } /** * The host the sample map is embedded from. * * The sample exists only on production. A dev site pointed at a local * or staging Mapsemble through MAPSEMBLE_APP_BASE (or its filter) would * otherwise ask that app for a map it has never heard of and render an * empty box, so the sample ignores the override — both widget.js and * the widget's embedHost come from here. The Drupal module and the * Framer plugin pin it the same way. */ private function sample_app_base(): string { /** * Filters the host the sample map is embedded from. * * Applied to the sample map only; every other map follows the app * base. For a self-hosted or staging Mapsemble that publishes its * own copy of the sample map under the same id. * * @param string $base The sample's app base URL, no trailing slash. */ return rtrim( (string) apply_filters( 'mapsemble_sample_app_base', Client::DEFAULT_APP_BASE ), '/' ); } /** * Applies the validated display args onto the boot settings. * * @param array $settings Boot settings so far. * @param array $args Raw args. * @return array The settings with the display keys applied. */ private function apply_display_args( array $settings, array $args ): array { // Only the opt-out travels. The widget defaults fillVisibleViewport // to true and may compute it; pinning `true` from here would take // that freedom away. if ( $this->fill_opted_out( $args ) ) { $settings['fillVisibleViewport'] = false; } $filters = $this->sanitize_filters( $args['filters'] ?? null ); if ( array() !== $filters ) { $settings['filters'] = $filters; } $translations = ( new Translations() )->for_widget(); if ( null !== $translations ) { $settings['translations'] = $translations; } // Only forwarded when set and valid. The widget applies its own // defaults otherwise ('mobile' capture, '0px' offsets), so passing a // junk value through would replace a working default with a broken // one — worse than the setting simply not applying. $capture = trim( (string) ( $args['scroll_capture'] ?? '' ) ); if ( in_array( $capture, self::SCROLL_CAPTURE_MODES, true ) ) { $settings['scrollCapture'] = $capture; } foreach ( array( 'scroll_offset_top' => 'scrollOffsetTop', 'scroll_offset_bottom' => 'scrollOffsetBottom', ) as $arg => $key ) { $offset = trim( (string) ( $args[ $arg ] ?? '' ) ); if ( $this->is_css_length( $offset ) ) { $settings[ $key ] = $offset; } } return $settings; } /** * Discards the per-request container-id registry. * * Same contract as MapConfigRepository::flush(): a real site is one * process per request, a test run is one process for dozens of them. */ public function flush(): void { $this->used_container_ids = array(); } /** * Hands out a unique container id (and settings key) for a map id. * * The first use of a map id keeps the unsuffixed `mapsemble-{id}` form * with the map id itself as the settings key — full back-compat with * page caches and site JS reading `maps[id]`. Repeats get a numeric * suffix, keyed by container id so their settings cannot overwrite the * first instance's. The boot script iterates the settings values and * reads `containerId`, so it needs no change. * * @param string $map_id The Mapsemble map id. * @return array{container_id: string, settings_key: string} */ private function unique_container_id( string $map_id ): array { $base = 'mapsemble-' . $map_id; if ( ! isset( $this->used_container_ids[ $base ] ) ) { $this->used_container_ids[ $base ] = true; return array( 'container_id' => $base, 'settings_key' => $map_id, ); } $n = 2; while ( isset( $this->used_container_ids[ $base . '-' . $n ] ) ) { ++$n; } $container_id = $base . '-' . $n; $this->used_container_ids[ $container_id ] = true; return array( 'container_id' => $container_id, 'settings_key' => $container_id, ); } /** * Normalises the initial-filters argument. * * Accepts a native array (template functions, the block's render * callback) or a JSON object string (shortcode). Keys must be filter * slugs, values scalars or lists of scalars; anything else drops that * entry, and non-object input drops the lot — the same silent-drop * policy as the scroll settings. * * @param mixed $raw Raw argument value. * @return array */ private function sanitize_filters( $raw ): array { if ( is_string( $raw ) ) { $raw = '' !== trim( $raw ) ? json_decode( $raw, true ) : array(); } if ( ! is_array( $raw ) ) { return array(); } $filters = array(); foreach ( $raw as $slug => $value ) { if ( ! is_string( $slug ) || ! preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) ) { continue; } if ( is_array( $value ) ) { $scalars = array_values( array_filter( $value, 'is_scalar' ) ); if ( count( $scalars ) !== count( $value ) || array() === $scalars ) { continue; } $filters[ $slug ] = array_map( 'strval', $scalars ); } elseif ( is_scalar( $value ) && '' !== (string) $value ) { $filters[ $slug ] = (string) $value; } } return $filters; } /** * Whether the args turn the widget's viewport fill off. * * Only an explicit value read as false does; empty or unreadable leaves * the widget's own default (on). * * @param array $args Raw display args. */ private function fill_opted_out( array $args ): bool { $fill = trim( (string) ( $args['fill_viewport'] ?? '' ) ); return '' !== $fill && false === filter_var( $fill, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); } /** * Validates a CSS length for the container height. * * @param string $height Raw height value. */ private function sanitize_height( string $height ): string { return $this->is_css_length( $height ) ? $height : self::DEFAULT_HEIGHT; } /** * Whether a string is a CSS length this plugin is willing to emit. * * Deliberately a short allowlist rather than the full CSS grammar: these * values reach an inline `style` attribute and the widget's own layout * maths, and no map needs `calc()` to say how tall it is. * * @param string $value Candidate length. */ private function is_css_length( string $value ): bool { return 1 === preg_match( '/^\d+(\.\d+)?(px|vh|vw|em|rem|%)$/', $value ); } }