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

@@ -0,0 +1,112 @@
import { MedusaRequest, MedusaResponse } from "../types"
import { applyLocale } from "../middlewares/apply-locale"
import { MedusaContainer } from "@medusajs/types"
describe("applyLocale", () => {
let mockRequest: Partial<MedusaRequest>
let mockResponse: MedusaResponse
let nextFunction: jest.Mock
beforeEach(() => {
mockRequest = {
query: {},
get: jest.fn(),
scope: {
resolve: jest.fn().mockReturnValue({
graph: jest.fn().mockResolvedValue({
data: [{ supported_locales: [{ locale_code: "en-US" }] }],
}),
}),
} as unknown as MedusaContainer,
}
mockResponse = {} as MedusaResponse
nextFunction = jest.fn()
})
afterEach(() => {
jest.clearAllMocks()
})
it("should set locale from query parameter", async () => {
mockRequest.query = { locale: "en-US" }
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBe("en-US")
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should set locale from Content-Language header when query param is not present", async () => {
mockRequest.query = {}
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
if (header === "content-language") {
return "fr-FR"
}
return undefined
})
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBe("fr-FR")
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should prioritize query parameter over Content-Language header", async () => {
mockRequest.query = { locale: "de-DE" }
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
if (header === "content-language") {
return "fr-FR"
}
return undefined
})
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBe("de-DE")
expect(mockRequest.get).not.toHaveBeenCalled()
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should not set locale when neither query param nor header is present", async () => {
mockRequest.query = {}
;(mockRequest.get as jest.Mock).mockReturnValue(undefined)
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBeUndefined()
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should handle empty string in query parameter", async () => {
mockRequest.query = { locale: "" }
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
if (header === "content-language") {
return "es-ES"
}
return undefined
})
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
// Empty string is falsy, so it should fall back to header
expect(mockRequest.locale).toBe("es-ES")
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should handle various locale formats", async () => {
const locales = ["en", "en-US", "zh-Hans-CN", "pt-BR"]
for (const locale of locales) {
mockRequest.query = { locale }
mockRequest.locale = undefined
await applyLocale(
mockRequest as MedusaRequest,
mockResponse,
nextFunction
)
expect(mockRequest.locale).toBe(locale)
}
})
})

View File

@@ -0,0 +1,64 @@
import { ContainerRegistrationKeys, normalizeLocale } from "@medusajs/utils"
import type {
MedusaNextFunction,
MedusaRequest,
MedusaResponse,
} from "../types"
const CONTENT_LANGUAGE_HEADER = "content-language"
/**
* Middleware that resolves the locale for the current request.
*
* Resolution order:
* 1. Query parameter `?locale=en-US`
* 2. Content-Language header
*
* The resolved locale is set on `req.locale`.
*/
export async function applyLocale(
req: MedusaRequest,
_: MedusaResponse,
next: MedusaNextFunction
) {
// 1. Check query parameter
const queryLocale = req.query.locale as string | undefined
if (queryLocale) {
req.locale = normalizeLocale(queryLocale)
return next()
}
// 2. Check Content-Language header
const headerLocale = req.get(CONTENT_LANGUAGE_HEADER)
if (headerLocale) {
req.locale = normalizeLocale(headerLocale)
return next()
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [store],
} = await query.graph(
{
entity: "store",
fields: ["id", "supported_locales"],
pagination: {
take: 1,
},
},
{
cache: {
enable: true,
},
}
)
if (store?.supported_locales?.length) {
req.locale = store.supported_locales.find(
(locale) => locale.is_default
)?.locale_code
return next()
}
return next()
}

View File

@@ -3,5 +3,6 @@ export * from "./error-handler"
export * from "./exception-formatter"
export * from "./apply-default-filters"
export * from "./apply-params-as-filters"
export * from "./apply-locale"
export * from "./clear-filters-by-key"
export * from "./set-context"

View File

@@ -1,4 +1,9 @@
import { ContainerRegistrationKeys, parseCorsOrigins, FeatureFlag } from "@medusajs/utils"
import {
ContainerRegistrationKeys,
FeatureFlag,
isFileDisabled,
parseCorsOrigins,
} from "@medusajs/utils"
import cors, { CorsOptions } from "cors"
import type {
ErrorRequestHandler,
@@ -20,6 +25,7 @@ import type {
} from "./types"
import { Logger, MedusaContainer } from "@medusajs/types"
import { join } from "path"
import { configManager } from "../config"
import { MiddlewareFileLoader } from "./middleware-file-loader"
import { authenticate, AuthType } from "./middlewares"
@@ -109,6 +115,38 @@ export class ApiLoader {
}
}
/**
* Checks if a route file is disabled for a given matcher and method
* by trying to find the corresponding route file path
*/
#isRouteFileDisabled(matcher: string): boolean {
const routePathSegments = matcher
.split("/")
.filter(Boolean)
.map((segment) => {
if (segment.startsWith(":")) {
return `[${segment.slice(1)}]`
}
return segment
})
for (const sourceDir of this.#sourceDirs) {
for (const ext of [".ts", ".js"]) {
const routeFilePath = join(
sourceDir,
...routePathSegments,
`route${ext}`
)
if (isFileDisabled(routeFilePath)) {
return true
}
}
}
return false
}
/**
* Registers a middleware or a route handler with Express
*/
@@ -145,6 +183,14 @@ export class ApiLoader {
? route.methods
: [route.methods]
methods.forEach((method) => {
const isDisabled = this.#isRouteFileDisabled(route.matcher)
if (isDisabled) {
this.#logger.debug(
`skipping disabled route middleware registration for ${method} ${route.matcher}`
)
return
}
this.#logger.debug(
`registering route middleware ${method} ${route.matcher}`
)

View File

@@ -183,6 +183,14 @@ export interface MedusaRequest<
* requests that allows for additional_data
*/
additionalDataValidator?: ZodOptional<ZodNullable<ZodObject<any, any>>>
/**
* The locale for the current request, resolved from:
* 1. Query parameter `?locale=`
* 2. Content-Language header
* 3. Store's default locale
*/
locale?: string
}
export interface AuthContext {

View File

@@ -27,6 +27,7 @@ import {
IStockLocationService,
IStoreModuleService,
ITaxModuleService,
ITranslationModuleService,
IUserModuleService,
IWorkflowEngineService,
Logger,
@@ -34,8 +35,8 @@ import {
RemoteQueryFunction,
} from "@medusajs/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
import { Knex } from "../deps/mikro-orm-knex"
import { AwilixContainer, ResolveOptions } from "../deps/awilix"
import { Knex } from "../deps/mikro-orm-knex"
declare module "@medusajs/types" {
export interface ModuleImplementations {
@@ -80,6 +81,7 @@ declare module "@medusajs/types" {
[Modules.SETTINGS]: ISettingsModuleService
[Modules.CACHING]: ICachingModuleService
[Modules.INDEX]: IIndexService
[Modules.TRANSLATION]: ITranslationModuleService
}
}