Skip to content

Add legacy WordPress support on PHP 5.6#3469

Closed
JanJakes wants to merge 39 commits intotrunkfrom
legacy-wordpress-support-v2
Closed

Add legacy WordPress support on PHP 5.6#3469
JanJakes wants to merge 39 commits intotrunkfrom
legacy-wordpress-support-v2

Conversation

@JanJakes
Copy link
Copy Markdown
Member

@JanJakes JanJakes commented Apr 6, 2026

Summary

Run old WordPress versions (1.5 through 4.9 tested, any existing release should work) in the browser using PHP 5.6 WASM with SQLite as the database backend.

  • PHP 5.6 WASM compilation — asyncify (Node) and JSPI (browser) builds, with OpenSSL 1.1 compat patch and SQLite 3.51 upgrade
  • PHP 5.6 compatibility transpiler — transforms PHP 7+ syntax (type declarations, ??, Throwable, attributes, Closure::call()) to PHP 5.6, used to patch the SQLite Database Integration plugin. Guards nullsafe operators (?->) from corruption.
  • Pre-patched SQLite v2.2.22 — offline patcher adds polyfills (reinitialize_sqlite(), placeholder_escape(), etc.) and guards for old WP functions
  • Legacy WordPress boot processwp-load.php shim for WP < 2.0, extension_loaded('mysql') bypass, lazy $wpdb loader with reinitialize_sqlite(), MySQL/MySQLi function stubs, WP 1.5 seed data insertion (SQLite NOT NULL constraint fix)
  • Web worker and CLI integration — SQLite driver version selection per PHP/WP combination, mu-plugin guards for old WP functions
  • Bare version string support?wp=4.9 resolves to a wordpress.org download automatically (versions < 2.0 are normalized: 1.0→1.0.2, 1.2→1.2.2, 1.5→1.5.2)

Tested with WP 1.2 through 4.9 (31 versions).

Test plan

Automated (Playwright)

Requires Chrome/Chromium with JSPI support (Chrome 131+).

npm run dev
# Wait for "Local: http://127.0.0.1:5400/website-server/" in output

# In another terminal, run the full legacy boot test suite (31 WP versions):
node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs

Manual (browser)

  1. Start the dev server: npm run dev
  2. Open each URL in Chrome/Chromium (JSPI required):
  3. Verify each shows a WordPress front page with "Hello world!" post

Unit tests

npx nx test playground-wordpress --testFile=legacy-php-compat.spec.ts

Runs 28 tests covering the PHP 5.6 transpiler functions (stripPhp7TypeDeclarations, replaceNullCoalescing, replacePhp7ErrorClasses).

Known issues

  • WP 1.0.2 excluded — SQLite AST driver can't parse its enum() column types
  • WP 1.5 shows PHP warnings: "Creating default object from empty value" and deprecated preg_replace /e modifier (WP 1.5 code predates PHP 5.4/5.5)
  • prefetchUpdateChecks fails on all legacy WP versions (non-fatal)
  • assertValidDatabaseConnection always fails for legacy PHP (treated as non-fatal)
  • CLI production build only ships trunk SQLite ZIP — vite.config.ts needs updating to copy version-specific ZIPs
  • CLI handler missing v2.1.16 SQLite selection for WP < 6.x on modern PHP

