stub_dir = $stub_dir ? rtrim( $stub_dir, '/' ) . '/' : dirname( __DIR__ ) . '/templates/theme-stubs/'; } /** * Generate the theme file set. * * @param Parsed_Document $doc Parsed source document. * @param Rewritten_Assets $assets Rewritten asset output. * @param array[] $nav_items Nav_Mapper::map() output. * Always empty for a * multi-page conversion — * each page's own nav * travels inside its own * content instead of one * shared wp_nav_menu() call. * @param string $preferred_slug Desired theme slug. * @param string $version Theme version string. * @param bool $has_page_content Whether Generated_Theme:: * $pages will be populated * by the caller — removes * wpautop/wptexturize from * the_content (see * render_page_content_asset_filter()'s * own docblock). Default * false (the ordinary * single-page path). * * @return Generated_Theme */ public function generate( Parsed_Document $doc, Rewritten_Assets $assets, array $nav_items, $preferred_slug, $version = '1.0.0', $has_page_content = false ) { $slug = $this->sanitize_slug( $preferred_slug ); $function_prefix = str_replace( '-', '_', $slug ); $theme_name = '' !== $doc->title ? $doc->title : ucwords( str_replace( array( '-', '_' ), ' ', $slug ) ); // A multi-page conversion ships no framework JS (see // Multi_Page_Rewriter), so nothing wires up the mobile menu // toggle. Bundle a tiny dependency-free helper that does. $nav_helper_js = $has_page_content ? $this->load_nav_helper_js() : ''; if ( '' !== $nav_helper_js ) { $assets->enqueue_php = rtrim( $assets->enqueue_php ) . "\n" . sprintf( "wp_enqueue_script( %s, get_template_directory_uri() . '/assets/esipher-nav.js', array(), %s, true );", $this->php_string_literal( $slug . '-esipher-nav' ), $this->php_string_literal( $version ) ); } $tokens = array( '{{THEME_NAME}}' => $theme_name, '{{THEME_SLUG}}' => $slug, '{{THEME_VERSION}}' => $version, '{{THEME_DESCRIPTION}}' => $this->extract_description( $doc ), '{{FUNCTION_PREFIX}}' => $function_prefix, '{{ENQUEUE_CALLS}}' => $this->indent( $assets->enqueue_php ), '{{HEAD_META_TAGS}}' => $this->render_head_tags( $doc ), '{{NAV_MENU_CALL}}' => $this->render_nav_call( $nav_items, $function_prefix ), '{{NAV_FALLBACK_FUNCTION}}' => $this->render_nav_fallback_function( $nav_items, $function_prefix ), '{{BODY_CONTENT}}' => $assets->body_html, '{{ROOT_ASSET_SERVER}}' => $this->render_root_asset_server( $assets->root_files, $assets->favicon_request_path, $function_prefix ), '{{MODULE_SCRIPT_FILTER}}' => $this->render_module_script_filter( $assets->module_script_handles, $function_prefix ), '{{THEME_COLOR_CUSTOMIZER}}' => $this->render_theme_color_customizer( $assets->theme_color_vars, $slug, $function_prefix ), '{{PAGE_CONTENT_ASSET_FILTER}}' => $this->render_page_content_asset_filter( $has_page_content, $function_prefix ), ); $theme = new Generated_Theme(); $theme->slug = $slug; $theme->assets_to_copy = $assets->copy_manifest; foreach ( array( 'style.css', 'functions.php', 'header.php', 'footer.php', 'index.php', 'page.php' ) as $file ) { $theme->files[ $file ] = $this->render_stub( $file . '.tpl', $tokens ); } if ( '' !== $nav_helper_js ) { $theme->files['assets/esipher-nav.js'] = $nav_helper_js; } // CSS/JS files with rewritten content get that content written // directly rather than raw-copied — see rewrite_css_urls() and // Asset_Rewriter::rewrite_vite_preload_base(). foreach ( array_merge( $assets->css_rewrites, $assets->js_rewrites ) as $dest_relative => $content ) { $theme->files[ $dest_relative ] = $content; $source_path = array_search( $dest_relative, $theme->assets_to_copy, true ); if ( false !== $source_path ) { unset( $theme->assets_to_copy[ $source_path ] ); } } return $theme; } /** * Load a .tpl file and substitute every token in one pass. * * @param string $filename Stub filename, e.g. 'style.css.tpl'. * @param array $tokens Token => replacement map. * * @return string */ private function render_stub( $filename, array $tokens ) { $contents = file_get_contents( $this->stub_dir . $filename ); return strtr( $contents, $tokens ); } /** * @return string Contents of the bundled mobile-nav helper script, * or '' if it can't be read. */ private function load_nav_helper_js() { $path = dirname( __DIR__ ) . '/templates/theme-assets/esipher-nav.js'; if ( ! is_file( $path ) ) { return ''; } $js = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading this plugin's own bundled asset. return false !== $js ? $js : ''; } /** * Prefer the source document's meta description; fall back to a * generic note when none was present. * * @param Parsed_Document $doc Parsed source document. * * @return string */ private function extract_description( Parsed_Document $doc ) { foreach ( $doc->meta as $attrs ) { if ( isset( $attrs['name'] ) && 'description' === $attrs['name'] && ! empty( $attrs['content'] ) ) { return $attrs['content']; } } return 'Converted from an AI site-builder export by Esipher.'; } /** * Render the head / tags to carry over verbatim: every * except charset/viewport (header.php already emits those), * and every except rel="stylesheet" (already enqueued). * * @param Parsed_Document $doc Parsed source document. * * @return string */ private function render_head_tags( Parsed_Document $doc ) { $lines = array(); foreach ( $doc->meta as $attrs ) { if ( isset( $attrs['charset'] ) ) { continue; } if ( isset( $attrs['name'] ) && 'viewport' === $attrs['name'] ) { continue; } $lines[] = 'render_attributes( $attrs ) . '>'; } foreach ( $doc->links as $attrs ) { if ( isset( $attrs['rel'] ) && 'stylesheet' === strtolower( $attrs['rel'] ) ) { continue; } $rel = isset( $attrs['rel'] ) ? strtolower( $attrs['rel'] ) : ''; $as = isset( $attrs['as'] ) ? strtolower( $attrs['as'] ) : ''; if ( isset( $attrs['href'] ) && ( 'modulepreload' === $rel || ( 'preload' === $rel && 'image' === $as ) ) ) { $attrs['href'] = $this->rewrite_asset_preload_href( $attrs['href'] ); } $lines[] = 'render_attributes( $attrs ) . '>'; } return $this->indent( implode( "\n", $lines ) ); } /** * A or * carries over the export's original root-relative href (e.g. * "/assets/vendor-abc123.js" or "/assets/hero-abc123.jpg"), which no * longer matches where the theme actually serves that file from * (wp-content/themes/{slug}/assets/...) — rewritten the same way for * both, since both are the same underlying problem at different tag * types: * - modulepreload left as-is: the browser preloads and registers the * module under the stale root URL while the app's own import() * calls resolve it under the theme URL — two different URLs for the * same file means two separate ES module instances, so React, the * Supabase client, etc. all get instantiated twice and end up * fighting over the same DOM ("removeChild" errors, "Multiple * GoTrueClient instances"). Verified against a real heavily * code-split export that broke exactly this way. * - preload-as-image left as-is: just a wasted, always-404 request — * verified against a real export (the actual tag pointing at * the same file is separately, correctly rewritten via Asset_ * Rewriter's normal body-asset handling, so the image still * displays; this preload hint just never helps and litters the * console/network tab with a permanent failed request otherwise). * * External hrefs are left untouched in both cases. * * @param string $href Original href attribute value. * * @return string */ private function rewrite_asset_preload_href( $href ) { if ( preg_match( '#^(?:[a-z][a-z0-9+.-]*:)?//#i', $href ) ) { return $href; } $basename = basename( (string) wp_parse_url( $href, PHP_URL_PATH ) ); if ( '' === $basename ) { return $href; } return ''; } /** * Serialize an attribute assoc array back to `key="value"` pairs. * * @param array $attrs Attribute map. * * @return string */ private function render_attributes( array $attrs ) { $parts = array(); foreach ( $attrs as $key => $value ) { $parts[] = $key . '="' . str_replace( '"', '"', $value ) . '"'; } return implode( ' ', $parts ); } /** * A wp_nav_menu() call with a fallback to the hardcoded menu function, * so a real WP menu can be assigned later in Appearance > Menus * without ever breaking the header in the meantime. Omitted entirely * when no nav was found in the source export. * * @param array[] $nav_items Nav_Mapper::map() output. * @param string $function_prefix Theme function name prefix. * * @return string */ private function render_nav_call( array $nav_items, $function_prefix ) { if ( empty( $nav_items ) ) { return ''; } return sprintf( " 'primary', 'fallback_cb' => '%s_fallback_menu' ) ); ?>", $function_prefix ); } /** * A request interceptor, destined for functions.php, that serves * "passthrough" files back at their original site-root-relative path * — e.g. a request for /logo.ico or /assets/hero-abc123.png is * served from the theme's own copy — regardless of where in the * source (often compiled JS) the reference lives. Omitted when * Asset_Rewriter found no such files to copy through. * * $root_files is a fixed whitelist built entirely from real scanned * filenames (see Asset_Rewriter::copy_passthrough_files()), never * from request input, so an exact in_array() match is the only * safety check this needs — allowing a '/' in the matched path (for * the assets/... case) doesn't reopen any path-traversal risk. * * The robots.txt file is intentionally never among $root_files (see * Asset_Rewriter::discover_root_passthrough_files()), so this never * competes with WordPress core's own dynamic /robots.txt. * * @param string[] $root_files Site-root-relative paths * copied into the theme. * @param string|null $favicon_request_path Rewritten_Assets:: * $favicon_request_path. * @param string $function_prefix Theme function name prefix. * * @return string */ private function render_root_asset_server( array $root_files, $favicon_request_path, $function_prefix ) { if ( empty( $root_files ) ) { return ''; } $known_array = implode( ', ', array_map( array( $this, 'php_string_literal' ), $root_files ) ); // A WordPress Customizer "Site Icon" set via Appearance > // Customize > Site Identity only affects WP-native chrome // (admin, login, wp_site_icon()'s own tags) by default — // the SPA never calls any of that, it just requests whatever // favicon path was baked into its own markup. Redirecting that // one known path to the site icon's real URL when one is set // makes "Site Icon" actually change what the browser shows. $favicon_override = ''; if ( null !== $favicon_request_path && in_array( $favicon_request_path, $root_files, true ) ) { $favicon_override = "\n\tif ( " . $this->php_string_literal( $favicon_request_path ) . " === \$relative_path && has_site_icon() ) {\n" . "\t\twp_safe_redirect( get_site_icon_url( 512 ), 302 );\n" . "\t\texit;\n" . "\t}\n"; } $template = <<<'EOT' function {{PREFIX}}_serve_root_asset() { $known = array( {{KNOWN}} ); $request_path = isset( $_SERVER['REQUEST_URI'] ) ? wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : ''; $relative_path = ltrim( (string) $request_path, '/' ); if ( '' === $relative_path || ! in_array( $relative_path, $known, true ) ) { return; } {{FAVICON_OVERRIDE}} $file = get_template_directory() . '/' . $relative_path; if ( ! file_exists( $file ) ) { return; } // wp_check_filetype() doesn't reliably resolve some extensions this // passthrough mechanism regularly serves (.js in particular isn't in // WordPress core's allowed-mimes list, so it falls back to // application/octet-stream) — a service worker script served with // the wrong MIME type fails to register outright, so these are // resolved explicitly first. $known_mime_types = array( 'js' => 'application/javascript', 'mjs' => 'application/javascript', 'json' => 'application/json', 'webmanifest' => 'application/manifest+json', 'css' => 'text/css', 'ico' => 'image/x-icon', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'svg' => 'image/svg+xml', 'gif' => 'image/gif', 'webp' => 'image/webp', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'txt' => 'text/plain', 'xml' => 'application/xml', ); $extension = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ); if ( isset( $known_mime_types[ $extension ] ) ) { $content_type = $known_mime_types[ $extension ]; } else { $filetype = wp_check_filetype( $file ); $content_type = $filetype['type'] ? $filetype['type'] : 'application/octet-stream'; } // No explicit Content-Length: it would be the *uncompressed* file // size, but a server-level filter (e.g. Apache's mod_deflate) may // gzip the actual bytes sent afterward — declaring a length that // doesn't match what's really on the wire is tolerated by an // ordinary fetch() but broke real service worker registrations // outright ("unknown error fetching the script"), since that fetch // is validated more strictly. Let the server frame the response. header( 'Content-Type: ' . $content_type ); readfile( $file ); exit; } add_action( 'init', '{{PREFIX}}_serve_root_asset', 0 ); EOT; return strtr( $template, array( '{{PREFIX}}' => $function_prefix, '{{KNOWN}}' => $known_array, '{{FAVICON_OVERRIDE}}' => $favicon_override, ) ); } /** * A script_loader_tag filter, destined for functions.php, that * restores type="module" on exactly the enqueue handles that need it * — wp_enqueue_script() doesn't preserve the original