Skip to content

Commit 6f803f6

Browse files
authored
Add legacy WordPress support on PHP 5.2 (#3490)
> **Stacked on #3501** (Add PHP 5.2 WebAssembly builds and runtime support). Review that PR first. ## Summary Boot legacy WordPress (1.0 – 6.2) in the browser, with SQLite as the database. <img width="743" height="586" alt="Screenshot 2026-04-15 at 13 30 53" src="https://github.com/user-attachments/assets/1c42ebab-cfb8-4968-88e8-b8a628a42515" /> The Playground settings panel gains an **"Include older versions"** checkbox that exposes every WordPress release from 1.0 through 6.9 (with jazz release code names) and auto-locks the PHP version to whatever runs it. Replaces and closes #3469 (the PHP 5.6 version of the same feature). ## What's in this PR Builds on the PHP 5.2 WASM runtime from #3501 and adds: - **Offline PHP 7+ → 5.2 downgrader** (`scripts/php52-downgrader/`) — a nikic/PhpParser pipeline of ~20 visitors that strips modern syntax so arbitrary PHP 7-era files parse on PHP 5.2. - **SQLite plugin patcher** (`scripts/patch-sqlite-for-php52.mjs`) — produces a PHP 5.2-compatible build of the upstream SQLite integration plugin. - **Legacy WordPress boot flow** (`wordpress/src/boot.ts`, `legacy-wp-fixes.ts`, `mysql-shims.ts`) — legacy-aware installer path, PDO table fallback for WP < 3.5, ~3400 lines of targeted source patches for WP 1.0 – 2.8 parser/SQL quirks, auth/nonce compat, `wp-db.php` SQLite compatibility, and more. - **WP 5.0–6.2 on PHP 7.4** — extends the legacy boot to cover the mid-modern range where PHP 8.x is unreliable. - **Site settings UI** — "Include older versions" checkbox + grouped dropdown + automatic PHP locking: WP 1.0 – 4.9 → PHP 5.2, WP 5.0 – 6.2 → PHP 7.4, WP 6.3+ → default. - **Release code names** — WP version picker shows jazz-musician shorthand (e.g. "6.9 (Gene)", "4.9 (Tipton)"). - **TinyMCE content_css fix** — inlines editor CSS via `content_style` to work around TinyMCE's `document.open()` creating a service-worker-uncontrolled iframe. ## Test plan ### Automated (Playwright, all 32 legacy versions) ```bash npm run dev # Wait for http://localhost:5400/website-server/ to be ready node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs ``` Covers front page, single post, admin dashboard, new post page, and plugin activation across every WP minor release from 1.0 to 6.2. ### Manual UI walk-through 1. `npm run dev` and open http://localhost:5400/. 2. Open the settings gear → tick **Include older versions**. 3. Verify the WP dropdown shows two groups and every release from 1.0 to 6.9. 4. Pick **WP 4.9** → PHP locks to 5.2 (disabled). 5. Pick **WP 5.5** → PHP locks to 7.4 (disabled). 6. Pick **WP 4.8**, go to Posts → Add New, verify TinyMCE loads without CSS errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 5772fa4 commit 6f803f6

56 files changed

Lines changed: 8943 additions & 323 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,33 @@ jobs:
394394
node-version: 20
395395
- run: packages/php-wasm/cli/tests/smoke-test.sh
396396

397+
test-legacy-wp-version-boot:
398+
if: github.repository == 'WordPress/wordpress-playground' || github.event_name == 'pull_request'
399+
runs-on: ubuntu-latest
400+
timeout-minutes: 30
401+
steps:
402+
- uses: actions/checkout@v4
403+
with:
404+
submodules: true
405+
- uses: ./.github/actions/prepare-playground
406+
- name: Install Playwright Browser
407+
run: npx playwright install chromium --with-deps
408+
- name: Start dev server
409+
run: |
410+
npm run dev > /tmp/playground-dev.log 2>&1 &
411+
timeout=120; elapsed=0
412+
until curl -s -o /dev/null http://127.0.0.1:5400/website-server/ 2>/dev/null; do
413+
sleep 3
414+
elapsed=$((elapsed + 3))
415+
if [ $elapsed -ge $timeout ]; then
416+
echo "Dev server failed to start within ${timeout}s"
417+
cat /tmp/playground-dev.log | tail -50
418+
exit 1
419+
fi
420+
done
421+
- name: Test legacy WordPress version boot
422+
run: node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs
423+
397424
# Redis extension tests - verifies the php-redis extension loads
398425
# and provides the expected API, and can connect to a real Redis server.
399426
# Redis requires JSPI because asyncify cannot properly handle exceptions

‎packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,11 @@ export class BlueprintsV1Handler {
117117
sqliteIntegrationPluginZip = undefined;
118118
} else {
119119
this.cliOutput.updateProgress('Preparing SQLite database');
120-
// Use pre-patched v2.2.22 for legacy PHP (closures replaced
120+
// Use pre-patched v3.0.0-rc.3 for legacy PHP (closures replaced
121121
// with named functions, PHP 5.2 polyfills added offline).
122122
const phpVersion = this.args.php || RecommendedPHPVersion;
123123
const isLegacyPhp = isLegacyPHPVersion(phpVersion);
124-
const sqliteVersion = isLegacyPhp ? 'v2.2.22-php52' : 'trunk';
124+
const sqliteVersion = isLegacyPhp ? 'v3.0.0-rc.3-php52' : 'trunk';
125125
sqliteIntegrationPluginZip =
126126
await fetchSqliteIntegration(sqliteVersion);
127127
}

‎packages/playground/cli/src/blueprints-v1/download.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import path, { basename } from 'path';
77
export const CACHE_FOLDER = path.join(os.homedir(), '.wordpress-playground');
88

99
export async function fetchSqliteIntegration(
10-
version: 'trunk' | 'v2.1.16' | 'v2.2.22' | 'v2.2.22-php52' = 'trunk'
10+
version: 'trunk' | 'v2.1.16' | 'v3.0.0-rc.3-php52' = 'trunk'
1111
): Promise<File> {
1212
// Production builds: the ZIP sits next to the bundled JS.
1313
const dir =

‎packages/playground/cli/tests/run-cli.spec.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,81 @@ describe.each(blueprintVersions)(
329329
expect(response.text).toContain('My WordPress Website');
330330
});
331331

332+
// Regression test: Playground must not write its own drop-ins
333+
// (db.php, object-cache.php, advanced-cache.php, sunrise.php)
334+
// into a user-mounted wp-content. Studio and other consumers
335+
// mount real wp-content directories into Playground, and any
336+
// Playground-managed file written at the wp-content root would
337+
// silently take over the user's external site.
338+
test('should not drop any new files at the wp-content root when wp-content is mounted', async () => {
339+
const hostWpContent = await mkdtemp(
340+
path.join(tmpdir(), 'playground-test-mount-wpcontent-')
341+
);
342+
// Minimal wp-content skeleton. `plugins/` and `themes/`
343+
// stay empty so WP's unzip step fills them in; any file
344+
// added at the root of hostWpContent after boot must come
345+
// from Playground itself.
346+
mkdirSync(path.join(hostWpContent, 'plugins'));
347+
mkdirSync(path.join(hostWpContent, 'themes'));
348+
writeFileSync(
349+
path.join(hostWpContent, 'index.php'),
350+
'<?php // Silence is golden.\n'
351+
);
352+
const filesBefore = new Set(readdirSync(hostWpContent));
353+
354+
try {
355+
await using cliServer = await runCLI({
356+
...suiteCliArgs,
357+
command: 'server',
358+
'mount-before-install': [
359+
{
360+
hostPath: hostWpContent,
361+
vfsPath: '/wordpress/wp-content',
362+
},
363+
],
364+
});
365+
366+
// Confirm the site booted so we're actually exercising
367+
// the full boot → install flow, not a no-op path.
368+
const homeResponse = await fetch(
369+
new URL('/', cliServer.serverUrl)
370+
);
371+
expect(homeResponse.status).toBe(200);
372+
373+
// No Playground-managed drop-in should appear at the
374+
// wp-content root. These four names cover the WP
375+
// drop-ins that a rogue Playground write could abuse.
376+
for (const dropIn of [
377+
'db.php',
378+
'object-cache.php',
379+
'advanced-cache.php',
380+
'sunrise.php',
381+
]) {
382+
expect(existsSync(path.join(hostWpContent, dropIn))).toBe(
383+
false
384+
);
385+
}
386+
387+
// Belt-and-suspenders: any net-new *file* at the
388+
// wp-content root is a Playground drop-in regression.
389+
// Directories added by WordPress itself during install
390+
// (e.g. `database/` for the SQLite DB, `fonts/` for
391+
// the Fonts API, `upgrade/`) are legitimate and ignored.
392+
const filesAfter = new Set(readdirSync(hostWpContent));
393+
const unexpectedNewFiles = [...filesAfter]
394+
.filter((f) => !filesBefore.has(f))
395+
.filter(
396+
(f) =>
397+
!lstatSync(
398+
path.join(hostWpContent, f)
399+
).isDirectory()
400+
);
401+
expect(unexpectedNewFiles).toEqual([]);
402+
} finally {
403+
rmSync(hostWpContent, { recursive: true, force: true });
404+
}
405+
}, 120000);
406+
332407
// Regression test: mounting files under /tmp (which is already
333408
// NODEFS-mounted to a shared host directory) used to race
334409
// across 6 workers and intermittently fail with ErrnoError 20

‎packages/playground/client/src/blueprints-v1-handler.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ export class BlueprintsV1Handler {
106106
* Pre-fetch WordPress update checks to speed up the initial wp-admin load.
107107
* Skip for old WordPress versions — the functions called by prefetch
108108
* (wp_check_php_version, wp_update_plugins, etc.) don't exist or crash
109-
* on legacy WP, and the resulting PHP errors create noise.
109+
* on legacy WP, and the resulting PHP errors create noise. WP 5.0
110+
* (Gutenberg 1.0) also crashes the runtime with exit code 255 inside
111+
* prefetchUpdateChecks when using the modern SQLite driver, so extend
112+
* the skip range up to (but not including) WP 5.1.
110113
*
111114
* parseFloat extracts the major version from strings like "6.8",
112115
* "4.9.26", etc. Non-numeric values like "nightly" or "trunk"
@@ -116,7 +119,7 @@ export class BlueprintsV1Handler {
116119
* @see https://github.com/WordPress/wordpress-playground/pull/2295
117120
*/
118121
const wpMajor = parseFloat(runtimeConfiguration.wpVersion);
119-
const isLegacyWpVersion = Number.isFinite(wpMajor) && wpMajor < 5;
122+
const isLegacyWpVersion = Number.isFinite(wpMajor) && wpMajor < 5.1;
120123
if (runtimeConfiguration.networking && !isLegacyWpVersion) {
121124
await playground.prefetchUpdateChecks();
122125
}

‎packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,12 @@ class PlaygroundWorkerEndpointBlueprintsV1 extends PlaygroundWorkerEndpoint {
169169
}
170170

171171
// Select the right SQLite version:
172-
// - PHP 5.2: pre-patched v2.2.22 (closures replaced, PHP 5.2
172+
// - PHP 5.2: pre-patched v3.0.0-rc.3 (closures replaced, PHP 5.2
173173
// polyfills added)
174174
// - Everything else: whatever the caller requested
175175
const isLegacyPhp = isLegacyPHPVersion(phpVersion);
176176
const effectiveSqliteVersion = isLegacyPhp
177-
? 'v2.2.22-php52'
177+
? 'v3.0.0-rc.3-php52'
178178
: sqliteDriverVersion!;
179179
const sqliteDriverModuleDetails = getSqliteDriverModuleDetails(
180180
effectiveSqliteVersion
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { AllPHPVersion } from '@php-wasm/universal';
2+
3+
/**
4+
* WordPress versions that ship as non-minified downloads from
5+
* wordpress.org (or via the Playground CORS proxy). The web worker
6+
* handles these via its `!isMinifiedVersion` branch in
7+
* playground-worker-endpoint-blueprints-v1.ts.
8+
*
9+
* Ordered newest-first so the UI dropdown shows the most recent
10+
* older versions at the top of the "older versions" group.
11+
*/
12+
export const OlderWordPressVersions = [
13+
// WP 6.0 – 6.2 work on PHP 7.4+ but run best on PHP 8.x. Still not
14+
// minified today, so they're fetched from wordpress.org like the
15+
// legacy bucket.
16+
'6.2',
17+
'6.1',
18+
'6.0',
19+
// WP 5.x — PHP 5.6.20+ required; PHP 7.4 is the safest choice.
20+
'5.9',
21+
'5.8',
22+
'5.7',
23+
'5.6',
24+
'5.5',
25+
'5.4',
26+
'5.3',
27+
'5.2',
28+
'5.1',
29+
'5.0',
30+
// WP 4.x — PHP 5.2.4+ required; our only 5.x WASM build is 5.2.
31+
'4.9',
32+
'4.8',
33+
'4.7',
34+
'4.6',
35+
'4.5',
36+
'4.4',
37+
'4.3',
38+
'4.2',
39+
'4.1',
40+
'4.0',
41+
// WP 3.x
42+
'3.9',
43+
'3.8',
44+
'3.7',
45+
'3.6',
46+
'3.5',
47+
'3.4',
48+
'3.3',
49+
'3.2',
50+
'3.1',
51+
'3.0',
52+
// WP 2.x (2.4 was never released)
53+
'2.9',
54+
'2.8',
55+
'2.7',
56+
'2.6',
57+
'2.5',
58+
'2.3',
59+
'2.2',
60+
'2.1',
61+
'2.0',
62+
// WP 1.x (1.1, 1.3, 1.4 were never released)
63+
'1.5',
64+
'1.2',
65+
'1.0',
66+
] as const;
67+
68+
export type OlderWordPressVersion = (typeof OlderWordPressVersions)[number];
69+
70+
/**
71+
* Returns the PHP version a given WordPress release must run on
72+
* inside Playground, or `null` if any supported modern PHP version
73+
* will do.
74+
*
75+
* - WP < 5.0 (the legacy bucket): only our PHP 5.2 WASM build works.
76+
* WP 4.x officially requires PHP 5.2.4+, but Playground's 5.6+
77+
* builds have been retired so 5.2 is the only option available
78+
* here.
79+
* - WP 5.0 – 6.2 (the older-but-not-legacy bucket): PHP 7.4 is the
80+
* safest single choice — old enough for WP 5.0's PHP 5.2.4 era
81+
* code (which runs fine on 7.4) yet new enough that nothing
82+
* depends on PHP 5 quirks. PHP 8.x would work for WP 5.6+ but not
83+
* reliably for WP 5.0 – 5.5, so we force 7.4 across the whole
84+
* bucket.
85+
* - WP 6.3+ (the minified bucket): returns `null`. The UI lets the
86+
* user pick any supported PHP version and we default to the
87+
* recommended one.
88+
*/
89+
export function getForcedPhpVersionForWordPress(
90+
wpVersion: string | undefined
91+
): AllPHPVersion | null {
92+
if (!wpVersion) {
93+
return null;
94+
}
95+
const major = parseFloat(wpVersion);
96+
if (!Number.isFinite(major)) {
97+
return null;
98+
}
99+
if (major < 5) {
100+
return '5.2';
101+
}
102+
if (major < 6.3) {
103+
return '7.4';
104+
}
105+
return null;
106+
}
107+
108+
/** True for WP versions that live in the non-minified "older" bucket. */
109+
export function isOlderWordPressVersion(
110+
wpVersion: string | undefined
111+
): boolean {
112+
if (!wpVersion) {
113+
return false;
114+
}
115+
return (OlderWordPressVersions as readonly string[]).includes(wpVersion);
116+
}

0 commit comments

Comments
 (0)