Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions cli/commands/site/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
96 changes: 96 additions & 0 deletions cli/commands/site/tests/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' ),
Expand Down Expand Up @@ -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( () => {
Expand Down Expand Up @@ -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' ) );
Expand Down
75 changes: 75 additions & 0 deletions cli/lib/language-packs.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions cli/lib/server-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
}
26 changes: 26 additions & 0 deletions common/lib/wp-locales.ts
Original file line number Diff line number Diff line change
@@ -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',
];
3 changes: 3 additions & 0 deletions forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading