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 15:16:43 -04:00
committed by GitHub
parent 012e30801e
commit 226984cf0f
25 changed files with 314 additions and 9 deletions
@@ -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)
)
})
})
@@ -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)
}
@@ -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,
}
}
@@ -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()
}
@@ -0,0 +1,3 @@
export { generateI18nHash } from "./generate-i18n-hash"
export { getI18nIndexFilesFromSources } from "./helpers"
export { generateI18n } from "./generate-i18n"