.js`.** * If this enqueued the entry as `main.js?ver=` — which is what * {@see BuiltAsset} does for the stylesheet and what this did before the * split — the browser would hold TWO modules for one file, because a query * string makes a different URL and a different module record. The second * one carries its own React, and the builder's first `useState` throws * *"Invalid hook call"* against a copy of React that never rendered it. The * screen goes blank, and no test anywhere catches it. * * So there is one URL for the entry, it carries no query, and its name is * what busts its cache. The stylesheet keeps its `?ver`: a stylesheet has * no module identity and nothing imports it. */ private const ENTRY = 'main-*.js'; private const BUILDER_CHUNK = 'builder-*.js'; /** * @param AdminNotices $notices where a broken build is reported, since * `admin_notices` is emptied on this screen. */ public static function enqueueAdmin(string $handle, AdminNotices $notices): void { self::enqueueAdminFrom( $handle, WCONVERT_DIR . self::ADMIN_DIST, WCONVERT_URL . self::ADMIN_DIST, self::TEXT_DOMAIN, '', $notices ); } /** * The same bundle, from whichever plugin directory built it. * * ======================================================================== * TWO CALLERS, ONE MACHINE, AND THE SECOND IS PRO'S REPLACEMENT. * ======================================================================== * [[Pro]] ships a COMPLETE replacement admin bundle — free's screens plus * its own, composed in Pro's entry where the bundler can see them — and * dequeues free's (ADR 0014, extended to the admin). It is the same * artifact built from a different entry into a different plugin, so it * needs everything this file knows and none of it copied: the hashed entry * that must carry no `?ver`, the builder chunk whose absence is a blank * screen on the fifth click, the `type="module"` filter, the fail-closed * glob. * * Pro passes its OWN directory and URL rather than borrowing free's * constants — the same reasoning `ProLoaderEnqueue::DIST` carries: the two * paths read alike by a coincidence of two Vite configs, not by contract, * and reaching for free's would make Pro's asset path change when free * moved its build output. * * @param string $textDomain The catalogue `wp_set_script_translations()` * loads, for the entry and for every chunk. One * domain per handle is all WordPress allows, which * is why free's strings in Pro's bundle arrive * through {@see self::inlineAdminTranslations()}. * @param string $languagesDir Where that domain's `.json` catalogues live, * or '' for WordPress's own languages folder — * free's case, since wp.org's packs land there. * @param AdminNotices|null $notices Where a broken build is reported, or * null where the caller reports it itself. * @return bool Whether a usable build was found and enqueued. */ public static function enqueueAdminFrom( string $handle, string $distDir, string $distUrl, string $textDomain, string $languagesDir, ?AdminNotices $notices ): bool { $entry = self::built($distDir, self::ENTRY); // An incomplete build renders as a blank
with nothing in any log. // Say so instead. **Both files, since #73 split the bundle in two**: the // entry alone booting is not the screen working, because the builder // lives in a chunk the entry fetches at the moment a merchant opens it // — which is the one moment nobody is reading a build log. if ($entry === null || self::built($distDir, self::BUILDER_CHUNK) === null) { if ($notices !== null) { self::noticeMissingAdminBundle($notices); } return false; } // The CSS is optional: an admin screen with no styles is degraded, an // admin screen with no script is broken, so only the script is fatal. if (is_file($distDir . 'main.css')) { wp_enqueue_style( $handle, $distUrl . 'main.css', [], BuiltAsset::version($distDir . 'main.css') ); } // ==================================================================== // `null`, WHICH IS THE ONLY VALUE THAT ADDS NO QUERY AT ALL. // ==================================================================== // The constant above says why no query: a `?ver` gives the browser a // second module for the very file the builder chunk imports, and a // second React with it. // // **`false` does not do that**, however much it reads like it should. // `WP_Scripts::get_normalized_src()` tests // `empty($obj->ver) && null !== $obj->ver`, so `false` — the parameter's // own default — falls through to the SITE's WordPress version and the // script is served as `main-.js?ver=7.1`. Only `null` is the // absence of a version rather than a request for the default one, and // the difference here is a blank admin screen. The hash in the file's // name is its version, so a release still busts every cache. // phpcs:disable WordPress.WP.EnqueuedResourceParameters.MissingVersion -- see above: a `?ver` loads React twice. wp_enqueue_script( $handle, $distUrl . $entry, ['wp-i18n', 'wp-api-fetch'], null, true ); // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion self::serveAsModule($handle); wp_set_script_translations($handle, $textDomain, $languagesDir); self::inlineCatalogues($handle, $distDir, $distUrl, [self::BUILDER_CHUNK], $textDomain, $languagesDir); return true; } /** * FREE's admin catalogue, on another plugin's handle — Pro's. * * ======================================================================== * WP.ORG KEYS A CATALOGUE BY THE FILE IT CAME FROM, AND PRO'S ISN'T FREE'S. * ======================================================================== * Pro's bundle is free's screens plus Pro's, so most of its strings are in * free's domain. But a language pack names each JSON catalogue after the * md5 of the shipped file's path — `public/admin/main-.js` in FREE's * plugin — and Pro's bundle is a different file with a different hash. So * binding `wconvert` to Pro's handle, which is what this replaced, looked * up a catalogue nothing ever writes: every free string rendered in English * on a Pro install, on a site whose free install was fully translated. * * The strings are the same strings, though, so free's own catalogues are * the right ones to read. Each of free's built files — entry and chunks — * is registered (never enqueued) so `load_script_textdomain()` resolves its * real path, and every one of them is merged into the `wconvert` domain on * the caller's handle. Which of Pro's chunks a string ended up in no longer * matters: `setLocaleData` is per domain, not per file. * * Free owns the path because the files are free's. Pro spelling * `public/admin/` for free would be the coupling {@see self::enqueueAdminFrom()} * refuses in the other direction. * * What this cannot reach is a free string that only Pro's bundle carries — * a free module free's own entry never imports. No free catalogue holds it. * * @param string $freeDir Free's plugin directory, trailing slash. A * parameter only so a test can point it at a * fixture; production passes nothing. * @param string $freeUrl Free's plugin URL, trailing slash. */ public static function inlineAdminTranslations( string $handle, string $freeDir = WCONVERT_DIR, string $freeUrl = WCONVERT_URL ): void { self::inlineCatalogues( $handle, $freeDir . self::ADMIN_DIST, $freeUrl . self::ADMIN_DIST, [self::ENTRY, self::BUILDER_CHUNK], self::TEXT_DOMAIN, '' ); } /** * Built files' strings, handed to `wp.i18n` before the entry runs. * * ======================================================================== * A CATALOGUE IS PER FILE, AND THE BUILDER IS SEVERAL FILES. * ======================================================================== * WordPress names a script's JSON catalogue after the md5 of the script's * path, and wp.org's language packs make one per JavaScript file. So * `wp_set_script_translations()` on the entry loads the entry's strings * and none of the builder's — and the builder chunks are never enqueued, * because the entry `import()`s them, so there is no handle of theirs for * WordPress to print a catalogue against. * * Each file is therefore registered — never enqueued, so nothing prints * its tag — purely so `load_script_textdomain()` can resolve its path, and * its catalogue is merged into the domain with `setLocaleData`. Merged * rather than replaced: `setLocaleData` adds to the domain's data. * * The handle carries the domain as well as the file name, so free's * `builder-` and Pro's can never claim one registration between * them on Pro's screen, where both sets are read. * * @param list $patterns The built files to read, as globs. */ private static function inlineCatalogues( string $handle, string $distDir, string $distUrl, array $patterns, string $textDomain, string $languagesDir ): void { foreach ($patterns as $pattern) { $files = glob($distDir . $pattern); foreach (is_array($files) ? $files : [] as $file) { $fileHandle = $handle . '-' . $textDomain . '-' . basename($file, '.js'); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- registered for its path only and never printed; see above. wp_register_script($fileHandle, $distUrl . basename($file), [], null, true); self::inlineTranslations($handle, $textDomain, $languagesDir, $fileHandle); } } } /** * A catalogue on a handle it was not bound to, inlined. * * ======================================================================== * WORDPRESS ALLOWS ONE DOMAIN PER HANDLE, AND PRO'S BUNDLE HOLDS TWO. * ======================================================================== * Pro's admin bundle is free's screens plus Pro's, so its strings are * free's (`wconvert`) and Pro's (`wconvert-pro`) in one file. * `wp_set_script_translations()` binds ONE domain to a handle — call it * twice and the second replaces the first — so exactly one of the two can * be loaded the ordinary way, and the other has to be handed to `wp.i18n` * directly. This does that: it reads the JSON catalogue WordPress would * have served and calls `setLocaleData` before the bundle runs. The lazily * loaded chunks go through it too, in either domain, since no handle of * theirs is ever printed. * * It is free's file because the machinery is not premium — it is the same * enqueue this class already does — and because free's tree may not name * Pro. * * `ViteHelper::inlinePremiumTranslations()` in WSMS is the same method, and * the difference is worth recording: WSMS needs it because its free and * premium React builds share one output path, and WConvert's two live in * two plugin directories (ADR 0014). The two-catalogue problem is real * here anyway — it is a property of one SCRIPT carrying two domains, not * of where that script was written. * * **Silent where there is nothing to load**, and correctly so: an untranslated * locale has no catalogue, which is the normal case rather than a fault. * * @param string $handle The registered script the data is attached to. * @param string $textDomain The domain the catalogue is merged into. * @param string $languagesDir Where that domain's `.json` catalogues live, * or '' for WordPress's own languages folder. * @param string|null $source The script whose catalogue is read, where it * is not `$handle` — a chunk, or free's file. */ private static function inlineTranslations(string $handle, string $textDomain, string $languagesDir, ?string $source = null): void { $json = load_script_textdomain($source ?? $handle, $textDomain, $languagesDir); if (!is_string($json) || $json === '') { return; } $decoded = json_decode($json, true); $messages = is_array($decoded) ? ($decoded['locale_data']['messages'] ?? null) : null; if (!is_array($messages)) { return; } // The header entry carries the domain the data belongs to, and // `setLocaleData` needs it to agree with the second argument — a // catalogue announcing a different domain is loaded and never read. $messages['']['domain'] = $textDomain; wp_add_inline_script( $handle, 'wp.i18n.setLocaleData( ' . wp_json_encode($messages, JSON_HEX_TAG | JSON_HEX_AMP) . ', "' . esc_js($textDomain) . '" );', 'before' ); } /** * A built file's name, or null where the build produced none. * * `glob()` returns false on failure and an empty array on no match, and the * two mean different things to nobody here — a directory that cannot be * searched and a directory with nothing in it are both a build this cannot * vouch for. Which is the point: the check fails closed, exactly as the * `is_file()` test it replaced always did. * * The basename rather than the path, because the caller needs it as a URL. */ private static function built(string $distDir, string $pattern): ?string { $matches = glob($distDir . $pattern); return is_array($matches) && $matches !== [] ? basename($matches[0]) : null; } /** * Serve the admin bundle as an ES module. * * ======================================================================== * THE SPLIT IS WHY, AND `script_loader_tag` IS THE ONLY PORTABLE WHERE. * ======================================================================== * The bundle was an IIFE until #73, and an IIFE cannot code-split — there * is nowhere in one for a second chunk to go, so Vite silently flattens any * `import()` back into the entry. Splitting the builder out therefore meant * an ES build (ADR 0038), and an ES build is inert in a classic * `