* until Installer::create_pages() resolves it — the same reason * Asset_Rewriter::ASSETS_URL_PLACEHOLDER exists: the *final* post ID * (and its permalink, which also depends on the active permalink * structure and any post_name collision suffix) isn't known until * every page has actually been created, which happens well after * this class has finished. Followed immediately by the target * route's own key (e.g. "%%ESIPHER_INTERNAL_LINK%%mysanity-app"; the * home route's key is '', so its placeholder has nothing after it at * all) — see resolve_internal_links() in Installer for the other * half of this. */ const INTERNAL_LINK_PLACEHOLDER = '%%ESIPHER_INTERNAL_LINK%%'; /** * Discover and rewrite every route under a multi-page root. * * @param string $multi_page_root Absolute path, as returned by * Source_Detector::resolve_multi_page_root(). * @param string $preferred_slug Desired theme slug, for enqueue * handle namespacing (see * Asset_Rewriter::rewrite()). * * @return array{pages: Generated_Page[], assets: Rewritten_Assets, home_doc: Parsed_Document, unresolvable_asset_count: int, unresolvable_assets: string[]} { * @type Generated_Page[] $pages Ordered parents-before-children. * @type Rewritten_Assets $assets Shared across every page. * @type Parsed_Document $home_doc The home route's parsed * document, for Theme_Generator's * head-tags/title/description. * @type int $unresolvable_asset_count Distinct images referenced * only via a cloud-hosted * optimized-image URL (see * UNRESOLVABLE_ASSET_PATTERN) * with no local file at the path * that reference itself implies * — nothing Esipher can resolve * without fetching it from * somewhere (see Converter's own * $options['recover_images']). * @type string[] $unresolvable_assets The same set, as the actual * matched path strings (e.g. * "__l5e/assets-v1/{id}/{file}") * rather than just a count. * } * * @throws Multi_Page_Rewriter_Exception If more than MAX_PAGES routes * are found, or no home route * (index.html directly at * $multi_page_root) exists. */ public function discover( $multi_page_root, $preferred_slug ) { $multi_page_root = rtrim( $multi_page_root, '/' ); $routes = array(); $this->find_index_files( $multi_page_root, '', $routes ); $this->find_sibling_html_pages( $multi_page_root, $routes ); if ( count( $routes ) > self::MAX_PAGES ) { throw new Multi_Page_Rewriter_Exception( sprintf( 'This export has %1$d pages, more than the %2$d Esipher currently supports converting at once.', count( $routes ), self::MAX_PAGES // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- an exception message, not page output. ) ); } if ( ! isset( $routes[''] ) ) { throw new Multi_Page_Rewriter_Exception( 'No home page (index.html directly inside the static export folder) was found.' ); } // Parents before children, alphabetical within a depth for // deterministic output — a child's post_parent can only be // resolved once its parent's real post ID exists (Installer). uksort( $routes, array( $this, 'compare_routes' ) ); $html_parser = new Html_Parser(); $asset_rewriter = new Asset_Rewriter(); $block_converter = new Block_Converter(); $docs = array(); $unresolvable_assets = array(); foreach ( $routes as $route => $file_path ) { $html = file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $html = (string) $html; if ( preg_match_all( self::UNRESOLVABLE_ASSET_PATTERN, $html, $matches ) ) { foreach ( $matches[0] as $match ) { // A real local file at the path this reference itself // implies means it's not actually unresolvable after // all — either Asset_Recoverer placed one there (see // Converter's own $options['recover_images']), or one // genuinely already existed. Either way, // Asset_Rewriter's own local-file resolution below // picks it up as an ordinary asset from here on. if ( is_file( $multi_page_root . '/' . $match ) ) { continue; } $unresolvable_assets[ $match ] = true; } } // $strip_nav = false: each page keeps its own nav embedded in // its own content rather than one shared wp_nav_menu() call // — see Html_Parser::parse()'s docblock for why. $docs[ $route ] = $html_parser->parse( $html, false ); } $home_doc = $docs['']; // The entry script is deliberately never enqueued for a // multi-page conversion. Verified against a real export: its // compiled bundle calls hydrateRoot(document, ...) — TanStack // Start's own pattern of hydrating the *entire* document, not a // specific mount-point element — which requires WordPress's whole // rendered page to structurally match what the export's own SSR // would have produced. It never will (WordPress always injects // its own
/ content — admin bar, other plugins' // hooks, etc.), so the hydration mismatch throws and React's // recovery unmounts much of what was already correctly // server-rendered — turning a working static page into a blank // one. Since every page's content is already fully rendered // static HTML (unlike a CSR SPA, where dropping the script would // leave nothing to show at all), never loading it is strictly // safer than the alternative. The stylesheet (real visual // styling) is unaffected and still gets enqueued normally below. $asset_scan_doc = clone $home_doc; $asset_scan_doc->scripts = array(); // Establishes every other shared concern — enqueued style, // favicon, theme-color vars, root-passthrough discovery — from // the home route's document. Every other route carries the // identical tag (verified against the real // export this was built against), so this is correct to only do // once. Its own rewritten body_html (PHP-echo-snippet style) is // unused here — every page's actual content is built via // rewrite_additional_page_body() below instead, since // post_content can't execute embedded PHP. $assets = $asset_rewriter->rewrite( $asset_scan_doc, $multi_page_root, $preferred_slug ); $pages = array(); foreach ( $routes as $route => $file_path ) { $doc = $docs[ $route ]; $segments = '' === $route ? array() : explode( '/', $route ); $parent_route = count( $segments ) <= 1 ? null : implode( '/', array_slice( $segments, 0, -1 ) ); $page = new Generated_Page(); $page->route = $route; $page->slug = '' === $route ? 'home' : end( $segments ); $page->parent_route = $parent_route; $page->title = '' !== $doc->title ? $doc->title : $page->slug; $rewritten_body = $asset_rewriter->rewrite_additional_page_body( $doc->body_html, $multi_page_root, $assets ); $rewritten_body = $this->rewrite_internal_links( $rewritten_body, $routes ); $page->content = $block_converter->convert( $rewritten_body ); $page->is_home = ( '' === $route ); $pages[] = $page; } return array( 'pages' => $pages, 'assets' => $assets, 'home_doc' => $home_doc, 'unresolvable_asset_count' => count( $unresolvable_assets ), 'unresolvable_assets' => array_keys( $unresolvable_assets ), ); } /** * Route comparator, for uksort(): shallower routes (fewer * '/'-separated segments) first, alphabetical within the same depth. * * @param string $a First route key. * @param string $b Second route key. * * @return int */ private function compare_routes( $a, $b ) { $depth_a = '' === $a ? 0 : substr_count( $a, '/' ) + 1; $depth_b = '' === $b ? 0 : substr_count( $b, '/' ) + 1; if ( $depth_a !== $depth_b ) { return $depth_a <=> $depth_b; } return strcmp( $a, $b ); } /** * Recursively collect every index.html under $dir into $routes * (route key => absolute file path). A shared assets/ folder (real * exports keep hashed JS/CSS/images there, never a route) is skipped * as a minor optimization against walking a potentially large binary * asset tree — not a correctness requirement, since such a folder * would never contain an index.html anyway. * * @param string $dir Directory to scan. * @param string $relative_prefix Route prefix built up so far ('' at * the root). * @param string[] $routes Mutated: route key => file path. */ private function find_index_files( $dir, $relative_prefix, array &$routes ) { $entries = scandir( $dir ); if ( false === $entries ) { return; } foreach ( $entries as $entry ) { if ( '.' === $entry || '..' === $entry || 'assets' === $entry ) { continue; } $path = $dir . '/' . $entry; if ( is_dir( $path ) ) { $this->find_index_files( $path, '' === $relative_prefix ? $entry : $relative_prefix . '/' . $entry, $routes ); } elseif ( 'index.html' === $entry ) { $routes[ $relative_prefix ] = $path; } } } /** * Find every other real page under the "flat sibling .html files" * convention — see Source_Detector::resolve_multi_page_root()'s own * docblock (convention 2) for the full reasoning. Each file's own * name (minus the extension) becomes its route directly, always a * single segment — so every one of these pages naturally comes out * as an independent top-level page later in discover()'s own * parent-route computation, never nested under another. * * Scoped strictly to $multi_page_root's own top level, unlike * find_index_files() — a nested folder here belongs to *that* * method's convention instead, not this one. * * @param string $multi_page_root Root directory to scan. * @param array $routes Route => file path map, appended * to in place. A route already * present (from find_index_files(), * i.e. the two conventions somehow * both matched the same name) is * left untouched rather than * overwritten. */ private function find_sibling_html_pages( $multi_page_root, array &$routes ) { $entries = scandir( $multi_page_root ); if ( false === $entries ) { return; } foreach ( $entries as $entry ) { if ( 'index.html' === $entry ) { continue; } if ( '.html' !== strtolower( substr( $entry, -5 ) ) ) { continue; } if ( in_array( strtolower( $entry ), self::NON_PAGE_HTML_FILENAMES, true ) ) { continue; } $path = $multi_page_root . '/' . $entry; if ( ! is_file( $path ) ) { continue; } $route = substr( $entry, 0, -5 ); if ( ! isset( $routes[ $route ] ) ) { $routes[ $route ] = $path; } } } /** * Rewrite every that points at another page in this * same conversion to INTERNAL_LINK_PLACEHOLDER + that page's route * key, resolved later by Installer::create_pages(). Necessary * specifically for the "flat sibling .html files" convention: those * exports link between pages with a real relative filename * (`href="mysanity-app.html"`) rather than a clean absolute path — * confirmed live against a real converted export, where clicking * such a link 404'd outright, since WordPress's own permalink for * that page is never literally ".../mysanity-app.html". The * `static-export/` convention's own links (already root-relative, * extension-less — `href="/about"`) already happen to match * WordPress's own default permalink structure without needing this * at all, so this is purely additive: nothing here matches that * convention's own hrefs. * * @param string $body_html Body HTML (already asset-rewritten). * @param array $routes This page's own route => file path map, * i.e. every other page a link here could * legitimately point at. * * @return string */ private function rewrite_internal_links( $body_html, array $routes ) { $dom = new \DOMDocument(); $previous_setting = libxml_use_internal_errors( true ); $dom->loadHTML( '' . $body_html . '', LIBXML_NOWARNING | LIBXML_NOERROR ); libxml_clear_errors(); libxml_use_internal_errors( $previous_setting ); $links = $dom->getElementsByTagName( 'a' ); $changed = false; foreach ( $links as $link ) { if ( ! $link->hasAttribute( 'href' ) ) { continue; } $match = $this->matching_route_for_href( $link->getAttribute( 'href' ), $routes ); if ( null === $match ) { continue; } $link->setAttribute( 'href', self::INTERNAL_LINK_PLACEHOLDER . $match['route'] . $match['suffix'] ); $changed = true; } if ( ! $changed ) { return $body_html; } $body_nodes = $dom->getElementsByTagName( 'body' ); $html = ''; foreach ( $body_nodes->item( 0 )->childNodes as $child ) { $html .= (string) $dom->saveHTML( $child ); } return $html; } /** * Whether $href refers to one of $routes' own page files — see * rewrite_internal_links()'s own docblock for the convention this * matches. Deliberately narrow: skipped outright for anything with a * scheme (`https://...`, `mailto:...`) or a protocol-relative * `//host/...` form, so an external URL that coincidentally shares a * page's filename is never mistaken for an internal link. * * @param string $href Raw href attribute value. * @param array $routes Route => file path map. * * @return array{route: string, suffix: string}|null $match['suffix'] * preserves any #fragment/?query the original * href had, e.g. "case-studies.html#top". */ private function matching_route_for_href( $href, array $routes ) { if ( '' === $href || false !== strpos( $href, '://' ) || 0 === strpos( $href, '//' ) ) { return null; } $cut = strcspn( $href, '#?' ); $path = substr( $href, 0, $cut ); $suffix = substr( $href, $cut ); $path = ltrim( $path, '/' ); if ( 0 === strpos( $path, './' ) ) { $path = substr( $path, 2 ); } if ( 'index.html' === $path ) { return array( 'route' => '', 'suffix' => $suffix, ); } if ( '.html' !== strtolower( substr( $path, -5 ) ) ) { return null; } $route = substr( $path, 0, -5 ); if ( ! isset( $routes[ $route ] ) ) { return null; } return array( 'route' => $route, 'suffix' => $suffix, ); } }