+
{ selectedSite.phpVersion }
+ { nativePhpVersionWarning && (
+
+
+
+
+
+ ) }
diff --git a/apps/studio/src/components/tests/content-tab-settings.test.tsx b/apps/studio/src/components/tests/content-tab-settings.test.tsx
index 058a4fba1e..9f2b31a44a 100644
--- a/apps/studio/src/components/tests/content-tab-settings.test.tsx
+++ b/apps/studio/src/components/tests/content-tab-settings.test.tsx
@@ -45,11 +45,11 @@ let testStore = createTestStore( {
} );
// We need to create a new store each time to avoid reducer conflicts
-function createCustomTestStore() {
+function createCustomTestStore( nativePhpRuntime = false ) {
const store = createTestStore( {
preloadedState: {
betaFeatures: {
- features: {},
+ features: { nativePhpRuntime },
loading: false,
},
},
@@ -291,6 +291,26 @@ describe( 'ContentTabSettings', () => {
} );
describe( 'PHP version', () => {
+ it( 'shows a native PHP fallback warning for unsupported stored PHP versions', async () => {
+ const user = userEvent.setup();
+ testStore = createCustomTestStore( true );
+
+ renderWithProvider(
+
+ );
+
+ await waitFor( () => {
+ expect( getAllCustomDomains ).toHaveBeenCalled();
+ } );
+ await user.hover( screen.getByRole( 'img', { name: 'PHP version warning' } ) );
+
+ expect(
+ await screen.findByText(
+ 'Native PHP does not support PHP 7.4. This site will run with PHP 8.2 instead.'
+ )
+ ).toBeVisible();
+ } );
+
it( 'changes PHP version when site is not running', async () => {
const user = userEvent.setup();
diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts
index e7bc8becfb..e9c146ef0d 100644
--- a/apps/studio/src/index.ts
+++ b/apps/studio/src/index.ts
@@ -29,6 +29,7 @@ import {
hasActiveSyncOperations,
hasUploadingPushOperations,
} from 'src/lib/active-sync-operations';
+import { getBetaFeatures } from 'src/lib/beta-features';
import {
bumpStat,
bumpAggregatedUniqueStat,
@@ -341,6 +342,7 @@ async function appBoot() {
await runMigrations( migrations ).catch( Sentry.captureException );
await setupSentryUserId();
+ await getBetaFeatures();
// Fetch data from CLI and subscribe to CLI events before starting the user data
// watcher. The watcher can trigger getMainWindow() which creates the window early,
diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts
index f57b868f55..d9c4a12707 100644
--- a/apps/studio/src/ipc-types.d.ts
+++ b/apps/studio/src/ipc-types.d.ts
@@ -5,8 +5,6 @@ interface ShowNotificationOptions extends Electron.NotificationConstructorOption
showIcon: boolean;
}
-type SiteRuntime = 'playground' | 'native-php';
-
interface StoppedSiteDetails {
running: false;
@@ -46,7 +44,6 @@ interface StoppedSiteDetails {
enableDebugDisplay?: boolean;
sortOrder?: number;
landingPage?: string;
- runtime?: SiteRuntime;
}
interface StartedSiteDetails extends StoppedSiteDetails {
@@ -102,8 +99,9 @@ interface FeatureFlags {
enableStudioCodeUi: boolean;
}
-// eslint-disable-next-line @typescript-eslint/no-empty-object-type
-interface BetaFeatures {}
+interface BetaFeatures {
+ nativePhpRuntime?: boolean;
+}
interface AppGlobals extends FeatureFlags {
platform: NodeJS.Platform;
diff --git a/apps/studio/src/lib/beta-features.ts b/apps/studio/src/lib/beta-features.ts
index a7770bbc3a..3432b41268 100644
--- a/apps/studio/src/lib/beta-features.ts
+++ b/apps/studio/src/lib/beta-features.ts
@@ -1,3 +1,5 @@
+import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
+import { __ } from '@wordpress/i18n';
import { lockAppdata, unlockAppdata, loadUserData, saveUserData } from 'src/storage/user-data';
export interface BetaFeatureDefinition {
@@ -10,14 +12,23 @@ export interface BetaFeatureDefinition {
/**
* Default values for beta features.
*/
-const BETA_FEATURE_DEFAULTS: Record< keyof BetaFeatures, boolean > = {};
+const BETA_FEATURE_DEFAULTS: Record< keyof BetaFeatures, boolean > = {
+ nativePhpRuntime: false,
+};
/**
* Returns beta feature definitions with translated labels and descriptions.
* Must be called at runtime (not at module load) to ensure translations are loaded.
*/
export function getBetaFeaturesDefinition(): Record< keyof BetaFeatures, BetaFeatureDefinition > {
- return {};
+ return {
+ nativePhpRuntime: {
+ key: 'nativePhpRuntime',
+ label: __( 'Native PHP runtime' ),
+ default: BETA_FEATURE_DEFAULTS.nativePhpRuntime,
+ description: __( 'Run Studio sites with native PHP instead of Playground.' ),
+ },
+ };
}
function buildBetaFeatures( userData: BetaFeatures | undefined ): BetaFeatures {
@@ -29,9 +40,17 @@ function buildBetaFeatures( userData: BetaFeatures | undefined ): BetaFeatures {
return features;
}
+function applyBetaFeaturesToEnvironment( features: BetaFeatures ): void {
+ process.env.STUDIO_RUNTIME = features.nativePhpRuntime
+ ? SITE_RUNTIME_NATIVE_PHP
+ : SITE_RUNTIME_PLAYGROUND;
+}
+
export async function getBetaFeatures(): Promise< BetaFeatures > {
const userData = await loadUserData();
- return buildBetaFeatures( userData.betaFeatures );
+ const betaFeatures = buildBetaFeatures( userData.betaFeatures );
+ applyBetaFeaturesToEnvironment( betaFeatures );
+ return betaFeatures;
}
export async function updateBetaFeature(
@@ -42,10 +61,12 @@ export async function updateBetaFeature(
await lockAppdata();
const userData = await loadUserData();
const betaFeatures = await getBetaFeatures();
- // @ts-expect-error If `BetaFeatures` is empty, `key` will be `never`, and we cannot use it to
- // assign to`betaFeatures`.That's fine. Just rely on type checking when this function is called.
+ // If `BetaFeatures` is empty, `key` will be `never`, and we cannot use it to
+ // assign to `betaFeatures`. That's fine. Just rely on type checking when this
+ // function is called.
betaFeatures[ key ] = value;
userData.betaFeatures = betaFeatures;
+ applyBetaFeaturesToEnvironment( betaFeatures );
await saveUserData( userData );
} finally {
await unlockAppdata();
diff --git a/apps/studio/src/menu.ts b/apps/studio/src/menu.ts
index 76e8283e51..8332694797 100644
--- a/apps/studio/src/menu.ts
+++ b/apps/studio/src/menu.ts
@@ -164,7 +164,7 @@ async function getAppMenu(
{
label: __( 'Beta Features' ),
submenu: betaFeaturesMenu,
- enabled: false,
+ enabled: betaFeaturesMenu.length > 0,
},
{ type: 'separator' },
...( process.platform === 'win32'
diff --git a/apps/studio/src/modules/add-site/components/create-site-form.tsx b/apps/studio/src/modules/add-site/components/create-site-form.tsx
index 928022d5d2..0adaa54f7e 100644
--- a/apps/studio/src/modules/add-site/components/create-site-form.tsx
+++ b/apps/studio/src/modules/add-site/components/create-site-form.tsx
@@ -8,7 +8,12 @@ import {
validateAdminEmail,
validateAdminUsername,
} from '@studio/common/lib/passwords';
-import { SupportedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions';
+import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
+import {
+ getRecommendedPHPVersionForRuntime,
+ getSupportedPHPVersionsForRuntime,
+ SupportedPHPVersion,
+} from '@studio/common/types/php-versions';
import { Icon, SelectControl, Notice } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { __, sprintf, _n } from '@wordpress/i18n';
@@ -23,6 +28,7 @@ import { SiteFormError } from 'src/components/site-form-error';
import TextControlComponent from 'src/components/text-control';
import { WPVersionSelector } from 'src/components/wp-version-selector';
import { cx } from 'src/lib/cx';
+import { useRootSelector } from 'src/stores';
import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api';
import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation';
import type { CreateSiteFormValues, PathValidationResult } from 'src/hooks/use-add-site';
@@ -75,10 +81,17 @@ export const CreateSiteForm = ( {
}: CreateSiteFormProps ) => {
const { __, isRTL } = useI18n();
const { data: isCertificateTrusted } = useCheckCertificateTrustQuery();
+ const runtime = useRootSelector( ( state ) =>
+ state.betaFeatures.features.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND
+ );
+ const supportedPhpVersions = getSupportedPHPVersionsForRuntime( runtime );
+ const recommendedPhpVersion = getRecommendedPHPVersionForRuntime( runtime );
const [ siteName, setSiteName ] = useState( defaultValues.siteName ?? '' );
const [ sitePath, setSitePath ] = useState( defaultValues.sitePath ?? '' );
const [ phpVersion, setPhpVersion ] = useState< SupportedPHPVersion >(
- defaultValues.phpVersion ?? SupportedPHPVersions[ 0 ] ?? '8.2'
+ defaultValues.phpVersion && supportedPhpVersions.includes( defaultValues.phpVersion )
+ ? defaultValues.phpVersion
+ : recommendedPhpVersion
);
const [ wpVersion, setWpVersion ] = useState(
defaultValues.wpVersion ?? DEFAULT_WORDPRESS_VERSION
@@ -127,12 +140,27 @@ export const CreateSiteForm = ( {
// Sync versions from defaultValues (initial load and deeplink flows)
useEffect( () => {
if ( defaultValues.phpVersion !== undefined ) {
- setPhpVersion( defaultValues.phpVersion );
+ setPhpVersion(
+ supportedPhpVersions.includes( defaultValues.phpVersion )
+ ? defaultValues.phpVersion
+ : recommendedPhpVersion
+ );
}
if ( defaultValues.wpVersion !== undefined ) {
setWpVersion( defaultValues.wpVersion );
}
- }, [ defaultValues.phpVersion, defaultValues.wpVersion ] );
+ }, [
+ defaultValues.phpVersion,
+ defaultValues.wpVersion,
+ recommendedPhpVersion,
+ supportedPhpVersions,
+ ] );
+
+ useEffect( () => {
+ if ( ! supportedPhpVersions.includes( phpVersion ) ) {
+ setPhpVersion( recommendedPhpVersion );
+ }
+ }, [ phpVersion, recommendedPhpVersion, supportedPhpVersions ] );
// Sync admin credentials from Blueprint when they change (only if user hasn't edited)
useEffect( () => {
@@ -239,7 +267,14 @@ export const CreateSiteForm = ( {
setSitePath( result.path );
}
},
- [ onSiteNameChange, hasCustomPath ]
+ [
+ onSiteNameChange,
+ hasCustomPath,
+ setDoesPathContainWordPress,
+ setPathError,
+ setSiteName,
+ setSitePath,
+ ]
);
const handleSelectPath = useCallback( async () => {
@@ -273,7 +308,18 @@ export const CreateSiteForm = ( {
if ( result.name && ! siteName ) {
setSiteName( result.name );
}
- }, [ onSelectPath, onSiteNameChange, sitePath, siteName, hasCustomPath ] );
+ }, [
+ onSelectPath,
+ onSiteNameChange,
+ sitePath,
+ siteName,
+ hasCustomPath,
+ setDoesPathContainWordPress,
+ setHasCustomPath,
+ setPathError,
+ setSiteName,
+ setSitePath,
+ ] );
const handleCustomDomainChange = useCallback(
( value: string ) => {
@@ -449,7 +495,7 @@ export const CreateSiteForm = ( {
id="php-version-select"
value={ phpVersion }
- options={ SupportedPHPVersions.map( ( version ) => ( {
+ options={ supportedPhpVersions.map( ( version ) => ( {
label: version,
value: version,
} ) ) }
diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx
index 0de0b9f6db..ddc3cba9a7 100644
--- a/apps/studio/src/modules/site-settings/edit-site-details.tsx
+++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx
@@ -1,4 +1,4 @@
-import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants';
+import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants';
import {
generateCustomDomainFromSiteName,
getDomainNameValidationError,
@@ -10,10 +10,17 @@ import {
validateAdminUsername,
} from '@studio/common/lib/passwords';
import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart';
-import { SupportedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions';
-import { SelectControl, TabPanel } from '@wordpress/components';
+import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
+import {
+ getClosestNativePhpVersion,
+ getRecommendedPHPVersionForRuntime,
+ getSupportedPHPVersionsForRuntime,
+ SupportedPHPVersion,
+} from '@studio/common/types/php-versions';
+import { Icon, SelectControl, TabPanel } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
import { sprintf } from '@wordpress/i18n';
+import { cautionFilled } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import Button from 'src/components/button';
@@ -28,6 +35,7 @@ import { WPVersionSelector } from 'src/components/wp-version-selector';
import { useSiteDetails } from 'src/hooks/use-site-details';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
+import { useRootSelector } from 'src/stores';
import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api';
type EditSiteDetailsProps = {
@@ -61,6 +69,34 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
const [ adminEmail, setAdminEmail ] = useState(
selectedSite?.adminEmail || 'admin@localhost.com'
);
+ const runtime = useRootSelector( ( state ) =>
+ state.betaFeatures.features.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND
+ );
+ const supportedPhpVersions = getSupportedPHPVersionsForRuntime( runtime );
+ const recommendedPhpVersion = getRecommendedPHPVersionForRuntime( runtime );
+ const selectedSitePhpVersion = selectedSite?.phpVersion;
+ const resolvedNativePhpVersion =
+ runtime === SITE_RUNTIME_NATIVE_PHP && selectedSitePhpVersion
+ ? getClosestNativePhpVersion( selectedSitePhpVersion )
+ : undefined;
+ const selectedSitePhpVersionForRuntime =
+ selectedSitePhpVersion &&
+ supportedPhpVersions.includes( selectedSitePhpVersion as SupportedPHPVersion )
+ ? ( selectedSitePhpVersion as SupportedPHPVersion )
+ : resolvedNativePhpVersion ?? recommendedPhpVersion;
+ const showNativePhpVersionWarning =
+ runtime === SITE_RUNTIME_NATIVE_PHP &&
+ selectedSitePhpVersion !== undefined &&
+ resolvedNativePhpVersion !== undefined &&
+ resolvedNativePhpVersion !== selectedSitePhpVersion;
+ const nativePhpVersionWarning =
+ showNativePhpVersionWarning && selectedSitePhpVersion && resolvedNativePhpVersion
+ ? sprintf(
+ __( 'Native PHP does not support PHP %1$s. This site will run with PHP %2$s instead.' ),
+ selectedSitePhpVersion,
+ resolvedNativePhpVersion
+ )
+ : undefined;
useEffect( () => {
if ( selectedSite?.adminEmail || ! selectedSite?.id ) {
@@ -92,7 +128,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
}, [ isEditingSite, setIsEditModalOpen ] );
const [ siteName, setSiteName ] = useState( selectedSite?.name ?? '' );
const [ selectedPhpVersion, setSelectedPhpVersion ] = useState< SupportedPHPVersion >(
- ( selectedSite?.phpVersion as SupportedPHPVersion ) ?? DEFAULT_PHP_VERSION
+ selectedSitePhpVersionForRuntime
);
const getEffectiveWpVersion = useCallback(
() =>
@@ -173,7 +209,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
return;
}
setSiteName( selectedSite.name );
- setSelectedPhpVersion( selectedSite.phpVersion as SupportedPHPVersion );
+ setSelectedPhpVersion( selectedSitePhpVersionForRuntime );
setSelectedWpVersion( getEffectiveWpVersion() );
setUseCustomDomain( Boolean( selectedSite.customDomain ) );
setCustomDomain( selectedSite.customDomain ?? null );
@@ -186,7 +222,31 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
setAdminEmail( selectedSite.adminEmail || 'admin@localhost.com' );
setEnableDebugLog( selectedSite.enableDebugLog ?? false );
setEnableDebugDisplay( selectedSite.enableDebugDisplay ?? false );
- }, [ selectedSite, getEffectiveWpVersion ] );
+ }, [
+ selectedSite,
+ getEffectiveWpVersion,
+ selectedSitePhpVersionForRuntime,
+ setAdminEmail,
+ setAdminPassword,
+ setAdminUsername,
+ setCustomDomain,
+ setCustomDomainError,
+ setEnableDebugDisplay,
+ setEnableDebugLog,
+ setEnableHttps,
+ setEnableXdebug,
+ setErrorUpdatingWpVersion,
+ setSelectedPhpVersion,
+ setSelectedWpVersion,
+ setSiteName,
+ setUseCustomDomain,
+ ] );
+
+ useEffect( () => {
+ if ( ! supportedPhpVersions.includes( selectedPhpVersion ) ) {
+ setSelectedPhpVersion( recommendedPhpVersion );
+ }
+ }, [ selectedPhpVersion, recommendedPhpVersion, supportedPhpVersions ] );
const onSiteEdit = async ( event: FormEvent ) => {
event.preventDefault();
@@ -328,12 +388,30 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
htmlFor="php-version-select"
className="flex flex-1 flex-col gap-1.5 leading-4"
>
- { __( 'PHP version' ) }
+
+ { __( 'PHP version' ) }
+ { nativePhpVersionWarning && (
+
+
+
+
+
+ ) }
+
id="php-version-select"
disabled={ isEditingSite }
value={ selectedPhpVersion }
- options={ SupportedPHPVersions.map( ( version ) => ( {
+ options={ supportedPhpVersions.map( ( version ) => ( {
label: version,
value: version,
} ) ) }
diff --git a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx
index ffe33ef7d6..5d07a4faee 100644
--- a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx
+++ b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, within } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { vi } from 'vitest';
@@ -69,8 +69,15 @@ vi.mock( 'src/hooks/use-offline', () => ( {
useOffline: vi.fn().mockReturnValue( false ),
} ) );
-const renderWithProvider = ( children: React.ReactElement ) => {
- const store = createTestStore();
+const renderWithProvider = ( children: React.ReactElement, nativePhpRuntime = false ) => {
+ const store = createTestStore( {
+ preloadedState: {
+ betaFeatures: {
+ features: { nativePhpRuntime },
+ loading: false,
+ },
+ },
+ } );
return render( { children } );
};
@@ -223,6 +230,35 @@ describe( 'EditSiteDetails', () => {
expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled();
} );
+ it( 'should show a native PHP fallback warning for unsupported stored PHP versions', async () => {
+ const user = userEvent.setup();
+ vi.mocked( useSiteDetails ).mockReturnValue(
+ createMock< ReturnType< typeof useSiteDetails > >( {
+ ...baseMockSiteDetails,
+ selectedSite: {
+ ...baseMockSiteDetails.selectedSite,
+ phpVersion: '7.4',
+ },
+ isEditModalOpen: true,
+ } )
+ );
+
+ renderWithProvider( , true );
+
+ await waitFor( () => {
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+ } );
+ const dialog = screen.getByRole( 'dialog' );
+ expect( within( dialog ).getByLabelText( 'PHP version' ) ).toHaveValue( '8.2' );
+ await user.hover( within( dialog ).getByRole( 'img', { name: 'PHP version warning' } ) );
+
+ expect(
+ await screen.findByText(
+ 'Native PHP does not support PHP 7.4. This site will run with PHP 8.2 instead.'
+ )
+ ).toBeVisible();
+ } );
+
it( 'should enable the save button when WordPress version is changed', async () => {
vi.mocked( useSiteDetails ).mockReturnValue(
createMock< ReturnType< typeof useSiteDetails > >( {
diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts
index 4df5a3fbd6..89a2850796 100644
--- a/tools/common/lib/cli-events.ts
+++ b/tools/common/lib/cli-events.ts
@@ -8,9 +8,6 @@ import { z } from 'zod';
import { authTokenSchema } from '@studio/common/lib/auth-token-schema';
import { snapshotSchema } from '@studio/common/types/snapshot';
-export const siteRuntimeSchema = z.enum( [ 'playground', 'native-php' ] );
-export type SiteRuntime = z.infer< typeof siteRuntimeSchema >;
-
/**
* Site data included in events. This is the data Studio needs to display sites.
*/
@@ -34,7 +31,6 @@ export const siteDetailsSchema = z.object( {
technicalSiteDirectory: z.string().optional(),
runtimeBlueprintPath: z.string().optional(),
landingPage: z.string().optional(),
- runtime: siteRuntimeSchema.optional(),
} );
export type SiteDetails = z.infer< typeof siteDetailsSchema >;
diff --git a/tools/common/lib/php-binary-metadata.ts b/tools/common/lib/php-binary-metadata.ts
index ed13faa5aa..d104d253d7 100644
--- a/tools/common/lib/php-binary-metadata.ts
+++ b/tools/common/lib/php-binary-metadata.ts
@@ -1,20 +1,14 @@
import { sprintf } from '@wordpress/i18n';
import { z } from 'zod';
import {
- isSupportedPHPVersion,
- SupportedPHPVersions,
- type SupportedPHPVersion,
+ getClosestNativePhpVersion,
+ LatestNativePhpSupportedVersion,
+ NativePhpSupportedVersions,
+ type NativePhpSupportedVersion,
} from '@studio/common/types/php-versions';
import phpBinaryCdnMetadataJson from './php-binary-cdn-metadata.json';
-const phpBinaryCdnMetadataVersions = Object.keys(
- ( phpBinaryCdnMetadataJson as { versions?: Record< string, unknown > } ).versions ?? {}
-);
-
-export const NativePhpSupportedVersions = SupportedPHPVersions.filter( ( version ) =>
- phpBinaryCdnMetadataVersions.includes( version )
-) as [ SupportedPHPVersion, ...SupportedPHPVersion[] ];
-export type NativePhpSupportedVersion = ( typeof NativePhpSupportedVersions )[ number ];
+export { NativePhpSupportedVersions, type NativePhpSupportedVersion };
const nativePhpVersionSchema = z.enum( NativePhpSupportedVersions );
export const MinimumNativePhpSupportedVersion =
@@ -34,16 +28,18 @@ export function validateNativePhpVersion( version: string ): NativePhpSupportedV
return result.data;
}
-export function coerceNativePhpVersion( version: SupportedPHPVersion ): NativePhpSupportedVersion {
+export function resolveNativePhpVersion( version: string ): NativePhpSupportedVersion {
const result = nativePhpVersionSchema.safeParse( version );
- return result.success ? result.data : MinimumNativePhpSupportedVersion;
-}
+ if ( result.success ) {
+ return result.data;
+ }
-export function resolveNativePhpVersion( version: string ): NativePhpSupportedVersion {
- if ( isSupportedPHPVersion( version ) ) {
- return coerceNativePhpVersion( version );
+ if ( ! version ) {
+ return LatestNativePhpSupportedVersion;
}
- return validateNativePhpVersion( version );
+
+ const resolvedVersion = getClosestNativePhpVersion( version );
+ return resolvedVersion ?? validateNativePhpVersion( version );
}
const phpBinaryArtifactSchema = z.object( {
@@ -52,8 +48,8 @@ const phpBinaryArtifactSchema = z.object( {
} );
const phpBinaryCdnMetadataSchema = z.object( {
- versions: z.partialRecord(
- nativePhpVersionSchema,
+ versions: z.record(
+ z.string(),
z.object( {
version: z.string().regex( /^\d+\.\d+\.\d+$/ ),
artifacts: z.record( z.string(), phpBinaryArtifactSchema ),
diff --git a/tools/common/lib/site-runtime.ts b/tools/common/lib/site-runtime.ts
new file mode 100644
index 0000000000..938eddf366
--- /dev/null
+++ b/tools/common/lib/site-runtime.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod';
+
+export const SITE_RUNTIME_PLAYGROUND = 'playground';
+export const SITE_RUNTIME_NATIVE_PHP = 'native-php';
+
+export const siteRuntimeSchema = z.enum( [ SITE_RUNTIME_PLAYGROUND, SITE_RUNTIME_NATIVE_PHP ] );
+export type SiteRuntime = z.infer< typeof siteRuntimeSchema >;
diff --git a/tools/common/lib/tests/php-binary-metadata.test.ts b/tools/common/lib/tests/php-binary-metadata.test.ts
new file mode 100644
index 0000000000..d0c39b7d14
--- /dev/null
+++ b/tools/common/lib/tests/php-binary-metadata.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import {
+ NativePhpSupportedVersions,
+ resolveNativePhpVersion,
+ validateNativePhpVersion,
+} from '@studio/common/lib/php-binary-metadata';
+
+describe( 'Native PHP binary metadata', () => {
+ it( 'supports officially supported PHP versions', () => {
+ expect( NativePhpSupportedVersions ).toEqual( [ '8.5', '8.4', '8.3', '8.2' ] );
+ } );
+
+ it.each( [ '8.1', '8.0', '7.4' ] )( 'rejects unsupported PHP %s', ( version ) => {
+ expect( () => validateNativePhpVersion( version ) ).toThrow(
+ `PHP ${ version } is not supported by the native-php runtime. Supported versions: 8.5, 8.4, 8.3, 8.2.`
+ );
+ } );
+
+ it.each( [
+ [ '8.1', '8.2' ],
+ [ '8.0', '8.2' ],
+ [ '7.4', '8.2' ],
+ [ '8.6', '8.5' ],
+ [ '', '8.5' ],
+ ] )( 'resolves PHP %s to native PHP %s', ( version, expectedVersion ) => {
+ expect( resolveNativePhpVersion( version ) ).toBe( expectedVersion );
+ } );
+
+ it( 'rejects malformed PHP versions when resolving native PHP', () => {
+ expect( () => resolveNativePhpVersion( 'nonsense' ) ).toThrow(
+ 'PHP nonsense is not supported by the native-php runtime.'
+ );
+ } );
+} );
diff --git a/tools/common/types/php-versions.ts b/tools/common/types/php-versions.ts
index 0e2b4cdfb0..adb0ae82e4 100644
--- a/tools/common/types/php-versions.ts
+++ b/tools/common/types/php-versions.ts
@@ -1,13 +1,10 @@
-/**
- * Local type definitions for PHP versions.
- * These replace imports from @php-wasm/universal to avoid bundling PHP-WASM in the desktop app.
- *
- * Note: Keep these values in sync with @php-wasm/universal if the upstream package changes.
- */
+import { SITE_RUNTIME_NATIVE_PHP, type SiteRuntime } from '../lib/site-runtime';
export const SupportedPHPVersions = [ '8.5', '8.4', '8.3', '8.2', '8.1', '8.0', '7.4' ] as const;
+export const NativePhpSupportedVersions = [ '8.5', '8.4', '8.3', '8.2' ] as const;
export const LatestSupportedPHPVersion = '8.5' as const;
+export const LatestNativePhpSupportedVersion = NativePhpSupportedVersions[ 0 ];
/**
* We don't have an opportunity to retrieve PHP version from Jetpack connected sites,
@@ -18,6 +15,16 @@ export const PressablePHPVersion = '8.5' as const;
export const SupportedPHPVersionsList: string[] = [ ...SupportedPHPVersions ];
export type SupportedPHPVersion = ( typeof SupportedPHPVersions )[ number ];
+export type NativePhpSupportedVersion = ( typeof NativePhpSupportedVersions )[ number ];
+
+function getPhpVersionScore( version: string ): number | undefined {
+ const match = version.match( /^(\d+)\.(\d+)$/ );
+ if ( ! match ) {
+ return undefined;
+ }
+
+ return Number( match[ 1 ] ) * 100 + Number( match[ 2 ] );
+}
export function isSupportedPHPVersion(
version: string | undefined
@@ -25,8 +32,36 @@ export function isSupportedPHPVersion(
return SupportedPHPVersions.includes( version as SupportedPHPVersion );
}
+export function getSupportedPHPVersionsForRuntime(
+ runtime: SiteRuntime
+): readonly SupportedPHPVersion[] {
+ return runtime === SITE_RUNTIME_NATIVE_PHP ? NativePhpSupportedVersions : SupportedPHPVersions;
+}
+
+export function getClosestNativePhpVersion(
+ version: string
+): NativePhpSupportedVersion | undefined {
+ const targetScore = getPhpVersionScore( version );
+ if ( targetScore === undefined ) {
+ return undefined;
+ }
+
+ return NativePhpSupportedVersions.reduce< NativePhpSupportedVersion >( ( closest, candidate ) => {
+ const closestDistance = Math.abs( getPhpVersionScore( closest )! - targetScore );
+ const candidateDistance = Math.abs( getPhpVersionScore( candidate )! - targetScore );
+ return candidateDistance < closestDistance ? candidate : closest;
+ }, NativePhpSupportedVersions[ 0 ] );
+}
+
/**
* The recommended PHP version for new sites.
* This replaces RecommendedPHPVersion from @wp-playground/common.
*/
export const RecommendedPHPVersion: SupportedPHPVersion = '8.4';
+
+export function getRecommendedPHPVersionForRuntime( runtime: SiteRuntime ): SupportedPHPVersion {
+ const supportedVersions = getSupportedPHPVersionsForRuntime( runtime );
+ return supportedVersions.includes( RecommendedPHPVersion )
+ ? RecommendedPHPVersion
+ : supportedVersions[ 0 ] ?? RecommendedPHPVersion;
+}