@JanJakes JanJakes force-pushed the legacy-wordpress-support-v2 branch from bda9d53 to 90815ba Compare April 6, 2026 09:32
@JanJakes JanJakes requested a review from adamziel April 6, 2026 09:37
@JanJakes JanJakes force-pushed the legacy-wordpress-support-v2 branch 6 times, most recently from 18d442f to 9c61190 Compare April 7, 2026 12:00
@JanJakes JanJakes changed the title Add legacy WordPress support (PHP 5.6 + WP 4.9/3.9/2.9/1.5) Add legacy WordPress support on PHP 5.6 Apr 7, 2026
@JanJakes JanJakes force-pushed the legacy-wordpress-support-v2 branch 5 times, most recently from 79c3c39 to 373d90a Compare April 8, 2026 11:44
JanJakes added 4 commits April 8, 2026 13:54
Introduce LegacyPHPVersions and AllPHPVersions types alongside
the existing SupportedPHPVersions. PHP 5.6 is classified as a
"legacy" version — available but not in the standard supported
list. Update Blueprint types to accept legacy PHP versions and
regenerate the Blueprint JSON schema.
Add PHP 5.6.40 compilation support for both asyncify and JSPI
build modes. Key changes to the build pipeline:

- Add OpenSSL 1.1 compatibility patch (1100 lines) since PHP 5.6
  directly accesses opaque OpenSSL structs removed in later versions
- Replace PHP 5.6's bundled SQLite 3.8.10.2 with SQLite 3.51.0
  (the v2.2.22 SQLite driver requires >= 3.37.0)
- Add EMULATE_FUNCTION_POINTER_CASTS for JSPI build to fix a sort
  crash in PHP 5.6's zend_qsort
- Patch C source files for compatibility with older PHP internal APIs
- Add node-builds/5-6 and web-builds/5-6 packages with pre-compiled
  WASM binaries
- Add PHP 5.6 loader entries in node and web runtime packages
Add functions that transform PHP 7+ code to PHP 5.6 compatible
code for the SQLite integration plugin:

- stripPhp7TypeDeclarations: removes parameter and return type
  declarations, including from interface/abstract methods (`;`
  terminators). Handles `mixed`, `never`, and nullable types.
- replaceNullCoalescing: transforms ?? operators to isset()
  ternaries, with special handling for class constants and
  method calls to avoid double evaluation. Includes a safety
  limit to prevent infinite loops.
- replacePhp7ErrorClasses: replaces Throwable with Exception,
  removes PHP 8 attributes and declare(strict_types), transforms
  Closure::call(), replaces callable property invocations with
  call_user_func(), and handles isset() on class constant arrays.

These are used both at runtime (when extracting the SQLite
plugin ZIP) and offline (in the pre-patching script).
Add an offline patcher script that transforms the SQLite Database
Integration v2.2.22 plugin into a PHP 5.6-compatible version:

- Removes all PHP 7+ syntax using the transpiler functions
- Renames 'throw' method (reserved word in PHP 5.6)
- Adds polyfills: placeholder_escape(), reinitialize_sqlite(),
  init_charset(), get_caller(), log_query(), and wpdb properties
- Guards WordPress function calls that don't exist in old versions
- Makes schema parse failures non-fatal for old WP schemas
- Skips SQLite's wp_install() override for WP < 3.0

The pre-patched ZIP is committed as a build artifact. Add version
entries for v2.2.22 and v2.2.22-php56 in the SQLite driver module
details.

Regenerate with:
  node --experimental-strip-types scripts/patch-sqlite-for-php56.mjs
@JanJakes JanJakes force-pushed the legacy-wordpress-support-v2 branch 3 times, most recently from 60f9b99 to e9870f7 Compare April 8, 2026 17:51
Make WordPress 1.2 through 4.9 bootable on PHP 5.6 with SQLite by
patching WP source files at boot time. All legacy-specific code lives
in dedicated modules (legacy-wp-fixes.ts, mysql-shims.ts) to keep
boot.ts and index.ts focused on the core boot flow.

WP source file patching (legacy-wp-fixes.ts):
- wp-settings.php: disable extension_loaded('mysql') check, suppress
  E_DEPRECATED/E_STRICT, remove set_magic_quotes_runtime, guard
  get_magic_quotes_gpc, fix =& new syntax, replace $HTTP_SERVER_VARS
- wp-db.php: guard $wpdb creation, patch mysql_connect to call
  db_connect(), inject wpdb method polyfills (set_prefix, timer_start,
  timer_stop, init_charset, bail, check_connection)
- install.php: fix relative paths, replace $HTTP_GET_VARS/$HTTP_POST_VARS,
  combine WP 1.x multi-step installer into single request
- functions.php: fix $all_options stdClass, eval db.php for parser bug
- schema.php: add wp_get_db_schema() polyfill for WP < 3.3
- Create wp-load.php shim for WP < 2.0, version.php stub for WP < 1.5
- Pre-create WP 1.x tables via PDO, seed admin user and default content
- Strip doc comments and skip large includes for WP 2.8 (parser bug)

SQLite integration (index.ts, legacy-wp-fixes.ts):
- Patch SQLite plugin for PHP 5.6 (strip type declarations, null
  coalescing, rename reserved words, replace __DIR__ and require_once)
- Write db.php with MySQL/MySQLi stubs and PHP polyfills
- Lazy $wpdb loader calls reinitialize_sqlite() for old WP
- Functional mysql_* stubs (mysql-shims.ts) delegate to $wpdb

Boot flow adjustments (boot.ts):
- Skip ensureWpConfig for legacy PHP, copy wp-config-sample.php instead
- Non-fatal install errors and database validation for legacy PHP
- Skip isWordPressInstalled() for legacy PHP (crashes WASM on old WP)

Also add JavaScript unzip fallback for PHP builds without ZipArchive.
Web worker (playground-worker-endpoint-blueprints-v1.ts):
- Select SQLite driver version based on PHP and WP version:
  PHP 5.6 uses pre-patched v2.2.22-php56, WP < 6.x on modern
  PHP uses v2.1.16, modern WP uses trunk
- Pass phpVersion to bootWordPress for legacy detection
- Disable WP_DEBUG for legacy PHP

CLI (blueprints-v1-handler.ts, download.ts, worker-thread-v1.ts):
- Add getPrebuiltWordPressPath() to use bundled WP ZIPs
- Pass SQLite version parameter to fetchSqliteIntegration()
- Pass phpVersion through to boot process

Mu-plugins (0-playground.php, wp_http_fetch.php, wp_http_dummy.php):
- Guard wp_doing_ajax() and wp_doing_cron() with function_exists()
- Guard get_current_user_id() for WP < 3.0 compatibility
- Guard Requests class and Requests_Transport interface checks
- Replace [] syntax with array() for PHP 5.6 compatibility
Test that WordPress versions 1.2 through 4.9 boot on PHP 5.6 with
SQLite and display "Hello world!". Starts the dev server, then runs
each version through the browser via Playwright.

WP 1.0.2 is excluded — the SQLite AST driver can't parse its enum()
column types, causing WHERE clause failures.
When a non-minified WP version like "4.9" or "1.5" is passed via
?wp= parameter, construct a wordpress.org download URL and route
it through the CORS proxy instead of silently falling back to the
latest minified WP build.

Versions >= 2.0 work as wordpress-<major>.<minor>.zip (wordpress.org
redirects to latest patch). Versions < 2.0 need normalization since
wordpress.org only hosts explicit patch versions (1.0.2, 1.2.2, 1.5.2).

Also fix SQLite version selection to use the actual requested WP
version instead of the minified fallback, ensuring old WP on modern
PHP correctly gets the v2.1.16 SQLite driver.
Three issues prevented wp-admin from working on PHP 5.6:

1. template.php and media.php were replaced with empty stubs as a
   workaround for the WASM parser size limit. The real fix is to
   strip doc comments (which stripDocCommentsFromWpIncludes already
   does). Remove the stub replacement. Also preserve ?> tags during
   comment stripping — PHP's ?> closes PHP mode even inside //
   comments.

2. Old WordPress (< 3.7) uses relative paths in wp-admin scripts.
   Patch these to use dirname(__FILE__) for absolute resolution.
   Covers index.php, index-extra.php, and admin.php across all
   old WP versions.

3. Auth cookies don't persist correctly for old WordPress admin
   sessions. Fix by re-generating auth cookies on every admin
   request: mu-plugin for WP 2.8+, direct admin.php patch for
   WP < 2.8 (no mu-plugin support). Handles three auth eras:
   - WP 2.5+: wp_generate_auth_cookie + $_COOKIE population
   - WP 2.0-2.4: USER_COOKIE/PASS_COOKIE with md5(md5(password))
   - WP 1.5: COOKIEHASH-based hardcoded cookie names

Also skip prefetchUpdateChecks for WP < 5.0 (the functions it
calls don't exist in old WP), skip 0-playground.php mu-plugin
for WP < 3.0 (closures in hooks cause fatal errors), and set up
wp_user_roles + usermeta when missing after install.
1Password's inline tooltip enters a tight inject/remove loop on
Playground's sandboxed iframes, flickering the UI and generating
thousands of cached chrome-extension inline-tooltip.css requests
per second. Add data-1p-ignore to the username and password
inputs to tell the extension to skip these fields entirely.
Replace the "throw if already acquired" assertion with a
1-concurrency Semaphore so parallel requests queue instead
of failing. This makes maxPhpInstances=1 viable for contexts
where concurrent HTTP requests hit the same PHP instance.
PHP 5.6's zend_compile_file uses mmap() to read source files into
the lexer buffer, trusting fstat for the buffer size. When reading
through PROXYFS on a secondary instance, fstat can return a stale
size (e.g. a file rewritten from 570 to 538 bytes between MEMFS
init and PROXYFS mount), causing the mmap buffer to extend past
the real EOF. The parser reads garbage and reports "syntax error".

PHP 7+ removed the mmap path from zend_stream_fixup entirely,
falling back to a read-based path that reallocates on size
mismatch — which is why only PHP 5.6 is affected.

Add a withMmap option to proxyFileSystem() and set it to false
for legacy PHP. Without stream_ops.mmap, Emscripten returns
ENOSYS from mmap() and PHP uses the safe read-based fallback.
The mmap patch was originally added for ICU (intl extension),
which legacy PHP does not use.
JanJakes added 26 commits April 10, 2026 14:43
The patchWpAdminRelativePaths function only patched require/require_once
statements with relative paths (./file.php, ../file.php) but not
include/include_once. Old WordPress versions (3.0-3.6) use include()
with relative paths in wp-admin files — e.g. include('./edit-post-rows.php')
and include('./admin-footer.php') in edit.php. Since PHP resolves these
relative to CWD (the document root in Playground, not wp-admin/), the
includes silently fail.

Extend all regex patterns to match include and include_once alongside
require and require_once, preserving the original keyword in the
replacement to maintain correct PHP semantics (include vs require).
The legacy-PHP db.php guard in 0-sqlite.php reads wp-content/db.php
into a variable called $content to check for the @playground-managed
marker. Because this preload runs via auto_prepend_file in global
scope before every request, the $content global persists into
WordPress code.

WP 1.0 and 1.5's post.php initializes variables from
$HTTP_POST_VARS/$HTTP_GET_VARS only when they are NOT already set
(`if (!isset($$wpvar))`). Since $content was already set to the
entire db.php source code by the preload, it was never overwritten
with an empty string, causing the "Write Post" textarea to display
hundreds of lines of PHP code.

Rename the variable to $_pg_db_php and unset it after use.
runPostInstallLegacyFixups() was reading requestHandler.siteUrl to
derive the Playground scope URL for updating wp_options.siteurl and
wp_options.home. But PHPRequestHandler exposes the URL as
absoluteUrl, not siteUrl. The result was an empty string, so the
PLAYGROUND_SITE_URL env var was never set and the database retained
the placeholder 'http://localhost'.

WP 1.5+ was unaffected because it respects the WP_HOME/WP_SITEURL
constants defined at boot time. WP 1.0 reads siteurl directly from
the database, so it served all URLs (CSS, links) pointing at
http://localhost instead of the scoped Playground URL.
…ermalinks

The placeholder_escape() polyfill for WordPress < 4.8.3 was missing the
add_filter('query', 'remove_placeholder_escape') call that modern WordPress
includes. Without it, the _real_escape() -> add_placeholder_escape() chain
replaced % characters with hash placeholders, but nothing restored them
before the query was executed. This caused permalink structure tokens like
%year%, %monthnum%, %day%, %postname% to be stored in the database as
{hash}year{hash}, {hash}monthnum{hash}, etc.

The fix adds the filter registration to match wpdb::placeholder_escape()
behavior in WordPress >= 4.8.3. The 'query' filter fires in
WP_SQLite_DB::query() via apply_filters('query', $query), restoring %
characters before the SQL reaches SQLite.

Affects WP 3.0–4.5 running on PHP 5.6 via the SQLite integration driver.
The regex patterns in patchWpAdminRelativePaths() only matched
single-quoted paths ('file.php'), missing double-quoted variants
("file.php") used in several WP 1.5-2.3 admin files such as
link-manager.php's include_once ("./admin-header.php").

Use ['"] with a backreference (\2) to match both quote styles.
Also remove two redundant "space before parens" patterns that
were subsets of the \s* patterns above them.
Two problems prevented auto-login on legacy WordPress:

1. The internal mu-plugins (including 1-auto-login.php) loaded via
   the muplugins_loaded hook, which only exists in WP 2.8+. On older
   WP, the hook never fires and the mu-plugins never load.

2. The auto-login code called wp_set_current_user(), wp_set_auth_cookie(),
   is_user_logged_in(), and get_user_by() — functions that don't exist
   in WP < 2.5.

Fix #1: Register playground_load_mu_plugins on the init hook as a
fallback (priority -1000 to run before other init callbacks). A static
$loaded flag prevents double-loading when both hooks fire on WP 2.8+.

Fix #2: For legacy PHP, the auto-login body now detects which auth
API is available and uses the appropriate method:
- WP 2.5+: standard wp_set_current_user() + wp_set_auth_cookie()
- WP 1.5-2.4: USER_COOKIE/PASS_COOKIE with double-md5 password hash
- WP 1.0-1.2: wordpressuser_/wordpresspass_ cookies + global user vars
The legacy admin auth mu-plugin (0-legacy-admin-auth.php) was calling
wp_set_auth_cookie() on every admin request, which creates a new
session token each time. WordPress 4.0+ embeds the session token in
nonces, so when the token changes between form render and form submit,
nonce verification fails with "Are you sure you want to do this?"

The fix:
- Check is_user_logged_in() before re-authenticating. If the user
  already has valid cookies from a prior request, do nothing.
- Only call wp_set_auth_cookie() once — when the user is not yet
  logged in — so the session token remains stable across requests.
- For WP < 4.0 (where wp_set_auth_cookie doesn't update $_COOKIE
  in-process), still populate $_COOKIE with wp_generate_auth_cookie()
  but only when $_COOKIE is empty, to satisfy auth_redirect().

Also adds the same is_user_logged_in() guard to the inline admin.php
auth patch used for WP 2.0–2.7, avoiding unnecessary cookie overrides
when the auto-login mu-plugin has already authenticated the user.
Setting permalink_structure via update_option() stores the desired URL
pattern but doesn't generate the actual rewrite rules that WordPress
needs to resolve pretty URLs. Without flushing, the rewrite_rules
option stays empty and WordPress can't map URLs like
/2026/04/10/hello-world/ back to the correct post query.

Modern WordPress (6.x) auto-regenerates rules when the option is
missing, but WP 3.0-4.5 requires an explicit flush_rewrite_rules()
call. Add it to both the legacy and modern code paths for correctness.
Legacy WordPress (2.0–3.5) assigns properties on uninitialized
variables ($obj->prop = value), which was valid in PHP 4 but
triggers E_WARNING in PHP 5.6. These warnings appear on rendered
pages across WP 2.0, 2.5, 3.0, and 3.5 in files like
wp-includes/functions.php, wp-admin/includes/theme.php, and
wp-admin/includes/post.php.

The fix adds a targeted suppression in the Playground error handler
for this specific warning message, matching the existing pattern
used for other benign warnings. This avoids modifying WP core
(downloaded as unmodified zip releases) and is more precise than
lowering the error_reporting level broadly.
WP 2.5-2.7 admin-ajax.php loads wp-config.php directly (bypassing
admin.php) and immediately checks is_user_logged_in(), dying with
-1 if the user isn't authenticated. Since WP < 2.8 has no mu-plugin
support, neither the Playground auth mu-plugin nor the admin.php
auth patch apply to AJAX requests.

The preload auto-login fires at init but skips re-authentication
once the playground_auto_login_already_happened cookie is set.
Auth cookies from the HttpCookieStore don't validate through WP
2.5's wp_validate_auth_cookie() because they were generated during
the initial auto-login redirect, leaving AJAX requests without a
valid session.

Inject auth code (wp_set_current_user + $_COOKIE population)
before the is_user_logged_in() gate in admin-ajax.php, matching
the existing patchAdminAuthRedirect approach for admin.php.

This fixes:
- Post save (AJAX autosave returning "You do not have permission")
- Plugin activation (nonce verification failures after redirect)
The boot-time flush_rewrite_rules() call ran in a standalone php.run()
script that loaded wp-load.php but never fired WordPress init hooks.
Post types and taxonomies weren't registered, so the generated rewrite
rules were incomplete or empty. This caused pretty permalink URLs like
/2026/04/10/hello-world/ to 404 on WP 2.5, 3.0, and 3.5.

For WP < 3.0, flush_rewrite_rules() doesn't exist at all, so rewrite
rules were never flushed.

Move the flush to a mu-plugin that runs on 'init' at priority 99999,
where WordPress is fully bootstrapped and all post types/taxonomies
are registered. The mu-plugin:

- Checks if permalink_structure is set but rewrite_rules is empty
- Uses $wp_rewrite->flush_rules() which works on WP 1.5+ (unlike
  flush_rewrite_rules() which requires WP 3.0+)
- Writes a flag file to avoid repeated flushes on subsequent requests

The first real WordPress request triggers the flush before
WP::parse_request() runs, so permalink resolution works immediately.
On WP < 4.8.3, wpdb::prepare() passes values through vsprintf()
without escaping '%' characters first — the placeholder_escape
mechanism was only added in WP 4.8.3. The permalink pattern
'/%year%/%monthnum%/%day%/%postname%/' contains sequences like
%y, %m, %d, %p that vsprintf() interprets as format specifiers,
mangling the stored value to an empty or garbled string.

Bypass wpdb entirely by writing permalink_structure directly to
the SQLite database via PDO with parameterized queries. This
fixes pretty permalinks on WP 2.5, 3.0, and 3.5.
Two issues prevented /?p=1 and pretty permalink URLs from resolving
posts on legacy WordPress (2.5, 3.0, 3.5):

1. SQLite date function type mismatch: WordPress < 4.0 generates date
   queries with string comparisons like YEAR(post_date)='2026'. The
   SQLite driver's user-defined YEAR/MONTH/DAYOFMONTH functions return
   PHP integers. SQLite's strict type system treats integer != text
   (4 != '4'), so these WHERE clauses silently matched zero rows.
   Fix: a query filter in the preload strips quotes from numeric
   values in date function comparisons.

2. Preload hooks destroyed by wp-settings.php: WordPress 2.5–3.x
   explicitly unsets $wp_filter at boot to prevent register_globals
   interference. This destroyed the playground_load_mu_plugins hook
   set by the auto_prepend_file preload, so Playground's mu-plugins
   never loaded (including the rewrite-rules flusher and other
   essential boot-time fixes). Fix: patch wp-settings.php to remove
   $wp_filter from the unset() call.

Also adds a guard to skip mu-plugins on WP < 2.8, where the hook
system cannot handle closures and functions like site_url() don't
exist yet.
Add three new test phases to the legacy WordPress boot test:

- View single post: clicks "Hello world!" on the front page and
  verifies the post content loads (tests pretty permalinks)
- New post page: navigates to post-new.php and verifies the editor
  loads without nonce errors
- Plugin activation: clicks Activate on the first available plugin
  and verifies it activates without permission errors

Extended tests run for WP 2.5+ (where the plugin/post UI exists).
WP 1.0-2.3 still test front page, post viewing, and admin only.
WP 2.8-2.9's call_user_func_array() in plugin.php treats callbacks
as arrays, which fatally crashes on Closure objects. Convert all
anonymous functions registered via add_action()/add_filter() to
named functions for compatibility:

- playground_save_wp_env_info (wp_loaded)
- playground_allowed_redirect_hosts (allowed_redirect_hosts)
- playground_disable_admin_email_check (admin_email_check_interval)
Post view: use exact name matching for the "Hello world!" link to
avoid clicking the "Edit Hello world!" admin bar link that appears
first in DOM order on Twenty Seventeen themes.

newPost: also check innerHTML for PHP errors to catch fatal errors
hidden inside collapsed divs (WP 3.3's WP_Screen crash).
The auto-login mu-plugin (1-auto-login.php) was never loaded for
WordPress versions before 2.8 because playground_load_mu_plugins()
skipped all mu-plugins on these old versions. The blanket skip was
intended to avoid crashes from closures and missing APIs, but the
auto-login mu-plugin is specifically written for legacy WP and uses
only named functions.

Changes:

- Allow 1-auto-login.php to load on WP < 2.8 by exempting it from
  the blanket mu-plugin skip in playground_load_mu_plugins()

- Call playground_auto_login() and playground_auto_login_redirect_target()
  directly after loading the mu-plugin, because PHP 5.6's foreach
  iterates over a copy of the array, so add_action() calls inside
  the mu-plugin won't fire for the current init invocation

- Add PLAYGROUND_SKIP_AUTO_LOGIN_REDIRECT flag to prevent the
  auto-login from sending Set-Cookie headers and redirects when
  called from the preload context. In Playground's service worker,
  Set-Cookie + redirect causes a loop because the cookie isn't
  applied before the redirected request fires. The legacy auto-login
  already populates $_COOKIE in-process, making the redirect
  unnecessary.

- Widen the $wp_filter unset detection in patchWpSettingsPhp() to
  match more formatting variants (e.g. without space after open
  paren)

- Add no-parentheses require/include path fixes for wp-admin files
  (e.g. require_once 'admin.php') used by WP 2.0
Three regressions introduced by 57d2491:

1. WP 2.6-2.7 fatal error: patchAdminAuthRedirect() called
   get_current_user_id() which doesn't exist before WP 2.8.
   Replace with wp_get_current_user()->ID (available since WP 2.0).

2. WP 2.5 admin login page: the PLAYGROUND_SKIP_AUTO_LOGIN_REDIRECT
   flag prevented wp_set_auth_cookie() from running, but the WP 2.5+
   auto-login branch didn't populate $_COOKIE either. auth_redirect()
   in admin.php found no cookies and redirected to wp-login.php.
   Fix: when skip-redirect is active, generate auth cookies in-process
   via wp_generate_auth_cookie() and set them in $_COOKIE directly.

3. WP 2.1 front page 404: the home/siteurl DB options didn't include
   the Playground scope path. WP < 2.6 doesn't support WP_HOME/
   WP_SITEURL constants, so parse_request() couldn't strip the scope
   prefix from REQUEST_URI. The remaining path matched no rewrite
   rule, triggering a 404. Fix: always update siteurl/home in
   postInstallLegacyFixups when the DB value doesn't match
   PLAYGROUND_SITE_URL, not just when it equals 'http://localhost'.
WordPress 2.0 and earlier don't natively override get_option('siteurl')
with the WP_SITEURL constant — that check was added in WP 2.2. Without
it, get_settings('siteurl') returns the raw DB value, which can differ
from the Playground's scoped URL. This causes all admin navigation links
to point to the wrong URL (missing the /scope:xxx/ prefix), breaking
navigation in WP 2.0's admin dashboard.

Add option_siteurl and option_home filters in the legacy PHP preload
that return the WP_SITEURL / WP_HOME constant values when defined. This
replicates the behavior that modern WordPress provides natively.
WP 1.0's apply_filters() expects $wp_filter[$tag] to be a flat array
of function name strings. WP 1.2 expects $wp_filter[$tag][$priority]
to hold plain function name strings. WP 1.5+ expects the associative
array format with 'function' and 'accepted_args' keys.

Our playground_add_filter() was using the WP 1.5+ format
unconditionally, which crashed WP 1.0 ("Function name must be a
string") and WP 1.2 ("Array callback has to contain indices 0 and 1")
when the SQLite integration's wpdb called apply_filters('query', ...).

Detect the WordPress version from wp-includes/version.php at runtime
and store hooks in the format the target version expects.
Two fixes for legacy WordPress admin in the Playground environment:

1. Bypass check_admin_referer() on WP < 2.5. The function checks
   $_SERVER['HTTP_REFERER'] against the siteurl, but the Referer
   header is missing or incorrect in Playground's service worker.
   This caused plugin activation (and other admin actions) to fail
   with "you need to enable sending referrers". WP 2.5+ uses
   nonce-based verification and is unaffected.

2. Fix empty posts listing on the WP 1.5 dashboard. The recent
   posts query includes "AND post_date_gmt < '$today'" which fails
   in SQLite, hiding the Posts section entirely. The condition is
   redundant since post_status = 'publish' already excludes
   scheduled posts on WP 1.x.
The regex in patchCheckAdminReferer() used [^}]* which cannot match
past the closing brace of an inner if-block. WP 1.2 wraps its die()
call in `if (...) { die(...); }` (braced), while WP 1.5 uses a
braceless `if (...) die(...)`. The old regex stopped at the if-block's
closing brace, leaving the function's closing brace as orphaned syntax
and producing a parse error on line 377.

Replace [^}]* with (?:[^{}]|\{[^}]*\})* to allow one level of brace
nesting, correctly matching both WP 1.2 and WP 1.5 function bodies.
Remove the EXTENDED_TEST_VERSIONS gate that skipped WP < 2.5.
All versions from 1.0 to 4.9 now run all 5 test phases.
Use post.php for WP < 2.5 (post-new.php didn't exist yet).
WP 1.5's Magpie RSS library calls error() as a standalone function
from fetch_rss() and _response_to_rss(), but error() is only defined
as a method on the RSSCache class. In Playground, outbound HTTP always
fails, so every fetch_rss() call hits the error path and causes a
fatal "Call to undefined function error()" that @ cannot suppress.

This killed the dashboard rendering mid-page — everything after the
Blog Stats section (which precedes the first fetch_rss() call) was
missing, including the Dashboard heading and admin footer.

Patch rss-functions.php to define the missing global error() function
as a no-op stub (matching the RSSCache::error() behavior when
MAGPIE_DEBUG is off).
@JanJakes
Copy link
Copy Markdown
Member Author

Closing in favor of #3490.

@JanJakes JanJakes closed this Apr 15, 2026
adamziel pushed a commit that referenced this pull request Apr 29, 2026
> **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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants