Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Blueprints: Add setSiteLanguage step to change the language #1538

Merged
merged 11 commits into from
Jul 12, 2024

Conversation

bgrgicak
Copy link
Collaborator

@bgrgicak bgrgicak commented Jun 24, 2024

Motivation for the change, related issues

To unlock Playground to non-English speakers we want to make it easier for sites to be loaded in a local language.

Users can easily switch the site language by adding a language=LANGUAGE_CODE query parameter.

Implementation details

This PR adds a language parameter to the Query API which adds a setSiteLanguage blueprint step.

We could have achieved this without a new step, but it would require adding two steps to the blueprint generator (add constant, run PHP).

The step downloads core, plugin, and theme translations from Download.WordPress.org and moves the files into the translation directory.

Step usage example:

{
	"steps": [
		{
			"step": "setSiteLanguage",
			"language": "pl_PL"
		}
	]
}

Testing Instructions (or ideally a Blueprint)

  • Checkout this branch
  • Open this Playground url
  • Confirm that the theme is translated (the menu label should be Menú)
  • Check the admin bar and confirm it's translated
  • Open Akismet settings (/wp-admin/options-general.php?page=akismet-key-config&view=start) and confirm they are translated
@bgrgicak
Copy link
Collaborator Author

@adamziel do you know why the documentation build is failing? I tried debugging it locally but didn't get far.

@bgrgicak bgrgicak changed the title Add setSiteLanguage step and language query param Jun 24, 2024
@bgrgicak bgrgicak changed the title Add langauge selection support Jun 24, 2024
@akirk
Copy link
Member

akirk commented Jun 25, 2024

I'd like to add my https://github.com/akirk/playground-step-library/blob/main/steps/setLanguage.js implementation to the discussion here. This is quite imperfect as it should use language packs (see https://api.wordpress.org/translations/core/1.0/ and https://api.wordpress.org/translations/plugins/1.0/?slug=friends) so that we get all the JSON files as well.

But note how we also need to take into account plugins and themes that are installed, we need to get their translations, too.

@bgrgicak
Copy link
Collaborator Author

I'd like to add my https://github.com/akirk/playground-step-library/blob/main/steps/setLanguage.js implementation to the discussion here. This is quite imperfect as it should use language packs (see https://api.wordpress.org/translations/core/1.0/ and https://api.wordpress.org/translations/plugins/1.0/?slug=friends) so that we get all the JSON files as well.

Thank you @akirk this is amazing!
I will come back to this and update the code, but let me know in case you would like to take over.

@akirk
Copy link
Member

akirk commented Jun 26, 2024

I am not sure I should, I don't know the code base well enough and will be off next week and I don't want to stall this.

I just tried to say that this is possible with the step library but that you also need to iterate the plugins to be installed, something that I think you had not addressed yet.

@brandonpayton
Copy link
Member

brandonpayton commented Jun 27, 2024

@adamziel, @bgrgicak, and @akirk, I think this is a good example of a place that calls for separate config languages/concepts (DSLs, not human language translations):

  1. Platform/Foundation
  2. Actions that follow Platform-level "truth" (most of today's Blueprints)

IMO, human language selection is part of platform truth -- "The current language is X" -- as is FS construction of various mount configurations. Things like mounts can have an order of application, but they are more like platform declarations than actions. They are foundational. They are the basis upon which everything else is built.

Actions are things done upon the established foundation or platform at runtime: Install this, write that, login, etc.

There is a bit of gray area in this conceptualization of Platform vs Runtime, because each runtime action builds upon the version of reality established by the previous action. But I think we can also separate the two concepts by asking:

Is this part of setting up WordPress Playground?

Both language and directory mounts are relevant to the Playground boot process. We want to establish the filesystem before booting, and as part of booting Wordpress, we could download the language for WordPress itself.

Then, at runtime, the installPlugin step could recognize the current language and download the right translation as part of its install work.

@brandonpayton
Copy link
Member

Instead of saying "separate DSLs", we could instead say things like:

  • Language selection belongs outside the "steps" list in the Blueprints schema
  • Directory mounts belong outside the "steps" list in the Blueprints schema

I also meant to ask:
What do you think?

@brandonpayton
Copy link
Member

brandonpayton commented Jun 27, 2024

Related to the platform vs actions idea, one question is:
Does the order in which the step is included matter? Does it need to come before or after other steps?

@bgrgicak
Copy link
Collaborator Author

Does the order in which the step is included matter? Does it need to come before or after other steps?

If we want to translate plugin and themes, it should come at the end.

@bgrgicak
Copy link
Collaborator Author

Sorry I'm focused on wrapping up offline support and will get back to this next week.

@bgrgicak
Copy link
Collaborator Author

bgrgicak commented Jul 3, 2024

Language selection belongs outside the "steps" list in the Blueprints schema
Directory mounts belong outside the "steps" list in the Blueprints schema

I agree, but steps are the only thing we have today. We discussed in the past that there could be a way to separate boot actions from runtime actions in the blueprint. These look like good examples of it.
By separating them, we could also run only the runtime part of the blueprint on load and skip the boot part if Playground was already booted (for example reload when using browser storage).

@bgrgicak bgrgicak force-pushed the add/set-site-language-step branch 2 times, most recently from a719571 to 8cc084f Compare July 5, 2024 10:25
@bgrgicak
Copy link
Collaborator Author

bgrgicak commented Jul 8, 2024

I moved the code to compile and implemented downloads in JS instead of PHP.

I had to use downloads.wordpress.org because translate.wordpress.org doesn't support CORS when requests come from localhost. My problem with downloads.wordpress.org is that it requires the plugin/theme version number in the URL.

@akirk @pkevan Do you know of any methods that would allow us to download the latest translations of a plugin/theme without knowing the version number?
If not we could add support to the downloads.wordpress.org API or allow CORS requests to translate.wordpress.org from localhost.

Theme/plugin translation code diff
diff --git a/packages/playground/blueprints/src/lib/steps/set-site-language.ts b/packages/playground/blueprints/src/lib/steps/set-site-language.ts
index 885c5a3d..f0de0e55 100644
--- a/packages/playground/blueprints/src/lib/steps/set-site-language.ts
+++ b/packages/playground/blueprints/src/lib/steps/set-site-language.ts
@@ -1,6 +1,12 @@
-import { StepDefinition, StepHandler } from '.';
+import {
+	InstallPluginStep,
+	InstallThemeStep,
+	StepDefinition,
+	StepHandler,
+} from '.';
 import { Blueprint } from '../blueprint';
 import { getWordPressVersion } from '@wp-playground/wordpress-builds';
+import { CorePluginReference, CoreThemeReference } from '../resources';
 
 export interface SetSiteLanguageStep {
 	step: 'setSiteLanguage';
@@ -66,10 +72,88 @@ export const compileSetSiteLanguageSteps = (
 		},
 	];
 
+	const plugins: string[] = [];
+	const themes: string[] = [];
+	for (const step of blueprint.steps) {
+		if (!step || typeof step === 'string') {
+			continue;
+		}
+		if (step.step === 'installPlugin') {
+			const pluginStep = step as InstallPluginStep<CorePluginReference>;
+			if (!pluginStep.pluginZipFile.slug) {
+				continue;
+			}
+			plugins.push(pluginStep.pluginZipFile.slug);
+		} else if (step.step === 'installTheme') {
+			const themeStep = step as InstallThemeStep<CoreThemeReference>;
+			if (!themeStep.themeZipFile.slug) {
+				continue;
+			}
+		}
+	}
+	if (plugins.length) {
+		siteLanguageSteps.push({
+			step: 'mkdir',
+			path: '/wordpress/wp-content/languages/plugins',
+		});
+	}
+	if (themes.length) {
+		siteLanguageSteps.push({
+			step: 'mkdir',
+			path: '/wordpress/wp-content/languages/themes',
+		});
+	}
+
+	for (const plugin of plugins) {
+		siteLanguageSteps.push(
+			{
+				step: 'unzip',
+				extractToPath: `/wordpress/wp-content/languages/plugins/${plugin}`,
+				zipFile: {
+					resource: 'url',
+					caption: `Downloading ${plugin}.mo`,
+					url: `https://downloads.wordpress.org/translation/plugins/${plugin}/stable/${language}.zip`, // TODO find working url
+				},
+			},
+			{
+				step: 'mv',
+				fromPath: `/wordpress/wp-content/languages/plugins/${plugin}/${plugin}-${language}.mo`,
+				toPath: `/wordpress/wp-content/languages/plugins/${plugin}-${language}.mo`,
+			},
+			{
+				step: 'rmdir',
+				path: `/wordpress/wp-content/languages/plugins/${plugin}`,
+			}
+		);
+	}
+
+	for (const theme of themes) {
+		siteLanguageSteps.push(
+			{
+				step: 'unzip',
+				extractToPath: `/wordpress/wp-content/languages/themes/${theme}`,
+				zipFile: {
+					resource: 'url',
+					caption: `Downloading ${theme}.mo`,
+					url: `https://downloads.wordpress.org/translation/themes/${theme}/stable/${language}.zip`, // TODO find working url
+				},
+			},
+			{
+				step: 'mv',
+				fromPath: `/wordpress/wp-content/languages/themes/${theme}/${theme}-${language}.mo`,
+				toPath: `/wordpress/wp-content/languages/themes/${theme}-${language}.mo`,
+			},
+			{
+				step: 'rmdir',
+				path: `/wordpress/wp-content/languages/themes/${theme}`,
+			}
+		);
+	}
+
 	blueprint.steps?.splice(setSiteLanguageStepIndex, 0, ...siteLanguageSteps);
 	return blueprint;
 };
 
 export const setSiteLanguage: StepHandler<SetSiteLanguageStep> = async () => {
-	// Implemented in packages/playground/blueprints/src/lib/compile.ts, search for `compileSetSiteLanguageSteps`
+	// Implemented in packages/playground/blueprints/src/lib/compile.ts, search for 'compileSetSiteLanguageSteps'
 };

