pages is non-empty (a multi-page conversion — see * Multi_Page_Rewriter), also creates each as a real WordPress Page, * parents before children. The site's static-front-page setting is * only ever touched when $args['activate'] is also true — creating * pages must never change what the *currently* active theme's * homepage shows. * * @param Generated_Theme $theme Theme to install. * @param array $args { * Optional install arguments. * * @type bool $activate Activate the theme after writing it. * @type string $on_collision 'fail' (default) or 'suffix' — how to * handle an existing theme directory * with the same slug. * } * * @return Install_Result * * @throws Installer_Exception On an unwritable theme root, a slug * collision with $on_collision = 'fail', * or any filesystem write failure. */ public function install( Generated_Theme $theme, array $args = array() ) { $args = array_merge( array( 'activate' => false, 'on_collision' => 'fail', ), $args ); $wp_filesystem = $this->get_filesystem(); $theme_root = get_theme_root(); if ( ! $wp_filesystem->is_writable( $theme_root ) ) { throw new Installer_Exception( sprintf( /* translators: %s: theme root directory path. */ esc_html__( 'Theme directory is not writable: %s', 'esipher' ), esc_html( $theme_root ) ) ); } $slug = $this->resolve_slug( $wp_filesystem, $theme_root, $theme->slug, $args['on_collision'] ); $theme_dir = trailingslashit( $theme_root ) . $slug; $this->ensure_dir( $wp_filesystem, $theme_dir ); foreach ( $theme->files as $relative_path => $contents ) { $dest = trailingslashit( $theme_dir ) . $relative_path; $this->ensure_dir( $wp_filesystem, dirname( $dest ) ); if ( ! $wp_filesystem->put_contents( $dest, $contents, FS_CHMOD_FILE ) ) { throw new Installer_Exception( sprintf( /* translators: %s: relative file path within the theme. */ esc_html__( 'Unable to write theme file: %s', 'esipher' ), esc_html( $relative_path ) ) ); } } foreach ( $theme->assets_to_copy as $source_path => $dest_relative ) { $dest = trailingslashit( $theme_dir ) . $dest_relative; $this->ensure_dir( $wp_filesystem, dirname( $dest ) ); if ( ! $wp_filesystem->copy( $source_path, $dest, true, FS_CHMOD_FILE ) ) { throw new Installer_Exception( sprintf( /* translators: %s: relative asset path within the theme. */ esc_html__( 'Unable to copy asset: %s', 'esipher' ), esc_html( $dest_relative ) ) ); } } $result = new Install_Result(); $result->slug = $slug; $result->theme_dir = $theme_dir; $home_post_id = ! empty( $theme->pages ) ? $this->create_pages( $theme->pages, $slug ) : null; if ( ! empty( $args['activate'] ) ) { $previous_stylesheet = get_stylesheet(); update_option( self::PREVIOUS_THEME_OPTION, $previous_stylesheet ); switch_theme( $slug ); $result->activated = true; $result->previous_theme = $previous_stylesheet; if ( null !== $home_post_id ) { update_option( self::PREVIOUS_FRONT_PAGE_OPTION, array( 'show_on_front' => get_option( 'show_on_front' ), 'page_on_front' => (int) get_option( 'page_on_front' ), ) ); update_option( 'show_on_front', 'page' ); update_option( 'page_on_front', $home_post_id ); } } return $result; } /** * Create each Generated_Page as a real WordPress Page, in the given * (parent-before-child) order, resolving post_parent from the * already-created pages seen so far. * * Re-converting the same site (the ordinary "I updated my export, * upload it again" workflow, using the same preferred_slug) updates * each route's *existing* page in place rather than inserting a new * one every time. Verified necessary against a real repeated- * conversion session: WordPress only enforces post_name uniqueness * among *siblings*, so a route whose ancestor chain includes a * different post_parent each time (as it would here, since every * top-level page was itself also being freshly inserted) never * actually collides — every re-run silently piled up a full new set * of orphaned duplicate pages instead of erroring, while every * *route's own permalink* stayed permanently pinned to whichever * page was created for it first. The one visible exception was the * front page, whose content always looked current only because * install()'s show_on_front/page_on_front repoint happens to * follow $home_post_id wherever it goes — every other page looked * frozen at its first-ever content forever after. * * Matching is by route, not by slug/title, so a page whose title or * slug legitimately changed between two conversions of the same * export is still recognized as "the same page" and updated in * place — see ROUTE_META_KEY. Scoped to THEME_SLUG_META_KEY so an * intentionally-separate copy (on_collision=suffix giving the new * theme its own slug) creates its own fresh pages rather than * touching the original's. * * Also resolves Asset_Rewriter::ASSETS_URL_PLACEHOLDER in each page's * content to a real, absolute URL here — see theme_assets_url()'s own * docblock for why this has to happen at install time rather than via * a the_content filter in the generated theme itself — and, in a * second pass once every page has a final post ID, Multi_Page_Rewriter:: * INTERNAL_LINK_PLACEHOLDER to each linked page's real permalink (see * resolve_internal_links()). * * @param Generated_Page[] $pages Ordered parents-before-children. * @param string $theme_slug This install's final theme * slug — scopes route matching to * "this same site", not any other * converted site's pages. * * @return int|null The home page's post ID, or null if none of the * pages was flagged as home. * * @throws Installer_Exception If wp_insert_post() fails for any page. */ private function create_pages( array $pages, $theme_slug ) { $route_to_id = array(); $home_post_id = null; $assets_url = $this->theme_assets_url( $theme_slug ); $pages_with_links = array(); foreach ( $pages as $page ) { $parent_id = 0; if ( null !== $page->parent_route && isset( $route_to_id[ $page->parent_route ] ) ) { $parent_id = $route_to_id[ $page->parent_route ]; } $post_data = array( 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => $page->title, 'post_name' => sanitize_title( $page->slug ), 'post_content' => str_replace( Asset_Rewriter::ASSETS_URL_PLACEHOLDER, $assets_url, $page->content ), 'post_parent' => $parent_id, ); $existing_id = $this->find_existing_page_id( $theme_slug, $page->route ); if ( null !== $existing_id ) { $post_data['ID'] = $existing_id; } $post_id = wp_insert_post( $post_data, true ); if ( is_wp_error( $post_id ) ) { throw new Installer_Exception( sprintf( /* translators: 1: page route, 2: underlying WP_Error message. */ esc_html__( "Unable to create page '%1\$s': %2\$s", 'esipher' ), esc_html( $page->route ), esc_html( $post_id->get_error_message() ) ) ); } update_post_meta( $post_id, self::ROUTE_META_KEY, $page->route ); update_post_meta( $post_id, self::THEME_SLUG_META_KEY, $theme_slug ); $route_to_id[ $page->route ] = $post_id; if ( false !== strpos( $page->content, Multi_Page_Rewriter::INTERNAL_LINK_PLACEHOLDER ) ) { $pages_with_links[] = $post_id; } if ( $page->is_home ) { $home_post_id = $post_id; } } $this->resolve_internal_links( $pages_with_links, $route_to_id ); return $home_post_id; } /** * Resolve every Multi_Page_Rewriter::INTERNAL_LINK_PLACEHOLDER left * in $post_ids' content to the real permalink of the page matching * its route — the same "resolve once we know every final identifier" * reasoning as theme_assets_url(), but this one needs *every* page's * final post ID first, not just this install's slug, so it can only * run here, after the whole loop above has finished. * * @param int[] $post_ids Pages whose content actually * contains the placeholder — an * ordinary conversion with no * flat-sibling-style pages has * none, and this is a no-op. * @param array $route_to_id Every route's final post ID. */ private function resolve_internal_links( array $post_ids, array $route_to_id ) { if ( empty( $post_ids ) ) { return; } $replacements = array(); foreach ( $route_to_id as $route => $post_id ) { $replacements[ Multi_Page_Rewriter::INTERNAL_LINK_PLACEHOLDER . $route ] = get_permalink( $post_id ); } // Longest placeholder first: the home route's own key is '', so // its placeholder is a strict text prefix of every other // route's — replacing it first would truncate e.g. // "...LINK%%mysanity-app" down to just the resolved home URL // followed by a dangling "mysanity-app". uksort( $replacements, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); foreach ( $post_ids as $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { continue; } $content = $post->post_content; foreach ( $replacements as $placeholder => $url ) { $content = str_replace( $placeholder, $url, $content ); } if ( $content === $post->post_content ) { continue; } wp_update_post( array( 'ID' => $post_id, 'post_content' => $content, ) ); } } /** * This install's real, absolute theme assets URL — what * Asset_Rewriter::ASSETS_URL_PLACEHOLDER stands in for inside a * multi-page conversion's page content until create_pages() * resolves it, here, at the one point this class already knows the * *final* slug (post-collision-resolution). * * Resolved once, directly, rather than via a the_content filter in * the generated theme (which is how the single-page pipeline's * equivalent — a `` snippet inside an actual .php * template — effectively works): a filter keyed off * get_template_directory_uri() only resolves correctly once this * theme is the *active* one, and even then never runs at all inside * the Block Editor's own canvas, which reads raw post_content * directly rather than calling the_content() on it. Confirmed live: * a successfully-bundled image displayed correctly on the front end * but showed as broken in the editor, active theme or not. Baking in * the real URL up front fixes both. * * Trade-off, accepted: if this theme's directory is later renamed or * reinstalled under a different slug, these URLs go stale until the * next reconversion — the same way any other absolute URL baked into * WordPress content would (a domain move already requires * `wp search-replace` for the same reason). * * @param string $theme_slug This install's final theme slug. * * @return string */ private function theme_assets_url( $theme_slug ) { return esc_url( trailingslashit( get_theme_root_uri() ) . $theme_slug . '/assets' ); } /** * Find a page (any status — including one an editor moved to draft * or trash, which should still be recognized and updated in place * rather than orphaned by a new duplicate) previously created for * $route by a conversion of this same theme slug. * * @param string $theme_slug Scopes the search to "this same site". * @param string $route The Generated_Page's own route key. * * @return int|null */ private function find_existing_page_id( $theme_slug, $route ) { $query = new \WP_Query( array( 'post_type' => 'page', // 'any' looks like it should mean "every status", but // WordPress's own definition of it specifically excludes // 'trash' (and 'auto-draft') — confirmed the hard way: // a page that had ended up in trash was invisible to // this lookup, so a reconversion silently created a // fresh duplicate instead of restoring/updating the // trashed one, exactly the bug this method exists to // prevent. Listed explicitly instead so trash is // genuinely included. 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ), 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- a small, bounded, infrequent (once per conversion) lookup; no realistic scale concern here. array( 'key' => self::THEME_SLUG_META_KEY, 'value' => $theme_slug, ), array( 'key' => self::ROUTE_META_KEY, 'value' => $route, ), ), ) ); return ! empty( $query->posts ) ? (int) $query->posts[0] : null; } /** * Restore whichever theme was active immediately before the last * activating install(), then forget that state. * * @return string The stylesheet that was restored. * * @throws Installer_Exception If no previous theme was recorded. */ public function rollback() { $previous_stylesheet = get_option( self::PREVIOUS_THEME_OPTION ); if ( ! $previous_stylesheet ) { throw new Installer_Exception( esc_html__( 'No previous theme recorded to roll back to.', 'esipher' ) ); } switch_theme( $previous_stylesheet ); delete_option( self::PREVIOUS_THEME_OPTION ); // A multi-page conversion's activating install() may also have // overwritten show_on_front/page_on_front — restore those too, // if so. The Pages it created are deliberately left in place // either way: this project avoids permanently deleting content // on rollback. $previous_front_page = get_option( self::PREVIOUS_FRONT_PAGE_OPTION ); if ( is_array( $previous_front_page ) && isset( $previous_front_page['show_on_front'], $previous_front_page['page_on_front'] ) ) { update_option( 'show_on_front', $previous_front_page['show_on_front'] ); update_option( 'page_on_front', $previous_front_page['page_on_front'] ); delete_option( self::PREVIOUS_FRONT_PAGE_OPTION ); } return $previous_stylesheet; } /** * Initialize WP_Filesystem for direct, credential-less access. A full * request_filesystem_credentials() prompt flow belongs to the admin * UI (a later phase) — for this CLI-first installer, a clear * exception is raised if direct filesystem access isn't available. * * @return \WP_Filesystem_Base * * @throws Installer_Exception If the filesystem can't be initialized. */ private function get_filesystem() { global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! WP_Filesystem() || ! $wp_filesystem ) { throw new Installer_Exception( esc_html__( 'Unable to initialize the WordPress filesystem.', 'esipher' ) ); } return $wp_filesystem; } /** * Recursively create a directory via WP_Filesystem only (never a raw * PHP mkdir()), so this works correctly under FTP/SSH filesystem * methods, not just direct/local access. * * @param \WP_Filesystem_Base $wp_filesystem Filesystem abstraction. * @param string $dir Directory to ensure exists. * * @throws Installer_Exception If a directory can't be created. */ private function ensure_dir( $wp_filesystem, $dir ) { if ( $wp_filesystem->is_dir( $dir ) ) { return; } $parent = dirname( $dir ); if ( $parent !== $dir && ! $wp_filesystem->is_dir( $parent ) ) { $this->ensure_dir( $wp_filesystem, $parent ); } if ( ! $wp_filesystem->mkdir( $dir ) ) { throw new Installer_Exception( sprintf( /* translators: %s: directory path. */ esc_html__( 'Unable to create directory: %s', 'esipher' ), esc_html( $dir ) ) ); } } /** * Delete an existing theme directory for $slug, if one exists — * the piece an *automated* re-sync needs that a manual re-upload * normally gets from a human clicking "force"/checking the * auto-suffix box, or deleting the old theme via Appearance > * Themes first. Automated syncs (Github_Sync_Service) want neither: * a fresh copy every push (on_collision=suffix) would pile up * "mysite-2", "mysite-3", ... theme directories and, per * create_pages()'s own docblock, orphan a fresh set of duplicate * pages on every single push. Clearing the slug first and then * calling install() with on_collision='fail' (which now succeeds, * since the directory is gone) reproduces exactly the "delete the * old one, upload the new one" sequence a human doing this * manually would — including create_pages()'s route+theme_slug * matching updating existing Page posts in place rather than * creating new ones, since it keys off post meta on the Page * posts themselves, not the (now-recreated) theme directory. * * Deliberately narrow: only ever removes a directory this same * plugin would be about to recreate under the identical slug in * the same call, never invoked on a slug the caller didn't already * choose to reconvert. * * @param string $slug Theme slug to clear. * * @throws Installer_Exception If deletion fails. */ public function delete_theme_directory_if_exists( $slug ) { $wp_filesystem = $this->get_filesystem(); $theme_root = trailingslashit( get_theme_root() ); $theme_dir = $theme_root . $slug; if ( ! $wp_filesystem->is_dir( $theme_dir ) ) { return; } if ( ! $wp_filesystem->delete( $theme_dir, true ) ) { throw new Installer_Exception( sprintf( /* translators: %s: theme slug. */ esc_html__( 'Unable to remove the previous version of theme "%s" before reconverting it.', 'esipher' ), esc_html( $slug ) ) ); } } /** * Resolve the requested slug against existing theme directories: pass * through unchanged if free, auto-suffix (-2, -3, ...) if * $on_collision is 'suffix', or throw if it's 'fail' (the default, * matching typical WP-CLI scaffold conventions). * * Checks the filesystem directly via WP_Filesystem rather than * wp_get_theme()->exists(): WordPress caches theme directory listings * for the duration of a request, so a second install() call in the * same request (e.g. a CLI run right after a prior one, or this * class's own collision tests) would otherwise see a stale "doesn't * exist yet" result for a directory this class itself just wrote. * * @param \WP_Filesystem_Base $wp_filesystem Filesystem abstraction. * @param string $theme_root Theme root directory. * @param string $preferred_slug Requested slug. * @param string $on_collision 'fail' or 'suffix'. * * @return string * * @throws Installer_Exception If the slug collides and * $on_collision is 'fail'. */ private function resolve_slug( $wp_filesystem, $theme_root, $preferred_slug, $on_collision ) { $theme_root = trailingslashit( $theme_root ); if ( ! $wp_filesystem->is_dir( $theme_root . $preferred_slug ) ) { return $preferred_slug; } if ( 'suffix' !== $on_collision ) { throw new Installer_Exception( sprintf( /* translators: %s: theme slug. */ esc_html__( "Theme slug '%s' already exists. Enable auto-suffixing to install alongside it instead.", 'esipher' ), esc_html( $preferred_slug ) ) ); } $index = 2; do { $candidate = $preferred_slug . '-' . $index; ++$index; } while ( $wp_filesystem->is_dir( $theme_root . $candidate ) ); return $candidate; } }