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.
''
);
?>
-
-
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.
''
);
?>
-
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( '', esc_html( $result->error_message ) );
return;
}
foreach ( $result->warnings as $warning ) {
printf( '', esc_html( $warning ) );
}
if ( '' !== $result->theme_dir ) {
printf(
'',
esc_html(
sprintf(
/* translators: %s: installed theme slug. */
__( "Converted successfully — theme '%s' installed.", 'esipher' ),
$result->slug
)
)
);
if ( $result->activated ) {
printf(
'',
esc_html__( 'Theme activated.', 'esipher' ),
esc_url( home_url( '/' ) ),
esc_html__( 'View site', 'esipher' )
);
} else {
printf(
'',
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( '', 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.
);
?>