*/ class Reactpress_Admin { /** * The ID of this plugin. * * @since 1.0.0 * @access private * @var string $plugin_name The ID of this plugin. */ private $plugin_name; /** * The version of this plugin. * * @since 1.0.0 * @access private * @var string $version The current version of this plugin. */ private $version; /** * Initialize the class and set its properties. * * @since 1.0.0 * @param string $plugin_name The name of this plugin. * @param string $version The version of this plugin. */ public function __construct($plugin_name, $version) { $this->plugin_name = $plugin_name; $this->version = $version; } /** * Register the stylesheets for the admin area. * * @since 1.0.0 */ public function enqueue_styles() { $valid_pages = ['reactpress']; $page = sanitize_title($_REQUEST['page'] ?? ""); if (in_array($page, $valid_pages)) { wp_enqueue_style($this->plugin_name, plugin_dir_url(__FILE__) . 'css/reactpress-admin.css', array(), $this->version, 'all'); } } /** * Register the JavaScript for the admin area. * * @since 1.0.0 */ public function enqueue_scripts() { $valid_pages = ['reactpress']; $page = sanitize_title($_REQUEST['page'] ?? ""); if (in_array($page, $valid_pages)) { wp_enqueue_script('rp-jquery-validate', plugin_dir_url(__FILE__) . 'js/jquery.validate.min.js', array('jquery'), $this->version, false); wp_enqueue_script($this->plugin_name, plugin_dir_url(__FILE__) . 'js/reactpress-admin.js', array('jquery'), $this->version, false); wp_localize_script($this->plugin_name, "rp", array( 'ajaxurl' => admin_url('admin-ajax.php'), 'apps' => get_option(('repr_apps')) )); } } /** * Add admin menu. * * @return void * @since 1.0.0 */ public function add_admin_menu() { add_menu_page( 'ReactPress', 'ReactPress', 'manage_options', 'reactpress', fn () => require_once('partials/reactpress-admin-display.php'), REPR_MENU_ICON ); } /** * Add page template. * (C) https://www.pradipdebnath.com/2019/08/17/how-to-add-page-template-from-plugin-in-wordpress/ * * @param array $templates The list of page templates * @return array $templates The modified list of page templates* * @since 1.0.0 */ public function repr_add_page_template($templates) { $templates[REPR_PLUGIN_PATH . 'templates/react-page-template.php'] = __('Page Template From ReactPress', 'text-domain'); return $templates; } public function repr_handle_admin_ajax_request() { /** * Handles all request from the admin frontend. * * @since 1.0.0 */ $appname = strtolower(sanitize_file_name($_POST['appname'] ?? '')); $app_options_list = $this->get_apps(); $pageslug = sanitize_title_for_query($_POST['pageslug'] ?? ''); $param = sanitize_file_name($_REQUEST['param'] ?? ""); $template = sanitize_file_name($_POST['template'] ?? ''); $type = sanitize_file_name($_POST['type'] ?? 'development'); try { if (!empty($param)) { if ($param === "create_react_app" && $appname && $pageslug) { $existing_appnames = array_map(fn ($el) => $el['appname'], $app_options_list); if (in_array($appname, $existing_appnames) || $this->does_pageslug_exist($pageslug)) { echo wp_json_encode([ 'status' => 0, 'message' => "App name or page slug already exists.", ]); } else { $this->create_new_app($app_options_list, $appname, $pageslug, $template, $type); $this->insert_react_page($appname, $pageslug); if ($type === 'development') { $this->write_index_html($appname, $this->get_index_html_content($pageslug)); } echo wp_json_encode([ 'status' => 1, 'message' => "React app created.", 'appname' => $appname, 'pageslug' => $pageslug ]); } } elseif ($param === "edit_url_slug" && $appname && $pageslug) { $app_option = $this->get_app_options($app_options_list, $appname); if (($app_option && $app_option['pageslug'])) { $this->update_react_page($app_option['pageslug'], $pageslug); $this->change_pageslug_option($app_options_list, $appname, $pageslug); $this->add_build_path($appname); $this->write_index_html($appname, $this->get_index_html_content($pageslug)); echo wp_json_encode([ 'status' => 1, 'message' => 'Url Slug changed.' ]); } elseif (!$this->does_pageslug_exist($pageslug) && $pageslug) { $this->insert_react_page($appname, $pageslug); $this->add_app_options($app_options_list, $appname, $pageslug); $this->add_build_path($appname); $this->write_index_html($appname, $this->get_index_html_content($pageslug)); echo wp_json_encode([ 'status' => 1, 'message' => 'Url Slug created.' ]); } else { echo wp_json_encode([ 'status' => 0, 'message' => "There exists another page that already has this page slug.", ]); } } elseif ($param === "update_index_html" && $appname && $pageslug) { $this->write_index_html($appname, $this->get_index_html_content($pageslug)); echo wp_json_encode([ 'status' => 1, 'message' => 'Index.html updated.', ]); } elseif ($param === "start_react_app" && $appname && $pageslug) { $this->write_index_html($appname, $this->get_index_html_content($pageslug)); $parts = $this->start_react_app($appname); $parts; if (empty($parts)) { echo wp_json_encode(['status' => 0, 'message' => "Couldn't load app."]); } else { echo wp_json_encode([ 'status' => 1, 'ip' => substr($parts[1], 2), 'message' => 'App started.', 'port' => $parts[2], 'protocol' => $parts[0], ]); } } elseif ($param === 'stop_react_app' && $appname) { $this->stop_react_app($appname); echo wp_json_encode([ 'status' => 1, 'message' => 'App stopped.', ]); } elseif ($param === 'is_react_app_running' && $appname) { if ($this->is_react_app_running($appname)) { $parts = $this->get_app_uri($this->app_path($appname), 1); echo wp_json_encode([ 'status' => 1, 'ip' => substr($parts[1], 2), 'message' => 'App is running.', 'port' => $parts[2], 'protocol' => $parts[0], ]); } } elseif ($param === 'delete_react_app' && $appname) { if ($this->is_react_app_running($appname)) { $this->stop_react_app($appname); } $options = get_option('repr_apps'); $is_option_deleted = update_option('repr_apps', array_filter( $options, fn ($el) => $el['appname'] !== $appname )); $is_appdir_removed = repr_delete_directory($this->app_path($appname)); if ($is_appdir_removed) { echo wp_json_encode([ 'status' => 1, 'message' => 'App deleted.', ]); } else { echo wp_json_encode([ 'status' => 1, 'message' => "Couldn't remove files. Please remove directory by hand.", ]); } } elseif ($param === "build_react_app" && $appname && $pageslug) { if ($this->build_react_app($appname)) { echo wp_json_encode([ 'status' => 1, 'message' => "App {$appname} build. View", ]); } else { echo wp_json_encode([ 'status' => 0, 'message' => "Build failed.", ]); } } else { echo wp_json_encode([ 'status' => 0, 'message' => "Request unknown.", ]); } } } catch (Exception $e) { echo (wp_json_encode([ 'status' => 0, 'message' => $e->getMessage(), 'code' => $e->getCode(), // User-defined Exception code 'filename' => $e->getFile(), // Source filename 'line' => $e->getLine(), // Source line 'trace' => $e->getTraceAsString(), // Formated string of trace ])); } finally { wp_die(); } } /** * Checks if the given string is already used as a pageslug of any * post in the current WP site. * @param string $pageslug * @return bool * @since 1.2.0 */ public function does_pageslug_exist(string $pageslug) { global $wpdb; return !empty($wpdb->get_row( $wpdb->prepare("SELECT * FROM " . $wpdb->prefix . "posts WHERE post_name = %s", $pageslug) )); } /** * Downloads the content of the page with the given pageslug and removes the * the react assets of the build page, that we can use the content for our * development server. * * @param string $pageslug * @return string * @since 1.0.0 */ public function get_index_html_content(string $pageslug) { $file_contents = file_get_contents(site_url() . '/' . $pageslug); $file_contents_arr = explode(PHP_EOL, $file_contents); // filter all build assets out of the file, that they don't conflict // with the dev assets. $filtered_arr = array_filter($file_contents_arr, fn ($el) => !strpos($el, "id='rp-react-app-asset-")); $filtered_contents = implode(PHP_EOL, $filtered_arr); return $filtered_contents; } /** * Writes the given to the index.html file in the app directory of * the given appname. * * @param string $appname * @param string $content * @return bool if the writing of the file succeded or not * @since 1.0.0 */ public function write_index_html(string $appname, string $content) { $index_html_path = sprintf("%sapps/%s/public/index.html", REPR_PLUGIN_PATH, $appname); return file_put_contents($index_html_path, $content); } /** * Writes a new app to the database and starts the creation of the app * in the filesystem. * * @param mixed $app_options_list * @param string $appname * @param string $pageslug * @param string $template * @param string $type * @return void * @since 1.0.0 */ function create_new_app( $app_options_list, string $appname, string $pageslug, string $template, string $type ) { $this->add_app_options($app_options_list, $appname, $pageslug); if ($appname && $pageslug && $type) { return $this->create_react_app($appname, $type, $template,); } else { return true; } } /** * Start create-react-app or just creates the directory for the given * appname. * * @param string $appname * @param string $type * @param string $template * @return void * @since 1.0.0 */ function create_react_app(string $appname, string $type, string $template = '') { $output = []; $retval = ''; $template_option = $template ? " --template {$template}" : ''; chdir(REPR_PLUGIN_PATH); if ($type === 'deployment') { return mkdir("apps/{$appname}", 0777, true); } else { exec("npx create-react-app apps/{$appname}{$template_option}", $output, $retval); $this->fix_hot_reloading($appname); $this->add_build_path($appname); return end($output) === 'Happy hacking!' ? true : false; } } /** * Add .env file to fix hot reloading in VM. * See https://stackoverflow.com/questions/43274925/development-server-of-create-react-app-does-not-auto-refresh#answer-43281575 * * @param string $appname * @return void */ function fix_hot_reloading(string $appname) { $index_html_path = sprintf("%sapps/%s/.env", REPR_PLUGIN_PATH, $appname); $content = "# Fix hot reloading in VMs\nCHOKIDAR_USEPOLLING=true"; return file_put_contents($index_html_path, $content); } /** * Reads the React app uri out of the out.log file of the * app. Which is a splash screen of create-react-app. * * @param string $apppath the directory path of the app * @param integer $max_trys the number of trys to read the uri. * Important when out.log file is still being created. * @return array contains the protocol, the ip and the * port of the uri. * @since 1.0.0 */ function get_app_uri(string $apppath, int $max_trys = 1) { $regex = '/http:\/\/\d+\.\d+\.\d+\.\d+:\d*/'; $matches = []; $trys = 0; while (empty($matches) && $trys < $max_trys) { try { $file_content = file_get_contents("{$apppath}/out.log"); } catch (Exception $e) { return []; } preg_match($regex, $file_content, $matches); $trys += 1; if ($trys < $max_trys) { sleep(1); } } if (empty($matches)) { return []; } else { return explode(':', $matches[0]); } } /** * Builds the given react app, adds the right source path to * package.json, that react loads assets in the right way. * * @param string $appname * @return int 0 if no success * @since 1.0.0 */ function build_react_app($appname) { $apppath = $this->app_path($appname); // We need the relative path, that we can deploy our // build app to another server later. $relative_apppath = $this->app_path($appname, true); $relative_apppath = $relative_apppath ? $relative_apppath : "/wp-content/plugins/reactpress/apps/{$appname}/"; $homepage = "{$relative_apppath}/build"; $path_package_json = "{$apppath}/package.json"; $package_json_contents = file_get_contents($path_package_json); if (stripos($package_json_contents, $homepage)) { chdir($apppath); shell_exec("npm run build"); return 1; } else { // Add a homepage attribute during the build process and remove it again, // that the developer can build with the public/index.html without WP. file_put_contents( $path_package_json, str_replace("react-scripts build", "PUBLIC_URL={$homepage} react-scripts build", $package_json_contents) ); chdir($apppath); shell_exec("npm run build"); return 2; } return 0; } /** * Add the right build path to package.json * * @param string $appname * @return int 0 if no success * @since 1.2.0 */ function add_build_path($appname) { $apppath = $this->app_path($appname); // We need the relative path, that we can deploy our // build app to another server later. $relative_apppath = $this->app_path($appname, true); $relative_apppath = $relative_apppath ? $relative_apppath : "/wp-content/plugins/reactpress/apps/{$appname}/"; $homepage = "{$relative_apppath}/build"; $path_package_json = "{$apppath}/package.json"; $package_json_contents = file_get_contents($path_package_json); if (!$package_json_contents) { return 0; } elseif (stripos($package_json_contents, $homepage)) { return 1; } else { // Add a homepage attribute during the build process and remove it again, // that the developer can build with the public/index.html without WP. file_put_contents( $path_package_json, str_replace("react-scripts build", "PUBLIC_URL={$homepage} react-scripts build", $package_json_contents) ); return 2; } return 0; } /** * Get the pid of the processes start by npm start. * * @param string $appname * @return array of the pids * @since 1.0.0 */ function get_node_pids($appname) { $apppath = $this->app_path($appname); $command = "ps aux | grep {$apppath} | grep -v grep"; $processes = []; exec($command, $processes); return array_map(fn ($el) => preg_split("/\ + /", $el)[1], $processes); } /** * Checks if the given app is running * * @param string $appname * @return boolean * @since 1.0.0 */ function is_react_app_running(string $appname) { return !empty($this->get_node_pids($appname)); } /** * Creates the path the app, beginning from root of filesystem * or htdocs. * * @param string $appname * @param boolean $relative_to_home_path * @return string * @since 1.0.0 */ function app_path(string $appname, $relative_to_home_path = false): string { $apppath = escapeshellcmd(REPR_PLUGIN_PATH . "apps/{$appname}"); $document_root = $_SERVER['DOCUMENT_ROOT'] ?? ''; if ($relative_to_home_path) { return explode($document_root, $apppath)[1]; } else { return $apppath; } } /** * Start the react app and extracts the right url auf the development * server. * * @param string $appname * @return void * @since 1.0.0 */ function start_react_app(string $appname) { $apppath = $this->app_path($appname); chdir($apppath); shell_exec("npm start > out.log 2>&1 & echo $! > pid.log"); return $this->get_app_uri($apppath, 100); } /** * Stops all processes, that were started for the given app * * @param string $appname * @return void * @since 1.0.0 */ function stop_react_app($appname) { // end node processes based on the app path $SIGHUP = 1; $apppath = $this->app_path($appname); foreach ($this->get_node_pids($appname) as $pid) { posix_kill((int)$pid, $SIGHUP); } // cancel npm start $npm_pid = file_get_contents("{$apppath}/pid.log"); posix_kill((int)$npm_pid, $SIGHUP); // cleanup remove out.log and pid.log unlink("{$apppath}/pid.log"); unlink("{$apppath}/out.log"); } /** * Helper function to add an element to an array * without mutationg the original array. * * @param array $array * @param [type] $entry * @return void * @since 1.0.0 */ function array_add(array $array, $entry) { return array_merge($array, [$entry]); } /** * Creates a new page with the given name and slug. Rejects if the slug is already * taken * * @param string $appname * @param string $pageslug * @return void * @since 1.0.0 */ public function insert_react_page(string $appname, string $pageslug) { global $wpdb; // check if post_name (which is the slug and should be unique) exist $get_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM " . $wpdb->prefix . "posts WHERE post_name = %s", $pageslug ) ); if (!empty($get_data)) { // alreade we have data with this post name return ['status' => 'false', 'message' => 'Page already exists.']; } else { $result = wp_insert_post( array( 'post_title' => $appname, 'post_name' => $pageslug, 'post_status' => 'publish', 'post_author' => '1', 'post_content' => REPR_REACT_ROOT_TAG, 'post_type' => "page", // Assign page template 'page_template' => REPR_PLUGIN_PATH . 'templates/react-page-template.php', ) ); if ($result) { return ['status' => 'true', 'message' => 'Page created.']; } else { return ['status' => 'false', 'message' => "Couldn't create page"]; } } } /** * Change the slug of a page to the new one. * * @param string $oldSlug * @param string $newSlug * @return void * @since 1.0.0 */ public function update_react_page(string $oldSlug, string $newSlug) { global $wpdb; // check if post_name (which is the slug and should be unique) exist $get_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM " . $wpdb->prefix . "posts WHERE post_name = %s", $oldSlug ) ); if (empty($get_data)) { // alreade we have data with this post name return ['status' => 'false', 'message' => 'Couldn\'t find page.']; } else { $result = wp_update_post( array( 'ID' => $get_data->ID, 'post_name' => $newSlug, ) ); if ($result) { return ['status' => 'true', 'message' => 'Page updated.']; } else { return ['status' => 'false', 'message' => "Couldn't update page"]; } } } /** * Checks if the dev environment is suitable. If not * produces a message for the user. * * @return string * @since 1.0.0 */ public function environment_message(): string { $has_exec = function_exists('exec'); $has_shell_exec = function_exists('shell_exec'); $is_posix = strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN'; $has_npm_v6 = false; if ($has_exec && $has_shell_exec) { $has_npm_v6 = (int)shell_exec('npm -v') >= 6; } $message = ''; if (!($has_exec && $has_shell_exec)) { $message .= "
exec and shell_exec.npm 6 or higher to create React apps from the admin interface. However you can go to the app directory and use create-react-app from there