diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index a8f92a1086..77afa93196 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -44,6 +44,7 @@ import { updateSiteAutoStart, updateSiteLatestCliPid, } from 'cli/lib/appdata'; +import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { connect, disconnect, emitSiteEvent } from 'cli/lib/pm2-manager'; import { getServerFilesPath } from 'cli/lib/server-files'; import { getPreferredSiteLanguage } from 'cli/lib/site-language'; @@ -190,10 +191,33 @@ export async function runCommand( const setupSteps: StepDefinition[] = []; - if ( isOnlineStatus ) { - const siteLanguage = await getPreferredSiteLanguage( options.wpVersion ); + const siteLanguage = await getPreferredSiteLanguage( options.wpVersion ); - if ( siteLanguage && siteLanguage !== DEFAULT_LOCALE ) { + if ( siteLanguage && siteLanguage !== DEFAULT_LOCALE ) { + // For the 'latest' WP version, try using bundled language packs first to avoid + // a network round-trip. Fall back to the Playground setSiteLanguage step for + // non-latest versions or when bundled packs aren't available. + let isUsingBundledLanguagePacks = false; + if ( options.wpVersion === DEFAULT_WORDPRESS_VERSION ) { + isUsingBundledLanguagePacks = await copyLanguagePackToSite( sitePath, siteLanguage ); + } + + if ( isUsingBundledLanguagePacks ) { + setupSteps.push( + { + step: 'defineWpConfigConsts', + consts: { + WPLANG: siteLanguage, + }, + }, + { + step: 'setSiteOptions', + options: { + WPLANG: siteLanguage, + }, + } + ); + } else if ( isOnlineStatus ) { setupSteps.push( { step: 'setSiteLanguage', diff --git a/cli/commands/site/tests/create.test.ts b/cli/commands/site/tests/create.test.ts index 870468aaba..13b759696f 100644 --- a/cli/commands/site/tests/create.test.ts +++ b/cli/commands/site/tests/create.test.ts @@ -24,6 +24,7 @@ import { updateSiteAutoStart, SiteData, } from 'cli/lib/appdata'; +import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { getServerFilesPath } from 'cli/lib/server-files'; import { getPreferredSiteLanguage } from 'cli/lib/site-language'; @@ -60,6 +61,7 @@ vi.mock( 'cli/lib/appdata', async () => { getSiteUrl: vi.fn( ( site ) => `http://localhost:${ site.port }` ), }; } ); +vi.mock( 'cli/lib/language-packs' ); vi.mock( 'cli/lib/pm2-manager' ); vi.mock( 'cli/lib/server-files', () => ( { getServerFilesPath: vi.fn( () => '/test/server-files' ), @@ -158,6 +160,7 @@ describe( 'CLI: studio site create', () => { ); vi.mocked( isOnline ).mockResolvedValue( true ); vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'en' ); + vi.mocked( copyLanguagePackToSite ).mockResolvedValue( false ); } ); afterEach( () => { @@ -612,6 +615,99 @@ describe( 'CLI: studio site create', () => { } ); } ); + describe( 'Language Packs', () => { + it( 'should use bundled language packs and skip setSiteLanguage for latest WP version', async () => { + vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'sv_SE' ); + vi.mocked( copyLanguagePackToSite ).mockResolvedValue( true ); + + await runCommand( mockSitePath, { ...defaultTestOptions } ); + + expect( copyLanguagePackToSite ).toHaveBeenCalledWith( mockSitePath, 'sv_SE' ); + expect( startWordPressServer ).toHaveBeenCalledWith( + expect.anything(), + expect.any( Logger ), + expect.objectContaining( { + blueprint: expect.objectContaining( { + steps: expect.arrayContaining( [ + expect.objectContaining( { + step: 'defineWpConfigConsts', + consts: { WPLANG: 'sv_SE' }, + } ), + expect.objectContaining( { + step: 'setSiteOptions', + options: { WPLANG: 'sv_SE' }, + } ), + ] ), + } ), + } ) + ); + // Should NOT include setSiteLanguage step + const calls = vi.mocked( startWordPressServer ).mock.calls; + const blueprintSteps = ( calls[ 0 ][ 2 ] as { blueprint?: Blueprint } )?.blueprint + ?.steps as StepDefinition[]; + expect( blueprintSteps.some( ( s ) => s.step === 'setSiteLanguage' ) ).toBe( false ); + } ); + + it( 'should fall back to setSiteLanguage when bundled packs are not available', async () => { + vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'sv_SE' ); + vi.mocked( copyLanguagePackToSite ).mockResolvedValue( false ); + + await runCommand( mockSitePath, { ...defaultTestOptions } ); + + expect( startWordPressServer ).toHaveBeenCalledWith( + expect.anything(), + expect.any( Logger ), + expect.objectContaining( { + blueprint: expect.objectContaining( { + steps: expect.arrayContaining( [ + expect.objectContaining( { + step: 'setSiteLanguage', + language: 'sv_SE', + } ), + expect.objectContaining( { + step: 'setSiteOptions', + options: { WPLANG: 'sv_SE' }, + } ), + ] ), + } ), + } ) + ); + } ); + + it( 'should use setSiteLanguage for non-latest WP versions', async () => { + vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'sv_SE' ); + + await runCommand( mockSitePath, { + ...defaultTestOptions, + wpVersion: '6.5', + } ); + + expect( copyLanguagePackToSite ).not.toHaveBeenCalled(); + expect( startWordPressServer ).toHaveBeenCalledWith( + expect.anything(), + expect.any( Logger ), + expect.objectContaining( { + blueprint: expect.objectContaining( { + steps: expect.arrayContaining( [ + expect.objectContaining( { + step: 'setSiteLanguage', + language: 'sv_SE', + } ), + ] ), + } ), + } ) + ); + } ); + + it( 'should not set language when locale is English', async () => { + vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'en' ); + + await runCommand( mockSitePath, { ...defaultTestOptions } ); + + expect( copyLanguagePackToSite ).not.toHaveBeenCalled(); + } ); + } ); + describe( 'Error Handling', () => { it( 'should handle WordPress server start failure', async () => { vi.mocked( startWordPressServer ).mockRejectedValue( new Error( 'Server start failed' ) ); diff --git a/cli/lib/language-packs.ts b/cli/lib/language-packs.ts new file mode 100644 index 0000000000..dae1037f97 --- /dev/null +++ b/cli/lib/language-packs.ts @@ -0,0 +1,75 @@ +import fs from 'fs'; +import path from 'path'; +import { pathExists } from 'common/lib/fs-utils'; +import { getLanguagePacksPath } from 'cli/lib/server-files'; + +/** + * Filters files in a directory that match a given WordPress locale. + * Matches .l10n.php files ({locale}.l10n.php, {domain}-{locale}.l10n.php) + * and .json files ({locale}-{hash}.json). + */ +function filterLocaleFiles( files: string[], wpLocale: string ): string[] { + return files.filter( ( file ) => { + const name = path.basename( file ); + return ( + name === `${ wpLocale }.l10n.php` || + name.endsWith( `-${ wpLocale }.l10n.php` ) || + ( name.startsWith( `${ wpLocale }-` ) && name.endsWith( '.json' ) ) + ); + } ); +} + +/** + * Copies matching locale files from a source directory to a destination directory. + */ +async function copyLocaleFiles( + sourceDir: string, + destDir: string, + wpLocale: string +): Promise< number > { + if ( ! ( await pathExists( sourceDir ) ) ) { + return 0; + } + const allFiles = await fs.promises.readdir( sourceDir ); + const localeFiles = filterLocaleFiles( allFiles, wpLocale ); + if ( localeFiles.length === 0 ) { + return 0; + } + await fs.promises.mkdir( destDir, { recursive: true } ); + for ( const file of localeFiles ) { + await fs.promises.copyFile( path.join( sourceDir, file ), path.join( destDir, file ) ); + } + return localeFiles.length; +} + +/** + * Copies bundled WordPress language pack files for a given locale into a site's + * wp-content/languages/ directory, including core, plugin, and theme translations. + * Returns true if files were copied successfully. + */ +export async function copyLanguagePackToSite( + sitePath: string, + wpLocale: string +): Promise< boolean > { + const languagePacksDir = getLanguagePacksPath(); + if ( ! ( await pathExists( languagePacksDir ) ) ) { + return false; + } + + const destBase = path.join( sitePath, 'wp-content', 'languages' ); + + let totalCopied = 0; + totalCopied += await copyLocaleFiles( languagePacksDir, destBase, wpLocale ); + totalCopied += await copyLocaleFiles( + path.join( languagePacksDir, 'plugins' ), + path.join( destBase, 'plugins' ), + wpLocale + ); + totalCopied += await copyLocaleFiles( + path.join( languagePacksDir, 'themes' ), + path.join( destBase, 'themes' ), + wpLocale + ); + + return totalCopied > 0; +} diff --git a/cli/lib/server-files.ts b/cli/lib/server-files.ts index bb7a8e9f21..64757b61c9 100644 --- a/cli/lib/server-files.ts +++ b/cli/lib/server-files.ts @@ -15,3 +15,7 @@ export function getWpCliPharPath(): string { export function getSqliteCommandPath(): string { return path.join( getServerFilesPath(), SQLITE_COMMAND_FOLDER ); } + +export function getLanguagePacksPath(): string { + return path.join( getServerFilesPath(), 'language-packs' ); +} diff --git a/common/lib/wp-locales.ts b/common/lib/wp-locales.ts new file mode 100644 index 0000000000..8077482ac6 --- /dev/null +++ b/common/lib/wp-locales.ts @@ -0,0 +1,26 @@ +/** + * WordPress locale codes for all non-English locales supported by Studio. + * Used for downloading and installing WordPress core language packs. + */ +export const WP_LOCALES = [ + 'ar', + 'de_DE', + 'es_ES', + 'fr_FR', + 'he_IL', + 'hu_HU', + 'id_ID', + 'it_IT', + 'ja', + 'ko_KR', + 'nl_NL', + 'pl_PL', + 'pt_BR', + 'ru_RU', + 'sv_SE', + 'tr_TR', + 'vi', + 'uk', + 'zh_CN', + 'zh_TW', +]; diff --git a/forge.config.ts b/forge.config.ts index fc5543e68b..50bbfded1a 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -137,6 +137,9 @@ const config: ForgeConfig = { if ( isErrnoException( err ) && err.code !== 'ENOENT' ) throw err; } + console.log( 'Downloading language packs ...' ); + await execAsync( 'npm run download-language-packs' ); + console.log( 'Building CLI ...' ); await execAsync( 'npm run cli:build' ); diff --git a/package.json b/package.json index dd3971990b..e661767f6b 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "test:watch": "vitest", "e2e": "npx playwright install && npx playwright test", "test:metrics": "npx playwright test --config=./metrics/playwright.metrics.config.ts", - "make-pot": "node ./scripts/make-pot.mjs" + "make-pot": "node ./scripts/make-pot.mjs", + "download-language-packs": "ts-node ./scripts/download-language-packs.ts" }, "devDependencies": { "@automattic/color-studio": "^4.1.0", diff --git a/scripts/download-language-packs.ts b/scripts/download-language-packs.ts new file mode 100644 index 0000000000..1434b62665 --- /dev/null +++ b/scripts/download-language-packs.ts @@ -0,0 +1,162 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs-extra'; +import { extractZip } from '../common/lib/extract-zip'; +import { WP_LOCALES } from '../common/lib/wp-locales'; + +const WP_SERVER_FILES_PATH = path.join( __dirname, '..', 'wp-files' ); + +const MAX_RETRIES = 3; +const INITIAL_RETRY_DELAY_MS = 1000; + +async function fetchWithRetry( url: string, attempt = 1 ): Promise< Response > { + try { + const response = await fetch( url ); + if ( response.ok ) { + return response; + } + if ( attempt >= MAX_RETRIES ) { + throw new Error( `HTTP ${ response.status }` ); + } + const delay = INITIAL_RETRY_DELAY_MS * Math.pow( 2, attempt - 1 ); + console.warn( + `[language-packs] Request failed (status ${ response.status }), retrying in ${ delay }ms (attempt ${ attempt }/${ MAX_RETRIES })...` + ); + await new Promise( ( resolve ) => setTimeout( resolve, delay ) ); + return fetchWithRetry( url, attempt + 1 ); + } catch ( error ) { + if ( attempt >= MAX_RETRIES ) { + throw error; + } + const delay = INITIAL_RETRY_DELAY_MS * Math.pow( 2, attempt - 1 ); + console.warn( + `[language-packs] Request failed (${ + error instanceof Error ? error.message : error + }), retrying in ${ delay }ms (attempt ${ attempt }/${ MAX_RETRIES })...` + ); + await new Promise( ( resolve ) => setTimeout( resolve, delay ) ); + return fetchWithRetry( url, attempt + 1 ); + } +} + +interface TranslationEntry { + language: string; + package: string; +} + +interface TranslationsApiResponse { + translations: TranslationEntry[]; +} + +// Known single-file plugins whose directory name doesn't match the WordPress.org slug. +const SINGLE_FILE_PLUGIN_SLUGS: Record< string, string > = { + 'hello.php': 'hello-dolly', +}; + +// Older bundled themes that aren't worth downloading translations for. +const SKIPPED_THEME_SLUGS = [ 'twentytwentythree', 'twentytwentyfour' ]; + +function getBundledSlugs( contentDir: string ): string[] { + const entries = fs.readdirSync( contentDir, { withFileTypes: true } ); + const slugs: string[] = []; + for ( const entry of entries ) { + if ( entry.name === 'index.php' ) { + continue; + } + if ( entry.isDirectory() ) { + slugs.push( entry.name ); + } else if ( SINGLE_FILE_PLUGIN_SLUGS[ entry.name ] ) { + slugs.push( SINGLE_FILE_PLUGIN_SLUGS[ entry.name ] ); + } + } + return slugs; +} + +async function downloadTranslationsFromApi( + apiUrl: string, + destPath: string, + label: string +): Promise< void > { + const response = await fetchWithRetry( apiUrl ); + const data: TranslationsApiResponse = await response.json(); + + const translationsToDownload = data.translations.filter( ( t ) => + WP_LOCALES.includes( t.language ) + ); + + console.log( + `[language-packs] Downloading ${ label } (${ translationsToDownload.length } locales) ...` + ); + + await fs.ensureDir( destPath ); + + for ( const translation of translationsToDownload ) { + const { language, package: packageUrl } = translation; + const zipResponse = await fetchWithRetry( packageUrl ); + + const safeLabel = label.replace( /\//g, '-' ); + const zipPath = path.join( os.tmpdir(), `wp-language-${ safeLabel }-${ language }.zip` ); + const buffer = Buffer.from( await zipResponse.arrayBuffer() ); + await fs.writeFile( zipPath, buffer ); + await extractZip( zipPath, destPath ); + await fs.remove( zipPath ); + } +} + +async function removePoAndMoFiles( dirPath: string ): Promise< void > { + const entries = await fs.readdir( dirPath, { withFileTypes: true } ); + for ( const entry of entries ) { + const fullPath = path.join( dirPath, entry.name ); + if ( entry.isDirectory() ) { + await removePoAndMoFiles( fullPath ); + } else if ( entry.name.endsWith( '.po' ) || entry.name.endsWith( '.mo' ) ) { + await fs.remove( fullPath ); + } + } +} + +async function downloadLanguagePacks(): Promise< void > { + const languagesPath = path.join( WP_SERVER_FILES_PATH, 'latest', 'languages' ); + const wpContentPath = path.join( WP_SERVER_FILES_PATH, 'latest', 'wordpress', 'wp-content' ); + + // Core translations + await downloadTranslationsFromApi( + 'https://api.wordpress.org/translations/core/1.0/', + languagesPath, + 'core translations' + ); + + // Plugin translations + const pluginSlugs = getBundledSlugs( path.join( wpContentPath, 'plugins' ) ); + for ( const slug of pluginSlugs ) { + await downloadTranslationsFromApi( + `https://api.wordpress.org/translations/plugins/1.0/?slug=${ slug }`, + path.join( languagesPath, 'plugins' ), + `plugin translations: ${ slug }` + ); + } + + // Theme translations (skip older bundled themes) + const themeSlugs = getBundledSlugs( path.join( wpContentPath, 'themes' ) ).filter( + ( slug ) => ! SKIPPED_THEME_SLUGS.includes( slug ) + ); + for ( const slug of themeSlugs ) { + await downloadTranslationsFromApi( + `https://api.wordpress.org/translations/themes/1.0/?slug=${ slug }`, + path.join( languagesPath, 'themes' ), + `theme translations: ${ slug }` + ); + } + + // Remove .po and .mo files — WordPress 6.5+ uses .l10n.php and .json files. + await removePoAndMoFiles( languagesPath ); +} + +downloadLanguagePacks() + .then( () => { + console.log( '[language-packs] Done' ); + } ) + .catch( ( err ) => { + console.error( '[language-packs] Error downloading language packs:', err ); + process.exit( 1 ); + } ); diff --git a/src/lib/server-files-paths.ts b/src/lib/server-files-paths.ts index d265d39b2d..68f25963ad 100644 --- a/src/lib/server-files-paths.ts +++ b/src/lib/server-files-paths.ts @@ -64,3 +64,10 @@ export function getWpCliFolderPath(): string { export function getWpCliPath(): string { return path.join( getWpCliFolderPath(), 'wp-cli.phar' ); } + +/** + * The path where bundled WordPress language packs are stored. + */ +export function getLanguagePacksPath(): string { + return path.join( getBasePath(), 'language-packs' ); +} diff --git a/src/setup-wp-server-files.ts b/src/setup-wp-server-files.ts index a307f707dd..3bc61d1390 100644 --- a/src/setup-wp-server-files.ts +++ b/src/setup-wp-server-files.ts @@ -3,7 +3,12 @@ import fs from 'fs-extra'; import semver from 'semver'; import { recursiveCopyDirectory } from 'common/lib/fs-utils'; import { updateLatestWPCliVersion } from 'src/lib/download-utils'; -import { getWordPressVersionPath, getSqlitePath, getWpCliPath } from 'src/lib/server-files-paths'; +import { + getLanguagePacksPath, + getWordPressVersionPath, + getSqlitePath, + getWpCliPath, +} from 'src/lib/server-files-paths'; import { getSqliteCommandPath, updateLatestSQLiteCommandVersion, @@ -108,12 +113,38 @@ async function copyBundledTranslations() { await fs.copyFile( bundledTranslationsPath, installedTranslationsPath ); } +async function copyBundledLanguagePacks() { + const bundledLanguagePacksPath = path.join( + getResourcesPath(), + 'wp-files', + 'latest', + 'languages' + ); + if ( ! ( await fs.pathExists( bundledLanguagePacksPath ) ) ) { + return; + } + const installedLanguagePacksPath = getLanguagePacksPath(); + await fs.ensureDir( installedLanguagePacksPath ); + await recursiveCopyDirectory( bundledLanguagePacksPath, installedLanguagePacksPath ); +} + export async function setupWPServerFiles() { - await copyBundledLatestWPVersion(); - await copyBundledSqlite(); - await copyBundledWPCLI(); - await copyBundledSQLiteCommand(); - await copyBundledTranslations(); + const steps: Array< [ string, () => Promise< void > ] > = [ + [ 'WordPress version', copyBundledLatestWPVersion ], + [ 'SQLite integration', copyBundledSqlite ], + [ 'WP-CLI', copyBundledWPCLI ], + [ 'SQLite command', copyBundledSQLiteCommand ], + [ 'translations', copyBundledTranslations ], + [ 'language packs', copyBundledLanguagePacks ], + ]; + + for ( const [ name, step ] of steps ) { + try { + await step(); + } catch ( error ) { + console.error( `Failed to set up bundled ${ name }:`, error ); + } + } } /**