Skip to content

Commit cd251bf

Browse files
authored
[Blueprints] Rewrite scoped URLs when importing a Playground export (#2989)
## Summary When exporting a Playground and restoring it in a different browser tab or session, image and media URLs break because they still reference the original scope path segment (e.g., `/scope:abc123/`). This change fixes that by recording the site URL at export time and replacing it during import. - The export now includes a `playground-export.json` manifest containing the site URL - During import, if the old URL differs from the new one, we run SQL UPDATE queries to replace all occurrences across `wp_posts`, `wp_postmeta`, `wp_options`, `wp_usermeta`, `wp_termmeta`, and `wp_comments` tables This is a simple approach using SQL REPLACE queries that handles the ASCII-based scope pattern (`/scope:xxx/`) reliably. It can be upgraded to use the WordPress-importer URL rewriting pipeline once it's reusable outside of the WXR import pipeline. Fixes #2982 ## Test plan - [ ] Export a Playground with uploaded images in post content - [ ] Import the zip into a new Playground tab (different scope) - [ ] Verify images display correctly (URLs updated to new scope) - [ ] Run `npx nx test playground-blueprints --testFile=import-wordpress-files.spec.ts`
1 parent 6f52ebe commit cd251bf

File tree

3 files changed

+435
-7
lines changed

3 files changed

+435
-7
lines changed
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import type { PHP, PHPRequestHandler } from '@php-wasm/universal';
2+
import { RecommendedPHPVersion } from '@wp-playground/common';
3+
import { importWordPressFiles } from './import-wordpress-files';
4+
import { zipWpContent } from './zip-wp-content';
5+
import {
6+
getSqliteDriverModule,
7+
getWordPressModule,
8+
} from '@wp-playground/wordpress-builds';
9+
import { bootWordPressAndRequestHandler } from '@wp-playground/wordpress';
10+
import { loadNodeRuntime } from '@php-wasm/node';
11+
import { phpVar } from '@php-wasm/util';
12+
import { setURLScope } from '@php-wasm/scopes';
13+
14+
describe('Blueprint step importWordPressFiles', () => {
15+
let sourceHandler: PHPRequestHandler;
16+
let sourcePHP: PHP;
17+
let targetHandler: PHPRequestHandler;
18+
let targetPHP: PHP;
19+
20+
const sourceScope = 'source-scope-123';
21+
const targetScope = 'target-scope-456';
22+
23+
beforeEach(async () => {
24+
// Boot source playground with a specific scope
25+
const sourceSiteUrl = setURLScope(
26+
new URL('http://playground-domain/'),
27+
sourceScope
28+
).toString();
29+
30+
sourceHandler = await bootWordPressAndRequestHandler({
31+
createPhpRuntime: async () =>
32+
await loadNodeRuntime(RecommendedPHPVersion),
33+
siteUrl: sourceSiteUrl,
34+
wordPressZip: await getWordPressModule(),
35+
sqliteIntegrationPluginZip: await getSqliteDriverModule(),
36+
});
37+
sourcePHP = await sourceHandler.getPrimaryPhp();
38+
39+
// Boot target playground with a different scope
40+
const targetSiteUrl = setURLScope(
41+
new URL('http://playground-domain/'),
42+
targetScope
43+
).toString();
44+
45+
targetHandler = await bootWordPressAndRequestHandler({
46+
createPhpRuntime: async () =>
47+
await loadNodeRuntime(RecommendedPHPVersion),
48+
siteUrl: targetSiteUrl,
49+
wordPressZip: await getWordPressModule(),
50+
sqliteIntegrationPluginZip: await getSqliteDriverModule(),
51+
});
52+
targetPHP = await targetHandler.getPrimaryPhp();
53+
});
54+
55+
afterEach(async () => {
56+
sourcePHP.exit();
57+
targetPHP.exit();
58+
await sourceHandler[Symbol.asyncDispose]();
59+
await targetHandler[Symbol.asyncDispose]();
60+
});
61+
62+
it('should include playground-export.json manifest in the exported zip', async () => {
63+
const zipBuffer = await zipWpContent(sourcePHP);
64+
65+
// Check that the zip contains the manifest by inspecting it
66+
await targetPHP.writeFile('/tmp/check.zip', zipBuffer);
67+
const result = await targetPHP.run({
68+
code: `<?php
69+
$zip = new ZipArchive();
70+
$zip->open('/tmp/check.zip');
71+
$manifest = $zip->getFromName('playground-export.json');
72+
$zip->close();
73+
echo $manifest;
74+
`,
75+
});
76+
77+
expect(result.text).toBeTruthy();
78+
const manifest = JSON.parse(result.text);
79+
expect(manifest.siteUrl).toContain(`scope:${sourceScope}`);
80+
});
81+
82+
it('should replace old scope URLs with new scope URLs in post content during import', async () => {
83+
// Create a post with an image URL containing the source scope
84+
const sourceUrl = await sourcePHP.absoluteUrl;
85+
const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/test-image.png`;
86+
87+
await sourcePHP.run({
88+
code: `<?php
89+
require ${phpVar(await sourcePHP.documentRoot)} . '/wp-load.php';
90+
wp_insert_post([
91+
'post_title' => 'Test Post with Image',
92+
'post_content' => '<img src="${imageUrl}" alt="test">',
93+
'post_status' => 'publish',
94+
]);
95+
`,
96+
});
97+
98+
// Export from source
99+
const zipBuffer = await zipWpContent(sourcePHP);
100+
const zipFile = new File([zipBuffer], 'export.zip');
101+
102+
// Import into target
103+
await importWordPressFiles(targetPHP, {
104+
wordPressFilesZip: zipFile,
105+
});
106+
107+
// Check that the URLs were updated
108+
const result = await targetPHP.run({
109+
code: `<?php
110+
require ${phpVar(await targetPHP.documentRoot)} . '/wp-load.php';
111+
$posts = get_posts(['post_status' => 'publish', 'numberposts' => 1]);
112+
echo $posts[0]->post_content;
113+
`,
114+
});
115+
116+
// The image URL should now contain the target scope instead of source scope
117+
expect(result.text).toContain(`scope:${targetScope}`);
118+
expect(result.text).not.toContain(`scope:${sourceScope}`);
119+
});
120+
121+
it('should replace URLs in post meta during import', async () => {
122+
const sourceUrl = await sourcePHP.absoluteUrl;
123+
const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/featured.jpg`;
124+
125+
await sourcePHP.run({
126+
code: `<?php
127+
require ${phpVar(await sourcePHP.documentRoot)} . '/wp-load.php';
128+
$post_id = wp_insert_post([
129+
'post_title' => 'Test Post',
130+
'post_content' => 'Test content',
131+
'post_status' => 'publish',
132+
]);
133+
update_post_meta($post_id, '_custom_image_url', ${phpVar(imageUrl)});
134+
`,
135+
});
136+
137+
// Export and import
138+
const zipBuffer = await zipWpContent(sourcePHP);
139+
const zipFile = new File([zipBuffer], 'export.zip');
140+
await importWordPressFiles(targetPHP, {
141+
wordPressFilesZip: zipFile,
142+
});
143+
144+
// Check that the meta URL was updated
145+
const result = await targetPHP.run({
146+
code: `<?php
147+
require ${phpVar(await targetPHP.documentRoot)} . '/wp-load.php';
148+
$posts = get_posts(['post_status' => 'publish', 'numberposts' => 1]);
149+
echo get_post_meta($posts[0]->ID, '_custom_image_url', true);
150+
`,
151+
});
152+
153+
expect(result.text).toContain(`scope:${targetScope}`);
154+
expect(result.text).not.toContain(`scope:${sourceScope}`);
155+
});
156+
157+
it('should replace URLs in options during import', async () => {
158+
const sourceUrl = await sourcePHP.absoluteUrl;
159+
const logoUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/logo.png`;
160+
161+
await sourcePHP.run({
162+
code: `<?php
163+
require ${phpVar(await sourcePHP.documentRoot)} . '/wp-load.php';
164+
update_option('custom_logo_url', ${phpVar(logoUrl)});
165+
`,
166+
});
167+
168+
// Export and import
169+
const zipBuffer = await zipWpContent(sourcePHP);
170+
const zipFile = new File([zipBuffer], 'export.zip');
171+
await importWordPressFiles(targetPHP, {
172+
wordPressFilesZip: zipFile,
173+
});
174+
175+
// Check that the option URL was updated
176+
const result = await targetPHP.run({
177+
code: `<?php
178+
require ${phpVar(await targetPHP.documentRoot)} . '/wp-load.php';
179+
echo get_option('custom_logo_url');
180+
`,
181+
});
182+
183+
expect(result.text).toContain(`scope:${targetScope}`);
184+
expect(result.text).not.toContain(`scope:${sourceScope}`);
185+
});
186+
187+
it('should infer scope from database when manifest is missing and still replace URLs', async () => {
188+
// Create a post with an image URL containing the source scope
189+
const sourceUrl = sourcePHP.absoluteUrl;
190+
const imageUrl = `${sourceUrl.replace(/\/$/, '')}/wp-content/uploads/2024/01/legacy-image.png`;
191+
192+
// First, update the siteurl option in the database to match the scoped URL.
193+
// This simulates a site where the user changed the URL or where the option
194+
// was set correctly during setup. By default, the database may contain a
195+
// different URL than the scoped one we're using.
196+
await sourcePHP.run({
197+
code: `<?php
198+
require ${phpVar(sourcePHP.documentRoot)} . '/wp-load.php';
199+
global $wpdb;
200+
$wpdb->update(
201+
$wpdb->options,
202+
['option_value' => ${phpVar(sourceUrl)}],
203+
['option_name' => 'siteurl']
204+
);
205+
wp_insert_post([
206+
'post_title' => 'Legacy Post with Image',
207+
'post_content' => '<img src="${imageUrl}" alt="legacy">',
208+
'post_status' => 'publish',
209+
]);
210+
`,
211+
});
212+
213+
// Export from source, then remove the manifest to simulate a legacy export
214+
const zipBuffer = await zipWpContent(sourcePHP);
215+
await targetPHP.writeFile('/tmp/with-manifest.zip', zipBuffer);
216+
217+
// Remove the manifest from the zip
218+
await targetPHP.run({
219+
code: `<?php
220+
$zip = new ZipArchive();
221+
$zip->open('/tmp/with-manifest.zip');
222+
$zip->deleteName('playground-export.json');
223+
$zip->close();
224+
`,
225+
});
226+
227+
const modifiedZipBuffer = await targetPHP.readFileAsBuffer(
228+
'/tmp/with-manifest.zip'
229+
);
230+
const zipFile = new File([modifiedZipBuffer], 'legacy-export.zip');
231+
232+
// Import into target - should infer the old scope from the database
233+
await importWordPressFiles(targetPHP, {
234+
wordPressFilesZip: zipFile,
235+
});
236+
237+
// Check that the URLs were updated despite no manifest
238+
const result = await targetPHP.run({
239+
code: `<?php
240+
require ${phpVar(targetPHP.documentRoot)} . '/wp-load.php';
241+
$posts = get_posts(['post_status' => 'publish', 'numberposts' => 1]);
242+
echo $posts[0]->post_content;
243+
`,
244+
});
245+
246+
// The image URL should now contain the target scope instead of source scope
247+
expect(result.text).toContain(`scope:${targetScope}`);
248+
expect(result.text).not.toContain(`scope:${sourceScope}`);
249+
});
250+
});

0 commit comments

Comments
 (0)