Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ jobs:
with:
languages: ${{ matrix.language }}

# The :Gutenberg Gradle build generates `SupportedLocales.kt` from
# `src/main/assets/supported-locales.json`, which is emitted by the
# JS build's Vite plugin. Without it, Autobuild fails before any
# Kotlin sources are extracted.
- name: Set up Node.js
if: matrix.language == 'java-kotlin'
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'

- name: Populate Android assets via JS build
if: matrix.language == 'java-kotlin'
run: make build

- name: Autobuild
uses: github/codeql-action/autobuild@v3

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ local.properties
wp_com_oauth_credentials.json

## Production Build Products
/android/Gutenberg/src/main/assets/supported-locales.json
/android/Gutenberg/src/main/assets/assets
/android/Gutenberg/src/main/assets/index.html

Expand Down
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ npm-dependencies: ## Install npm dependencies
.PHONY: prep-translations
prep-translations: ## Fetch and cache locale string files
# Skip unless...
# - src/translations doesn't exist
# - src/translations doesn't contain any fetched bundles (only `.gitkeep` is committed)
# - REFRESH_L10N is set to true or 1
# - prep-translations was invoked directly
@if [ ! -d "src/translations" ] || [ "$(REFRESH_L10N)" = "true" ] || [ "$(REFRESH_L10N)" = "1" ] || echo "$(MAKECMDGOALS)" | grep -q "^prep-translations$$"; then \
@if [ -z "$$(find src/translations -maxdepth 1 -name '*.json' -print -quit 2>/dev/null)" ] || [ "$(REFRESH_L10N)" = "true" ] || [ "$(REFRESH_L10N)" = "1" ] || echo "$(MAKECMDGOALS)" | grep -q "^prep-translations$$"; then \
echo "--- :npm: Preparing Translations"; \
if ! npm run prep-translations -- --force; then \
if [ "$(STRICT_L10N)" = "true" ] || [ "$(STRICT_L10N)" = "1" ]; then \
Expand All @@ -56,7 +56,7 @@ prep-translations: ## Fetch and cache locale string files
fi; \
fi; \
else \
echo "--- :white_check_mark: Skipping translations fetch (src/translations already exists). Use REFRESH_L10N=1 to force refresh."; \
echo "--- :white_check_mark: Skipping translations fetch (bundles already present in src/translations). Use REFRESH_L10N=1 to force refresh."; \
fi

.PHONY: e2e-dependencies
Expand Down Expand Up @@ -284,7 +284,7 @@ test-ios-e2e-dev: ## Run iOS E2E tests against the Vite dev server (must be runn
| xcbeautify

.PHONY: test-android
test-android: ## Run Android tests
test-android: build ## Run Android tests
@echo "--- :android: Running Android Tests"
./android/gradlew -p ./android :gutenberg:test

Expand Down Expand Up @@ -345,7 +345,7 @@ test-android-e2e-dev: ## Run Android E2E tests against the Vite dev server (must
./android/gradlew -p ./android :app:connectedDebugAndroidTest

.PHONY: test-android-library-e2e
test-android-library-e2e: ## Run instrumented tests for the Gutenberg Android library module
test-android-library-e2e: build ## Run instrumented tests for the Gutenberg Android library module
$(ENSURE_ANDROID_DEVICE)
@echo "--- :android: Running Android Library Instrumented Tests"
@mkdir -p android/Gutenberg/build/outputs/buildkite-logs
Expand Down
13 changes: 12 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ let package = Package(
dependencies: ["SwiftSoup", "SVGView", "GutenbergKitResources"],
path: "ios/Sources/GutenbergKit",
exclude: ["Gutenberg"],
packageAccess: false
packageAccess: false,
plugins: ["SupportedLocalesPlugin"]
),
.executableTarget(
name: "GenerateSupportedLocales",
path: "ios/Plugins/GenerateSupportedLocales"
),
.plugin(
name: "SupportedLocalesPlugin",
capability: .buildTool(),
dependencies: ["GenerateSupportedLocales"],
path: "ios/Plugins/SupportedLocalesPlugin"
),
.target(
name: "GutenbergKitHTTP",
Expand Down
92 changes: 92 additions & 0 deletions android/Gutenberg/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import groovy.json.JsonSlurper

plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.jetbrains.kotlin.android)
Expand All @@ -6,6 +8,82 @@ plugins {
id("kotlin-parcelize")
}

// Generates `SupportedLocales.kt` from the JS-build manifest so the set of
// shipped locales is checked at compile time, not at runtime. Run `make
// build` from the repo root to populate `src/main/assets/supported-locales.json`
// before assembling the library.
//
// Registered above `android { ... }` so the `main` source set can reference
// `generatedLocalesDir` directly. AGP's source-set DSL only accepts
// path-shaped notations (String / File / Path / Directory) for `srcDir`,
// so the task→consumer dependency is wired explicitly below for every
// consumer of the source set (compile + source jars for publishing).
val supportedLocalesManifest = layout.projectDirectory.file("src/main/assets/supported-locales.json")
val generatedLocalesDir = layout.buildDirectory.dir("generated/source/locales/main")

val generateSupportedLocales = tasks.register("generateSupportedLocales") {
description = "Generates SupportedLocales.kt from the shipped translation manifest."
group = "build"

// Use `inputs.files(...)` (plural) instead of `inputs.file(...)` so a
// missing manifest doesn't trip Gradle's strict input validation before
// our own error message can surface.
inputs.files(supportedLocalesManifest)
.withPropertyName("manifest")
.withPathSensitivity(PathSensitivity.RELATIVE)
outputs.dir(generatedLocalesDir)

doFirst {
if (!supportedLocalesManifest.asFile.exists()) {
throw GradleException(
"supported-locales.json is missing from src/main/assets/. " +
"Run `make build` from the repo root to populate translation " +
"assets before assembling the :Gutenberg library."
)
}
}

doLast {
val manifest = supportedLocalesManifest.asFile

val parsed = JsonSlurper().parse(manifest) as? List<*>
?: throw GradleException(
"supported-locales.json is not a JSON array. Re-run `make build`."
)
val locales = parsed.map {
it as? String ?: throw GradleException(
"supported-locales.json contains a non-string entry: $it. Re-run `make build`."
)
}.sorted()

if (locales.isEmpty()) {
// An empty manifest typically means `make prep-translations` was
// skipped or failed silently — `src/translations/` only had
// `.gitkeep` when Vite scanned it. Without this guard the
// library ships with a runtime-empty `SupportedLocales`, which
// resolves every locale to English.
throw GradleException(
"supported-locales.json is empty. Run `make prep-translations REFRESH_L10N=1` " +
"from the repo root, then `make build`, before assembling the :Gutenberg library."
)
}

val outDir = generatedLocalesDir.get().asFile
.resolve("org/wordpress/gutenberg/model")
.also { it.mkdirs() }
outDir.resolve("SupportedLocales.kt").writeText(buildString {
appendLine("// Generated by :Gutenberg:generateSupportedLocales — do not edit.")
appendLine("package org.wordpress.gutenberg.model")
appendLine()
appendLine("internal object SupportedLocales {")
appendLine(" val ALL: Set<String> = setOf(")
locales.forEach { appendLine(" \"$it\",") }
appendLine(" )")
appendLine("}")
})
}
}

android {
namespace = "org.wordpress.gutenberg"
compileSdk = 34
Expand Down Expand Up @@ -50,6 +128,9 @@ android {
}

sourceSets {
getByName("main") {
java.srcDir(generatedLocalesDir)
}
getByName("androidTest") {
// Make shared test fixtures available as assets for instrumented tests.
assets.srcDir(rootProject.file("../test-fixtures"))
Expand Down Expand Up @@ -108,3 +189,14 @@ project.afterEvaluate {
}
}
}

// Wire the generator into every task that reads the `main` source set's
// sources: Kotlin compilation and the source-jar tasks AGP creates for the
// maven publication. AGP's source-set DSL only accepts a path string for
// `srcDir`, so the dependency can't be inferred from the source set itself.
tasks.matching {
val name = it.name
(name.startsWith("compile") && name.endsWith("Kotlin")) ||
(name.startsWith("source") && name.endsWith("Jar"))
}.configureEach { dependsOn(generateSupportedLocales) }

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.net.URI
import java.util.Locale
import java.util.UUID

@Parcelize
Expand Down Expand Up @@ -96,7 +97,37 @@ data class EditorConfiguration(
fun setNamespaceExcludedPaths(namespaceExcludedPaths: Array<String>) = apply { this.namespaceExcludedPaths = namespaceExcludedPaths }
fun setAuthHeader(authHeader: String) = apply { this.authHeader = authHeader }
fun setEditorSettings(editorSettings: String?) = apply { this.editorSettings = editorSettings }
fun setLocale(locale: String?) = apply { this.locale = locale }
/**
* Stores [locale] verbatim without running the resolver. Reserved for
* `toBuilder` round-trip and tests — consumers should always go
* through [setLocale] with a [Locale].
*/
@JvmSynthetic
internal fun setLocaleTag(locale: String?) = apply { this.locale = locale }

/**
* Resolves [locale] against the bundled translations and stores the
* resulting tag for serialization.
*
* The resolution chain tries, in order:
* 1. exact `language-region` (e.g. `pt-BR` → `pt-br`)
* 2. `language-<script-implied region>` for macrolanguages we ship
* disjoint regional bundles for (e.g. `zh-Hant-HK` → `zh-tw`)
* 3. `language` only (e.g. `fr-CA` → `fr`)
* 4. `en`
*
* Legacy ISO 639-1 codes that Android's `Locale` class still emits
* (`iw` for Hebrew, `in` for Indonesian, `no` for Norwegian Bokmål)
* are mapped to canonical bundle names before lookup.
*
* Languages for which no bundle ships at all silently resolve to
* `en`. The resolver does not log or signal the fallback — consumers
* expecting coverage for a specific language should verify the build
* manifest includes it.
*/
fun setLocale(locale: Locale) = apply {
this.locale = LocaleResolver.Default.resolve(locale)
}
fun setCookies(cookies: Map<String, String>) = apply { this.cookies = cookies }
fun setEnableAssetCaching(enableAssetCaching: Boolean) = apply { this.enableAssetCaching = enableAssetCaching }
fun setCachedAssetHosts(cachedAssetHosts: Set<String>) = apply { this.cachedAssetHosts = cachedAssetHosts }
Expand Down Expand Up @@ -150,7 +181,7 @@ data class EditorConfiguration(
.setNamespaceExcludedPaths(namespaceExcludedPaths)
.setAuthHeader(authHeader)
.setEditorSettings(editorSettings)
.setLocale(locale)
.setLocaleTag(locale)
.setCookies(cookies)
.setEnableAssetCaching(enableAssetCaching)
.setCachedAssetHosts(cachedAssetHosts)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.wordpress.gutenberg.model

import java.util.Locale

/**
* Resolves an arbitrary locale tag to one of the bundles GutenbergKit
* actually ships translations for.
*
* Consumers historically hand [EditorConfiguration] an opaque locale string
* — on Android, often the output of [Locale.getLanguage], which strips the
* region. The editor then silently falls back to English whenever the tag
* doesn't match a shipped `translations/<tag>.json` file exactly. The
* resolver moves that decision into the library, so a device configured for
* `pt_BR` ends up with the Brazilian Portuguese bundle — and a tag like
* `nl-BE`, for which we don't ship a regional bundle, falls back to `nl`
* instead of all the way to English.
*
* Resolution chain for an input locale:
* 1. `language-region`
* 2. `language-<script-implied region>` (e.g. `zh-Hant-HK` → `zh-tw`)
* 3. `language`
* 4. `en`
*
* Inputs are parsed as BCP-47 via [Locale.forLanguageTag], so script-tagged
* inputs like `zh-Hans-CN` collapse to `zh-cn` rather than falling through
* to English. Underscore-separated identifiers (`pt_BR`, `EN_GB`) are
* pre-normalised to dashes before parsing. Legacy ISO 639-1 codes that
* Android's `Locale` class still emits (`iw` for Hebrew, `in` for
* Indonesian, `no` for Norwegian Bokmål) are mapped to their canonical
* equivalents before lookup. Variant and Unicode-extension subtags (e.g.
* `de-DE-u-ca-gregory`) are ignored — the editor doesn't vary translations
* by calendar or numbering system.
*
* The supported set is generated at build time from the JS build manifest
* (see `:Gutenberg:generateSupportedLocales`), so the resolver and the
* shipped bundles cannot drift.
*/
internal class LocaleResolver(supportedLocales: Collection<String>) {
private val supportedLocales: Set<String> =
supportedLocales.map { normalize(it) }.toSet()

constructor() : this(SupportedLocales.ALL)

/**
* Resolves a string locale tag against the shipped translation bundles.
*
* Accepts BCP-47 tags (`pt-BR`, `zh-Hant-HK`) and the underscore-separated
* variant Android's platform APIs emit (`pt_BR`). Inputs that aren't valid
* BCP-47 — POSIX locales like `pt_BR.UTF-8`, anything `Locale.forLanguageTag`
* can't parse to a non-empty language — fall back to `en`.
*/
fun resolve(tag: String?): String {
if (tag.isNullOrEmpty()) return DEFAULT_LOCALE
// Java's BCP-47 parser uses '-'; pre-normalise '_' so platform-native
// identifiers like `pt_BR` parse cleanly.
return resolve(Locale.forLanguageTag(tag.replace('_', '-')))
}

/** Resolves a [Locale] against the shipped translation bundles. */
fun resolve(locale: Locale): String {
val rawLanguage = locale.language.lowercase(Locale.ROOT)
if (rawLanguage.isEmpty()) return DEFAULT_LOCALE
val language = LANGUAGE_ALIASES[rawLanguage] ?: rawLanguage

val region = locale.country.lowercase(Locale.ROOT)
if (region.isNotEmpty()) {
val full = "$language-$region"
if (supportedLocales.contains(full)) return full
}

// For macrolanguages where we ship disjoint regional bundles only
// (e.g. `zh-cn`/`zh-tw` with no language-only `zh`), fall back to a
// script-implied region before the language-only step. Without this,
// `zh-Hant-HK` and bare `zh-Hans` end up at English even though the
// script subtag clearly indicates Traditional/Simplified intent.
val script = locale.script.lowercase(Locale.ROOT)
if (script.isNotEmpty()) {
val implied = scriptImpliedTag(language, script)
if (implied != null && supportedLocales.contains(implied)) return implied
}

if (supportedLocales.contains(language)) return language

return DEFAULT_LOCALE
}

companion object {
// Reused by `EditorConfiguration.Builder.setLocale` so the
// supported-set HashSet isn't rebuilt on every call.
val Default: LocaleResolver = LocaleResolver()

private const val DEFAULT_LOCALE = "en"

// Android's `Locale` class still emits the legacy ISO 639-1 codes for
// Hebrew (`iw`) and Indonesian (`in`) for backward compat, and the
// deprecated `no` macrolanguage tag survives in some configurations
// for the Bokmål bundle we ship as `nb`. Translate before lookup so
// users on those devices don't silently land on English.
private val LANGUAGE_ALIASES = mapOf(
"iw" to "he",
"in" to "id",
"no" to "nb",
)

private fun normalize(tag: String): String =
tag.lowercase(Locale.ROOT).replace('_', '-')

private fun scriptImpliedTag(language: String, script: String): String? = when {
language == "zh" && script == "hans" -> "zh-cn"
language == "zh" && script == "hant" -> "zh-tw"
else -> null
}
}
}
Loading
Loading