feat(admin-*,dashboard): add dashboard i18n extensions (#13763)

* virtual i18n module

* changeset

* fallback ns

fallback to the default "translation" ns if the key isnt found. Allows to use a single "useTranslation("customNs")" hook for both custom and medusa-provided keys

* simplify merges

* optional for backward compat

* fix HMR

* fix generated deepMerge

* test
This commit is contained in:
Leonardo Benini
2025-10-23 21:16:43 +02:00
committed by GitHub
parent 012e30801e
commit 226984cf0f
25 changed files with 314 additions and 9 deletions

View File

@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest"
import * as utils from "../../utils"
import { generateI18n } from "../generate-i18n"
// Mock the dependencies
vi.mock("../../utils", async () => {
const actual = await vi.importActual("../../utils")
return {
...actual,
crawl: vi.fn(),
}
})
const expectedI18nSingleSource = `
resources: i18nTranslations0
`
const expectedI18nMultipleSources = `
resources: deepMerge(deepMerge(i18nTranslations0, i18nTranslations1), i18nTranslations2)
`
const expectedI18nNoSources = `
resources: {}
`
describe("generateI18n", () => {
it("should generate i18n with single source", async () => {
const mockFiles = ["Users/user/medusa/src/admin/i18n/index.ts"]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
const result = await generateI18n(
new Set(["Users/user/medusa/src/admin"])
)
expect(result.imports).toEqual([
`import i18nTranslations0 from "Users/user/medusa/src/admin/i18n/index.ts"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedI18nSingleSource)
)
})
it("should handle windows paths", async () => {
const mockFiles = ["C:\\medusa\\src\\admin\\i18n\\index.ts"]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
const result = await generateI18n(new Set(["C:\\medusa\\src\\admin"]))
expect(result.imports).toEqual([
`import i18nTranslations0 from "C:/medusa/src/admin/i18n/index.ts"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedI18nSingleSource)
)
})
it("should generate i18n with multiple sources", async () => {
vi.mocked(utils.crawl)
.mockResolvedValueOnce(["Users/user/medusa/src/admin/i18n/index.ts"])
.mockResolvedValueOnce(["Users/user/medusa/src/plugin1/i18n/index.ts"])
.mockResolvedValueOnce(["Users/user/medusa/src/plugin2/i18n/index.ts"])
const result = await generateI18n(
new Set([
"Users/user/medusa/src/admin",
"Users/user/medusa/src/plugin1",
"Users/user/medusa/src/plugin2",
])
)
expect(result.imports).toEqual([
`import i18nTranslations0 from "Users/user/medusa/src/admin/i18n/index.ts"`,
`import i18nTranslations1 from "Users/user/medusa/src/plugin1/i18n/index.ts"`,
`import i18nTranslations2 from "Users/user/medusa/src/plugin2/i18n/index.ts"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedI18nMultipleSources)
)
})
it("should handle no i18n sources", async () => {
vi.mocked(utils.crawl).mockResolvedValue([])
const result = await generateI18n(
new Set(["Users/user/medusa/src/admin"])
)
expect(result.imports).toEqual([])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedI18nNoSources)
)
})
})

View File

@@ -0,0 +1,15 @@
import fs from "fs/promises"
import { generateHash } from "../utils"
import { getI18nIndexFilesFromSources } from "./helpers"
export async function generateI18nHash(sources: Set<string>): Promise<string> {
const indexFiles = await getI18nIndexFilesFromSources(sources)
const contents = await Promise.all(
indexFiles.map(file => fs.readFile(file, "utf-8"))
)
const totalContent = contents.join("")
return generateHash(totalContent)
}

View File

@@ -0,0 +1,33 @@
import { outdent } from "outdent"
import { normalizePath } from "../utils"
import { getI18nIndexFilesFromSources } from "./helpers"
export async function generateI18n(sources: Set<string>) {
const indexFiles = await getI18nIndexFilesFromSources(sources)
const imports = indexFiles.map((file, index) => {
const normalizedPath = normalizePath(file)
return `import i18nTranslations${index} from "${normalizedPath}"`
})
let mergeCode = '{}'
if (indexFiles.length === 1) {
mergeCode = 'i18nTranslations0'
} else if (indexFiles.length > 1) {
// Only happens in dev mode if there are 2+ plugins linked with plugin:develop
// Chain deepMerge calls since it only accepts 2 arguments
mergeCode = indexFiles.slice(1).reduce((acc, _, index) => {
return `deepMerge(${acc}, i18nTranslations${index + 1})`
}, 'i18nTranslations0')
}
const code = outdent`
resources: ${mergeCode}
`
return {
imports,
code,
}
}

View File

@@ -0,0 +1,15 @@
import { crawl } from "../utils"
/**
* Get i18n index files from sources
* Looks for src/admin/i18n/index.ts in each source
*/
export async function getI18nIndexFilesFromSources(
sources: Set<string>
): Promise<string[]> {
return (await Promise.all(
Array.from(sources).map(async (source) =>
crawl(`${source}/i18n`, "index", { min: 0, max: 0 })
)
)).flat()
}

View File

@@ -0,0 +1,3 @@
export { generateI18nHash } from "./generate-i18n-hash"
export { getI18nIndexFilesFromSources } from "./helpers"
export { generateI18n } from "./generate-i18n"

View File

@@ -3,12 +3,14 @@ import { rm, writeFile } from "node:fs/promises"
import path from "node:path"
import type * as Vite from "vite"
import { generateCustomFieldHashes } from "./custom-fields"
import { generateI18nHash } from "./i18n"
import { generateRouteHashes } from "./routes"
import { MedusaVitePlugin } from "./types"
import { AdminSubdirectory, isFileInAdminSubdirectory } from "./utils"
import {
generateVirtualDisplayModule,
generateVirtualFormModule,
generateVirtualI18nModule,
generateVirtualLinkModule,
generateVirtualMenuItemModule,
generateVirtualRouteModule,
@@ -84,6 +86,7 @@ export const medusaVitePlugin: MedusaVitePlugin = (options) => {
const menuItemModule = await generateVirtualMenuItemModule(sources, true)
const formModule = await generateVirtualFormModule(sources, true)
const displayModule = await generateVirtualDisplayModule(sources, true)
const i18nModule = await generateVirtualI18nModule(sources, true)
// Create the index.js content that re-exports everything
return `
@@ -93,13 +96,15 @@ export const medusaVitePlugin: MedusaVitePlugin = (options) => {
${menuItemModule.code}
${formModule.code}
${displayModule.code}
${i18nModule.code}
const plugin = {
widgetModule,
routeModule,
menuItemModule,
formModule,
displayModule
displayModule,
i18nModule
}
export default plugin
@@ -229,6 +234,11 @@ const loadConfigs: Record<string, ModuleConfig> = {
moduleGenerator: async (sources) => generateVirtualMenuItemModule(sources),
hashKey: vmod.virtual.menuItem,
},
[vmod.resolved.i18n]: {
hashGenerator: async (sources) => generateI18nHash(sources),
moduleGenerator: async (sources) => generateVirtualI18nModule(sources),
hashKey: vmod.virtual.i18n,
},
}
type WatcherConfig = {
@@ -292,4 +302,17 @@ const watcherConfigs: WatcherConfig[] = [
},
],
},
{
subdirectory: "i18n",
hashGenerator: async (sources) => ({
i18nHash: await generateI18nHash(sources),
}),
modules: [
{
virtualModule: vmod.virtual.i18n,
resolvedModule: vmod.resolved.i18n,
hashKey: "i18nHash",
},
],
},
]

View File

@@ -49,7 +49,7 @@ export function generateModule(code: string) {
}
}
export const VALID_FILE_EXTENSIONS = [".tsx", ".jsx", ".js"]
export const VALID_FILE_EXTENSIONS = [".tsx", ".jsx", ".js", ".ts"]
/**
* Crawls a directory and returns all files that match the criteria.
@@ -176,7 +176,7 @@ export function generateHash(content: string) {
return crypto.createHash("md5").update(content).digest("hex")
}
const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets"] as const
const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets", "i18n"] as const
export type AdminSubdirectory = (typeof ADMIN_SUBDIRECTORIES)[number]

View File

@@ -0,0 +1,27 @@
import outdent from "outdent"
import { generateModule } from "../utils"
import { generateI18n } from "../i18n"
export async function generateVirtualI18nModule(
sources: Set<string>,
pluginMode = false
) {
const i18n = await generateI18n(sources)
const imports = [
'import { deepMerge } from "@medusajs/admin-shared"',
...i18n.imports
]
const code = outdent`
${imports.join("\n")}
${pluginMode
? `const i18nModule = { ${i18n.code} }`
: `export default { ${i18n.code} }`
}
`
return generateModule(code)
}

View File

@@ -4,3 +4,4 @@ export * from "./generate-virtual-link-module"
export * from "./generate-virtual-menu-item-module"
export * from "./generate-virtual-route-module"
export * from "./generate-virtual-widget-module"
export * from "./generate-virtual-i18n-module"

View File

@@ -1,6 +1,7 @@
import {
DISPLAY_VIRTUAL_MODULE,
FORM_VIRTUAL_MODULE,
I18N_VIRTUAL_MODULE,
LINK_VIRTUAL_MODULE,
MENU_ITEM_VIRTUAL_MODULE,
ROUTE_VIRTUAL_MODULE,
@@ -13,6 +14,7 @@ const RESOLVED_DISPLAY_VIRTUAL_MODULE = `\0${DISPLAY_VIRTUAL_MODULE}`
const RESOLVED_ROUTE_VIRTUAL_MODULE = `\0${ROUTE_VIRTUAL_MODULE}`
const RESOLVED_MENU_ITEM_VIRTUAL_MODULE = `\0${MENU_ITEM_VIRTUAL_MODULE}`
const RESOLVED_WIDGET_VIRTUAL_MODULE = `\0${WIDGET_VIRTUAL_MODULE}`
const RESOLVED_I18N_VIRTUAL_MODULE = `\0${I18N_VIRTUAL_MODULE}`
const VIRTUAL_MODULES = [
LINK_VIRTUAL_MODULE,
@@ -21,6 +23,7 @@ const VIRTUAL_MODULES = [
ROUTE_VIRTUAL_MODULE,
MENU_ITEM_VIRTUAL_MODULE,
WIDGET_VIRTUAL_MODULE,
I18N_VIRTUAL_MODULE,
] as const
const RESOLVED_VIRTUAL_MODULES = [
@@ -30,6 +33,7 @@ const RESOLVED_VIRTUAL_MODULES = [
RESOLVED_ROUTE_VIRTUAL_MODULE,
RESOLVED_MENU_ITEM_VIRTUAL_MODULE,
RESOLVED_WIDGET_VIRTUAL_MODULE,
RESOLVED_I18N_VIRTUAL_MODULE,
] as const
export function resolveVirtualId(id: string) {
@@ -55,6 +59,8 @@ export type VirtualModule =
| typeof ROUTE_VIRTUAL_MODULE
| typeof MENU_ITEM_VIRTUAL_MODULE
| typeof WIDGET_VIRTUAL_MODULE
| typeof I18N_VIRTUAL_MODULE
const resolvedVirtualModuleIds = {
link: RESOLVED_LINK_VIRTUAL_MODULE,
form: RESOLVED_FORM_VIRTUAL_MODULE,
@@ -62,6 +68,7 @@ const resolvedVirtualModuleIds = {
route: RESOLVED_ROUTE_VIRTUAL_MODULE,
menuItem: RESOLVED_MENU_ITEM_VIRTUAL_MODULE,
widget: RESOLVED_WIDGET_VIRTUAL_MODULE,
i18n: RESOLVED_I18N_VIRTUAL_MODULE,
} as const
const virtualModuleIds = {
@@ -71,6 +78,7 @@ const virtualModuleIds = {
route: ROUTE_VIRTUAL_MODULE,
menuItem: MENU_ITEM_VIRTUAL_MODULE,
widget: WIDGET_VIRTUAL_MODULE,
i18n: I18N_VIRTUAL_MODULE,
} as const
export const vmod = {