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