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/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/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, diff --git a/packages/playground/wordpress/src/boot.ts b/packages/playground/wordpress/src/boot.ts index e960486d7c..3c4e9da084 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; @@ -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/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/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 deleted file mode 100644 index ba71e3f734..0000000000 --- a/packages/playground/wordpress/src/rewrite-wp-config.spec.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { PHP } from '@php-wasm/universal'; -import { defineWpConfigConstants, ensureWpConfig } from './rewrite-wp-config'; -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 { joinPaths } from '@php-wasm/util'; - -const documentRoot = '/tmp'; -const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); - -describe('defineWpConfigConstants', () => { - let php: PHP; - beforeEach(async () => { - php = new PHP(await loadNodeRuntime(RecommendedPHPVersion)); - }); - - it('should print warnings when a constant name conflicts, just to make sure other tests would fail', async () => { - const phpCode = ` { - php.writeFile( - wpConfigPath, - ` SITE_URL, - ]); - ` - ); - await defineWpConfigConstants(php, wpConfigPath, { - SITE_URL: 'http://test.url', - }); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).toContain(`define('SITE_URL','http://test.url');`); - - const response = await php.run({ code: rewritten }); - expect(response.errors).toHaveLength(0); - expect(response.json).toEqual({ - SITE_URL: 'http://test.url', - }); - }); - - it('should rewrite the define() calls for the constants that are already defined in the PHP code', async () => { - php.writeFile( - wpConfigPath, - ` SITE_URL, - ]); - ` - ); - await defineWpConfigConstants(php, wpConfigPath, { - SITE_URL: 'http://new.url', - }); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).not.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 preserve the third argument in existing define() calls', async () => { - php.writeFile( - wpConfigPath, - ` SITE_URL, - ]); - ` - ); - await defineWpConfigConstants(php, wpConfigPath, { - SITE_URL: 'http://new.url', - }); - - const rewritten = php.readFileAsText(wpConfigPath); - expect(rewritten).not.toContain( - `define('SITE_URL','http://initial.value',true);` - ); - expect(rewritten).toContain( - `define('SITE_URL','http://new.url',true);` - ); - - const response = await php.run({ code: rewritten }); - - expect(response.errors).toContain( - 'case-insensitive constants is no longer supported' - ); - 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, - ` WP_DEBUG, - "SAVEQUERIES" => SAVEQUERIES, - "WP_DEBUG_LOG" => WP_DEBUG_LOG, - "NEW_CONSTANT" => NEW_CONSTANT, -]); - ` - ); - const constants = { - WP_DEBUG: false, - WP_DEBUG_LOG: true, - SAVEQUERIES: true, - NEW_CONSTANT: 'new constant', - }; - await defineWpConfigConstants(php, wpConfigPath, constants); - - const rewritten = php.readFileAsText(wpConfigPath); - const response = await php.run({ code: rewritten }); - expect(response.errors).toHaveLength(0); - expect(response.json).toEqual(constants); - }); -}); - -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: ` - ${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(); - `, - }); - if (result.text !== '1') { - throw new Error('Failed to rewrite constants in wp-config.php.'); - } -} - -/** - * Ensures that the "wp-config.php" file exists and required constants are defined. - * - * When a required constant is missing, it will be defined with a default value. - * - * @param php The PHP instance. - * @param documentRoot The path to the document root. - */ -export async function ensureWpConfig( - php: UniversalPHP, - documentRoot: string -): Promise { - const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); - const defaults = { - DB_NAME: 'wordpress', - }; - - /** - * WordPress requires a wp-config.php file to be present during - * the site installation. - * - * If the mounted site doesn't have a wp-config.php file, - * we copy the wp-config-sample.php file to it if it exists. - * - * This enables Playground to mount a WordPress project - * that hasn't already been installed or configured. - * - * For example, a user can download a WordPress zip file - * from wordpress.org, extract it and mount the folder - * into Playground. - */ - if ( - !php.fileExists(wpConfigPath) && - php.fileExists(joinPaths(documentRoot, 'wp-config-sample.php')) - ) { - await php.writeFile( - wpConfigPath, - await php.readFileAsBuffer( - joinPaths(documentRoot, 'wp-config-sample.php') - ) - ); - } - - await defineWpConfigConstants(php, wpConfigPath, defaults, 'skip'); -} 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; + + 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/test/wp-config.spec.ts b/packages/playground/wordpress/src/test/wp-config.spec.ts new file mode 100644 index 0000000000..dacf826669 --- /dev/null +++ b/packages/playground/wordpress/src/test/wp-config.spec.ts @@ -0,0 +1,440 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { PHP } from '@php-wasm/universal'; +import { defineWpConfigConstants, ensureWpConfig } from '../wp-config'; +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 { joinPaths } from '@php-wasm/util'; + +const documentRoot = '/tmp'; +const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); + +// load wp-config-sample.php +const wpConfigSample = fs.readFileSync( + path.join(import.meta.dirname, 'wp-config-sample.php'), + 'utf8' +); + +describe('ensureWpConfig', () => { + 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'; +`); + }); + + 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', () => { + let php: PHP; + beforeEach(async () => { + php = new PHP(await loadNodeRuntime(RecommendedPHPVersion)); + }); + + it('should print warnings when a constant name conflicts, just to make sure other tests would fail', async () => { + const phpCode = ` { + php.writeFile( + wpConfigPath, + ` SITE_URL, + ]);` + ); + await defineWpConfigConstants(php, wpConfigPath, { + SITE_URL: 'http://test.url', + }); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).toContain(`define( 'SITE_URL', 'http://test.url' );`); + + const response = await php.run({ code: rewritten }); + expect(response.errors).toHaveLength(0); + expect(response.json).toEqual({ + SITE_URL: 'http://test.url', + }); + }); + + it('should update an existing constant', async () => { + php.writeFile( + wpConfigPath, + ` SITE_URL, + ]);` + ); + await defineWpConfigConstants(php, wpConfigPath, { + SITE_URL: 'http://new.url', + }); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).not.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 preserve the third argument in existing define() calls', async () => { + php.writeFile( + wpConfigPath, + ` SITE_URL, + ]);` + ); + await defineWpConfigConstants(php, wpConfigPath, { + SITE_URL: 'http://new.url', + }); + + const rewritten = php.readFileAsText(wpConfigPath); + expect(rewritten).not.toContain( + `define('SITE_URL','http://initial.value',true);` + ); + expect(rewritten).toContain( + `define('SITE_URL','http://new.url',true);` + ); + + const response = await php.run({ code: rewritten }); + + expect(response.errors).toContain( + 'case-insensitive constants is no longer supported' + ); + expect(response.text).toContain(`{"SITE_URL":"http:\\/\\/new.url"}`); + }); + + it('should handle a complex scenario', async () => { + php.writeFile( + wpConfigPath, + ` WP_DEBUG, + "SAVEQUERIES" => SAVEQUERIES, + "WP_DEBUG_LOG" => WP_DEBUG_LOG, + "NEW_CONSTANT" => NEW_CONSTANT, +]); + ` + ); + const constants = { + WP_DEBUG: false, + SAVEQUERIES: true, + NEW_CONSTANT: 'new constant', + }; + await defineWpConfigConstants(php, wpConfigPath, constants); + + const rewritten = php.readFileAsText(wpConfigPath); + const response = await php.run({ code: rewritten }); + expect(response.errors).toHaveLength(0); + expect(response.json).toEqual({ + WP_DEBUG: false, + SAVEQUERIES: true, + WP_DEBUG_LOG: 123, + NEW_CONSTANT: 'new constant', + }); + }); +}); 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..54e9e4a555 --- /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() ); + 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; + } +} diff --git a/packages/playground/wordpress/src/wp-config.ts b/packages/playground/wordpress/src/wp-config.ts new file mode 100644 index 0000000000..b23d3d8fef --- /dev/null +++ b/packages/playground/wordpress/src/wp-config.ts @@ -0,0 +1,156 @@ +import { joinPaths, phpVars } from '@php-wasm/util'; +import type { UniversalPHP } from '@php-wasm/universal'; + +/* @ts-ignore */ +import wpConfigTransformer from './wp-config-transformer.php?raw'; + +/** + * Ensures that the "wp-config.php" file exists and required constants are defined. + * + * When a required constant is missing, it will be defined with a default value. + * + * @param php The PHP instance. + * @param documentRoot The path to the document root. + */ +export async function ensureWpConfig( + php: UniversalPHP, + documentRoot: string, + defaultConstants: Record = {} +): Promise { + const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); + + // The default values for constants listed in "wp-config-sample.php". + const defaults = { + 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, + ...defaultConstants, + }; + + /** + * WordPress requires a wp-config.php file to be present during + * the site installation. + * + * If the mounted site doesn't have a wp-config.php file, + * we copy the wp-config-sample.php file to it if it exists. + * + * This enables Playground to mount a WordPress project + * that hasn't already been installed or configured. + * + * For example, a user can download a WordPress zip file + * from wordpress.org, extract it and mount the folder + * into Playground. + */ + if ( + !php.fileExists(wpConfigPath) && + php.fileExists(joinPaths(documentRoot, 'wp-config-sample.php')) + ) { + await php.writeFile( + wpConfigPath, + await php.readFileAsBuffer( + joinPaths(documentRoot, 'wp-config-sample.php') + ) + ); + } + + // 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({ + 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.'); + } +} + +/** + * 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 to define. + */ +export async function defineWpConfigConstants( + php: UniversalPHP, + wpConfigPath: string, + constants: Record +): Promise { + const js = phpVars({ wpConfigPath, constants }); + const result = await php.run({ + 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.errors.length > 0) { + throw new Error('Failed to rewrite constants in wp-config.php.'); + } +}