diff --git a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.spec.ts b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.spec.ts
new file mode 100644
index 0000000000..cc4cbd07a0
--- /dev/null
+++ b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.spec.ts
@@ -0,0 +1,250 @@
+import type { PHP, PHPRequestHandler } from '@php-wasm/universal';
+import { RecommendedPHPVersion } from '@wp-playground/common';
+import { importWordPressFiles } from './import-wordpress-files';
+import { zipWpContent } from './zip-wp-content';
+import {
+ getSqliteDriverModule,
+ getWordPressModule,
+} from '@wp-playground/wordpress-builds';
+import { bootWordPressAndRequestHandler } from '@wp-playground/wordpress';
+import { loadNodeRuntime } from '@php-wasm/node';
+import { phpVar } from '@php-wasm/util';
+import { setURLScope } from '@php-wasm/scopes';
+
+describe('Blueprint step importWordPressFiles', () => {
+ let sourceHandler: PHPRequestHandler;
+ let sourcePHP: PHP;
+ let targetHandler: PHPRequestHandler;
+ let targetPHP: PHP;
+
+ const sourceScope = 'source-scope-123';
+ const targetScope = 'target-scope-456';
+
+ beforeEach(async () => {
+ // Boot source playground with a specific scope
+ const sourceSiteUrl = setURLScope(
+ new URL('http://playground-domain/'),
+ sourceScope
+ ).toString();
+
+ sourceHandler = await bootWordPressAndRequestHandler({
+ createPhpRuntime: async () =>
+ await loadNodeRuntime(RecommendedPHPVersion),
+ siteUrl: sourceSiteUrl,
+ wordPressZip: await getWordPressModule(),
+ sqliteIntegrationPluginZip: await getSqliteDriverModule(),
+ });
+ sourcePHP = await sourceHandler.getPrimaryPhp();
+
+ // Boot target playground with a different scope
+ const targetSiteUrl = setURLScope(
+ new URL('http://playground-domain/'),
+ targetScope
+ ).toString();
+
+ targetHandler = await bootWordPressAndRequestHandler({
+ createPhpRuntime: async () =>
+ await loadNodeRuntime(RecommendedPHPVersion),
+ siteUrl: targetSiteUrl,
+ wordPressZip: await getWordPressModule(),
+ sqliteIntegrationPluginZip: await getSqliteDriverModule(),
+ });
+ targetPHP = await targetHandler.getPrimaryPhp();
+ });
+
+ afterEach(async () => {
+ sourcePHP.exit();
+ targetPHP.exit();
+ await sourceHandler[Symbol.asyncDispose]();
+ await targetHandler[Symbol.asyncDispose]();
+ });
+
+ it('should include playground-export.json manifest in the exported zip', async () => {
+ const zipBuffer = await zipWpContent(sourcePHP);
+
+ // Check that the zip contains the manifest by inspecting it
+ await targetPHP.writeFile('/tmp/check.zip', zipBuffer);
+ const result = await targetPHP.run({
+ code: `open('/tmp/check.zip');
+ $manifest = $zip->getFromName('playground-export.json');
+ $zip->close();
+ echo $manifest;
+ `,
+ });
+
+ expect(result.text).toBeTruthy();
+ const manifest = JSON.parse(result.text);
+ expect(manifest.siteUrl).toContain(`scope:${sourceScope}`);
+ });
+
+ it('should replace old scope URLs with new scope URLs in post content during import', async () => {
+ // Create a post with an image URL containing the source scope
+ const sourceUrl = await sourcePHP.absoluteUrl;
+ const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/test-image.png`;
+
+ await sourcePHP.run({
+ code: ` 'Test Post with Image',
+ 'post_content' => '
',
+ 'post_status' => 'publish',
+ ]);
+ `,
+ });
+
+ // Export from source
+ const zipBuffer = await zipWpContent(sourcePHP);
+ const zipFile = new File([zipBuffer], 'export.zip');
+
+ // Import into target
+ await importWordPressFiles(targetPHP, {
+ wordPressFilesZip: zipFile,
+ });
+
+ // Check that the URLs were updated
+ const result = await targetPHP.run({
+ code: ` 'publish', 'numberposts' => 1]);
+ echo $posts[0]->post_content;
+ `,
+ });
+
+ // The image URL should now contain the target scope instead of source scope
+ expect(result.text).toContain(`scope:${targetScope}`);
+ expect(result.text).not.toContain(`scope:${sourceScope}`);
+ });
+
+ it('should replace URLs in post meta during import', async () => {
+ const sourceUrl = await sourcePHP.absoluteUrl;
+ const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/featured.jpg`;
+
+ await sourcePHP.run({
+ code: ` 'Test Post',
+ 'post_content' => 'Test content',
+ 'post_status' => 'publish',
+ ]);
+ update_post_meta($post_id, '_custom_image_url', ${phpVar(imageUrl)});
+ `,
+ });
+
+ // Export and import
+ const zipBuffer = await zipWpContent(sourcePHP);
+ const zipFile = new File([zipBuffer], 'export.zip');
+ await importWordPressFiles(targetPHP, {
+ wordPressFilesZip: zipFile,
+ });
+
+ // Check that the meta URL was updated
+ const result = await targetPHP.run({
+ code: ` 'publish', 'numberposts' => 1]);
+ echo get_post_meta($posts[0]->ID, '_custom_image_url', true);
+ `,
+ });
+
+ expect(result.text).toContain(`scope:${targetScope}`);
+ expect(result.text).not.toContain(`scope:${sourceScope}`);
+ });
+
+ it('should replace URLs in options during import', async () => {
+ const sourceUrl = await sourcePHP.absoluteUrl;
+ const logoUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/logo.png`;
+
+ await sourcePHP.run({
+ code: ` {
+ // Create a post with an image URL containing the source scope
+ const sourceUrl = sourcePHP.absoluteUrl;
+ const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/legacy-image.png`;
+
+ // First, update the siteurl option in the database to match the scoped URL.
+ // This simulates a site where the user changed the URL or where the option
+ // was set correctly during setup. By default, the database may contain a
+ // different URL than the scoped one we're using.
+ await sourcePHP.run({
+ code: `update(
+ $wpdb->options,
+ ['option_value' => ${phpVar(sourceUrl)}],
+ ['option_name' => 'siteurl']
+ );
+ wp_insert_post([
+ 'post_title' => 'Legacy Post with Image',
+ 'post_content' => '
',
+ 'post_status' => 'publish',
+ ]);
+ `,
+ });
+
+ // Export from source, then remove the manifest to simulate a legacy export
+ const zipBuffer = await zipWpContent(sourcePHP);
+ await targetPHP.writeFile('/tmp/with-manifest.zip', zipBuffer);
+
+ // Remove the manifest from the zip
+ await targetPHP.run({
+ code: `open('/tmp/with-manifest.zip');
+ $zip->deleteName('playground-export.json');
+ $zip->close();
+ `,
+ });
+
+ const modifiedZipBuffer = await targetPHP.readFileAsBuffer(
+ '/tmp/with-manifest.zip'
+ );
+ const zipFile = new File([modifiedZipBuffer], 'legacy-export.zip');
+
+ // Import into target - should infer the old scope from the database
+ await importWordPressFiles(targetPHP, {
+ wordPressFilesZip: zipFile,
+ });
+
+ // Check that the URLs were updated despite no manifest
+ const result = await targetPHP.run({
+ code: ` 'publish', 'numberposts' => 1]);
+ echo $posts[0]->post_content;
+ `,
+ });
+
+ // The image URL should now contain the target scope instead of source scope
+ expect(result.text).toContain(`scope:${targetScope}`);
+ expect(result.text).not.toContain(`scope:${sourceScope}`);
+ });
+});
diff --git a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts
index 61fd78aa13..8575e284a2 100644
--- a/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts
+++ b/packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts
@@ -1,6 +1,6 @@
import type { StepHandler } from '.';
import { unzip } from './unzip';
-import { dirname, joinPaths, phpVar } from '@php-wasm/util';
+import { dirname, joinPaths, phpVar, phpVars } from '@php-wasm/util';
import type { UniversalPHP } from '@php-wasm/universal';
import { ensureWpConfig } from '@wp-playground/wordpress';
import { wpContentFilesExcludedFromExport } from '../utils/wp-content-files-excluded-from-exports';
@@ -60,6 +60,24 @@ export const importWordPressFiles: StepHandler<
});
importPath = joinPaths(importPath, pathInZip);
+ // Read the export manifest if it exists. The manifest contains the
+ // site URL (including scope) at export time, which we'll use later
+ // to update URLs in the database when the scope changes.
+ const manifestPath = joinPaths(importPath, 'playground-export.json');
+ let oldSiteUrl: string | null = null;
+ if (await playground.fileExists(manifestPath)) {
+ try {
+ const manifestContent =
+ await playground.readFileAsText(manifestPath);
+ const manifest = JSON.parse(manifestContent);
+ oldSiteUrl = manifest.siteUrl;
+ // Remove the manifest file - it's not needed in the document root
+ await playground.unlink(manifestPath);
+ } catch {
+ // Ignore error – tolerate missing and malformed manifests.
+ }
+ }
+
// Carry over any Playground-related files, such as the
// SQLite database plugin, from the current wp-content
// into the one that's about to be imported
@@ -113,9 +131,18 @@ export const importWordPressFiles: StepHandler<
// Ensure required constants are defined in wp-config.php.
await ensureWpConfig(playground, documentRoot);
+ const newSiteUrl = await playground.absoluteUrl;
+
+ // If the manifest didn't provide the old site URL, try to infer it from
+ // the database. The siteurl option still contains the URL from the export
+ // at this point, before we update it with defineSiteUrl.
+ if (!oldSiteUrl) {
+ oldSiteUrl = await inferSiteUrlFromDatabase(playground, documentRoot);
+ }
+
// Adjust the site URL
await defineSiteUrl(playground, {
- siteUrl: await playground.absoluteUrl,
+ siteUrl: newSiteUrl,
});
// Upgrade the database
@@ -128,8 +155,143 @@ export const importWordPressFiles: StepHandler<
require ${upgradePhp};
`,
});
+
+ // If the site URL changed (different scope), update all URLs in the database.
+ // This ensures that image and media URLs that reference the old scope
+ // are updated to use the new scope.
+ if (oldSiteUrl && oldSiteUrl !== newSiteUrl) {
+ await replaceSiteUrl(playground, documentRoot, oldSiteUrl, newSiteUrl);
+ }
};
+/**
+ * Extracts the scope path segment from a Playground URL.
+ * For example, "http://playground.wordpress.net/scope:abc123/" returns "/scope:abc123/".
+ * Returns null if no scope is found.
+ */
+function extractScopePath(url: string): string | null {
+ const match = url.match(/\/scope:[^/]+\/?/);
+ return match ? match[0].replace(/\/?$/, '/') : null;
+}
+
+/**
+ * Replaces the scope path segment in URLs stored in the database.
+ * Only replaces /scope:old-scope/ with /scope:new-scope/, leaving the rest
+ * of URLs intact. This is a targeted replacement that handles scope changes
+ * when importing a Playground export into a different scope.
+ *
+ * This approach is reasonably safe because:
+ * - The scope string is fairly unique (/scope:xyz/ pattern)
+ * - The database fits into memory anyway
+ * - There's no expectation of HTML entities or other escaping within the scope string
+ */
+async function replaceSiteUrl(
+ playground: UniversalPHP,
+ documentRoot: string,
+ oldSiteUrl: string,
+ newSiteUrl: string
+) {
+ const oldScopePath = extractScopePath(oldSiteUrl);
+ const newScopePath = extractScopePath(newSiteUrl);
+
+ // If we can't extract scope paths, there's nothing to replace
+ if (!oldScopePath || !newScopePath) {
+ return;
+ }
+
+ // If the scopes are the same, no replacement needed
+ if (oldScopePath === newScopePath) {
+ return;
+ }
+
+ await playground.run({
+ code: `query($wpdb->prepare(
+ "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s)",
+ $old_scope, $new_scope
+ ));
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->posts} SET post_excerpt = REPLACE(post_excerpt, %s, %s)",
+ $old_scope, $new_scope
+ ));
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->posts} SET guid = REPLACE(guid, %s, %s)",
+ $old_scope, $new_scope
+ ));
+
+ // Update URLs in post meta
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->postmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_value LIKE %s",
+ $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%'
+ ));
+
+ // Update URLs in options (handles both regular and serialized data)
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->options} SET option_value = REPLACE(option_value, %s, %s) WHERE option_value LIKE %s",
+ $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%'
+ ));
+
+ // Update URLs in user meta
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->usermeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_value LIKE %s",
+ $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%'
+ ));
+
+ // Update URLs in term meta
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->termmeta} SET meta_value = REPLACE(meta_value, %s, %s) WHERE meta_value LIKE %s",
+ $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%'
+ ));
+
+ // Update URLs in comments
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->comments} SET comment_content = REPLACE(comment_content, %s, %s) WHERE comment_content LIKE %s",
+ $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%'
+ ));
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$wpdb->comments} SET comment_author_url = REPLACE(comment_author_url, %s, %s) WHERE comment_author_url LIKE %s",
+ $old_scope, $new_scope, '%' . $wpdb->esc_like($old_scope) . '%'
+ ));
+ `,
+ env: {
+ DOCUMENT_ROOT: documentRoot,
+ OLD_SCOPE: oldScopePath,
+ NEW_SCOPE: newScopePath,
+ },
+ });
+}
+
+/**
+ * Attempts to infer the old site URL from the WordPress database.
+ * This is used when importing legacy exports that don't have a manifest file.
+ * We query the siteurl option directly from the database using raw SQL because
+ * get_option('siteurl') would return the WP_SITEURL constant value instead of
+ * what's stored in the database.
+ */
+async function inferSiteUrlFromDatabase(
+ playground: UniversalPHP,
+ documentRoot: string
+): Promise {
+ const js = phpVars({ documentRoot });
+ const result = await playground.run({
+ code: `get_row("SELECT option_value FROM {$wpdb->options} WHERE option_name = 'siteurl'");
+ echo $row ? $row->option_value : '';
+ `,
+ });
+ const siteUrl = result.text.trim();
+ return siteUrl || null;
+}
+
async function removePath(playground: UniversalPHP, path: string) {
if (await playground.fileExists(path)) {
if (await playground.isDir(path)) {
diff --git a/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts b/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts
index 7dc2448e9a..5fd9cc5b9f 100644
--- a/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts
+++ b/packages/playground/blueprints/src/lib/steps/zip-wp-content.ts
@@ -22,10 +22,20 @@ export const zipWpContent = async (
{ selfContained = false }: ZipWpContentOptions = {}
) => {
const zipPath = '/tmp/wordpress-playground.zip';
+ const manifestPath = '/tmp/playground-export.json';
const documentRoot = await playground.documentRoot;
const wpContentPath = joinPaths(documentRoot, 'wp-content');
+ // Create a manifest file containing metadata about this export,
+ // including the site URL (with scope). This will be used during import
+ // to update URLs in the database when the scope changes.
+ const siteUrl = await playground.absoluteUrl;
+ await playground.writeFile(
+ manifestPath,
+ new TextEncoder().encode(JSON.stringify({ siteUrl }))
+ );
+
let exceptPaths = wpContentFilesExcludedFromExport;
/*
* This is a temporary workaround to enable including the WordPress
@@ -45,6 +55,15 @@ export const zipWpContent = async (
(path) => path !== 'mu-plugins/sqlite-database-integration'
);
}
+
+ const additionalPaths: Record = {
+ [manifestPath]: 'playground-export.json',
+ };
+ if (selfContained) {
+ additionalPaths[joinPaths(documentRoot, 'wp-config.php')] =
+ 'wp-config.php';
+ }
+
const js = phpVars({
zipPath,
wpContentPath,
@@ -52,11 +71,7 @@ export const zipWpContent = async (
exceptPaths: exceptPaths.map((path) =>
joinPaths(documentRoot, 'wp-content', path)
),
- additionalPaths: selfContained
- ? {
- [joinPaths(documentRoot, 'wp-config.php')]: 'wp-config.php',
- }
- : {},
+ additionalPaths,
});
await runPhpWithZipFunctions(
playground,
@@ -69,6 +84,7 @@ export const zipWpContent = async (
const fileBuffer = await playground.readFileAsBuffer(zipPath);
playground.unlink(zipPath);
+ playground.unlink(manifestPath);
return fileBuffer;
};