@bgrgicak bgrgicak requested a review from adamziel July 8, 2024 07:51
@pkevan
Copy link

pkevan commented Jul 8, 2024

I moved the code to compile and implemented downloads in JS instead of PHP.

I had to use downloads.wordpress.org because translate.wordpress.org doesn't support CORS when requests come from localhost. My problem with downloads.wordpress.org is that it requires the plugin/theme version number in the URL.

@akirk @pkevan Do you know of any methods that would allow us to download the latest translations of a plugin/theme without knowing the version number? If not we could add support to the downloads.wordpress.org API or allow CORS requests to translate.wordpress.org from localhost.

Theme/plugin translation code diff

Not exactly sure what you are trying to do here, but you can use: https://api.wordpress.org/plugins/info/1.0/wordpress-beta-tester.json to grab all versions, then pick the appropriate one. Unsure if there are other methods available, or if it's worth building a specific endpoint on api.wordpress.org.

@bgrgicak
Copy link
Collaborator Author

bgrgicak commented Jul 8, 2024

Not exactly sure what you are trying to do here,

We want to add translation support to Playground.
When a user specifies a language, we automatically download all site translations.

but you can use: api.wordpress.org/plugins/info/1.0/wordpress-beta-tester.json to grab all versions, then pick the appropriate one. Unsure if there are other methods available, or if it's worth building a specific endpoint on api.wordpress.org.

Thanks! I want to avoid that because it adds one extra request for each plugin and theme we want to install.
This is why a endpoint that accepts just the plugin/theme name without a version would work well for us. It exists on https://translate.wordpress.org/projects/wp-plugins/${plugin}/dev/${lang}/default/export-translations?format=mo but we can't work with it because of CORS issues.
Would it be possible to allow CORS requests from localhost on translate.wordpress.org?

@pkevan
Copy link

pkevan commented Jul 8, 2024

Adding CORS support is a request via systems, see: https://make.wordpress.org/systems/?s=cors

@bgrgicak bgrgicak marked this pull request as draft July 9, 2024 05:48
@bgrgicak
Copy link
Collaborator Author

I'd like to add my akirk/playground-step-library@main/steps/setLanguage.js implementation to the discussion here. This is quite imperfect as it should use language packs (see api.wordpress.org/translations/core/1.0 and api.wordpress.org/translations/plugins/1.0?slug=friends) so that we get all the JSON files as well.

@akirk I explored this approach, but it complicates things a lot if we use blueprint steps to get translations.
If we do it with PHP it's a simple function call for core translations. With JS it's a zip download, extraction, cleanup...

When it comes to plugin and theme translations it's even more complicated because we need plugin version data and this adds extra requests.
I'm now trying to find a way to trigger the download of plugin/theme translations, but for some reason can't find how to do it. 😕

@akirk
Copy link
Member

akirk commented Jul 11, 2024

There is a function wp_download_language_pack() that should do the trick.

@bgrgicak
Copy link
Collaborator Author

There is a function wp_download_language_pack() that should do the trick.

That's what I'm using, but it looks like it downloads only core translations.

@bgrgicak bgrgicak marked this pull request as ready for review July 11, 2024 08:27
@bgrgicak
Copy link
Collaborator Author

@adamziel @akirk this should now be ready for review, sorry for the delay.

@bgrgicak bgrgicak requested a review from akirk July 11, 2024 08:34
@akirk
Copy link
Member

akirk commented Jul 11, 2024

For reference, I cooked up this little piece of PHP code to install a language pack:

function install_language_pack( $type, $slug, $lang ) {
	if ( ! in_array( $type, [ 'plugin', 'theme' ] ) ) {
		return new WP_Error( 'invalid_type', __( 'Invalid type' ) );
	}
	require_once ABSPATH . 'wp-admin/includes/translation-install.php';
	require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
	$language_updates = translations_api( $type . 's', compact( 'slug' ) );
	if ( is_wp_error( $language_updates ) ) {
		return $language_updates;
	}
	$packages = array_filter( $language_updates['translations'], fn( $pkg ) => $pkg['language'] === $lang );
	if ( empty( $packages ) ) {
		return;
	}
	$upgrader = new Language_Pack_Upgrader( new Automatic_Upgrader_Skin() );
	$update = (object) $packages[0];
	$update->type = $type;
	$update->slug = $slug;
	return $upgrader->bulk_upgrade( array( $update ) );
}

For example:

install_language_pack( 'plugin', 'friends', 'de_DE' );
=> array(1) {
  [0]=>
  array(7) {
    ["source"]=>
    string(70) "/Users/alex/Sites/localhost/wp/wp-content/upgrade/friends-2.9.3-de_de/"
    ["source_files"]=>
    array(7) {
      [0]=>
      string(22) "friends-de_DE.l10n.php"
      [1]=>
      string(51) "friends-de_DE-7ffd8fc6ef503cd5a5c483bb3aca9bc5.json"
      [2]=>
      string(51) "friends-de_DE-93774191f1ed3f0224434004c216505a.json"
      [3]=>
      string(16) "friends-de_DE.mo"
      [4]=>
      string(51) "friends-de_DE-9a6fba47b355b62e8f6712f5c40b6192.json"
      [5]=>
      string(51) "friends-de_DE-85be8386efdca7f7b168c9c67defc614.json"
      [6]=>
      string(16) "friends-de_DE.po"
    }
    ["destination"]=>
    string(59) "/Users/alex/Sites/localhost/wp/wp-content/languages/plugins"
    ["destination_name"]=>
    string(0) ""
    ["local_destination"]=>
    string(59) "/Users/alex/Sites/localhost/wp/wp-content/languages/plugins"
    ["remote_destination"]=>
    string(60) "/Users/alex/Sites/localhost/wp/wp-content/languages/plugins/"
    ["clear_destination"]=>
    bool(true)
  }
}
@bgrgicak
Copy link
Collaborator Author

For reference, I cooked up this little piece of PHP code to install a language pack:

Thanks @akirk! Let's keep the JS implementation for now, but this will be useful when we get to implementing PHP blueprints.

@adamziel
Copy link
Collaborator

LGTM, I just have one note. When I use an invalid language code, like pl instead of pl_PL, I expected it to fail. However, instead of failing, it just booted an english site. Let's make sure to fail in these cases:

http://localhost:5400/website-server/?plugin=friends&theme=twentytwentythree&language=pl

Copy link
Member

@akirk akirk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot say too much about whether the code approach is right (I see one unrelated change that Adam already flagged) but from a language pack perspective it looks good and works nicely.

I am happy to see that actually an old behavior on translate.wordpress.org has been improved: language packs will always be built for the newest version so that constructing the download URL using the plugin version number just works.

It used to be the case that you really needed that API call to identify the plugin version for which the last language pack was built. (for example, if it was translated to 100% in Spanish in version 1.0 and then because of a huge string update the translation ratio changed to 50% in version 2.0, the language pack would remain at 1.0, nowadays it looks like it is also build for 2.0).

Comment on lines +140 to +147
/**
* If a core translation wasn't found we should throw an error because it means the language is not supported or the language code isn't correct.
*/
if (type === 'core') {
throw new Error(
`Failed to download translations for WordPress. Please check if the language code ${language} is correct. You can find all available languages and translations on https://translate.wordpress.org/.`
);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find a way in WP to check if the language code is valid, so I'm relying on API errors.
If a translation for WordPress core doesn't exist, the step will fail.

Theme and plugin translations are still allowed to fail because they have much lower translation rates.

@bgrgicak bgrgicak requested a review from adamziel July 12, 2024 04:42

for (const { url, type } of translations) {
try {
const response = await fetch(url);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last nit: Let's fetch using a promise queue instead of doing it sequentially

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, let's merge as is and do the promise queue in the PHP implementation.

@adamziel adamziel changed the title Add language selection support Jul 12, 2024
@adamziel adamziel merged commit c7fac48 into trunk Jul 12, 2024
5 checks passed
@adamziel adamziel deleted the add/set-site-language-step branch July 12, 2024 11:30
@adamziel
Copy link
Collaborator

I added a usage example to the PR description and updated this Blueprint to use the new step:

https://github.com/WordPress/blueprints/blob/trunk/blueprints/translations/blueprint.json

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment