detect -> parse -> rewrite -> generate -> install * pipeline for a single export ZIP. */ class Converter { /** * Convert an export ZIP into an installed (optionally activated) * WordPress theme. * * @param string $zip_path Path to the export ZIP file. * @param array $options { * Optional. * * @type string $preferred_slug Desired theme slug. Defaults * to the ZIP's filename, * sanitized by Theme_Generator. * @type bool $activate Activate the theme once * installed. Default false. * @type string $on_collision 'fail' (default) or 'suffix' * — see Installer::install(). * @type bool $recover_images For a multi-page export, * attempt to fetch any * cloud-hosted-only image * (see Multi_Page_Rewriter:: * UNRESOLVABLE_ASSET_PATTERN) * from the original Lovable * project's own still-live * hosting. Default false — * the only network request * this plugin ever makes, so * it's opt-in: a deliberate, * informed user action, never * the default. See * Asset_Recoverer's own * docblock. * @type callable|null $on_message Optional function( string * $level, string $message ), * $level one of 'info'/ * 'warning', called as each * line is produced — lets a * CLI caller stream output * live. Every line is always * collected into the returned * Conversion_Result * regardless of whether this * is passed. * } * * @return Conversion_Result */ public function convert( $zip_path, array $options = array() ) { $options = array_merge( array( 'preferred_slug' => pathinfo( $zip_path, PATHINFO_FILENAME ), 'activate' => false, 'on_collision' => 'fail', 'recover_images' => false, 'on_message' => null, ), $options ); $result = new Conversion_Result(); $emit = function ( $level, $message ) use ( $options, $result ) { $result->log[] = $message; if ( 'warning' === $level ) { $result->warnings[] = $message; } if ( is_callable( $options['on_message'] ) ) { call_user_func( $options['on_message'], $level, $message ); } }; if ( ! is_readable( $zip_path ) ) { $result->error_message = "ZIP file not readable: {$zip_path}"; return $result; } $extract_dir = trailingslashit( get_temp_dir() ) . 'esipher-' . uniqid( '', true ); $detector = new Source_Detector(); $emit( 'info', "Extracting {$zip_path} ..." ); try { ( new Zip_Extractor() )->extract( $zip_path, $extract_dir ); } catch ( Zip_Extraction_Exception $e ) { $entry_names = ( new Zip_Extractor() )->peek_entry_names( $zip_path ); if ( $detector->entry_names_look_like_mobile_app( $entry_names ) ) { $result->error_message = $this->mobile_app_message( $e ); return $result; } if ( $detector->entry_names_look_like_ssr_only_export( $entry_names ) ) { $result->error_message = $this->ssr_only_message( $e ); return $result; } $result->error_message = 'Extraction failed: ' . $e->getMessage(); return $result; } // Unwrap a single top-level wrapper folder (common for GitHub-style // ZIP downloads, Lovable's included) before looking for index.html. $source_root = $detector->resolve_root( $extract_dir ); $emit( 'info', 'Detecting source builder ...' ); $source_key = $detector->detect( $source_root ); $adapter = $detector->get_adapter( $source_key ); $emit( 'info', " -> detected: {$source_key}" ); $metadata = $adapter->get_metadata( $source_root ); $this->emit_backend_dependency_warnings( $metadata, $emit ); // A multi-page static export (see Source_Detector:: // resolve_multi_page_root()) is checked for before the single-page // index.html lookup below, and against BOTH the pristine // extraction root and the resolve_root()-unwrapped one. That's // deliberate: when static-export/ was the ZIP's only top-level // entry (e.g. a user zipped just that output folder, rather than // the whole project it was built from), resolve_root()'s single- // wrapper-folder unwrap steps *into* static-export/ itself, // making its own home-page index.html look exactly like an // ordinary single-page root — silently converting only that one // route and dropping every other page, with no error at all to // hint anything went wrong. Checking $extract_dir first catches // that case; checking $source_root still covers the original // "whole repo, static-export among several siblings inside one // wrapper folder" shape this feature was first built for, where // no unwrap happens until after this check either way. $multi_page_root = $detector->resolve_multi_page_root( $extract_dir ); if ( null === $multi_page_root ) { $multi_page_root = $detector->resolve_multi_page_root( $source_root ); } if ( null !== $multi_page_root ) { return $this->convert_multi_page( $multi_page_root, $options, $extract_dir, $result, $emit ); } $index_path = trailingslashit( $source_root ) . 'index.html'; if ( ! is_file( $index_path ) ) { // Some exports are a whole repo checkout rather than just its // build output (e.g. GitHub's own "Download ZIP" of the full // project) — index.html isn't at $source_root itself, but // sits one or more folders deeper (dist/index.html, alongside // src/, package.json, etc.), so resolve_root()'s // single-wrapper-folder unwrap never fires: there's more than // one entry to unwrap into. Only takes effect when nothing // was found at the expected root already, and only when the // search turns up exactly one unambiguous candidate — see // Source_Detector::find_deep_index_html()'s own docblock. $deep_index_dir = $detector->find_deep_index_html( $source_root ); if ( null !== $deep_index_dir ) { $source_root = $deep_index_dir; $index_path = trailingslashit( $source_root ) . 'index.html'; } } if ( ! is_file( $index_path ) ) { // Detection needs $source_root to still exist, so it must run // before cleanup_dir() — not after, or every check below // would find nothing and silently fall through to the // generic message. Checked in this priority order: a // project matching the more specific mobile-app/SSR-only // markers is reported as that even if it also happens to // have a buildable package.json; only once neither of those // applies does "no index.html, but a genuinely buildable // project" (Source_Detector::is_unbuilt_source_export()'s // own no-index.html case — an SSR framework's raw source, // e.g. TanStack Start, has none until actually built) get a // chance to fall through to the unbuilt-source handling // below instead of dead-ending here. $is_mobile_app = $detector->is_mobile_app_export( $source_root ); $is_ssr_only = $detector->is_ssr_only_export( $source_root ); if ( $is_mobile_app || $is_ssr_only || ! $detector->is_unbuilt_source_export( $source_root ) ) { $this->cleanup_dir( $extract_dir ); if ( $is_mobile_app ) { $result->error_message = $this->mobile_app_message(); return $result; } if ( $is_ssr_only ) { $result->error_message = $this->ssr_only_message(); return $result; } $result->error_message = 'No index.html found at the root of the export.'; return $result; } } if ( $detector->is_unbuilt_source_export( $source_root ) ) { // Esipher converts a finished build's output, not a project's // raw, uncompiled source. Building it here would mean running // the project's own npm/build scripts on the server, which // this plugin deliberately never does — the bundled "Esipher // Build" GitHub Actions template and Connected Projects both // build it for you instead. So raw source is reported with // instructions rather than converted. $package_manager = $detector->detect_package_manager( $source_root ); $result->unbuilt_source = true; $result->package_manager = $package_manager; $this->cleanup_dir( $extract_dir ); $result->error_message = $this->unbuilt_source_message( $package_manager, is_file( $index_path ) ); return $result; } // Local file within the extraction dir we just created, not a // remote fetch — wp_remote_get() doesn't apply here. $html = $adapter->clean_markup( file_get_contents( $index_path ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $emit( 'info', 'Parsing markup ...' ); $doc = ( new Html_Parser() )->parse( $html ); $emit( 'info', 'Mapping nav ...' ); $nav_items = empty( $doc->nav_html ) ? array() : ( new Nav_Mapper() )->map( $doc->nav_html[0] ); $emit( 'info', ' -> ' . count( $nav_items ) . ' top-level nav item(s)' ); $emit( 'info', 'Rewriting assets ...' ); $assets = ( new Asset_Rewriter() )->rewrite( $doc, $source_root, $options['preferred_slug'] ); $emit( 'info', ' -> ' . count( $assets->copy_manifest ) . ' local asset(s) to copy' ); $emit( 'info', 'Generating theme files ...' ); $version = defined( 'ESIPHER_VERSION' ) ? ESIPHER_VERSION : '1.0.0'; $theme = ( new Theme_Generator() )->generate( $doc, $assets, $nav_items, $options['preferred_slug'], $version ); $emit( 'info', sprintf( ' -> theme slug: %s (%d files)', $theme->slug, count( $theme->files ) ) ); try { $install_result = ( new Installer() )->install( $theme, array( 'activate' => (bool) $options['activate'], 'on_collision' => $options['on_collision'], ) ); } catch ( Installer_Exception $e ) { $this->cleanup_dir( $extract_dir ); $result->error_message = 'Install failed: ' . $e->getMessage(); return $result; } $emit( 'info', "Installed to {$install_result->theme_dir}" ); if ( $install_result->activated ) { $emit( 'info', sprintf( "Activated '%s'. Previous theme '%s' saved; roll back to restore it.", $install_result->slug, $install_result->previous_theme ) ); } $this->cleanup_dir( $extract_dir ); $result->success = true; $result->slug = $install_result->slug; $result->theme_dir = $install_result->theme_dir; $result->activated = $install_result->activated; $result->previous_theme = $install_result->previous_theme; return $result; } /** * The multi-page counterpart of the rest of convert() — discovers * every route under a multi-page static export (Multi_Page_Rewriter) * and installs each as a real WordPress Page instead of the * single-page pipeline's one PHP-template-embedded body. See * Installer::install()'s docblock for what "install" means here. * * @param string $multi_page_root Resolved multi-page root, * from Source_Detector:: * resolve_multi_page_root(). * @param array $options Same $options convert() * was called with. * @param string $extract_dir Temporary extraction * directory, for cleanup. * @param Conversion_Result $result Mutated and returned. * @param callable $emit Same logging closure * convert() uses. * * @return Conversion_Result */ private function convert_multi_page( $multi_page_root, array $options, $extract_dir, Conversion_Result $result, callable $emit ) { $emit( 'info', 'Detected a multi-page static export.' ); try { $discovery = ( new Multi_Page_Rewriter() )->discover( $multi_page_root, $options['preferred_slug'] ); } catch ( Multi_Page_Rewriter_Exception $e ) { $this->cleanup_dir( $extract_dir ); $result->error_message = $e->getMessage(); return $result; } $emit( 'info', ' -> ' . count( $discovery['pages'] ) . ' page(s) discovered' ); if ( $discovery['unresolvable_asset_count'] > 0 ) { if ( ! empty( $options['recover_images'] ) ) { $discovery = $this->recover_unresolvable_images( $discovery, $multi_page_root, $extract_dir, $options, $emit ); } else { // Only mentions the flag when it wasn't already tried — // once it has been, recover_unresolvable_images()'s own // "still N image(s)" warning is the more useful, honest // message instead. $emit( 'warning', sprintf( '%d image(s) in this export are only referenced via a cloud-hosted optimized-image URL ' . '(e.g. Lovable\'s own asset service), their actual bytes were never included in the ' . 'downloaded ZIP, only a pointer back to that service. These images will not load after ' . 'conversion; this is a limitation of the export itself, not the conversion. Some AI ' . 'site builders keep serving these from the original project, retry with ' . '--recover-images (WP-CLI) to attempt fetching them from there.', $discovery['unresolvable_asset_count'] ) ); } } $version = defined( 'ESIPHER_VERSION' ) ? ESIPHER_VERSION : '1.0.0'; $theme = ( new Theme_Generator() )->generate( $discovery['home_doc'], $discovery['assets'], array(), $options['preferred_slug'], $version, true ); $theme->pages = $discovery['pages']; $emit( 'info', sprintf( ' -> theme slug: %s (%d files, %d pages)', $theme->slug, count( $theme->files ), count( $theme->pages ) ) ); try { $install_result = ( new Installer() )->install( $theme, array( 'activate' => (bool) $options['activate'], 'on_collision' => $options['on_collision'], ) ); } catch ( Installer_Exception $e ) { $this->cleanup_dir( $extract_dir ); $result->error_message = 'Install failed: ' . $e->getMessage(); return $result; } $emit( 'info', "Installed to {$install_result->theme_dir}" ); if ( $install_result->activated ) { $emit( 'info', sprintf( "Activated '%s'. Previous theme '%s' saved; roll back to restore it.", $install_result->slug, $install_result->previous_theme ) ); } $this->cleanup_dir( $extract_dir ); $result->success = true; $result->slug = $install_result->slug; $result->theme_dir = $install_result->theme_dir; $result->activated = $install_result->activated; $result->previous_theme = $install_result->previous_theme; return $result; } /** * $options['recover_images'] === true: attempt to fetch every one of * $discovery's unresolvable cloud-hosted images from the original * Lovable project's own still-live hosting (Asset_Recoverer), then * re-run Multi_Page_Rewriter::discover() so any newly-recovered * local files get picked up by its own asset-rewriting exactly like * an ordinary local file — see Asset_Recoverer's own docblock for * why placing the file at the exact local path its URL already * implies is enough for that to just happen, with no changes needed * to how discover() itself resolves assets. * * Never a hard failure: no Lovable project detected, every fetch * failing, or WP_Filesystem being unavailable all just mean nothing * gets recovered — $discovery is returned unchanged and the ordinary * unresolvable-images warning still applies to whatever's left. * * @param array $discovery discover()'s own return value. * @param string $multi_page_root Resolved multi-page root. * @param string $extract_dir Raw (pre-single-wrapper-unwrap) * extraction root — where a "whole * repo" zip's own src/assets/ (and * therefore the Lovable project id * descriptors) would be, if this * export has them at all. * @param array $options Same $options convert() was * called with. * @param callable $emit Same logging closure convert() * uses. * * @return array discover()'s return value — re-run against the same * $multi_page_root when anything was recovered, * otherwise the original, unchanged. */ private function recover_unresolvable_images( array $discovery, $multi_page_root, $extract_dir, array $options, callable $emit ) { $detector = new Source_Detector(); $project_url = $detector->find_lovable_project_url( $extract_dir ); if ( null === $project_url ) { $project_url = $detector->find_lovable_project_url( $detector->resolve_root( $extract_dir ) ); } if ( null === $project_url ) { $emit( 'warning', sprintf( '%d image(s) in this export are only referenced via a cloud-hosted optimized-image URL, and ' . '--recover-images couldn\'t find the original project to fetch them from (no src/assets/*.asset.json ' . 'descriptor in this ZIP), this is a limitation of the export itself, not the conversion.', $discovery['unresolvable_asset_count'] ) ); return $discovery; } $emit( 'info', sprintf( 'Attempting to recover %d cloud-hosted image(s) from the original project ...', $discovery['unresolvable_asset_count'] ) ); $recovery = ( new Asset_Recoverer() )->recover( $multi_page_root, $project_url, array_map( function ( $path ) { return '/' . $path; }, $discovery['unresolvable_assets'] ) ); if ( $recovery['recovered'] > 0 ) { $emit( 'info', " -> recovered {$recovery['recovered']} image(s)" ); try { $discovery = ( new Multi_Page_Rewriter() )->discover( $multi_page_root, $options['preferred_slug'] ); } catch ( Multi_Page_Rewriter_Exception $e ) { // Can't realistically happen — discover() already // succeeded once above against this same root, and // recovery only ever adds files, never removes the home // route's own index.html — but if it somehow did, fall // back to the pre-recovery discovery rather than lose it. $emit( 'warning', 'Re-scanning after image recovery failed unexpectedly: ' . $e->getMessage() ); } } if ( $recovery['failed'] > 0 ) { $emit( 'warning', sprintf( '%d image(s) could not be recovered even from the original project (it may have been deleted, ' . 'or is no longer public) — this is a limitation of the export itself, not the conversion.', $recovery['failed'] ) ); } return $discovery; } /** * Shared message text for a detected React Native/Expo export, * whether caught via a rejected extraction or a missing index.html. * * @param Zip_Extraction_Exception|null $e The original extraction * failure, when this was * reached via that path. * * @return string */ private function mobile_app_message( $e = null ) { $message = 'This looks like a React Native/Expo mobile app export, not a website build. ' . 'Esipher converts static web builds (Vite/React sites) into WordPress themes, ' . "mobile app projects can't be converted this way."; if ( $e ) { $message .= ' (Extraction was also rejected: ' . $e->getMessage() . ')'; } return $message; } /** * Shared message text for a detected SSR-only export, whether caught * via a rejected extraction or a missing index.html. * * @param Zip_Extraction_Exception|null $e The original extraction * failure, when this was * reached via that path. * * @return string */ private function ssr_only_message( $e = null ) { $message = "This looks like a server-rendered (SSR) app's build output (e.g. TanStack " . 'Start, Next.js, Remix), not a static site. Esipher converts static Vite/React ' . 'builds into WordPress themes; these frameworks render HTML per request on a ' . "server instead, so there's no static site here to convert."; if ( $e ) { $message .= ' (Extraction was also rejected: ' . $e->getMessage() . ')'; } return $message; } /** * Emits every warning Source_Adapter::get_metadata() has a flag for. * * @param array $metadata Adapter::get_metadata() result. * @param callable $emit The 'warning'/'info' log-line * callback already in scope * (see convert()'s own $emit). */ private function emit_backend_dependency_warnings( array $metadata, callable $emit ) { if ( ! empty( $metadata['supabase_detected'] ) ) { $emit( 'warning', 'Supabase client detected in this export. Auth, database reads/writes, and any ' . 'other Supabase-backed functionality will not work after conversion, this is a ' . 'known limitation, not a bug.' ); } if ( ! empty( $metadata['backend_api_detected'] ) ) { $emit( 'warning', 'Hardcoded backend API calls (/api/...) detected in this export. Any functionality ' . 'that depends on a live server, forms, login, bookings, dynamic data, will not ' . 'work after conversion, since that server is not part of the export.' ); } if ( ! empty( $metadata['realtime_detected'] ) ) { $emit( 'warning', 'Supabase Realtime (live-updating) subscriptions detected in this export. If the ' . 'converted site unexpectedly goes blank, especially a few seconds after loading ' . 'or right after logging in, open the browser console first: an error mentioning ' . '"subscribe()" or "postgres_changes" points to a known bug pattern in real Lovable ' . 'exports, where more than one place in the app subscribes to the same live channel ' . "and the second attempt crashes. That's a bug in the export's own code (verified to " . 'happen the same way on a correctly-built copy of the export, independent of this ' . "conversion), not something Esipher can fix here, it never touches the app's " . 'compiled JavaScript. The fix has to happen in the original project, wherever it ' . 'calls .channel(...).on("postgres_changes", ...).' ); } } /** * The exact install-and-build command for each package manager * Source_Detector::detect_package_manager() can identify — used so * unbuilt_source_message() can give a real, copy-pasteable command * instead of a generic guess. 'build' is by far the most common * script name for this across real exports seen so far; a project * using a different one will need to adjust it, which the message * text itself calls out. * * @var array */ const BUILD_COMMANDS = array( 'npm' => 'npm install && npm run build', 'yarn' => 'yarn install && yarn build', 'pnpm' => 'pnpm install && pnpm build', 'bun' => 'bun install && bun run build', ); /** * Message text for a detected unbuilt-source export (see * Source_Detector::is_unbuilt_source_export()) — reached two ways, * distinguished by $has_index_html: the classic case (a root * index.html that loads a .ts/.tsx/.jsx file directly), and an SSR * framework's raw source (e.g. TanStack Start), which has no * index.html at all until actually built. * * Esipher deliberately never runs this build itself, even though * most users uploading a raw source export would rather it just * worked: most WordPress hosts have no Node.js at all, many disable * exec()/shell_exec() specifically to prevent arbitrary code * execution, PHP's request timeout would kill a build that takes * more than 30-60 seconds, and — the real reason, not just a * practical one — `npm install` (or the yarn/pnpm/bun equivalent) * runs arbitrary install scripts from an unaudited dependency tree * as routine behavior, which is a materially bigger trust surface on * a live production site than anything else this plugin does (pure * PHP string manipulation, zero shell execution, zero outbound * network calls). The best available fix is a fast, specific * rejection instead: which package manager this export actually * uses (verified necessary against a real export that shipped a * bun.lock, not package-lock.json), and the exact command for it. * * @param string $package_manager Source_Detector:: * detect_package_manager()'s result. * @param bool $has_index_html Whether $source_root has a root * index.html at all — selects which * of the two explanations above to give. * * @return string */ private function unbuilt_source_message( $package_manager, $has_index_html = true ) { $command = isset( self::BUILD_COMMANDS[ $package_manager ] ) ? self::BUILD_COMMANDS[ $package_manager ] : self::BUILD_COMMANDS['npm']; $why = $has_index_html ? 'its index.html loads a .ts/.tsx/.jsx file directly — a browser can\'t execute that' : 'there\'s no index.html at all yet — some frameworks (like this one) only generate a static site once actually built'; return "This looks like the project's raw, unbuilt source code, not a production build: {$why}. " . "Esipher converts a Vite build's *output*, not its source: run `{$command}` in the project " . '(adjust the script name if this project calls it something other than "build"), or use ' . 'your builder\'s "download build"/"export production files" option, if it offers one ' . 'separately from the source, and upload the resulting dist/ folder as a ZIP instead.'; } /** * Recursively remove the temporary extraction directory. Uses direct * PHP filesystem calls deliberately: this is the system temp * directory (always local), not the wp-content/themes/ tree that * WP_Filesystem's FTP/SSH abstraction exists to protect. * * @param string $dir Directory to remove. */ private function cleanup_dir( $dir ) { if ( ! is_dir( $dir ) ) { return; } foreach ( scandir( $dir ) as $item ) { if ( '.' === $item || '..' === $item ) { continue; } $path = $dir . '/' . $item; if ( is_dir( $path ) ) { $this->cleanup_dir( $path ); } else { unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink } } rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir } }