Themes > Add New already requires, since a * converted theme's functions.php is arbitrary executable PHP, the * same trust level as any other theme install. */ const CAPABILITY = 'install_themes'; const MENU_SLUG = 'esipher'; const CONVERT_ACTION = 'esipher_convert'; const ROLLBACK_ACTION = 'esipher_rollback'; const STREAM_ACTION = 'esipher_convert_stream'; const RESULT_TRANSIENT = 'esipher_result_'; /** * The return value of add_menu_page() — the exact admin-page hook * suffix, used by enqueue_assets() to load the progress-log script * only on this one page rather than guessing at WordPress's hook-suffix * naming convention. * * @var string */ private $page_hook = ''; /** * Wire up the admin menu, the two admin-post handlers, the streaming * AJAX handler behind the "watch it convert live" progress log, and * that log's own script. */ public function register() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_post_' . self::CONVERT_ACTION, array( $this, 'handle_convert' ) ); add_action( 'admin_post_' . self::ROLLBACK_ACTION, array( $this, 'handle_rollback' ) ); add_action( 'wp_ajax_' . self::STREAM_ACTION, array( $this, 'handle_convert_stream' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); Admin_Layout::register(); } /** * Adds a top-level "Esipher" menu item — converting an export is this * plugin's one primary action, not a tweak to an existing theme's * appearance, so it needs to be immediately visible in the main admin * menu rather than tucked under an existing menu a user has no * particular reason to check. */ public function add_menu() { $this->page_hook = add_menu_page( __( 'Esipher', 'esipher' ), __( 'Esipher', 'esipher' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render_page' ), Admin_Layout::MENU_ICON ); } /** * Loads the progress-log script only on this plugin's own admin page * — never site-wide. Progressive enhancement: the classic form POST * to admin-post.php (handle_convert(), below) still works completely * unaided if this script never loads or fetch()/ReadableStream isn't * available; it's what the script's own JS falls back to. * * @param string $hook_suffix Current admin page's hook suffix. */ public function enqueue_assets( $hook_suffix ) { if ( $hook_suffix !== $this->page_hook ) { return; } wp_enqueue_script( 'esipher-convert-progress', ESIPHER_PLUGIN_URL . 'admin/js/convert-progress.js', array(), ESIPHER_VERSION, true ); wp_localize_script( 'esipher-convert-progress', 'esipherConvert', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'action' => self::STREAM_ACTION, 'nonce' => wp_create_nonce( self::CONVERT_ACTION ), 'themesUrl' => admin_url( 'themes.php' ), 'homeUrl' => home_url( '/' ), 'i18n' => array( 'converting' => __( 'Converting…', 'esipher' ), 'done' => __( 'Done.', 'esipher' ), 'failed' => __( 'Failed.', 'esipher' ), 'warning' => __( 'Warning: ', 'esipher' ), /* translators: %s: installed theme slug. */ 'installed' => __( "Converted successfully — theme '%s' installed.", 'esipher' ), 'activated' => __( 'Theme activated.', 'esipher' ), 'viewSite' => __( 'View site', 'esipher' ), 'notYet' => __( 'Not activated yet.', 'esipher' ), 'goThemes' => __( 'Go to Appearance > Themes to activate it', 'esipher' ), ), ) ); } /** * Renders the upload form, any result notices from a just-completed * admin-post redirect, and the rollback section when applicable. */ public function render_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'esipher' ) ); } $result = $this->consume_result_from_request(); Admin_Layout::open( self::MENU_SLUG, __( 'Esipher - Convert', 'esipher' ) ); $this->render_notices( $result ); Admin_Layout::card_open( __( 'Convert a new export', 'esipher' ), __( 'Turn an AI site-builder export ZIP (Lovable, Bolt.new or another Vite/React build) into a WordPress theme. Everything runs on this site; nothing is uploaded anywhere else.', 'esipher' ) ); ?>
render_how_to_card(); $this->render_rollback_section(); Admin_Layout::close(); } /** * The "how to use" steps, kept below the form so the form is the first * thing a returning user sees. */ private function render_how_to_card() { Admin_Layout::card_open( __( 'How it works', 'esipher' ) ); ?>

tag to the video walkthrough, 2: closing tag. */ esc_html__( 'Prefer video? Watch the full walkthrough: %1$shttps://youtu.be/6-ne9-7Wgt4%2$s', 'esipher' ), '', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static, developer-authored anchor-open tag, not dynamic content. '' ); ?>

  1. tag to the Esipher Build template repo, 2: closing tag. */ esc_html__( 'A Lovable or Bolt.new export is raw source, not a build, it needs to be built before Esipher can convert it. Use our %1$sEsipher Build%2$s template to do that: open that link, click "Use this template," then follow the steps in its README. It walks you through uploading your export and downloading the finished, ready-to-convert ZIP, no installs or command line needed.', 'esipher' ), '', // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static, developer-authored anchor-open tag, not dynamic content. '' ); ?>
  2. Themes.', 'esipher' ); ?>
