feat(): Introduce translation module and preliminary application of them (#14189)

* feat(): Translation first steps

* feat(): locale middleware

* feat(): readonly links

* feat(): feature flag

* feat(): modules sdk

* feat(): translation module re export

* start adding workflows

* update typings

* update typings

* test(): Add integration tests

* test(): centralize filters preparation

* test(): centralize filters preparation

* remove unnecessary importy

* fix workflows

* Define StoreLocale inside Store Module

* Link definition to extend Store with supported_locales

* store_locale migration

* Add supported_locales handling in Store Module

* Tests

* Accept supported_locales in Store endpoints

* Add locales to js-sdk

* Include locale list and default locale in Store Detail section

* Initialize local namespace in js-sdk

* Add locales route

* Make code primary key of locale table to facilitate upserts

* Add locales routes

* Show locale code as is

* Add list translations api route

* Batch endpoint

* Types

* New batchTranslationsWorkflow and various updates to existent ones

* Edit default locale UI

* WIP

* Apply translation agnostically

* middleware

* Apply translation agnostically

* fix Apply translation agnostically

* apply translations to product list

* Add feature flag

* fetch translations by batches of 250 max

* fix apply

* improve and test util

* apply to product list

* dont manage translations if no locale

* normalize locale

* potential todo

* Protect translations routes with feature flag

* Extract normalize locale util to core/utils

* Normalize locale on write

* Normalize locale for read

* Use feature flag to guard translations UI across the board

* Avoid throwing incorrectly when locale_code not present in partial updates

* move applyTranslations util

* remove old tests

* fix util tests

* fix(): product end points

* cleanup

* update lock

* remove unused var

* cleanup

* fix apply locale

* missing new dep for test utils

* Change entity_type, entity_id to reference, reference_id

* Remove comment

* Avoid registering translations route if ff not enabled

* Prevent registering express handler for disabled route via defineFileConfig

* Add tests

* Add changeset

* Update test

* fix integration tests, module and internals

* Add locale id plus fixed

* Allow to pass array of reference_id

* fix unit tests

* fix link loading

* fix store route

* fix sales channel test

* fix tests

---------

Co-authored-by: Nicolas Gorga <nicogorga11@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-12-08 19:33:08 +01:00
committed by GitHub
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions

View File

@@ -19,4 +19,5 @@ export * as SearchUtils from "./search"
export * as ShippingProfileUtils from "./shipping"
export * as UserUtils from "./user"
export * as CachingUtils from "./caching"
export * as TranslationsUtils from "./translations"
export * as DevServerUtils from "./dev-server"

View File

@@ -0,0 +1,172 @@
import { normalizeLocale } from "../normalize-locale"
describe("normalizeLocale", function () {
it("should normalize single segment locales to lowercase", function () {
const expectations = [
{
input: "eN",
output: "en",
},
{
input: "EN",
output: "en",
},
{
input: "En",
output: "en",
},
{
input: "en",
output: "en",
},
{
input: "fr",
output: "fr",
},
{
input: "FR",
output: "fr",
},
{
input: "de",
output: "de",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should normalize two segment locales (language-region)", function () {
const expectations = [
{
input: "en-Us",
output: "en-US",
},
{
input: "EN-US",
output: "en-US",
},
{
input: "en-us",
output: "en-US",
},
{
input: "En-Us",
output: "en-US",
},
{
input: "fr-FR",
output: "fr-FR",
},
{
input: "FR-fr",
output: "fr-FR",
},
{
input: "de-DE",
output: "de-DE",
},
{
input: "es-ES",
output: "es-ES",
},
{
input: "pt-BR",
output: "pt-BR",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should normalize three segment locales (language-script-region)", function () {
const expectations = [
{
input: "RU-cYrl-By",
output: "ru-Cyrl-BY",
},
{
input: "ru-cyrl-by",
output: "ru-Cyrl-BY",
},
{
input: "RU-CYRL-BY",
output: "ru-Cyrl-BY",
},
{
input: "zh-Hans-CN",
output: "zh-Hans-CN",
},
{
input: "ZH-HANS-CN",
output: "zh-Hans-CN",
},
{
input: "sr-Latn-RS",
output: "sr-Latn-RS",
},
{
input: "SR-LATN-RS",
output: "sr-Latn-RS",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should return locale as-is for more than three segments", function () {
const expectations = [
{
input: "en-US-x-private",
output: "en-US-x-private",
},
{
input: "en-US-x-private-extended",
output: "en-US-x-private-extended",
},
{
input: "en-US-x-private-extended-more",
output: "en-US-x-private-extended-more",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should handle edge cases", function () {
const expectations = [
{
input: "",
output: "",
},
{
input: "a",
output: "a",
},
{
input: "A",
output: "a",
},
{
input: "a-B",
output: "a-B",
},
{
input: "a-b-C",
output: "a-B-C",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
})

View File

@@ -185,6 +185,9 @@ function resolveModules(
{ resolve: MODULE_PACKAGE_NAMES[Modules.ORDER] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.SETTINGS] },
// TODO: re-enable this once we have the final release
// { resolve: MODULE_PACKAGE_NAMES[Modules.TRANSLATION] },
{
resolve: MODULE_PACKAGE_NAMES[Modules.AUTH],
options: {

View File

@@ -53,6 +53,7 @@ export * from "./medusa-container"
export * from "./merge-metadata"
export * from "./merge-plugin-modules"
export * from "./normalize-csv-value"
export * from "./normalize-locale"
export * from "./normalize-import-path-with-source"
export * from "./object-from-string-path"
export * from "./object-to-string-path"

View File

@@ -0,0 +1,40 @@
import { upperCaseFirst } from "./upper-case-first"
/**
* Normalizes a locale string to {@link https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag|BCP 47 language tag format}
* @param locale - The locale string to normalize
* @returns The normalized locale string
*
* @example
* input: "en-Us"
* output: "en-US"
*
* @example
* input: "eN"
* output: "en"
*
* @example
* input: "RU-cYrl-By"
* output: "ru-Cyrl-BY"
*/
export function normalizeLocale(locale: string) {
const segments = locale.split("-")
if (segments.length === 1) {
return segments[0].toLowerCase()
}
// e.g en-US
if (segments.length === 2) {
return `${segments[0].toLowerCase()}-${segments[1].toUpperCase()}`
}
// e.g ru-Cyrl-BY
if (segments.length === 3) {
return `${segments[0].toLowerCase()}-${upperCaseFirst(
segments[1].toLowerCase()
)}-${segments[2].toUpperCase()}`
}
return locale
}

View File

@@ -30,6 +30,7 @@ export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./caching"
export * from "./translations"
export * from "./dev-server"
export const MedusaModuleType = Symbol.for("MedusaModule")

View File

@@ -1,5 +1,13 @@
import { LinkModulesExtraFields, ModuleJoinerConfig } from "@medusajs/types"
import { camelToSnakeCase, isObject, pluralize, toPascalCase } from "../common"
import {
camelToSnakeCase,
getCallerFilePath,
isFileDisabled,
isObject,
MEDUSA_SKIP_FILE,
pluralize,
toPascalCase,
} from "../common"
import { composeLinkName } from "../link/compose-link-name"
export const DefineLinkSymbol = Symbol.for("DefineLink")
@@ -193,6 +201,11 @@ export function defineLink(
rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions
): DefineLinkExport {
const callerFilePath = getCallerFilePath()
if (isFileDisabled(callerFilePath ?? "")) {
return { [MEDUSA_SKIP_FILE]: true } as any
}
const serviceAObj = prepareServiceConfig(leftService)
const serviceBObj = prepareServiceConfig(rightService)

View File

@@ -28,6 +28,7 @@ export const Modules = {
LOCKING: "locking",
SETTINGS: "settings",
CACHING: "caching",
TRANSLATION: "translation",
} as const
export const MODULE_PACKAGE_NAMES = {
@@ -60,6 +61,7 @@ export const MODULE_PACKAGE_NAMES = {
[Modules.LOCKING]: "@medusajs/medusa/locking",
[Modules.SETTINGS]: "@medusajs/medusa/settings",
[Modules.CACHING]: "@medusajs/caching",
[Modules.TRANSLATION]: "@medusajs/translation",
}
export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries(

View File

@@ -0,0 +1,363 @@
import { FeatureFlag } from "../../feature-flags"
import { applyTranslations } from "../apply-translations"
jest.mock("../../feature-flags/flag-router", () => ({
...jest.requireActual("../../feature-flags/flag-router"),
FeatureFlag: {
isFeatureEnabled: jest.fn(),
},
}))
const mockFeatureFlagIsEnabled = FeatureFlag.isFeatureEnabled as jest.Mock
describe("applyTranslations", () => {
let mockQuery: { graph: jest.Mock }
let mockContainer: { resolve: jest.Mock }
let mockReq: { locale?: string }
beforeEach(() => {
jest.clearAllMocks()
mockQuery = {
graph: jest.fn().mockResolvedValue({ data: [] }),
}
mockContainer = {
resolve: jest.fn().mockReturnValue(mockQuery),
}
mockReq = {
locale: "en-US",
}
})
beforeEach(() => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
})
it("should apply translations to a simple object", async () => {
const inputObjects = [{ id: "prod_1", title: "Original Title" }]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Title" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Title")
})
it("should apply translations to nested objects", async () => {
const inputObjects = [
{
id: "prod_1",
title: "Product Title",
category: {
id: "cat_1",
name: "Category Name",
},
},
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Product Title", category: true },
},
{
reference_id: "cat_1",
translations: { name: "Translated Category Name" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Product Title")
expect(inputObjects[0].category.name).toBe("Translated Category Name")
})
it("should apply translations to arrays of objects", async () => {
const inputObjects = [
{
id: "prod_1",
title: "Product Title",
variants: [
{ id: "var_1", name: "Variant 1" },
{ id: "var_2", name: "Variant 2" },
],
},
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Product" },
},
{
reference_id: "var_1",
translations: { name: "Translated Variant 1" },
},
{
reference_id: "var_2",
translations: { name: "Translated Variant 2" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Product")
expect(inputObjects[0].variants[0].name).toBe("Translated Variant 1")
expect(inputObjects[0].variants[1].name).toBe("Translated Variant 2")
})
it("should use the locale from the request", async () => {
mockReq.locale = "fr-FR"
const inputObjects = [{ id: "prod_1", title: "Original" }]
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(mockQuery.graph).toHaveBeenCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
locale_code: "fr-FR",
}),
}),
expect.objectContaining({
cache: expect.objectContaining({
enable: true,
}),
})
)
})
it("should batch queries when there are more than 250 ids", async () => {
const inputObjects = Array.from({ length: 300 }, (_, i) => ({
id: `prod_${i}`,
title: `Product ${i}`,
}))
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(mockQuery.graph).toHaveBeenCalledTimes(2)
})
it("should apply translations to multiple input objects", async () => {
const inputObjects = [
{ id: "prod_1", title: "Product 1" },
{ id: "prod_2", title: "Product 2" },
]
mockQuery.graph.mockResolvedValue({
data: [
{ reference_id: "prod_1", translations: { title: "Translated 1" } },
{ reference_id: "prod_2", translations: { title: "Translated 2" } },
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated 1")
expect(inputObjects[1].title).toBe("Translated 2")
})
it("should handle translations with null values", async () => {
const inputObjects = [{ id: "prod_1", title: "Original" }]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: null,
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Original")
})
it("should return early when feature flag is disabled", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(false)
const inputObjects = [{ id: "prod_1", title: "Original" }]
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(mockContainer.resolve).not.toHaveBeenCalled()
expect(inputObjects[0].title).toBe("Original")
})
it("should not modify objects when no translations are found", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [{ id: "prod_1", title: "Original Title" }]
mockQuery.graph.mockResolvedValue({ data: [] })
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Original Title")
})
it("should handle empty input array without errors", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects: Record<string, any>[] = []
await expect(
applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
).resolves.not.toThrow()
})
it("should not modify properties that do not exist in the object", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [{ id: "prod_1", title: "Original" }]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { description: "Translated Description" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Original")
expect(inputObjects[0]).not.toHaveProperty("description")
})
it("should handle objects with undefined id gracefully", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [{ id: undefined, title: "Original" }]
mockQuery.graph.mockResolvedValue({ data: [] })
await expect(
applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects as any,
container: mockContainer as any,
})
).resolves.not.toThrow()
})
it("should only apply translations to matching keys", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [
{ id: "prod_1", title: "Original Title", handle: "original-handle" },
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Title" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Title")
expect(inputObjects[0].handle).toBe("original-handle")
})
it("should handle deeply nested structures", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [
{
id: "prod_1",
title: "Product",
category: {
id: "cat_1",
name: "Category",
parent: {
id: "cat_parent",
name: "Parent Category",
},
},
},
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Product" },
},
{
reference_id: "cat_1",
translations: { name: "Translated Category" },
},
{
reference_id: "cat_parent",
translations: { name: "Translated Parent" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Product")
expect(inputObjects[0].category.name).toBe("Translated Category")
expect(inputObjects[0].category.parent.name).toBe("Translated Parent")
})
})

View File

@@ -0,0 +1,139 @@
import { MedusaContainer, RemoteQueryFunction } from "@medusajs/types"
import { ContainerRegistrationKeys } from "../common/container"
import { isObject } from "../common/is-object"
import { FeatureFlag } from "../feature-flags/flag-router"
const excludedKeys = [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata",
]
function canApplyTranslationTo(object: Record<string, any>) {
return "id" in object && !!object.id
}
function gatherIds(object: Record<string, any>, gatheredIds: Set<string>) {
gatheredIds.add(object.id)
Object.entries(object).forEach(([, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => item && gatherIds(item, gatheredIds))
} else if (isObject(value)) {
gatherIds(value, gatheredIds)
}
})
}
function applyTranslation(
object: Record<string, any>,
entityIdToTranslation: Map<string, Record<string, any>>
) {
const translation = entityIdToTranslation.get(object.id)
const hasTranslation = !!translation
Object.entries(object).forEach(([key, value]) => {
if (excludedKeys.includes(key)) {
return
}
if (hasTranslation) {
if (
key in translation &&
typeof object[key] === typeof translation[key]
) {
object[key] = translation[key]
return
}
}
if (Array.isArray(value)) {
value.forEach(
(item) =>
item &&
canApplyTranslationTo(item) &&
applyTranslation(item, entityIdToTranslation)
)
} else if (isObject(value) && canApplyTranslationTo(value)) {
applyTranslation(value, entityIdToTranslation)
}
})
}
export async function applyTranslations({
localeCode,
objects,
container,
}: {
localeCode: string | undefined
objects: Record<string, any>[]
container: MedusaContainer
}) {
const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation")
if (!isTranslationEnabled) {
return
}
const locale = localeCode
if (!locale) {
return
}
const objects_ = objects.filter((o) => !!o)
if (!objects_.length) {
return
}
const gatheredIds: Set<string> = new Set()
for (const inputObject of objects_) {
gatherIds(inputObject, gatheredIds)
}
const query = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.QUERY
)
const queryBatchSize = 250
const queryBatches = Math.ceil(gatheredIds.size / queryBatchSize)
const entityIdToTranslation = new Map<string, Record<string, any>>()
for (let i = 0; i < queryBatches; i++) {
// TODO: concurrently fetch if needed
const queryBatch = Array.from(gatheredIds)
.slice(i * queryBatchSize, (i + 1) * queryBatchSize)
.sort()
const { data: translations } = await query.graph(
{
entity: "translations",
fields: ["translations", "reference_id"],
filters: {
reference_id: queryBatch,
locale_code: locale,
},
pagination: {
take: queryBatchSize,
},
},
{
cache: { enable: true },
}
)
for (const translation of translations) {
entityIdToTranslation.set(
translation.reference_id,
translation.translations ?? {}
)
}
}
for (const inputObject of objects_) {
applyTranslation(inputObject, entityIdToTranslation)
}
}

View File

@@ -0,0 +1 @@
export * from "./apply-translations"