tag fails to execute. Verified against a * real Lovable "download source" export (its raw project source, * as opposed to `npm run build`'s dist/ output) that converted * without any error and rendered a blank page once activated. * * Also true when there's no index.html at $dir at all, but there's * a genuinely buildable project there anyway — an SSR framework's * raw source (e.g. TanStack Start) has no static index.html until * it's actually built, so the .tsx-script check above can't apply; * has_buildable_package_json() is the signal instead. Verified * necessary against a real TanStack Start export: without this, * its raw source (no root index.html, by design) fell all the way * through to the generic "No index.html found" error instead of the * clearer "this is raw source, build it first" message. * * @param string $dir Extracted export root (already resolved via * resolve_root() by the caller). * * @return bool */ public function is_unbuilt_source_export( $dir ) { $index_path = rtrim( $dir, '/' ) . '/index.html'; if ( ! is_file( $index_path ) ) { return $this->has_buildable_package_json( $dir ); } $html = file_get_contents( $index_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents return (bool) preg_match( '/]*\bsrc=["\'][^"\']+\.(?:tsx|jsx|ts)["\']/i', (string) $html ); } /** * Does $dir have a package.json with a "build" script? Used as the * last-resort marker for "this is genuinely a raw, unbuilt project" * when there's no index.html at all to inspect instead (see * is_unbuilt_source_export()'s own docblock for why that case needs * a different signal). A project meant to be built has a "build" * script by npm/yarn/pnpm/bun convention. * * @param string $dir Extracted export root. * * @return bool */ public function has_buildable_package_json( $dir ) { $package_json_path = rtrim( $dir, '/' ) . '/package.json'; if ( ! is_file( $package_json_path ) ) { return false; } $decoded = json_decode( (string) file_get_contents( $package_json_path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents return is_array( $decoded ) && isset( $decoded['scripts']['build'] ) && is_string( $decoded['scripts']['build'] ); } /** * Package manager => lockfile that identifies it, checked in this * order (a project only ever commits one, but check the more * distinctive ones first in case more than one happens to be * present). Used only to tell a user which exact command to run — * Esipher never runs it itself; see is_unbuilt_source_export()'s * docblock and Converter::unbuilt_source_message() for why running a * build server-side isn't something this plugin attempts (most * WordPress hosts have no Node.js at all, commonly disable * exec()/shell_exec() specifically to prevent arbitrary code * execution, and `npm install` running arbitrary install scripts * from an unaudited dependency tree is a materially different trust * model than anything else this plugin does). * * @var array */ const PACKAGE_MANAGER_LOCKFILES = array( 'bun' => 'bun.lock', 'bun2' => 'bun.lockb', 'pnpm' => 'pnpm-lock.yaml', 'yarn' => 'yarn.lock', 'npm' => 'package-lock.json', ); /** * Identify which package manager an unbuilt export uses, from * whichever lockfile is present — so the rejection message can give * the exact command instead of a generic guess. Verified as * necessary against a real export that shipped a `bun.lock`, not * `package-lock.json`. Defaults to 'npm' (universally installed * alongside Node, so always a safe fallback command to suggest) when * no lockfile is found at all. * * @param string $dir Extracted export root. * * @return string One of 'npm', 'yarn', 'pnpm', 'bun'. */ public function detect_package_manager( $dir ) { $dir = rtrim( $dir, '/' ); foreach ( self::PACKAGE_MANAGER_LOCKFILES as $manager => $lockfile ) { if ( is_file( $dir . '/' . $lockfile ) ) { return 'bun2' === $manager ? 'bun' : $manager; } } return 'npm'; } /** * Does this export contain more than one real page, under either of * two conventions? * * 1. A pre-rendered multi-page static build shipped alongside its * (SSR-framework) source, in a `static-export/` subdirectory. * Verified against a real TanStack Start/Router export whose own * `scripts/export-static.mjs` runs the production SSR bundle * in-process, renders every route to a plain .html file, and * writes the result there — its own docblock literally describes * it as "the shape an HTML to WordPress converter expects". * Unlike the single-wrapper-folder case resolve_root() unwraps, * this directory sits alongside many *other* top-level entries * (package.json, src/, etc.), so it needs its own explicit check * rather than falling out of the normal index.html-at-root lookup. * Deliberately keyed off this one specific, self-documented * folder name rather than a fuzzy heuristic — narrow and * low-false-positive, matching how is_ssr_only_export() etc. key * off specific known markers. * * 2. A plain, hand-authored (or simple-static-site-generator) export * with no build tooling at all: index.html plus one or more other * real .html files sitting flat right next to it — no * `static-export/` wrapper, no nested route folders. Verified * against a real sample (a hand-coded portfolio site: index.html, * case-studies.html, and a dozen case-study detail pages, all * siblings, sharing one plain styles.css/script.js). Scoped * strictly to $dir's own top level — a stray .html file several * folders deep (docs/, a backup/, ...) is never treated as a page * under this convention, only real siblings of index.html itself. * Low false-positive risk against an ordinary single-page Vite/ * React build too: that shape never has loose .html files next to * its own index.html, only assets/ (hashed .js/.css). * * @param string $dir Extracted export root (already resolved via * resolve_root() by the caller). * * @return string|null Absolute path to the multi-page root, or null. */ public function resolve_multi_page_root( $dir ) { $dir = rtrim( $dir, '/' ); $candidate = $dir . '/static-export'; if ( is_dir( $candidate ) && $this->contains_any_index_html( $candidate ) ) { return $candidate; } return $this->has_sibling_html_pages( $dir ) ? $dir : null; } /** * Whether $dir itself directly contains an index.html plus at least * one other genuine page — see resolve_multi_page_root()'s own * docblock (convention 2) for the full reasoning and scope. * * @param string $dir Directory to check (already rtrim()'d by the * caller). * * @return bool */ private function has_sibling_html_pages( $dir ) { if ( ! is_file( $dir . '/index.html' ) ) { return false; } $entries = scandir( $dir ); if ( false === $entries ) { return false; } foreach ( $entries as $entry ) { if ( 'index.html' === $entry ) { continue; } if ( '.html' !== strtolower( substr( $entry, -5 ) ) ) { continue; } if ( in_array( strtolower( $entry ), Multi_Page_Rewriter::NON_PAGE_HTML_FILENAMES, true ) ) { continue; } if ( is_file( $dir . '/' . $entry ) ) { return true; } } return false; } /** * Recursively check whether $dir contains at least one index.html * anywhere within it (not just directly inside it). * * @param string $dir Directory to scan. * * @return bool */ private function contains_any_index_html( $dir ) { $entries = scandir( $dir ); if ( false === $entries ) { return false; } foreach ( $entries as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } $path = $dir . '/' . $entry; if ( is_file( $path ) && 'index.html' === $entry ) { return true; } if ( is_dir( $path ) && $this->contains_any_index_html( $path ) ) { return true; } } return false; } /** * Directory names never descended into while searching for a deeper * index.html — dependency trees and VCS metadata can be huge, are * never where a build's own index.html lives, and node_modules in * particular can itself contain any number of unrelated packages' * own index.html files that must never be mistaken for the export's. */ const DEEP_SEARCH_SKIP_DIRS = array( 'node_modules', '.git', '.github', '.svn', '.hg' ); /** * How many directory levels below $dir to search — deep enough for * any real project layout (e.g. `repo-main/packages/site/dist/`), * bounded so a pathological archive can't make this expensive. */ const DEEP_SEARCH_MAX_DEPTH = 6; /** * Total filesystem entries visited before giving up — a second, * independent bound alongside DEEP_SEARCH_MAX_DEPTH, so a very wide * (rather than very deep) tree can't make this expensive either. */ const DEEP_SEARCH_MAX_ENTRIES = 5000; /** * Fallback for when $dir has no index.html of its own: search inside * it for the shallowest one. Handles a "whole repo" ZIP (e.g. * GitHub's own "Download ZIP" of a project, rather than just its * `dist/` output) whose build lives a folder or two down — * `dist/index.html` alongside `src/`, `package.json`, etc. — * which resolve_root()'s single-wrapper-folder unwrap can't reach, * since more than one entry sits at that level. * * Only ever called by Converter once the expected location has * already come up empty, and never overrides a real index.html * already present at $dir — so an export's own genuine root * index.html (even an unbuilt one, which is_unbuilt_source_export() * still correctly rejects afterward) always wins over some deeper * file; this only fires when there is nothing to shadow. * * Deliberately conservative about ambiguity: when more than one * index.html sits at the same shallowest depth, this refuses to * guess which one is the real build rather than risk silently * converting the wrong page. * * @param string $dir Absolute path to search inside. * * @return string|null Absolute path to the directory containing the * one unambiguous shallowest index.html found, * or null if none (or more than one at the same * depth) was found. */ public function find_deep_index_html( $dir ) { $queue = array( rtrim( $dir, '/' ) ); $visited_count = 0; for ( $depth = 0; $depth <= self::DEEP_SEARCH_MAX_DEPTH && ! empty( $queue ); $depth++ ) { $next_queue = array(); $found = array(); foreach ( $queue as $current_dir ) { $entries = scandir( $current_dir ); if ( false === $entries ) { continue; } foreach ( $entries as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } ++$visited_count; if ( $visited_count > self::DEEP_SEARCH_MAX_ENTRIES ) { return null; } $path = $current_dir . '/' . $entry; if ( is_dir( $path ) ) { if ( in_array( $entry, self::DEEP_SEARCH_SKIP_DIRS, true ) ) { continue; } $next_queue[] = $path; continue; } if ( 'index.html' === $entry ) { $found[] = $current_dir; } } } if ( 1 === count( $found ) ) { return $found[0]; } if ( count( $found ) > 1 ) { return null; } $queue = $next_queue; } return null; } /** * Determine which adapter key handles the given export. * * @param string $extracted_dir Absolute path to the extracted export. * * @return string Adapter registry key, e.g. 'generic'. */ public function detect( $extracted_dir ) { foreach ( $this->get_registry() as $key => $class ) { $adapter = new $class(); if ( $adapter->detect( $extracted_dir ) ) { return $key; } } // Guaranteed fallback, even if a filter removed 'generic' itself. return 'generic'; } /** * Instantiate the adapter for a given registry key, falling back to * the generic adapter for an unknown key. * * @param string $key Adapter registry key. * * @return Adapters\Source_Adapter */ public function get_adapter( $key ) { $registry = $this->get_registry(); if ( ! isset( $registry[ $key ] ) ) { $key = 'generic'; } return new $registry[ $key ](); } /** * Where Lovable's own *.asset.json descriptors live within an * export's source (not its static-export/ build output) — see * find_lovable_project_url()'s own docblock. */ const LOVABLE_ASSETS_DIR = 'src/assets'; /** * How many directory levels below LOVABLE_ASSETS_DIR to search for a * *.asset.json descriptor — bounded so a pathological tree can't * make this expensive; real exports keep these one or two levels * deep (e.g. src/assets/work/*.asset.json). */ const ASSET_DESCRIPTOR_MAX_DEPTH = 4; /** * The real, live origin backing Lovable's `/__l5e/assets-v1/...` * cloud-hosted-image URLs for this export, or null if it doesn't * carry the descriptor convention this is keyed off. Used by * Asset_Recoverer to fetch images a finished export still references * but never actually bundled. * * Read from any one *.asset.json descriptor's own "project_id" * field (every one in a given export points at the same project) * — verified, against a real export, to actually serve those assets * back. * * @param string $dir Extracted export root (whole-repo shape, i.e. * the one that still has src/ in it — a * ZIP containing only static-export/ has no * descriptors to find at all, and this * correctly returns null for it). * * @return string|null */ public function find_lovable_project_url( $dir ) { $assets_dir = rtrim( $dir, '/' ) . '/' . self::LOVABLE_ASSETS_DIR; if ( ! is_dir( $assets_dir ) ) { return null; } $descriptor = $this->find_first_asset_descriptor( $assets_dir, 0 ); if ( null === $descriptor ) { return null; } $decoded = json_decode( (string) file_get_contents( $descriptor ), true ); if ( ! is_array( $decoded ) || empty( $decoded['project_id'] ) || ! is_string( $decoded['project_id'] ) ) { return null; } return 'https://' . $decoded['project_id'] . '.lovableproject.com'; } /** * Depth-bounded search for the first *.asset.json file under $dir — * every one in a real export carries the same "project_id", so * finding just one is enough. * * @param string $dir Directory to search. * @param int $depth Current recursion depth. * * @return string|null Absolute path to the first match found. */ private function find_first_asset_descriptor( $dir, $depth ) { if ( $depth > self::ASSET_DESCRIPTOR_MAX_DEPTH ) { return null; } $entries = scandir( $dir ); if ( false === $entries ) { return null; } $subdirs = array(); foreach ( $entries as $entry ) { if ( '.' === $entry || '..' === $entry ) { continue; } $path = $dir . '/' . $entry; if ( is_file( $path ) && preg_match( '/\.asset\.json$/i', $entry ) ) { return $path; } if ( is_dir( $path ) ) { $subdirs[] = $path; } } foreach ( $subdirs as $subdir ) { $found = $this->find_first_asset_descriptor( $subdir, $depth + 1 ); if ( null !== $found ) { return $found; } } return null; } /** * The adapter registry: key => fully-qualified class name, checked in * this order — 'generic' must stay last, since it's the guaranteed * catch-all. Filterable so future adapters (Replit, v0, ...) register * themselves without editing this class. * * @return array */ private function get_registry() { $default = array( 'lovable' => Adapters\Adapter_Lovable::class, 'generic' => Adapters\Adapter_Generic::class, ); if ( function_exists( 'apply_filters' ) ) { return apply_filters( 'esipher_source_adapters', $default ); } return $default; } }