prepare_uploaded_zip(); if ( ! $prepared['ok'] ) { $this->redirect_with_result( $this->error_result( $prepared['error'] ) ); } $this->redirect_with_result( $this->run_converter( $prepared['path'], $prepared['original_name'] ) ); } /** * The wp_ajax_esipher_convert_stream handler: the same validate-upload- * and-convert flow as handle_convert(), but streamed to the browser * as newline-delimited JSON as each Converter log line is produced — * driving the live progress log in admin/js/convert-progress.js — * instead of redirecting to a page that only shows the result after * everything has already finished. Ends the request itself; never * returns. */ public function handle_convert_stream() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'esipher' ) ) ); } check_ajax_referer( self::CONVERT_ACTION, 'nonce' ); // Best-effort: disable every layer of buffering this process // itself controls, so each line reaches the browser as soon as // it's emitted rather than arriving all at once at the very end // — the entire point of this endpoint. Some hosts' proxy layers // buffer regardless of any of this; in that case the live log // simply never updates and nothing is shown until the final // line, the same experience handle_convert()'s classic // redirect-based flow already provides — never worse than today. while ( ob_get_level() > 0 ) { ob_end_clean(); } nocache_headers(); header( 'Content-Type: application/x-ndjson' ); header( 'X-Accel-Buffering: no' ); $emit_line = function ( array $data ) { echo wp_json_encode( $data ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode() output is already safe, and this is a raw NDJSON stream, not HTML. if ( ob_get_level() > 0 ) { ob_flush(); } flush(); }; $prepared = $this->prepare_uploaded_zip(); if ( ! $prepared['ok'] ) { $emit_line( array( 'type' => 'done', 'success' => false, 'error' => $prepared['error'], ) ); exit; } $result = $this->run_converter( $prepared['path'], $prepared['original_name'], function ( $level, $message ) use ( $emit_line ) { $emit_line( array( 'type' => 'log', 'level' => $level, 'message' => $message, ) ); } ); $emit_line( array( 'type' => 'done', 'success' => $result->success, 'error' => $result->error_message, 'warnings' => $result->warnings, 'slug' => $result->slug, 'activated' => $result->activated, 'unbuiltSource' => $result->unbuilt_source, 'packageManager' => $result->package_manager, ) ); exit; } /** * Validate the uploaded ZIP and move it to a temp path — shared by * both handle_convert() and handle_convert_stream(), which only * differ in how they report the result. * * @return array{ok: bool, path?: string, original_name?: string, error?: string} */ private function prepare_uploaded_zip() { // $_FILES is server-populated structured data, not user text: its // error code / tmp path are consumed by validate_upload()'s // is_uploaded_file() check and then by wp_handle_upload(), which // does the actual validation and move. The request's nonce is // verified by the caller. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash $file = isset( $_FILES['esipher_zip'] ) ? $_FILES['esipher_zip'] : null; $upload_error = $this->validate_upload( $file ); if ( null !== $upload_error ) { return array( 'ok' => false, 'error' => $upload_error, ); } if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // wp_handle_upload() does the is_uploaded_file() + move safely and // respects the FTP/SSH filesystem method; it lands the file in the // uploads dir, which run_converter() deletes as soon as it's done. $moved = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => array( 'zip' => 'application/zip' ), ) ); if ( isset( $moved['error'] ) || empty( $moved['file'] ) ) { return array( 'ok' => false, 'error' => isset( $moved['error'] ) ? $moved['error'] : __( 'Failed to save the uploaded file.', 'esipher' ), ); } return array( 'ok' => true, 'path' => $moved['file'], 'original_name' => is_array( $file ) && isset( $file['name'] ) ? basename( $file['name'] ) : basename( $moved['file'] ), ); } /** * Read the shared form fields (slug/activate/force) from $_POST and * run Converter, always deleting the temporary uploaded ZIP * afterward — success or failure — so an export's contents (which * can include a bundled .env with real secrets, seen in real samples * this engine was tested against) never lingers on disk longer than * the conversion needs it. * * @param string $tmp_zip_path Path from prepare_uploaded_zip(). * @param string $original_name Original uploaded filename, for * deriving a default slug. * @param callable|null $on_message Optional Converter on_message * callback (see Converter::convert()). * * @return \Esipher\Conversion_Result */ private function run_converter( $tmp_zip_path, $original_name, $on_message = null ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- both // callers (handle_convert() via check_admin_referer(), handle_convert_stream() // via check_ajax_referer()) already verify the nonce before reaching // this method; these are the same already-authenticated request's // remaining form fields, not a separate unverified entry point. $preferred_slug = isset( $_POST['esipher_slug'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_slug'] ) ) : ''; $activate = ! empty( $_POST['esipher_activate'] ); $force = ! empty( $_POST['esipher_force'] ); $recover_images = ! empty( $_POST['esipher_recover_images'] ); // phpcs:enable WordPress.Security.NonceVerification.Missing $options = array( 'preferred_slug' => '' !== $preferred_slug ? $preferred_slug : pathinfo( $original_name, PATHINFO_FILENAME ), 'activate' => $activate, 'on_collision' => $force ? 'suffix' : 'fail', 'recover_images' => $recover_images, ); if ( null !== $on_message ) { $options['on_message'] = $on_message; } $result = ( new Converter() )->convert( $tmp_zip_path, $options ); if ( file_exists( $tmp_zip_path ) ) { wp_delete_file( $tmp_zip_path ); } return $result; } /** * The admin_post_esipher_rollback handler. */ public function handle_rollback() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ROLLBACK_ACTION, self::ROLLBACK_ACTION . '_nonce' ); try { $restored = ( new Installer() )->rollback(); $result = new Conversion_Result(); $result->success = true; $result->log[] = sprintf( /* translators: %s: restored theme's stylesheet slug. */ __( "Restored previous theme '%s'.", 'esipher' ), $restored ); } catch ( Installer_Exception $e ) { $result = $this->error_result( $e->getMessage() ); } $this->redirect_with_result( $result ); } /** * Validate an uploaded file before it's handed to wp_handle_upload(). * * @param array|null $file The relevant $_FILES entry, or null if absent. * * @return string|null A user-facing error message, or null if valid. */ private function validate_upload( $file ) { if ( empty( $_FILES ) && ! empty( $_SERVER['CONTENT_LENGTH'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- covered by check_admin_referer() in the caller. return __( "The uploaded file was too large and the server rejected it before PHP could even see it — it exceeded the server's own upload size limit.", 'esipher' ); } if ( ! is_array( $file ) || ! isset( $file['error'] ) || UPLOAD_ERR_NO_FILE === $file['error'] ) { return __( 'Please choose a ZIP file to upload.', 'esipher' ); } if ( UPLOAD_ERR_INI_SIZE === $file['error'] || UPLOAD_ERR_FORM_SIZE === $file['error'] ) { return sprintf( /* translators: %s: max upload size, e.g. "64 MB". */ __( 'The uploaded file exceeds the maximum upload size (%s).', 'esipher' ), size_format( wp_max_upload_size() ) ); } if ( UPLOAD_ERR_OK !== $file['error'] ) { return __( 'The file upload failed. Please try again.', 'esipher' ); } if ( ! is_uploaded_file( $file['tmp_name'] ) ) { return __( 'The file upload failed. Please try again.', 'esipher' ); } if ( 'zip' !== strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ) ) { return __( 'Please upload a .zip file.', 'esipher' ); } return null; } /** * Build a failed Conversion_Result from a plain message — used for * upload-validation failures that never reach Converter at all. * * @param string $message User-facing error message. * * @return Conversion_Result */ private function error_result( $message ) { $result = new Conversion_Result(); $result->error_message = $message; return $result; } /** * Stash a result in a short-lived transient and redirect back to the * admin page to display it — the POST-redirect-GET pattern, so * reloading the results page never resubmits the upload. Never * returns. * * @param Conversion_Result $result Result to display on redirect. */ private function redirect_with_result( Conversion_Result $result ) { $token = wp_generate_password( 12, false, false ); set_transient( self::RESULT_TRANSIENT . $token, $result, 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'esipher_result' => $token, ), admin_url( 'admin.php' ) ) ); exit; } /** * Read (and immediately delete) the result transient named by the * current request's esipher_result query arg, if any. * * @return Conversion_Result|null */ private function consume_result_from_request() { if ( empty( $_GET['esipher_result'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a one-time, plugin-generated transient token; nothing is mutated by reading it. return null; } $token = sanitize_key( wp_unslash( $_GET['esipher_result'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $key = self::RESULT_TRANSIENT . $token; $result = get_transient( $key ); delete_transient( $key ); return $result instanceof Conversion_Result ? $result : null; } /** * Render notices for a just-completed convert or rollback result. * * @param Conversion_Result|null $result Result to render, if any. */ private function render_notices( $result ) { if ( null === $result ) { return; } if ( ! $result->success ) { printf( '

%s

', esc_html( $result->error_message ) ); return; } foreach ( $result->warnings as $warning ) { printf( '

%s

', esc_html( $warning ) ); } if ( '' !== $result->theme_dir ) { printf( '

%s

', esc_html( sprintf( /* translators: %s: installed theme slug. */ __( "Converted successfully — theme '%s' installed.", 'esipher' ), $result->slug ) ) ); if ( $result->activated ) { printf( '

%s %s

', esc_html__( 'Theme activated.', 'esipher' ), esc_url( home_url( '/' ) ), esc_html__( 'View site', 'esipher' ) ); } else { printf( '

%s %s

', esc_html__( 'Not activated yet.', 'esipher' ), esc_url( admin_url( 'themes.php' ) ), esc_html__( 'Go to Appearance > Themes to activate it', 'esipher' ) ); } if ( ! empty( $result->log ) ) { echo '
' . esc_html__( 'Full conversion log', 'esipher' ) . '
' . esc_html( implode( "\n", $result->log ) ) . '
'; } } else { foreach ( $result->log as $line ) { printf( '

%s

', esc_html( $line ) ); } } } /** * Render the rollback form, only when there's actually a previous * theme recorded to roll back to. */ private function render_rollback_section() { $previous = get_option( Installer::PREVIOUS_THEME_OPTION ); if ( ! $previous ) { return; } Admin_Layout::card_open( __( 'Rollback', 'esipher' ) ); ?>

. */ esc_html__( 'A previous theme (%s) was saved before the last activation.', 'esipher' ), '' . esc_html( $previous ) . '' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the dynamic part is escaped; the surrounding tags are static, developer-authored markup. ); ?>