PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / site-switcher.js
jetpack / _inc Last commit date
blocks 2 days ago build 2 days ago content-guidelines-ai 2 days ago fonts 1 year ago genericons 4 months ago lib 2 days ago accessible-focus.js 5 years ago blogging-prompts.php 1 week ago class.jetpack-provision.php 7 months ago content-guidelines-ai.js 1 week ago content-guidelines-ai.php 1 week ago crowdsignal-shortcode.js 1 year ago crowdsignal-survey.js 5 years ago deprecate.js 7 months ago facebook-embed.js 4 years ago gallery-settings.js 5 years ago genericons.php 1 year ago jetpack-admin.js 3 years ago jetpack-deactivate-dialog.js 1 year ago jetpack-modules.js 1 year ago jetpack-modules.models.js 7 months ago jetpack-modules.views.js 7 months ago polldaddy-shortcode.js 3 months ago site-switcher-endpoint.php 5 months ago site-switcher.js 5 months ago site-switcher.php 5 months ago social-logos.php 3 months ago twitter-timeline.js 1 month ago
site-switcher.js
289 lines
1 /**
2 * Site Switcher for Command Palette
3 * Adds a dynamic "Switch to Site" command that searches across all user's WordPress.com sites
4 *
5 * Requires WordPress 6.9+ for admin-wide command palette support
6 *
7 * @package
8 */
9
10 import apiFetch from '@wordpress/api-fetch';
11 import { useCommandLoader } from '@wordpress/commands';
12 import { useMemo, useState, useEffect } from '@wordpress/element';
13 import { sprintf, __ } from '@wordpress/i18n';
14 import { siteLogo } from '@wordpress/icons';
15
16 const userId = window.jetpackSiteSwitcherConfig?.userId || 0;
17 const CACHE_KEY = `jetpack_site_switcher_sites_${ userId }`;
18 const CACHE_DURATION = 3600000; // 1 hour in milliseconds
19
20 /**
21 * Get cached sites from localStorage
22 */
23 function getCachedSites() {
24 try {
25 const cached = localStorage.getItem( CACHE_KEY );
26 if ( ! cached ) {
27 return null;
28 }
29
30 const { sites, timestamp } = JSON.parse( cached );
31
32 // Check if cache is still valid
33 if ( Date.now() - timestamp < CACHE_DURATION ) {
34 return sites;
35 }
36
37 // Cache expired, remove it
38 localStorage.removeItem( CACHE_KEY );
39 return null;
40 } catch {
41 // If localStorage is not available or JSON parsing fails, return null
42 return null;
43 }
44 }
45
46 /**
47 * Save sites to localStorage cache
48 */
49 function setCachedSites( sites ) {
50 try {
51 localStorage.setItem(
52 CACHE_KEY,
53 JSON.stringify( {
54 sites,
55 timestamp: Date.now(),
56 } )
57 );
58 } catch {
59 // Silently fail if localStorage is not available (e.g., private browsing)
60 }
61 }
62
63 /**
64 * Fetch compact sites list from WordPress.com API
65 */
66 async function fetchSitesFromWordPressCom() {
67 // Check localStorage cache first
68 const cachedSites = getCachedSites();
69 if ( cachedSites ) {
70 return cachedSites;
71 }
72
73 const apiPath = window.jetpackSiteSwitcherConfig?.apiPath;
74
75 try {
76 const data = await apiFetch( {
77 path: apiPath,
78 method: 'GET',
79 global: true,
80 } );
81
82 const sites = data.sites || [];
83
84 setCachedSites( sites );
85
86 return sites;
87 } catch {
88 return [];
89 }
90 }
91
92 /**
93 * Safely extract hostname from a URL string
94 *
95 * @param {string} urlString - The URL to parse
96 * @return {string} The hostname, or empty string if invalid
97 */
98 function getHostnameFromURL( urlString ) {
99 if ( ! urlString ) {
100 return '';
101 }
102 try {
103 return new URL( urlString ).hostname;
104 } catch {
105 return '';
106 }
107 }
108
109 /**
110 * Remove trailing slash from a URL string
111 *
112 * @param {string} url - The URL to process
113 * @return {string} URL without trailing slash
114 */
115 function untrailingslashit( url ) {
116 return url ? url.replace( /\/+$/, '' ) : url;
117 }
118
119 /**
120 * Escape special regex characters in a string
121 *
122 * @param {string} str - String to escape
123 * @return {string} Escaped string safe for use in RegExp
124 */
125 function escapeRegex( str ) {
126 return str.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
127 }
128
129 /**
130 * Custom hook to load site-switching commands based on search term
131 *
132 * @param {Object} props - Hook properties
133 * @param {string} props.search - Search term to filter sites
134 * @return {Object} Object containing commands array and loading state
135 */
136 function useSiteSwitcherCommandLoader( { search } ) {
137 const [ sites, setSites ] = useState( [] );
138 const [ isLoading, setIsLoading ] = useState( true );
139
140 // Fetch sites on mount
141 useEffect( () => {
142 fetchSitesFromWordPressCom()
143 .then( fetchedSites => {
144 setSites( fetchedSites );
145 setIsLoading( false );
146 } )
147 .catch( () => {
148 setIsLoading( false );
149 } );
150 }, [] );
151
152 // Generate and filter commands based on search term
153 const commands = useMemo( () => {
154 if ( ! sites || sites.length === 0 ) {
155 return [];
156 }
157
158 const searchLower = search ? search.toLowerCase() : '';
159
160 // Strip generic keywords from search to allow queries like "site dean" to find sites with "dean"
161 const genericKeywords = [
162 __( 'site', 'jetpack' ).toLowerCase(),
163 __( 'switch', 'jetpack' ).toLowerCase(),
164 __( 'switch site', 'jetpack' ).toLowerCase(),
165 ];
166
167 let cleanedSearch = searchLower;
168 genericKeywords.forEach( keyword => {
169 cleanedSearch = cleanedSearch.replace(
170 new RegExp( `\\b${ escapeRegex( keyword ) }\\b`, 'g' ),
171 ' '
172 );
173 } );
174 cleanedSearch = cleanedSearch.trim().replace( /\s+/g, ' ' );
175
176 // Check if the search is a prefix of any generic keyword (e.g., "swit" matches "switch")
177 // If so, treat it as a generic search and show all sites
178 const isGenericKeywordPrefix =
179 cleanedSearch && genericKeywords.some( keyword => keyword.startsWith( cleanedSearch ) );
180
181 // If search is empty after stripping generic keywords, or is a prefix of a generic keyword, show all sites
182 const filteredSites =
183 ! cleanedSearch || isGenericKeywordPrefix
184 ? sites
185 : sites.filter( site => {
186 const domain = getHostnameFromURL( site.URL );
187 return (
188 ( site.name && site.name.toLowerCase().includes( cleanedSearch ) ) ||
189 domain.toLowerCase().includes( cleanedSearch )
190 );
191 } );
192
193 // Filter out sites with invalid URLs (can't navigate to them anyway)
194 const validSites = filteredSites.filter( site => {
195 return site.URL && getHostnameFromURL( site.URL ) !== '';
196 } );
197
198 // Exclude the current site from the list
199 const currentURL = untrailingslashit( window.location.href.toLowerCase() );
200 const otherSites = validSites.filter( site => {
201 // Normalize site URL for comparison
202 const siteURL = untrailingslashit( site.URL.toLowerCase() );
203 // Check if current URL starts with site URL (handles multisite subdirectory installs)
204 // e.g., current: example.com/site1/wp-admin matches site: example.com/site1
205 return ! currentURL.startsWith( siteURL );
206 } );
207
208 return otherSites.map( site => {
209 // Extract domain from URL for display - don't want to display the protocol.
210 const domain = getHostnameFromURL( site.URL );
211
212 const iconElement = site.icon?.img ? <img src={ site.icon.img } alt="" /> : siteLogo;
213
214 // Use site name if available, otherwise just show domain
215 const label = site.name
216 ? sprintf(
217 /* translators: %1$s: site name, %2$s: site domain */
218 __( 'Switch to %1$s (%2$s)', 'jetpack' ),
219 site.name,
220 domain
221 )
222 : sprintf(
223 /* translators: %s: site domain */
224 __( 'Switch to %s', 'jetpack' ),
225 domain
226 );
227
228 return {
229 name: `jetpack/switch-to-site-${ domain }`,
230 label,
231 icon: iconElement,
232 callback: ( { close } ) => {
233 try {
234 window.location.href = new URL( '/wp-admin', site.URL ).href;
235 } catch {
236 // If URL is malformed, don't navigate
237 }
238 close();
239 },
240 keywords: [
241 site.name,
242 domain,
243 __( 'site', 'jetpack' ),
244 __( 'switch site', 'jetpack' ),
245 ].filter( Boolean ),
246 };
247 } );
248 }, [ sites, search ] );
249
250 return {
251 commands,
252 isLoading,
253 };
254 }
255
256 /**
257 * Component that registers the site switcher command loader
258 */
259 function JetpackSiteSwitcher() {
260 useCommandLoader( {
261 name: 'jetpack/site-switcher',
262 hook: useSiteSwitcherCommandLoader,
263 } );
264
265 return null;
266 }
267
268 // Render the site switcher into wp-admin
269 // This works with WordPress 6.9+ admin-wide command palette
270 if ( typeof window !== 'undefined' && window.wp && window.wp.element && window.wp.commands ) {
271 const { createRoot, createElement } = window.wp.element;
272
273 // Create a container for our site switcher
274 const container = document.createElement( 'div' );
275 container.id = 'jetpack-site-switcher';
276 container.style.display = 'none'; // Hidden, as we only need the hooks to run
277
278 // Wait for DOM to be ready
279 if ( document.readyState === 'loading' ) {
280 document.addEventListener( 'DOMContentLoaded', () => {
281 document.body.appendChild( container );
282 createRoot( container ).render( createElement( JetpackSiteSwitcher ) );
283 } );
284 } else {
285 document.body.appendChild( container );
286 createRoot( container ).render( createElement( JetpackSiteSwitcher ) );
287 }
288 }
289