From fcbc5db03aca614aa85a0737a392da6f7d5db274 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 22 Aug 2025 15:11:30 +0200 Subject: [PATCH 1/8] Safely auto-configure "wp-config.php" when some config is missing --- .../src/auto-configure-wp-config.php | 120 ++++++++++++++++++ .../wordpress/src/rewrite-wp-config.ts | 30 +++++ 2 files changed, 150 insertions(+) create mode 100644 packages/playground/wordpress/src/auto-configure-wp-config.php diff --git a/packages/playground/wordpress/src/auto-configure-wp-config.php b/packages/playground/wordpress/src/auto-configure-wp-config.php new file mode 100644 index 0000000000..149641591b --- /dev/null +++ b/packages/playground/wordpress/src/auto-configure-wp-config.php @@ -0,0 +1,120 @@ + $token ) { + if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { + unset( $non_whitespace_tokens[ $i ] ); + } + } + + // Then, inject what's missing. + $code = ''; + foreach ( $constants as $name => $value ) { + if ( ! constant_defined( $non_whitespace_tokens, $name ) ) { + $name = var_export( $name, true ); + $value = var_export( $value, true ); + $code = "if ( ! defined( $name ) ) {\n\tdefine( $name, $value );\n}\n"; + } + } + + // If there's something to inject, add the prefix and suffix. + if ( '' !== $code ) { + $code = $prefix . $code . $suffix; + } + + // Inject the code into the tokens. + $anchor = get_anchor_token_index( $non_whitespace_tokens ); + array_splice( $tokens, $anchor, 0, $code ); + $output = ''; + foreach ( $tokens as $token ) { + $output .= is_array( $token ) ? $token[1] : $token; + } + return $output; +} + +function constant_defined( $non_whitespace_tokens, $name ) { + foreach ( $non_whitespace_tokens as $token ) { + if ( is_array( $token ) && $token[0] === T_STRING && 'define' === strtolower( $token[1] ) ) { + if ( '(' === next( $non_whitespace_tokens ) ) { + $next = next( $non_whitespace_tokens ); + if ( is_array( $next ) && $name === eval( "return $next[1];" ) ) { + return true; + } + } + } + } +} + +function get_anchor_token_index( $non_whitespace_tokens ) { + // First try to find the "/** Sets up WordPress vars and included files. */" comment. + $anchor = find_first_token_index( + $non_whitespace_tokens, + T_DOC_COMMENT, + "Sets up WordPress vars and included files." + ); + + // If not found, try "require_once ABSPATH . 'wp-settings.php';". + if ( null === $anchor ) { + $require_anchor = find_first_token_index( $non_whitespace_tokens, T_REQUIRE_ONCE ); + if ( null !== $require_anchor ) { + $abspath = $non_whitespace_tokens[$require_anchor + 2] ?? null; + $path = $non_whitespace_tokens[$require_anchor + 6] ?? null; + if ( + ( is_array( $abspath ) && $abspath[1] === 'ABSPATH' ) + && ( is_array( $path ) && $path[1] === "'wp-settings.php'" ) + ) { + $anchor = $require_anchor; + } + } + } + + return $anchor; +} + +function find_first_token_index( $tokens, $type, $search = null ) { + foreach ( $tokens as $i => $token ) { + if ( ! is_array( $token ) ) { + continue; + } + if ( $type !== $token[0] ) { + continue; + } + if ( null === $search || false !== strpos( $token[1], $search ) ) { + return $i; + } + } + return null; +} diff --git a/packages/playground/wordpress/src/rewrite-wp-config.ts b/packages/playground/wordpress/src/rewrite-wp-config.ts index f025e0d028..f6f664320b 100644 --- a/packages/playground/wordpress/src/rewrite-wp-config.ts +++ b/packages/playground/wordpress/src/rewrite-wp-config.ts @@ -84,3 +84,33 @@ export async function ensureWpConfig( await defineWpConfigConstants(php, wpConfigPath, defaults, 'skip'); } + +/** + * Auto-configures a WordPress "wp-config.php" file. + * + * @param php The PHP instance. + * @param wpConfigPath The path to the "wp-config.php" file. + * @param constants The constants defaults to use. + */ +export async function autoConfigureWpConfig( + php: UniversalPHP, + wpConfigPath: string, + constants: Record +): Promise { + const js = phpVars({ wpConfigPath, constants }); + const result = await php.run({ + code: ` + ${autoConfigureWpConfig} + $wp_config_path = ${js.wpConfigPath}; + $wp_config = file_get_contents( $wp_config_path ); + $new_wp_config = auto_configure_wp_config( $wp_config, ${js.constants} ); + $return_value = file_put_contents($wp_config_path, $new_wp_config); + ob_clean(); + echo false === $return_value ? '0' : '1'; + ob_end_flush(); + `, + }); + if (result.text !== '1') { + throw new Error('Failed to auto-configure wp-config.php.'); + } +} From 1569d23a0e89e8a50f6a6b971684b67464dbd26b Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Thu, 18 Sep 2025 13:43:29 +0200 Subject: [PATCH 2/8] Implement a token-based WP config transformer --- .../src/test/wp-config-transformer.spec.ts | 600 ++++++++++++++++++ .../wordpress/src/wp-config-transformer.php | 455 +++++++++++++ 2 files changed, 1055 insertions(+) create mode 100644 packages/playground/wordpress/src/test/wp-config-transformer.spec.ts create mode 100644 packages/playground/wordpress/src/wp-config-transformer.php diff --git a/packages/playground/wordpress/src/test/wp-config-transformer.spec.ts b/packages/playground/wordpress/src/test/wp-config-transformer.spec.ts new file mode 100644 index 0000000000..62ce3bd95f --- /dev/null +++ b/packages/playground/wordpress/src/test/wp-config-transformer.spec.ts @@ -0,0 +1,600 @@ +import { PHP } from '@php-wasm/universal'; +import { RecommendedPHPVersion } from '@wp-playground/common'; +// eslint-disable-next-line @nx/enforce-module-boundaries -- ignore test-related interdependencies so we can test. +import { loadNodeRuntime } from '@php-wasm/node'; +import { phpVars } from '@php-wasm/util'; + +import wpConfigTransformer from '../wp-config-transformer.php?raw'; + +const wpConfigSample = ` + { + let php: PHP; + + beforeEach(async () => { + php = new PHP(await loadNodeRuntime(RecommendedPHPVersion)); + }); + + it('should dectect whether a constant is defined', async () => { + const js = phpVars({ wpConfig: wpConfigSample }); + const phpCode = `${wpConfigTransformer} + $transformer = new WP_Config_Transformer(${js.wpConfig}); + echo json_encode([ + 'DB_NAME' => $transformer->constant_exists( 'DB_NAME' ), + 'DB_USER' => $transformer->constant_exists( 'DB_USER' ), + 'DB_PASSWORD' => $transformer->constant_exists( 'DB_PASSWORD' ), + 'DB_HOST' => $transformer->constant_exists( 'DB_HOST' ), + 'DB_CHARSET' => $transformer->constant_exists( 'DB_CHARSET' ), + 'DB_COLLATE' => $transformer->constant_exists( 'DB_COLLATE' ), + 'WP_DEBUG' => $transformer->constant_exists( 'WP_DEBUG' ), + 'AUTH_KEY' => $transformer->constant_exists( 'AUTH_KEY' ), + 'SECURE_AUTH_KEY' => $transformer->constant_exists( 'SECURE_AUTH_KEY' ), + 'LOGGED_IN_KEY' => $transformer->constant_exists( 'LOGGED_IN_KEY' ), + 'NONCE_KEY' => $transformer->constant_exists( 'NONCE_KEY' ), + 'AUTH_SALT' => $transformer->constant_exists( 'AUTH_SALT' ), + 'SECURE_AUTH_SALT' => $transformer->constant_exists( 'SECURE_AUTH_SALT' ), + 'LOGGED_IN_SALT' => $transformer->constant_exists( 'LOGGED_IN_SALT' ), + 'NONCE_SALT' => $transformer->constant_exists( 'NONCE_SALT' ), + 'ABSPATH' => $transformer->constant_exists( 'ABSPATH' ), + 'WP_MEMORY_LIMIT' => $transformer->constant_exists( 'WP_MEMORY_LIMIT' ), + 'NEW_CONSTANT_1' => $transformer->constant_exists( 'NEW_CONSTANT_1' ), + 'NEW_CONSTANT_2' => $transformer->constant_exists( 'NEW_CONSTANT_2' ), + ]); + `; + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.json).toEqual({ + DB_NAME: true, + DB_USER: true, + DB_PASSWORD: true, + DB_HOST: true, + DB_CHARSET: true, + DB_COLLATE: true, + WP_DEBUG: true, + AUTH_KEY: true, + SECURE_AUTH_KEY: true, + LOGGED_IN_KEY: true, + NONCE_KEY: true, + AUTH_SALT: true, + SECURE_AUTH_SALT: true, + LOGGED_IN_SALT: true, + NONCE_SALT: true, + ABSPATH: true, + WP_MEMORY_LIMIT: false, + NEW_CONSTANT_1: false, + NEW_CONSTANT_2: false, + }); + }); + + it('should dectect whether anew constant is defined', async () => { + const js = phpVars({ wpConfig: wpConfigSample }); + const phpCode = `${wpConfigTransformer} + $transformer = new WP_Config_Transformer(${js.wpConfig}); + $transformer->define_constant( 'NEW_CONSTANT_1', 'new-constant-1' ); + $transformer->define_constant( 'NEW_CONSTANT_2', 'new-constant-2' ); + echo json_encode([ + 'DB_NAME' => $transformer->constant_exists( 'DB_NAME' ), + 'NEW_CONSTANT_1' => $transformer->constant_exists( 'NEW_CONSTANT_1' ), + 'NEW_CONSTANT_2' => $transformer->constant_exists( 'NEW_CONSTANT_2' ), + 'NEW_CONSTANT_3' => $transformer->constant_exists( 'NEW_CONSTANT_3' ), + ]); + `; + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.json).toEqual({ + DB_NAME: true, + NEW_CONSTANT_1: true, + NEW_CONSTANT_2: true, + NEW_CONSTANT_3: false, + }); + }); + + it('should not modify the wp-config.php file when no changes are made', async () => { + const js = phpVars({ wpConfig: wpConfigSample }); + const phpCode = `${wpConfigTransformer} + + $transformer = new WP_Config_Transformer(${js.wpConfig}); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(wpConfigSample); + }); + + it('should update an existing constant', async () => { + const wpConfig = `define_constant( 'WP_DEBUG', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `define_constant( 'WP_DEBUG', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `define_constant( 'WP_DEBUG', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + console.log(response.errors); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `define_constant( 'NEW_CONSTANT', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `define_constant( 'NEW_CONSTANT', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `define_constant( 'NEW_CONSTANT', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `define_constant( 'NEW_CONSTANT', true ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `inject_code_block( '/* INJECTED CODE */' ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `inject_code_block( '/* INJECTED CODE */' ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `inject_code_block( '/* INJECTED CODE */' ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const wpConfig = `remove_code_block( 'START', 'END' ); + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + expect(response.text).toEqual(` { + const js = phpVars({ wpConfig: wpConfigSample }); + const phpCode = `${wpConfigTransformer} + + $transformer = new WP_Config_Transformer(${js.wpConfig}); + $transformer->inject_code_block( "${codeSample}" ); + $transformer->define_constant( 'WP_DEBUG', true ); + $transformer->define_constant( 'DB_NAME', 'wordpress-database' ); + $transformer->define_constant( 'DB_COLLATE', 'utf8mb4_0900_ai_ci' ); + $transformer->define_constant( 'WP_MEMORY_LIMIT', '256M' ); + $transformer->define_constant( 'AUTOMATIC_UPDATER_DISABLED', false ); + $transformer->define_constant( 'AUTOMATIC_UPDATER_DISABLED', true ); // override previously set value + if ( ! $transformer->constant_exists( 'WP_DEBUG' ) ) { + throw new Exception( 'WP_DEBUG is not defined' ); + } + if ( ! $transformer->constant_exists( 'WP_MEMORY_LIMIT' ) ) { + throw new Exception( 'WP_MEMORY_LIMIT is not defined' ); + } + if ( ! $transformer->constant_exists( 'AUTOMATIC_UPDATER_DISABLED' ) ) { + throw new Exception( 'AUTOMATIC_UPDATER_DISABLED is not defined' ); + } + if ( $transformer->constant_exists( 'UNKNOWN_CONSTANT' ) ) { + throw new Exception( 'UNKNOWN_CONSTANT is defined' ); + } + echo $transformer->to_string(); + `; + + const response = await php.run({ code: phpCode }); + expect(response.errors).toEqual(''); + + expect(response.text).toContain(` +// ** Database settings - You can get this info from your web host ** // +/** The name of the database for WordPress */ +define( 'DB_NAME', 'wordpress-database' ); + +/** Database username */ +define( 'DB_USER', 'username_here' ); +`); + + expect(response.text).toContain(` +/** The database collate type. Don't change this if in doubt. */ +define( 'DB_COLLATE', 'utf8mb4_0900_ai_ci' ); + +/**#@+ + * Authentication unique keys and salts. +`); + + expect(response.text).toContain(` +/** + * For developers: WordPress debugging mode. + * + * Change this to true to enable the display of notices during development. + * It is strongly recommended that plugin and theme developers use WP_DEBUG + * in their development environments. + * + * For information on other constants that can be used for debugging, + * visit the documentation. + * + * @link https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/ + */ +define( 'WP_DEBUG', true ); + +/* Add any custom values between this line and the "stop editing" line. */ +`); + + expect(response.text).toContain(` +/* Add any custom values between this line and the "stop editing" line. */ + + + +define( 'WP_MEMORY_LIMIT', '256M' ); +define( 'AUTOMATIC_UPDATER_DISABLED', true ); +/* That's all, stop editing! Happy publishing. */ +`); + + expect(response.text).toContain(` +/** Absolute path to the WordPress directory. */ +if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', __DIR__ . '/' ); +} + +/* + * BEGIN: Added by WordPress Playground. + * + * This code was injected by WordPress Playground. + */ +if ( ! defined( 'DB_NAME' ) ) { + define( 'DB_NAME', 'wordpress-database' ); +} +if ( ! defined( 'DB_USER' ) ) { + define( 'DB_USER', 'wordpress' ); +} +/* END: Added by WordPress Playground. */ + +/** Sets up WordPress vars and included files. */ +require_once ABSPATH . 'wp-settings.php'; +`); + }); +}); diff --git a/packages/playground/wordpress/src/wp-config-transformer.php b/packages/playground/wordpress/src/wp-config-transformer.php new file mode 100644 index 0000000000..e63df242e0 --- /dev/null +++ b/packages/playground/wordpress/src/wp-config-transformer.php @@ -0,0 +1,455 @@ + + */ + private $tokens; + + /** + * Constructor. + * + * @param string $content The contents of the wp-config.php file. + */ + public function __construct( string $content ) { + $this->tokens = token_get_all( $content ); + + // Check if the file is a valid PHP file. + $is_valid_php_file = false; + foreach ( $this->tokens as $token ) { + if ( is_array( $token ) && T_OPEN_TAG === $token[0] ) { + $is_valid_php_file = true; + break; + } + } + if ( ! $is_valid_php_file ) { + throw new Exception( "The 'wp-config.php' file is not a valid PHP file." ); + } + } + + /** + * Create a new config transformer instance from a file. + * + * @param string $path The path to the wp-config.php file. + * @return self The new config transformer instance. + */ + public static function from_file( string $path ): self { + $content = file_get_contents( $path ); + if ( ! is_file( $path ) ) { + throw new Exception( sprintf( "The '%s' file does not exist.", $path ) ); + } + return new self( $content ); + } + + /** + * Get the transformed wp-config.php file contents. + * + * @return string The transformed wp-config.php file contents. + */ + public function to_string(): string { + $output = ''; + foreach ( $this->tokens as $token ) { + $output .= is_array( $token ) ? $token[1] : $token; + } + return $output; + } + + /** + * Save the transformed wp-config.php file contents to a file. + * + * @param string $path The path to the wp-config.php file. + */ + public function to_file( string $path ): void { + $result = file_put_contents( $path, $this->to_string(), LOCK_EX ); + if ( false === $result ) { + throw new Exception( sprintf( "Failed to write to the '%s' file.", $path ) ); + } + } + + /** + * Check if a constant is defined in the wp-config.php file. + * + * @param string $name The name of the constant. + * @return bool True if the constant is defined, false otherwise. + */ + public function constant_exists( string $name ): bool { + foreach ( $this->tokens as $i => $token ) { + $is_string_token = is_array( $token ) && T_STRING === $token[0]; + if ( $is_string_token && 'define' === strtolower( $token[1] ) ) { + $args = $this->collect_function_call_argument_locations( $i ); + $const_name = $this->evaluate_constant_name( + array_slice( $this->tokens, $args[0][0], $args[0][1] ) + ); + if ( $name === $const_name ) { + return true; + } + } + } + return false; + } + + /** + * Define a constant in the wp-config.php file. + * + * @param string $name The name of the constant. + * @param mixed $value The value of the constant. + */ + public function define_constant( string $name, $value ): void { + // Tokenize the new constant value for insertion in the tokens array. + $definition_tokens = token_get_all( + sprintf( + "tokens as $i => $token ) { + $is_string_token = is_array( $token ) && T_STRING === $token[0]; + if ( $is_string_token && 'define' === strtolower( $token[1] ) ) { + $args = $this->collect_function_call_argument_locations( $i ); + $const_name = $this->evaluate_constant_name( + array_slice( $this->tokens, $args[0][0], $args[0][1] ) + ); + + if ( $name === $const_name ) { + list ( $value_start, $value_length ) = $args[1]; + array_splice( $this->tokens, $value_start, $value_length, $value_tokens ); + $constant_updated = true; + } + } + } + + // If it's a new constant, inject it at the anchor location. + if ( ! $constant_updated ) { + $anchor = $this->get_new_constant_location(); + array_splice( $this->tokens, $anchor, 0, $define_tokens ); + + /* + * Ensure at least one newline (one "\n") before the new constant. + * This must be done after inserting the constant definition in order + * to avoid shifting the anchor location when a new token is inserted. + */ + $this->ensure_newlines( $anchor - 1, 1 ); + } + } + + /** + * Define multiple constants in the wp-config.php file. + * + * @param array $constants An array of name-value pairs of constants to define. + */ + public function define_constants( array $constants ): void { + foreach ( $constants as $name => $value ) { + $this->define_constant( $name, $value ); + } + } + + /** + * Inject code block into the wp-config.php file. + * + * @param string $code The code to inject. + */ + public function inject_code_block( string $code ): void { + // Tokenize the injected code for insertion in the token array. + $tokens = token_get_all( sprintf( 'get_injected_code_location(); + array_splice( $this->tokens, $anchor, 0, $code_tokens ); + + /* + * Ensure empty line before and after the code block (at least two "\n"). + * This must be done after inserting the injected code, and the location + * AFTER must be updated prior to the location BEFORE, in order to avoid + * shifting the anchor location when a new token is inserted. + */ + $this->ensure_newlines( $anchor + count( $code_tokens ), 2 ); + $this->ensure_newlines( $anchor - 1, 2 ); + } + + /** + * Remove code block defined by two comment fragments from the wp-config.php file. + * + * @param string $from_comment_fragment A comment fragment from which to remove the code. + * @param string $to_comment_fragment A comment fragment to which to remove the code. + */ + public function remove_code_block( string $from_comment_fragment, string $to_comment_fragment ): void { + $start = $this->find_first_token_location( T_COMMENT, $from_comment_fragment ); + $end = $this->find_first_token_location( T_COMMENT, $to_comment_fragment ); + if ( null === $start || null === $end ) { + return; + } + + // Remove the code, including the comment fragments. + array_splice( $this->tokens, $start, $end - $start + 1 ); + + // If previous and next tokens are whitespace, merge them. + $prev = $this->tokens[ $start - 1 ]; + $next = $this->tokens[ $start ] ?? null; + if ( + is_array( $prev ) && T_WHITESPACE === $prev[0] + && is_array( $next ) && T_WHITESPACE === $next[0] + ) { + $this->tokens[ $start - 1 ][1] = $prev[1] . $next[1]; + array_splice( $this->tokens, $start, 1 ); + } + + // Remove up to two empty lines (before & after), keeping at least one. + $token = $this->tokens[ $start - 1 ]; + if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { + $newlines = substr_count( $token[1], "\n" ); + if ( $newlines > 2 ) { + $limit = min( $newlines - 2, 4 ); + $value = $token[1]; + for ( $i = 0; $limit > 0; $i += 1 ) { + if ( "\n" === $value[ $i ] ) { + $value = substr_replace( $value, '', $i, 1 ); + $limit -= 1; + } + } + $this->tokens[ $start - 1 ][1] = $value; + } + } + } + + /** + * Parse arguments of a function call and collect their locations. + * + * @param int $start The location of the first token of the function call. + * @return array> The arguments of the function call. + */ + private function collect_function_call_argument_locations( int $start ): array { + // Find location of the opening parenthesis after the function name. + $i = $start; + while ( '(' !== $this->tokens[ $i ] ) { + $i += 1; + } + $i += 1; + + // Collect all function call argument locations. + $args = array(); + $arg_start = $this->skip_whitespace_and_comments( $i ); + $parens_level = 0; + for ( $i = $arg_start; $i < count( $this->tokens ); $i += 1 ) { + // Skip whitespace and comments, but preserve the index of the last + // non-whitespace token to calculate the exact argument boundaries. + $prev_i = $i; + $i = $this->skip_whitespace_and_comments( $i ); + $token = $this->tokens[ $i ]; + + if ( 0 === $parens_level && ( ',' === $token || ')' === $token ) ) { + $args[] = array( $arg_start, $prev_i - $arg_start ); + if ( ',' === $token ) { + // Start of the next argument. + $arg_start = $this->skip_whitespace_and_comments( $i + 1 ); + $i = $arg_start; + } else { + // End of the argument list. + break; + } + } elseif ( '(' === $token || '[' === $token || '{' === $token ) { + $parens_level += 1; + } elseif ( ')' === $token || ']' === $token || '}' === $token ) { + $parens_level -= 1; + } + } + return $args; + } + + /** + * Evaluate the constant name value from its tokens. + * + * @param array $name_tokens The tokens containing the constant name. + * @return string|null The evaluated constant name. + */ + private function evaluate_constant_name( array $name_tokens ): ?string { + // Decide whether the array represents a constant name or an expression. + $name_token = null; + for ( $i = 0; $i < count( $name_tokens ); $i += 1 ) { + $i = $this->skip_whitespace_and_comments( $i ); + $token = $name_tokens[ $i ]; + if ( is_array( $token ) ) { + if ( T_STRING === $token[0] || T_CONSTANT_ENCAPSED_STRING === $token[0] ) { + $name_token = $token; + } else { + return null; + } + } elseif ( '(' !== $token && ')' !== $token ) { + return null; + } + } + + // Get the constant name value. + return eval( 'return ' . $name_token[1] . ';' ); + } + + /** + * Skip whitespace and comment tokens and return the location of the first + * non-whitespace and non-comment token after the specified start location. + * + * @param int $start The start location in the token array. + * @return int The location of the first non-whitespace and non-comment token. + */ + private function skip_whitespace_and_comments( int $start ): int { + for ( $i = $start; $i < count( $this->tokens ); $i += 1 ) { + $token = $this->tokens[ $i ]; + if ( + is_array( $token ) + && ( T_WHITESPACE === $token[0] || T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) + ) { + continue; + } + break; + } + return $i; + } + + /** + * Ensure minimum number of newlines are present at the given index. + * + * @param int $index The index of the token to ensure newlines. + * @param int $count The number of newlines that should be present. + */ + private function ensure_newlines( int $index, int $count ): void { + $token = $this->tokens[ $index ] ?? null; + if ( is_array( $token ) && ( T_WHITESPACE === $token[0] || T_OPEN_TAG === $token[0] ) ) { + $newlines = substr_count( $token[1], "\n" ); + if ( $newlines < $count ) { + $this->tokens[ $index ][1] .= str_repeat( "\n", $count - $newlines ); + } + } else { + $new_token = array( T_WHITESPACE, str_repeat( "\n", $count ) ); + array_splice( $this->tokens, $index, 0, array( $new_token ) ); + } + } + + /** + * Get the location to inject new constant definitions in the token array. + * + * @return int The location for new constant definitions in the token array. + */ + private function get_new_constant_location(): int { + // First try to find the "That's all, stop editing!" comment. + $anchor = $this->find_first_token_location( T_COMMENT, "That's all, stop editing!" ); + if ( null !== $anchor ) { + return $anchor; + } + + // If not found, try the "Absolute path to the WordPress directory." doc comment. + $anchor = $this->find_first_token_location( T_DOC_COMMENT, 'Absolute path to the WordPress directory.' ); + if ( null !== $anchor ) { + return $anchor; + } + + // If not found, try the "Sets up WordPress vars and included files." doc comment. + $anchor = $this->find_first_token_location( T_DOC_COMMENT, 'Sets up WordPress vars and included files.' ); + if ( null !== $anchor ) { + return $anchor; + } + + // If not found, try "require_once ABSPATH . 'wp-settings.php';". + $anchor = $this->find_first_token_location( T_REQUIRE_ONCE ); + if ( null !== $anchor ) { + return $anchor; + } + + // If not found, fall back to the PHP opening tag. + $open_tag_anchor = $this->find_first_token_location( T_OPEN_TAG ); + if ( null !== $open_tag_anchor ) { + return $open_tag_anchor + 1; + } + + // If we still don't have an anchor, the file is not a valid PHP file. + throw new Exception( "The 'wp-config.php' file is not a valid PHP file." ); + } + + /** + * Get the location to inject new code in the token array. + * + * @return int The location for injected code in the token array. + */ + private function get_injected_code_location(): int { + // First try to find the "/** Sets up WordPress vars and included files. */" comment. + $anchor = $this->find_first_token_location( T_DOC_COMMENT, 'Sets up WordPress vars and included files.' ); + if ( null !== $anchor ) { + return $anchor; + } + + // If not found, try "require_once ABSPATH . 'wp-settings.php';". + $anchor = $this->find_require_wp_settings_location(); + if ( null !== $anchor ) { + return $anchor; + } + + // If not found, fall back to the PHP opening tag. + $open_tag_anchor = $this->find_first_token_location( T_OPEN_TAG ); + if ( null !== $open_tag_anchor ) { + return $open_tag_anchor + 1; + } + + // If we still don't have an anchor, the file is not a valid PHP file. + throw new Exception( "The 'wp-config.php' file is not a valid PHP file." ); + } + + /** + * Find location of the "wp-settings.php" require statement in the token array. + * + * This method searches for the following statement: + * + * require_once ABSPATH . 'wp-settings.php'; + * + * @return int|null The location of the require statement. + */ + private function find_require_wp_settings_location(): ?int { + $require_anchor = $this->find_first_token_location( T_REQUIRE_ONCE ); + if ( null === $require_anchor ) { + return null; + } + + $abspath = $this->tokens[ $require_anchor + 2 ] ?? null; + $path = $this->tokens[ $require_anchor + 6 ] ?? null; + if ( + ( is_array( $abspath ) && 'ABSPATH' === $abspath[1] ) + && ( is_array( $path ) && "'wp-settings.php'" === $path[1] ) + ) { + return $require_anchor; + } + return null; + } + + /** + * Find location of the first token of a given type in the token array. + * + * @param int $type The type of the token. + * @param string $search Optional. A search string to match against the token content. + * @return int|null The location of the first token. + */ + private function find_first_token_location( int $type, ?string $search = null ): ?int { + foreach ( $this->tokens as $i => $token ) { + if ( is_array( $token ) && $type === $token[0] ) { + if ( null === $search || false !== strpos( $token[1], $search ) ) { + return $i; + } + } + } + return null; + } +} From cee8ba8b3fed45ac751a8bcd20eff250566bb4b5 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Sep 2025 12:26:40 +0200 Subject: [PATCH 3/8] Use WP config transformer in "defineWpConfigConstants" --- .../rewrite-wp-config-to-define-constants.php | 345 ------------------ .../wordpress/src/rewrite-wp-config.spec.ts | 104 +----- .../wordpress/src/rewrite-wp-config.ts | 23 +- 3 files changed, 22 insertions(+), 450 deletions(-) delete mode 100644 packages/playground/wordpress/src/rewrite-wp-config-to-define-constants.php diff --git a/packages/playground/wordpress/src/rewrite-wp-config-to-define-constants.php b/packages/playground/wordpress/src/rewrite-wp-config-to-define-constants.php deleted file mode 100644 index f5c469b881..0000000000 --- a/packages/playground/wordpress/src/rewrite-wp-config-to-define-constants.php +++ /dev/null @@ -1,345 +0,0 @@ - false, - * 'WP_DEBUG_LOG' => true, - * 'SAVEQUERIES' => true, - * 'NEW_CONSTANT' => "new constant", - * ]; - * - * ```php - * 2 ? 'WP_DEBUG' : 'FOO', true); - // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - $open_parenthesis = 0; - while ($token = array_pop($tokens)) { - $buffer[] = $token; - if ($token === "(" || $token === "[" || $token === "{") { - ++$open_parenthesis; - } elseif ($token === ")" || $token === "]" || $token === "}") { - --$open_parenthesis; - } elseif ($token === "," && $open_parenthesis === 0) { - break; - } - - // Don't capture the comma as a part of the constant name - $name_buffer[] = $token; - } - - // Capture everything until the closing parenthesis - // define("WP_DEBUG", true); - // ^^^^^^ - $open_parenthesis = 0; - $is_second_argument = true; - while ($token = array_pop($tokens)) { - $buffer[] = $token; - if ($token === ")" && $open_parenthesis === 0) { - // Final parenthesis of the define call. - break; - } else if ($token === "(" || $token === "[" || $token === "{") { - ++$open_parenthesis; - } elseif ($token === ")" || $token === "]" || $token === "}") { - --$open_parenthesis; - } elseif ($token === "," && $open_parenthesis === 0) { - // This define call has more than 2 arguments! The third one is the - // boolean value indicating $is_case_insensitive. Let's continue capturing - // to $third_arg_buffer. - $is_second_argument = false; - } - if ($is_second_argument) { - $value_buffer[] = $token; - } else { - $third_arg_buffer[] = $token; - } - } - - // Capture until the semicolon - // define("WP_DEBUG", true) ; - // ^^^ - while ($token = array_pop($tokens)) { - $buffer[] = $token; - if ($token === ";") { - break; - } - } - - // Decide whether $name_buffer is a constant name or an expression - $name_token = null; - $name_token_index = $token; - $name_is_literal = true; - foreach ($name_buffer as $k => $token) { - if (is_array($token)) { - if ($token[0] === T_WHITESPACE || $token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) { - continue; - } else if ($token[0] === T_STRING || $token[0] === T_CONSTANT_ENCAPSED_STRING) { - $name_token = $token; - $name_token_index = $k; - } else { - $name_is_literal = false; - break; - } - } else if ($token !== "(" && $token !== ")") { - $name_is_literal = false; - break; - } - } - - // We can't handle expressions as constant names. Let's wrap that define - // call in an if(!defined()) statement, just in case it collides with - // a constant name. - if (!$name_is_literal) { - // Ensure the defined expression is not already accounted for - foreach ($defined_expressions as $defined_expression) { - if ($defined_expression === stringify_tokens(skip_whitespace($name_buffer))) { - $output = array_merge($output, $buffer); - continue 2; - } - } - $output = array_merge( - $output, - ["if(!defined("], - $name_buffer, - [")) {\n "], - ['define('], - $name_buffer, - [','], - $value_buffer, - $third_arg_buffer, - [");"], - ["\n}\n"] - ); - continue; - } - - // Yay, we have a literal constant name in the buffer now. Let's - // get its value: - $name = eval('return ' . $name_token[1] . ';'); - - // If the constant name is not in the list of constants we're looking, - // we can ignore it. - if (!array_key_exists($name, $constants)) { - $output = array_merge($output, $buffer); - continue; - } - - // If "$when_already_defined" is set to 'skip', ignore the definition, and - // remove the constant from the list so it doesn't get added to the output. - if ('skip' === $when_already_defined) { - $output = array_merge($output, $buffer); - unset($constants[$name]); - continue; - } - - // We now have a define() call that defines a constant we're looking for. - // Let's rewrite its value to the one - $output = array_merge( - $output, - ['define('], - $name_buffer, - [','], - [var_export($constants[$name], true)], - $third_arg_buffer, - [");"] - ); - - // Remove the constant from the list so we can process any remaining - // constants later. - unset($constants[$name]); - } while (count($tokens)); - - // Add any constants that weren't found in the file - if (count($constants)) { - $prepend = [ - " $value) { - $prepend = array_merge( - $prepend, - [ - "define(", - var_export($name, true), - ',', - var_export($value, true), - ");\n" - ] - ); - } - $prepend[] = "?>"; - $output = array_merge( - $prepend, - $output - ); - } - - // Translate the output tokens back into a string - return stringify_tokens($output); -} - -function stringify_tokens($tokens) { - $output = ''; - foreach ($tokens as $token) { - if (is_array($token)) { - $output .= $token[1]; - } else { - $output .= $token; - } - } - return $output; -} - -function skip_whitespace($tokens) { - $output = []; - foreach ($tokens as $token) { - if (is_array($token) && ($token[0] === T_WHITESPACE || $token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT)) { - continue; - } - $output[] = $token; - } - return $output; -} diff --git a/packages/playground/wordpress/src/rewrite-wp-config.spec.ts b/packages/playground/wordpress/src/rewrite-wp-config.spec.ts index ba71e3f734..c375f76fa7 100644 --- a/packages/playground/wordpress/src/rewrite-wp-config.spec.ts +++ b/packages/playground/wordpress/src/rewrite-wp-config.spec.ts @@ -38,7 +38,7 @@ describe('defineWpConfigConstants', () => { }); const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toContain(`define('SITE_URL','http://test.url');`); + expect(rewritten).toContain(`define( 'SITE_URL', 'http://test.url' );`); const response = await php.run({ code: rewritten }); expect(response.errors).toHaveLength(0); @@ -104,94 +104,6 @@ describe('defineWpConfigConstants', () => { expect(response.text).toContain(`{"SITE_URL":"http:\\/\\/new.url"}`); }); - it('should take define() calls where the constant name cannot be statically inferred and wrap them in if(!defined()) checks', async () => { - php.writeFile( - wpConfigPath, - ` SITE_URL, - ]); - ` - ); - await defineWpConfigConstants(php, wpConfigPath, {}); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toContain(`if(!defined('SITE'.'_URL'))`); - expect(rewritten).toContain( - `define('SITE'.'_URL','http://initial.value');` - ); - - const response = await php.run({ code: rewritten }); - expect(response.errors).toHaveLength(0); - expect(response.json).toEqual({ - SITE_URL: 'http://initial.value', - }); - }); - - it('should not wrap the existing define() calls in if(!defined()) guards twice', async () => { - const phpCode = ` SITE_URL, - ]); - `; - php.writeFile(wpConfigPath, phpCode); - await defineWpConfigConstants(php, wpConfigPath, {}); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toEqual(phpCode); - }); - - it('should not wrap the existing define() calls in if(!defined()) guards twice, even if the existing guard is formatted differently than the define() call', async () => { - const phpCode = ` SITE_URL, - ]); - `; - php.writeFile(wpConfigPath, phpCode); - await defineWpConfigConstants(php, wpConfigPath, {}); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toEqual(phpCode); - }); - - it('should not create conflicts between pre-existing "dynamically" named constants and the newly defined ones', async () => { - php.writeFile( - wpConfigPath, - ` SITE_URL, - ]); - ` - ); - await defineWpConfigConstants(php, wpConfigPath, { - SITE_URL: 'http://new.url', - }); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toContain(`if(!defined('SITE'.'_URL'))`); - expect(rewritten).toContain( - `define('SITE'.'_URL','http://initial.value');` - ); - expect(rewritten).toContain(`define('SITE_URL','http://new.url');`); - - const response = await php.run({ code: rewritten }); - expect(response.errors).toHaveLength(0); - expect(response.json).toEqual({ - SITE_URL: 'http://new.url', - }); - }); - it('should handle a complex scenario', async () => { php.writeFile( wpConfigPath, @@ -204,7 +116,7 @@ define('WP_DEBUG', true); // Expression define(true ? 'WP_DEBUG_LOG' : 'WP_DEBUG_LOG', 123); -// Guarded expressions shouldn't be wrapped twice +// Guarded expression if(!defined(1 ? 'A' : 'B')) { define(1 ? 'A' : 'B', 0); } @@ -224,7 +136,6 @@ echo json_encode([ ); const constants = { WP_DEBUG: false, - WP_DEBUG_LOG: true, SAVEQUERIES: true, NEW_CONSTANT: 'new constant', }; @@ -233,7 +144,12 @@ echo json_encode([ const rewritten = php.readFileAsText(wpConfigPath); const response = await php.run({ code: rewritten }); expect(response.errors).toHaveLength(0); - expect(response.json).toEqual(constants); + expect(response.json).toEqual({ + WP_DEBUG: false, + SAVEQUERIES: true, + WP_DEBUG_LOG: 123, + NEW_CONSTANT: 'new constant', + }); }); }); @@ -248,7 +164,7 @@ describe('ensureWpConfig', () => { await ensureWpConfig(php, documentRoot); const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toContain(`define('DB_NAME','wordpress');`); + expect(rewritten).toContain(`define( 'DB_NAME', 'wordpress' );`); }); it('should define required constants when only other constants are defined', async () => { @@ -261,7 +177,7 @@ describe('ensureWpConfig', () => { await ensureWpConfig(php, documentRoot); const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toContain(`define('DB_NAME','wordpress');`); + expect(rewritten).toContain(`define( 'DB_NAME', 'wordpress' );`); }); it('should not define required constants, when they are already defined', async () => { diff --git a/packages/playground/wordpress/src/rewrite-wp-config.ts b/packages/playground/wordpress/src/rewrite-wp-config.ts index f6f664320b..ffafe3b580 100644 --- a/packages/playground/wordpress/src/rewrite-wp-config.ts +++ b/packages/playground/wordpress/src/rewrite-wp-config.ts @@ -2,7 +2,7 @@ import { joinPaths, phpVars } from '@php-wasm/util'; import type { UniversalPHP } from '@php-wasm/universal'; /* @ts-ignore */ -import rewriteWpConfigToDefineConstants from './rewrite-wp-config-to-define-constants.php?raw'; +import wpConfigTransformer from './wp-config-transformer.php?raw'; /** * Defines constants in a WordPress "wp-config.php" file. @@ -23,18 +23,19 @@ export async function defineWpConfigConstants( ): Promise { const js = phpVars({ wpConfigPath, constants, whenAlreadyDefined }); const result = await php.run({ - code: ` - ${rewriteWpConfigToDefineConstants} - $wp_config_path = ${js.wpConfigPath}; - $wp_config = file_get_contents($wp_config_path); - $new_wp_config = rewrite_wp_config_to_define_constants($wp_config, ${js.constants}, ${js.whenAlreadyDefined}); - $return_value = file_put_contents($wp_config_path, $new_wp_config); - ob_clean(); - echo false === $return_value ? '0' : '1'; - ob_end_flush(); + code: `${wpConfigTransformer} + $wp_config_path = ${js.wpConfigPath}; + $transformer = Wp_Config_Transformer::from_file($wp_config_path); + foreach(${js.constants} as $name => $value) { + if ('skip' === ${js.whenAlreadyDefined} && $transformer->constant_exists($name)) { + continue; + } + $transformer->define_constant($name, $value); + } + $transformer->to_file($wp_config_path); `, }); - if (result.text !== '1') { + if (result.errors.length > 0) { throw new Error('Failed to rewrite constants in wp-config.php.'); } } From ac1668e7889fa842b225bc2d213f21c3f03b7adc Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Sep 2025 15:04:10 +0200 Subject: [PATCH 4/8] Use WP config transformer to inject required WP config constants --- .../src/lib/steps/define-wp-config-consts.ts | 7 +- .../src/auto-configure-wp-config.php | 120 ----- .../wordpress/src/rewrite-wp-config.spec.ts | 438 +++++++++++++++--- .../wordpress/src/rewrite-wp-config.ts | 136 +++--- 4 files changed, 452 insertions(+), 249 deletions(-) delete mode 100644 packages/playground/wordpress/src/auto-configure-wp-config.php diff --git a/packages/playground/blueprints/src/lib/steps/define-wp-config-consts.ts b/packages/playground/blueprints/src/lib/steps/define-wp-config-consts.ts index 00bd388d5f..7952884533 100644 --- a/packages/playground/blueprints/src/lib/steps/define-wp-config-consts.ts +++ b/packages/playground/blueprints/src/lib/steps/define-wp-config-consts.ts @@ -62,12 +62,7 @@ export const defineWpConfigConsts: StepHandler< case 'rewrite-wp-config': { const documentRoot = await playground.documentRoot; const wpConfigPath = joinPaths(documentRoot, '/wp-config.php'); - await defineWpConfigConstants( - playground, - wpConfigPath, - consts, - 'rewrite' - ); + await defineWpConfigConstants(playground, wpConfigPath, consts); break; } default: diff --git a/packages/playground/wordpress/src/auto-configure-wp-config.php b/packages/playground/wordpress/src/auto-configure-wp-config.php deleted file mode 100644 index 149641591b..0000000000 --- a/packages/playground/wordpress/src/auto-configure-wp-config.php +++ /dev/null @@ -1,120 +0,0 @@ - $token ) { - if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { - unset( $non_whitespace_tokens[ $i ] ); - } - } - - // Then, inject what's missing. - $code = ''; - foreach ( $constants as $name => $value ) { - if ( ! constant_defined( $non_whitespace_tokens, $name ) ) { - $name = var_export( $name, true ); - $value = var_export( $value, true ); - $code = "if ( ! defined( $name ) ) {\n\tdefine( $name, $value );\n}\n"; - } - } - - // If there's something to inject, add the prefix and suffix. - if ( '' !== $code ) { - $code = $prefix . $code . $suffix; - } - - // Inject the code into the tokens. - $anchor = get_anchor_token_index( $non_whitespace_tokens ); - array_splice( $tokens, $anchor, 0, $code ); - $output = ''; - foreach ( $tokens as $token ) { - $output .= is_array( $token ) ? $token[1] : $token; - } - return $output; -} - -function constant_defined( $non_whitespace_tokens, $name ) { - foreach ( $non_whitespace_tokens as $token ) { - if ( is_array( $token ) && $token[0] === T_STRING && 'define' === strtolower( $token[1] ) ) { - if ( '(' === next( $non_whitespace_tokens ) ) { - $next = next( $non_whitespace_tokens ); - if ( is_array( $next ) && $name === eval( "return $next[1];" ) ) { - return true; - } - } - } - } -} - -function get_anchor_token_index( $non_whitespace_tokens ) { - // First try to find the "/** Sets up WordPress vars and included files. */" comment. - $anchor = find_first_token_index( - $non_whitespace_tokens, - T_DOC_COMMENT, - "Sets up WordPress vars and included files." - ); - - // If not found, try "require_once ABSPATH . 'wp-settings.php';". - if ( null === $anchor ) { - $require_anchor = find_first_token_index( $non_whitespace_tokens, T_REQUIRE_ONCE ); - if ( null !== $require_anchor ) { - $abspath = $non_whitespace_tokens[$require_anchor + 2] ?? null; - $path = $non_whitespace_tokens[$require_anchor + 6] ?? null; - if ( - ( is_array( $abspath ) && $abspath[1] === 'ABSPATH' ) - && ( is_array( $path ) && $path[1] === "'wp-settings.php'" ) - ) { - $anchor = $require_anchor; - } - } - } - - return $anchor; -} - -function find_first_token_index( $tokens, $type, $search = null ) { - foreach ( $tokens as $i => $token ) { - if ( ! is_array( $token ) ) { - continue; - } - if ( $type !== $token[0] ) { - continue; - } - if ( null === $search || false !== strpos( $token[1], $search ) ) { - return $i; - } - } - return null; -} diff --git a/packages/playground/wordpress/src/rewrite-wp-config.spec.ts b/packages/playground/wordpress/src/rewrite-wp-config.spec.ts index c375f76fa7..0582cd7c31 100644 --- a/packages/playground/wordpress/src/rewrite-wp-config.spec.ts +++ b/packages/playground/wordpress/src/rewrite-wp-config.spec.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { PHP } from '@php-wasm/universal'; import { defineWpConfigConstants, ensureWpConfig } from './rewrite-wp-config'; import { RecommendedPHPVersion } from '@wp-playground/common'; @@ -8,6 +10,358 @@ import { joinPaths } from '@php-wasm/util'; const documentRoot = '/tmp'; const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); +const wpConfigSample = ` + { + let php: PHP; + beforeEach(async () => { + php = new PHP(await loadNodeRuntime(RecommendedPHPVersion)); + }); + + it('should define required constants when they are missing', async () => { + php.writeFile( + wpConfigPath, + ` DB_NAME, + 'DB_USER' => DB_USER, + 'DB_PASSWORD' => DB_PASSWORD, + 'DB_HOST' => DB_HOST, + 'DB_CHARSET' => DB_CHARSET, + 'DB_COLLATE' => DB_COLLATE, + 'AUTH_KEY' => AUTH_KEY, + 'SECURE_AUTH_KEY' => SECURE_AUTH_KEY, + 'LOGGED_IN_KEY' => LOGGED_IN_KEY, + 'NONCE_KEY' => NONCE_KEY, + 'AUTH_SALT' => AUTH_SALT, + 'SECURE_AUTH_SALT' => SECURE_AUTH_SALT, + 'LOGGED_IN_SALT' => LOGGED_IN_SALT, + 'NONCE_SALT' => NONCE_SALT, + 'WP_DEBUG' => WP_DEBUG, + ]);` + ); + await ensureWpConfig(php, documentRoot); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).toContain( + `define( 'DB_NAME', 'database_name_here' );` + ); + expect(rewritten).toContain(`define( 'DB_USER', 'username_here' );`); + expect(rewritten).toContain( + `define( 'DB_PASSWORD', 'password_here' );` + ); + expect(rewritten).toContain(`define( 'DB_HOST', 'localhost' );`); + expect(rewritten).toContain(`define( 'DB_CHARSET', 'utf8' );`); + expect(rewritten).toContain(`define( 'DB_COLLATE', '' );`); + expect(rewritten).toContain( + `define( 'AUTH_KEY', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'SECURE_AUTH_KEY', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'LOGGED_IN_KEY', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'NONCE_KEY', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'AUTH_SALT', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'SECURE_AUTH_SALT', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'LOGGED_IN_SALT', 'put your unique phrase here' );` + ); + expect(rewritten).toContain( + `define( 'NONCE_SALT', 'put your unique phrase here' );` + ); + expect(rewritten).toContain(`define( 'WP_DEBUG', false );`); + + const response = await php.run({ code: rewritten }); + expect(response.json).toEqual({ + DB_NAME: 'database_name_here', + DB_USER: 'username_here', + DB_PASSWORD: 'password_here', + DB_HOST: 'localhost', + DB_CHARSET: 'utf8', + DB_COLLATE: '', + AUTH_KEY: 'put your unique phrase here', + SECURE_AUTH_KEY: 'put your unique phrase here', + LOGGED_IN_KEY: 'put your unique phrase here', + NONCE_KEY: 'put your unique phrase here', + AUTH_SALT: 'put your unique phrase here', + SECURE_AUTH_SALT: 'put your unique phrase here', + LOGGED_IN_SALT: 'put your unique phrase here', + NONCE_SALT: 'put your unique phrase here', + WP_DEBUG: false, + }); + }); + + it('should only define missing constants', async () => { + php.writeFile( + wpConfigPath, + ` DB_NAME, + 'DB_USER' => DB_USER, + 'DB_PASSWORD' => DB_PASSWORD, + 'DB_COLLATE' => DB_COLLATE, + 'AUTH_KEY' => AUTH_KEY, + 'AUTH_SALT' => AUTH_SALT, + 'NONCE_SALT' => NONCE_SALT, + 'WP_DEBUG' => WP_DEBUG, + ]);` + ); + await ensureWpConfig(php, documentRoot); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).toContain( + `define( 'DB_NAME', 'database_name_here' );` + ); + expect(rewritten).toContain(`define( 'DB_USER', 'unchanged' );`); + expect(rewritten).not.toContain( + `define( 'DB_USER', 'username_here' );` + ); + expect(rewritten).toContain(`define( 'AUTH_KEY', 'unchanged' );`); + expect(rewritten).not.toContain( + `define( 'AUTH_KEY', 'put your unique phrase here' );` + ); + expect(rewritten).toContain(`define( 'WP_DEBUG', true );`); + expect(rewritten).not.toContain(`define( 'WP_DEBUG', false );`); + + const response = await php.run({ code: rewritten }); + expect(response.json).toEqual({ + DB_NAME: 'database_name_here', + DB_USER: 'unchanged', + DB_PASSWORD: 'password_here', + DB_COLLATE: '', + AUTH_KEY: 'unchanged', + AUTH_SALT: 'put your unique phrase here', + NONCE_SALT: 'put your unique phrase here', + WP_DEBUG: true, + }); + }); + + it('should not define required constants when they are already defined conditionally', async () => { + php.writeFile( + wpConfigPath, + ` DB_NAME, + ]);` + ); + await ensureWpConfig(php, documentRoot); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).not.toContain( + `define( 'DB_NAME', 'database_name_here' );` + ); + + const response = await php.run({ code: rewritten }); + expect(response.json).toEqual({ + DB_NAME: 'defined-conditionally', + }); + }); + + it('should define missing constants well-formatted', async () => { + php.writeFile( + wpConfigPath, + wpConfigSample + .replace("'DB_NAME'", "'UNKNOWN_CONSTANT'") + .replace("'DB_USER'", "'UNKNOWN_CONSTANT'") + ); + + await ensureWpConfig(php, documentRoot); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).toContain(` +/** Absolute path to the WordPress directory. */ +if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', __DIR__ . '/' ); +} + +/* + * BEGIN: Added by WordPress Playground. + * + * WordPress Playground detected that some required WordPress configuration was + * missing in this file. Since the auto-configure mode was enabled, the missing + * configuration was automatically added with sensible default values below. + * + * It's safe to remove this block and define the missing configuration manually, + * or you can keep it, as it won't interfere with any existing configuration. + */ +if ( ! defined( 'DB_NAME' ) ) { + define( 'DB_NAME', 'database_name_here' ); +} +if ( ! defined( 'DB_USER' ) ) { + define( 'DB_USER', 'username_here' ); +} +/* END: Added by WordPress Playground. */ + +/** Sets up WordPress vars and included files. */ +require_once ABSPATH . 'wp-settings.php'; +`); + }); + + it("should remove the injected configuration when it's no longer needed", async () => { + php.writeFile( + wpConfigPath, + wpConfigSample.replace("'DB_NAME'", "'UNKNOWN_CONSTANT'") + ); + await ensureWpConfig(php, documentRoot); + + // Inject configuration with the "DB_NAME" default value. + const rewritten1 = php.readFileAsText(wpConfigPath); + expect(rewritten1).toContain(`BEGIN: Added by WordPress Playground.`); + expect(rewritten1).toContain(`END: Added by WordPress Playground.`); + expect(rewritten1).toContain( + `define( 'DB_NAME', 'database_name_here' );` + ); + + php.writeFile( + wpConfigPath, + php + .readFileAsText(wpConfigPath) + .replace("'UNKNOWN_CONSTANT'", "'DB_NAME'") + .replace("'database_name_here'", "'my_database_name'") + ); + await ensureWpConfig(php, documentRoot); + + // Remove the injected configuration. + const rewritten2 = php.readFileAsText(wpConfigPath); + expect(rewritten2).not.toContain( + `START: Added by WordPress Playground.` + ); + expect(rewritten2).not.toContain(`END: Added by WordPress Playground.`); + expect(rewritten2).not.toContain( + `define( 'DB_NAME', 'database_name_here' );` + ); + expect(rewritten2).toContain( + `define( 'DB_NAME', 'my_database_name' );` + ); + expect(rewritten2).toContain(` +/** Absolute path to the WordPress directory. */ +if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', __DIR__ . '/' ); +} + +/** Sets up WordPress vars and included files. */ +require_once ABSPATH . 'wp-settings.php'; +`); + }); +}); + describe('defineWpConfigConstants', () => { let php: PHP; beforeEach(async () => { @@ -24,14 +378,13 @@ describe('defineWpConfigConstants', () => { expect(response.text).toContain('Constant SITE_URL already defined'); }); - it('should prepend constants not already present in the PHP code', async () => { + it('should define a new constants', async () => { php.writeFile( wpConfigPath, ` SITE_URL, - ]); - ` + echo json_encode([ + "SITE_URL" => SITE_URL, + ]);` ); await defineWpConfigConstants(php, wpConfigPath, { SITE_URL: 'http://test.url', @@ -47,15 +400,14 @@ describe('defineWpConfigConstants', () => { }); }); - it('should rewrite the define() calls for the constants that are already defined in the PHP code', async () => { + it('should update an existing constant', async () => { php.writeFile( wpConfigPath, ` SITE_URL, - ]); - ` + define('SITE_URL','http://initial.value'); + echo json_encode([ + "SITE_URL" => SITE_URL, + ]);` ); await defineWpConfigConstants(php, wpConfigPath, { SITE_URL: 'http://new.url', @@ -78,11 +430,10 @@ describe('defineWpConfigConstants', () => { php.writeFile( wpConfigPath, ` SITE_URL, - ]); - ` + define('SITE_URL','http://initial.value',true); + echo json_encode([ + "SITE_URL" => SITE_URL, + ]);` ); await defineWpConfigConstants(php, wpConfigPath, { SITE_URL: 'http://new.url', @@ -152,58 +503,3 @@ echo json_encode([ }); }); }); - -describe('ensureWpConfig', () => { - let php: PHP; - beforeEach(async () => { - php = new PHP(await loadNodeRuntime(RecommendedPHPVersion)); - }); - - it('should define required constants if they are not defined', async () => { - php.writeFile(wpConfigPath, ' { - php.writeFile( - wpConfigPath, - ` { - php.writeFile( - wpConfigPath, - ` { - php.writeFile( - wpConfigPath, - `, - whenAlreadyDefined: 'rewrite' | 'skip' = 'rewrite' -): Promise { - const js = phpVars({ wpConfigPath, constants, whenAlreadyDefined }); - const result = await php.run({ - code: `${wpConfigTransformer} - $wp_config_path = ${js.wpConfigPath}; - $transformer = Wp_Config_Transformer::from_file($wp_config_path); - foreach(${js.constants} as $name => $value) { - if ('skip' === ${js.whenAlreadyDefined} && $transformer->constant_exists($name)) { - continue; - } - $transformer->define_constant($name, $value); - } - $transformer->to_file($wp_config_path); - `, - }); - if (result.errors.length > 0) { - throw new Error('Failed to rewrite constants in wp-config.php.'); - } -} - /** * Ensures that the "wp-config.php" file exists and required constants are defined. * @@ -53,8 +17,24 @@ export async function ensureWpConfig( documentRoot: string ): Promise { const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); + + // The default values for constants listed in "wp-config-sample.php". const defaults = { - DB_NAME: 'wordpress', + DB_NAME: 'database_name_here', + DB_USER: 'username_here', + DB_PASSWORD: 'password_here', + DB_HOST: 'localhost', + DB_CHARSET: 'utf8', + DB_COLLATE: '', + AUTH_KEY: 'put your unique phrase here', + SECURE_AUTH_KEY: 'put your unique phrase here', + LOGGED_IN_KEY: 'put your unique phrase here', + NONCE_KEY: 'put your unique phrase here', + AUTH_SALT: 'put your unique phrase here', + SECURE_AUTH_SALT: 'put your unique phrase here', + LOGGED_IN_SALT: 'put your unique phrase here', + NONCE_SALT: 'put your unique phrase here', + WP_DEBUG: false, }; /** @@ -83,35 +63,87 @@ export async function ensureWpConfig( ); } - await defineWpConfigConstants(php, wpConfigPath, defaults, 'skip'); + // Ensure required constants are defined. + const js = phpVars({ wpConfigPath, constants: defaults }); + const result = await php.run({ + code: `${wpConfigTransformer} +$wp_config_path = ${js.wpConfigPath}; +$transformer = Wp_Config_Transformer::from_file($wp_config_path); + +$prefix = <<remove_code_block( + 'BEGIN: Added by WordPress Playground.', + 'END: Added by WordPress Playground.' +); + +// Then, inject what's missing. +$code = ''; +foreach ( ${js.constants} as $name => $value ) { + if ( ! $transformer->constant_exists( $name ) ) { + $code .= sprintf( + "if ( ! defined( %s ) ) {\n\tdefine( %s, %s );\n}\n", + var_export( $name, true ), + var_export( $name, true ), + var_export( $value, true ) + ); + } +} + +// If some constants are missing, add the prefix and suffix and inject them. +if ( '' !== $code ) { + $code = $prefix . "\n" . $code . $suffix; + $transformer->inject_code_block($code); +} +$transformer->to_file($wp_config_path); +`, + }); + if (result.errors.length > 0) { + throw new Error('Failed to auto-configure wp-config.php.'); + } } /** - * Auto-configures a WordPress "wp-config.php" file. + * Defines constants in a WordPress "wp-config.php" file. + * + * This function modifies the "wp-config.php" file to define the given constants. + * + * 1. When a constant is already defined, the definition will be updated. + * 2. When a constant is not defined, it will be added in an appropriate + * location within the file (typically before the "stop editing" line). * * @param php The PHP instance. * @param wpConfigPath The path to the "wp-config.php" file. - * @param constants The constants defaults to use. + * @param constants The constants to define. */ -export async function autoConfigureWpConfig( +export async function defineWpConfigConstants( php: UniversalPHP, wpConfigPath: string, constants: Record ): Promise { const js = phpVars({ wpConfigPath, constants }); const result = await php.run({ - code: ` - ${autoConfigureWpConfig} - $wp_config_path = ${js.wpConfigPath}; - $wp_config = file_get_contents( $wp_config_path ); - $new_wp_config = auto_configure_wp_config( $wp_config, ${js.constants} ); - $return_value = file_put_contents($wp_config_path, $new_wp_config); - ob_clean(); - echo false === $return_value ? '0' : '1'; - ob_end_flush(); + code: `${wpConfigTransformer} + $wp_config_path = ${js.wpConfigPath}; + $transformer = Wp_Config_Transformer::from_file($wp_config_path); + $transformer->define_constants(${js.constants}); + $transformer->to_file($wp_config_path); `, }); - if (result.text !== '1') { - throw new Error('Failed to auto-configure wp-config.php.'); + if (result.errors.length > 0) { + throw new Error('Failed to rewrite constants in wp-config.php.'); } } From 846af18c25644d762956e35ea472a65c799a18be Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Sep 2025 15:27:43 +0200 Subject: [PATCH 5/8] Improve file naming and organization --- packages/playground/wordpress/src/boot.ts | 2 +- packages/playground/wordpress/src/index.ts | 2 +- .../wordpress/src/test/wp-config-sample.php | 102 ++++++++++++++++ .../src/test/wp-config-transformer.spec.ts | 111 ++---------------- .../wp-config.spec.ts} | 111 +----------------- .../{rewrite-wp-config.ts => wp-config.ts} | 0 6 files changed, 117 insertions(+), 211 deletions(-) create mode 100644 packages/playground/wordpress/src/test/wp-config-sample.php rename packages/playground/wordpress/src/{rewrite-wp-config.spec.ts => test/wp-config.spec.ts} (77%) rename packages/playground/wordpress/src/{rewrite-wp-config.ts => wp-config.ts} (100%) diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index e960486d7c..db787eea3e 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -23,7 +23,7 @@ import { } from '.'; import { basename, dirname, joinPaths } from '@php-wasm/util'; import { logger } from '@php-wasm/logger'; -import { ensureWpConfig } from './rewrite-wp-config'; +import { ensureWpConfig } from './wp-config'; export type PhpIniOptions = Record; export type Hook = (php: PHP) => void | Promise; diff --git a/packages/playground/wordpress/src/index.ts b/packages/playground/wordpress/src/index.ts index ed8cdf717c..7a834df4bf 100644 --- a/packages/playground/wordpress/src/index.ts +++ b/packages/playground/wordpress/src/index.ts @@ -10,7 +10,7 @@ export { getFileNotFoundActionForWordPress, } from './boot'; export type { PhpIniOptions, PHPInstanceCreatedHook } from './boot'; -export { defineWpConfigConstants, ensureWpConfig } from './rewrite-wp-config'; +export { defineWpConfigConstants, ensureWpConfig } from './wp-config'; export { getLoadedWordPressVersion } from './version-detect'; export * from './version-detect'; diff --git a/packages/playground/wordpress/src/test/wp-config-sample.php b/packages/playground/wordpress/src/test/wp-config-sample.php new file mode 100644 index 0000000000..7752dc9f44 --- /dev/null +++ b/packages/playground/wordpress/src/test/wp-config-sample.php @@ -0,0 +1,102 @@ + { let php: PHP; diff --git a/packages/playground/wordpress/src/rewrite-wp-config.ts b/packages/playground/wordpress/src/wp-config.ts similarity index 100% rename from packages/playground/wordpress/src/rewrite-wp-config.ts rename to packages/playground/wordpress/src/wp-config.ts From 3ff280e343d280e7f6ad42f915b92b31391de14e Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Sep 2025 16:33:55 +0200 Subject: [PATCH 6/8] Allow configuring default constants in WP config --- .../blueprints-v1/blueprints-v1-handler.ts | 1 + .../cli/src/blueprints-v1/worker-thread-v1.ts | 3 ++ .../cli/src/blueprints-v2/worker-thread-v2.ts | 13 ++++++- packages/playground/cli/src/run-cli.ts | 15 ++++++++ packages/playground/wordpress/src/boot.ts | 10 +++++- .../wordpress/src/test/wp-config.spec.ts | 34 +++++++++++++++++++ .../playground/wordpress/src/wp-config.ts | 9 ++++- 7 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts index 36a83a5eff..c9891ff6fe 100644 --- a/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts +++ b/packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts @@ -142,6 +142,7 @@ export class BlueprintsV1Handler { mountsBeforeWpInstall, mountsAfterWpInstall, wordPressZip: wordPressZip && (await wordPressZip!.arrayBuffer()), + wpConfigDefaultConstants: this.args.wpConfigDefaultConstants, sqliteIntegrationPluginZip: await sqliteIntegrationPluginZip?.arrayBuffer(), firstProcessId: 0, diff --git a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts index f6cca4c78a..eff9634b9d 100644 --- a/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts +++ b/packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts @@ -50,6 +50,7 @@ export type WorkerBootOptions = { export type PrimaryWorkerBootOptions = WorkerBootOptions & { wpVersion?: string; wordPressZip?: ArrayBuffer; + wpConfigDefaultConstants?: Record; sqliteIntegrationPluginZip?: ArrayBuffer; dataSqlPath?: string; }; @@ -129,6 +130,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { mountsAfterWpInstall, phpVersion: php = RecommendedPHPVersion, wordPressZip, + wpConfigDefaultConstants, sqliteIntegrationPluginZip, firstProcessId, processIdSpaceLength, @@ -182,6 +184,7 @@ export class PlaygroundCliBlueprintV1Worker extends PHPWorker { wordPressZip !== undefined ? new File([wordPressZip], 'wordpress.zip') : undefined, + wpConfigDefaultConstants, sqliteIntegrationPluginZip: sqliteIntegrationPluginZip !== undefined ? new File( diff --git a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts index 8c6e0a3db7..6ff2902702 100644 --- a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts +++ b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts @@ -27,7 +27,7 @@ import { type ParsedBlueprintV2String, type RawBlueprintV2Data, } from '@wp-playground/blueprints'; -import { bootRequestHandler } from '@wp-playground/wordpress'; +import { bootRequestHandler, ensureWpConfig } from '@wp-playground/wordpress'; import { existsSync } from 'fs'; import path from 'path'; import { rootCertificates } from 'tls'; @@ -250,6 +250,17 @@ export class PlaygroundCliBlueprintV2Worker extends PHPWorker { return; } + /* + * Add required constants to "wp-config.php" if they are not already defined. + * This is needed, because some WordPress backups and exports may not include + * definitions for some of the necessary constants. + */ + await ensureWpConfig( + primaryPhp, + primaryPhp.requestHandler!.documentRoot, + args.wpConfigDefaultConstants + ); + await this.runBlueprintV2(args); } diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index 5d9f904b61..1098f0d93a 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -153,6 +153,20 @@ export async function parseOptionsAndRunCLI() { type: 'boolean', default: false, }) + .option('wp-config-default-constants', { + describe: + 'Configure default constant values to use in "wp-config.php", in case the constants are missing. Encoded as JSON string.', + type: 'string', + coerce: (value: string) => { + try { + return JSON.parse(value); + } catch (e /* eslint-disable-line @typescript-eslint/no-unused-vars */) { + throw new Error( + 'Invalid JSON string for --wp-config-default-constants' + ); + } + }, + }) .option('skip-wordpress-setup', { describe: 'Do not download, unzip, and install WordPress. Useful for mounting a pre-configured WordPress directory at /wordpress.', @@ -420,6 +434,7 @@ export interface RunCLIArgs { xdebug?: boolean; experimentalDevtools?: boolean; 'experimental-blueprints-v2-runner'?: boolean; + wpConfigDefaultConstants?: Record; // --------- Blueprint V1 args ----------- skipWordPressSetup?: boolean; diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index db787eea3e..3c4e9da084 100644 --- a/packages/playground/wordpress/src/boot.ts +++ b/packages/playground/wordpress/src/boot.ts @@ -126,6 +126,10 @@ export interface BootWordPressOptions { dataSqlPath?: string; /** Zip with the WordPress installation to extract in /wordpress. */ wordPressZip?: File | Promise | undefined; + /** + * Default constant values to use in "wp-config.php", in case they are missing. + */ + wpConfigDefaultConstants?: Record; /** Preloaded SQLite integration plugin. */ sqliteIntegrationPluginZip?: File | Promise; /** @@ -186,7 +190,11 @@ export async function bootWordPress( * This is needed, because some WordPress backups and exports may not include * definitions for some of the necessary constants. */ - await ensureWpConfig(php, requestHandler.documentRoot); + await ensureWpConfig( + php, + requestHandler.documentRoot, + options.wpConfigDefaultConstants + ); // Run "before database" hooks to mount/copy more files in if (options.hooks?.beforeDatabaseSetup) { await options.hooks.beforeDatabaseSetup(php); diff --git a/packages/playground/wordpress/src/test/wp-config.spec.ts b/packages/playground/wordpress/src/test/wp-config.spec.ts index 34b5a84ea8..dacf826669 100644 --- a/packages/playground/wordpress/src/test/wp-config.spec.ts +++ b/packages/playground/wordpress/src/test/wp-config.spec.ts @@ -261,6 +261,40 @@ if ( ! defined( 'ABSPATH' ) ) { require_once ABSPATH . 'wp-settings.php'; `); }); + + it('should allow configuring default constants', async () => { + php.writeFile( + wpConfigPath, + ` DB_NAME, + 'DB_USER' => DB_USER, + 'CUSTOM_CONSTANT' => CUSTOM_CONSTANT, + 'WP_DEBUG' => WP_DEBUG, + ]);` + ); + + await ensureWpConfig(php, documentRoot, { + DB_NAME: 'custom-name', + CUSTOM_CONSTANT: 'custom-value', + }); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).toContain(`define( 'DB_NAME', 'custom-name' );`); + expect(rewritten).toContain(`define( 'DB_USER', 'username_here' );`); + expect(rewritten).toContain( + `define( 'CUSTOM_CONSTANT', 'custom-value' );` + ); + expect(rewritten).toContain(`define( 'WP_DEBUG', false );`); + + const response = await php.run({ code: rewritten }); + expect(response.json).toEqual({ + DB_NAME: 'custom-name', + DB_USER: 'username_here', + CUSTOM_CONSTANT: 'custom-value', + WP_DEBUG: false, + }); + }); }); describe('defineWpConfigConstants', () => { diff --git a/packages/playground/wordpress/src/wp-config.ts b/packages/playground/wordpress/src/wp-config.ts index b69254213e..b23d3d8fef 100644 --- a/packages/playground/wordpress/src/wp-config.ts +++ b/packages/playground/wordpress/src/wp-config.ts @@ -14,7 +14,8 @@ import wpConfigTransformer from './wp-config-transformer.php?raw'; */ export async function ensureWpConfig( php: UniversalPHP, - documentRoot: string + documentRoot: string, + defaultConstants: Record = {} ): Promise { const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); @@ -35,6 +36,7 @@ export async function ensureWpConfig( LOGGED_IN_SALT: 'put your unique phrase here', NONCE_SALT: 'put your unique phrase here', WP_DEBUG: false, + ...defaultConstants, }; /** @@ -63,6 +65,11 @@ export async function ensureWpConfig( ); } + // When we still don't have a wp-config.php file, there's nothing to be done. + if (!php.fileExists(wpConfigPath)) { + return; + } + // Ensure required constants are defined. const js = phpVars({ wpConfigPath, constants: defaults }); const result = await php.run({ From b44e409ba10ba3561ba25a99f7c90b5f31364ad3 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Fri, 19 Sep 2025 17:46:02 +0200 Subject: [PATCH 7/8] Do not use LOCK_EX due to "Exclusive locks are not supported for this stream" error --- packages/playground/wordpress/src/wp-config-transformer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playground/wordpress/src/wp-config-transformer.php b/packages/playground/wordpress/src/wp-config-transformer.php index e63df242e0..54e9e4a555 100644 --- a/packages/playground/wordpress/src/wp-config-transformer.php +++ b/packages/playground/wordpress/src/wp-config-transformer.php @@ -68,7 +68,7 @@ public function to_string(): string { * @param string $path The path to the wp-config.php file. */ public function to_file( string $path ): void { - $result = file_put_contents( $path, $this->to_string(), LOCK_EX ); + $result = file_put_contents( $path, $this->to_string() ); if ( false === $result ) { throw new Exception( sprintf( "Failed to write to the '%s' file.", $path ) ); } From 75d0346e8b7d0fc26fea09f71d9377029627e9b6 Mon Sep 17 00:00:00 2001 From: Jan Jakes Date: Mon, 29 Sep 2025 15:09:32 +0200 Subject: [PATCH 8/8] Add CLI tests --- packages/playground/cli/tests/run-cli.spec.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/playground/cli/tests/run-cli.spec.ts b/packages/playground/cli/tests/run-cli.spec.ts index 4262b8f833..ede3acf6b5 100644 --- a/packages/playground/cli/tests/run-cli.spec.ts +++ b/packages/playground/cli/tests/run-cli.spec.ts @@ -11,6 +11,7 @@ import { exec } from 'node:child_process'; import { mkdirSync, readdirSync, + readFileSync, writeFileSync, symlinkSync, unlinkSync, @@ -131,6 +132,129 @@ describe.each(blueprintVersions)( expect(response.text).toContain(oldestSupportedVersion); }); + test('should add missing constants to wp-config.php', async () => { + const tmpDir = await mkdtemp( + path.join(tmpdir(), 'playground-test-') + ); + + const args: RunCLIArgs = { + ...suiteCliArgs, + command: 'server', + 'mount-before-install': [ + { + hostPath: tmpDir, + vfsPath: '/wordpress', + }, + ], + mode: 'create-new-site', + }; + + const newSiteArgs: RunCLIArgs = + version === 2 + ? { + ...args, + 'experimental-blueprints-v2-runner': true, + mode: 'create-new-site', + } + : args; + + const existingSiteArgs: RunCLIArgs = + version === 2 + ? { + ...args, + 'experimental-blueprints-v2-runner': true, + mode: 'apply-to-existing-site', + } + : { + ...args, + skipWordPressSetup: true, + }; + + // Create a new site so we can load it as an existing site later. + cliServer = await runCLI(newSiteArgs); + const wpConfigPath = path.join(tmpDir, 'wp-config.php'); + let wpConfig = readFileSync(wpConfigPath, 'utf8'); + expect(wpConfig).toContain( + "define( 'DB_NAME', 'database_name_here' );" + ); + expect(wpConfig).not.toContain( + 'BEGIN: Added by WordPress Playground.' + ); + expect(wpConfig).not.toContain( + 'END: Added by WordPress Playground.' + ); + + // Remove the "DB_NAME" constant. + writeFileSync( + wpConfigPath, + wpConfig.replace("'DB_NAME'", "'UNKNOWN_CONSTANT'") + ); + wpConfig = readFileSync(wpConfigPath, 'utf8'); + expect(wpConfig).not.toContain( + "define( 'DB_NAME', 'database_name_here' );" + ); + + // Use the existing site and confirm the missing constant is added. + cliServer = await runCLI(existingSiteArgs); + wpConfig = readFileSync(wpConfigPath, 'utf8'); + expect(wpConfig).toContain( + "define( 'DB_NAME', 'database_name_here' );" + ); + expect(wpConfig).toContain('BEGIN: Added by WordPress Playground.'); + expect(wpConfig).toContain('END: Added by WordPress Playground.'); + + // Ensure the "--wp-config-default-constants" argument works as well. + try { + cliServer = await runCLI({ + ...existingSiteArgs, + wpConfigDefaultConstants: { + DB_NAME: 'test_database_name', + CUSTOM_CONSTANT: 'test_custom_constant', + }, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + // The boot will fail due to incorrect database name, + // but the wp-config.php file should be updated. + } + + wpConfig = readFileSync(wpConfigPath, 'utf8'); + expect(wpConfig).not.toContain( + "define( 'DB_NAME', 'database_name_here' );" + ); + expect(wpConfig).toContain( + "define( 'DB_NAME', 'test_database_name' );" + ); + expect(wpConfig).toContain( + "define( 'CUSTOM_CONSTANT', 'test_custom_constant' );" + ); + expect(wpConfig).toContain('BEGIN: Added by WordPress Playground.'); + expect(wpConfig).toContain('END: Added by WordPress Playground.'); + + // Ensure the injected constants are removed when no longer needed. + writeFileSync( + wpConfigPath, + wpConfig.replace("'UNKNOWN_CONSTANT'", "'DB_NAME'") + ); + await runCLI(existingSiteArgs); + wpConfig = readFileSync(wpConfigPath, 'utf8'); + expect(wpConfig).toContain( + "define( 'DB_NAME', 'database_name_here' );" + ); + expect(wpConfig).not.toContain( + "define( 'DB_NAME', 'test_database_name' );" + ); + expect(wpConfig).not.toContain( + "define( 'CUSTOM_CONSTANT', 'test_custom_constant' );" + ); + expect(wpConfig).not.toContain( + 'BEGIN: Added by WordPress Playground.' + ); + expect(wpConfig).not.toContain( + 'END: Added by WordPress Playground.' + ); + }); + test('should run blueprint', async () => { cliServer = await runCLI({ ...suiteCliArgs,