Files
medusa-store/packages/admin/admin-vite-plugin/src/plugin.ts
Leonardo Benini 226984cf0f 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
2025-10-23 15:16:43 -04:00

319 lines
8.5 KiB
TypeScript

import { SourceMap } from "magic-string"
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,
generateVirtualWidgetModule,
} from "./virtual-modules"
import {
isResolvedVirtualModuleId,
isVirtualModuleId,
resolveVirtualId,
VirtualModule,
vmod,
} from "./vmod"
import { generateWidgetHash } from "./widgets"
enum Mode {
PLUGIN = "plugin",
APPLICATION = "application",
}
export const medusaVitePlugin: MedusaVitePlugin = (options) => {
const hashMap = new Map<VirtualModule, string>()
const _sources = new Set<string>(options?.sources ?? [])
const mode = options?.pluginMode ? Mode.PLUGIN : Mode.APPLICATION
let watcher: Vite.FSWatcher | undefined
function isFileInSources(file: string): boolean {
for (const source of _sources) {
if (file.startsWith(path.resolve(source))) {
return true
}
}
return false
}
async function loadVirtualModule(
config: ModuleConfig
): Promise<{ code: string; map: SourceMap } | null> {
const hash = await config.hashGenerator(_sources)
hashMap.set(config.hashKey, hash)
return config.moduleGenerator(_sources)
}
async function handleFileChange(
server: Vite.ViteDevServer,
config: WatcherConfig
) {
const hashes = await config.hashGenerator(_sources)
for (const module of config.modules) {
const newHash = hashes[module.hashKey]
if (newHash !== hashMap.get(module.virtualModule)) {
const moduleToReload = server.moduleGraph.getModuleById(
module.resolvedModule
)
if (moduleToReload) {
await server.reloadModule(moduleToReload)
}
hashMap.set(module.virtualModule, newHash)
}
}
}
// Function to generate the index.js file
async function generatePluginEntryModule(
sources: Set<string>
): Promise<string> {
// Generate all the module content
const widgetModule = await generateVirtualWidgetModule(sources, true)
const routeModule = await generateVirtualRouteModule(sources, true)
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 `
// Auto-generated index file for Medusa Admin UI extensions
${widgetModule.code}
${routeModule.code}
${menuItemModule.code}
${formModule.code}
${displayModule.code}
${i18nModule.code}
const plugin = {
widgetModule,
routeModule,
menuItemModule,
formModule,
displayModule,
i18nModule
}
export default plugin
`
}
const pluginEntryFile = path.resolve(
process.cwd(),
"src/admin/__admin-extensions__.js"
)
return {
name: "@medusajs/admin-vite-plugin",
enforce: "pre",
async buildStart() {
switch (mode) {
case Mode.PLUGIN: {
const code = await generatePluginEntryModule(_sources)
await writeFile(pluginEntryFile, code, "utf-8")
break
}
case Mode.APPLICATION: {
break
}
}
},
async buildEnd() {
switch (mode) {
case Mode.PLUGIN: {
try {
await rm(pluginEntryFile, { force: true })
} catch (error) {
// Ignore the error if the file doesn't exist
}
break
}
case Mode.APPLICATION: {
break
}
}
},
configureServer(server) {
watcher = server.watcher
watcher?.add(Array.from(_sources))
watcher?.on("all", async (_event, file) => {
if (!isFileInSources(file)) {
return
}
for (const config of watcherConfigs) {
if (isFileInAdminSubdirectory(file, config.subdirectory)) {
await handleFileChange(server, config)
}
}
})
},
resolveId(id) {
if (!isVirtualModuleId(id)) {
return null
}
return resolveVirtualId(id)
},
async load(id) {
if (!isResolvedVirtualModuleId(id)) {
return null
}
const config = loadConfigs[id]
if (!config) {
return null
}
return loadVirtualModule(config)
},
async closeBundle() {
if (watcher) {
await watcher.close()
}
},
}
}
type ModuleConfig = {
hashGenerator: (sources: Set<string>) => Promise<string>
moduleGenerator: (
sources: Set<string>
) => Promise<{ code: string; map: SourceMap }>
hashKey: VirtualModule
}
const loadConfigs: Record<string, ModuleConfig> = {
[vmod.resolved.widget]: {
hashGenerator: async (sources) => generateWidgetHash(sources),
moduleGenerator: async (sources) => generateVirtualWidgetModule(sources),
hashKey: vmod.virtual.widget,
},
[vmod.resolved.link]: {
hashGenerator: async (sources) =>
(await generateCustomFieldHashes(sources)).linkHash,
moduleGenerator: async (sources) => generateVirtualLinkModule(sources),
hashKey: vmod.virtual.link,
},
[vmod.resolved.form]: {
hashGenerator: async (sources) =>
(await generateCustomFieldHashes(sources)).formHash,
moduleGenerator: async (sources) => generateVirtualFormModule(sources),
hashKey: vmod.virtual.form,
},
[vmod.resolved.display]: {
hashGenerator: async (sources) =>
(await generateCustomFieldHashes(sources)).displayHash,
moduleGenerator: async (sources) => generateVirtualDisplayModule(sources),
hashKey: vmod.virtual.display,
},
[vmod.resolved.route]: {
hashGenerator: async (sources) =>
(await generateRouteHashes(sources)).defaultExportHash,
moduleGenerator: async (sources) => generateVirtualRouteModule(sources),
hashKey: vmod.virtual.route,
},
[vmod.resolved.menuItem]: {
hashGenerator: async (sources) =>
(await generateRouteHashes(sources)).configHash,
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 = {
subdirectory: AdminSubdirectory
hashGenerator: (sources: Set<string>) => Promise<Record<string, string>>
modules: {
virtualModule: VirtualModule
resolvedModule: string
hashKey: string
}[]
}
const watcherConfigs: WatcherConfig[] = [
{
subdirectory: "routes",
hashGenerator: async (sources) => generateRouteHashes(sources),
modules: [
{
virtualModule: vmod.virtual.route,
resolvedModule: vmod.resolved.route,
hashKey: "defaultExportHash",
},
{
virtualModule: vmod.virtual.menuItem,
resolvedModule: vmod.resolved.menuItem,
hashKey: "configHash",
},
],
},
{
subdirectory: "widgets",
hashGenerator: async (sources) => ({
widgetConfigHash: await generateWidgetHash(sources),
}),
modules: [
{
virtualModule: vmod.virtual.widget,
resolvedModule: vmod.resolved.widget,
hashKey: "widgetConfigHash",
},
],
},
{
subdirectory: "custom-fields",
hashGenerator: async (sources) => generateCustomFieldHashes(sources),
modules: [
{
virtualModule: vmod.virtual.link,
resolvedModule: vmod.resolved.link,
hashKey: "linkHash",
},
{
virtualModule: vmod.virtual.form,
resolvedModule: vmod.resolved.form,
hashKey: "formHash",
},
{
virtualModule: vmod.virtual.display,
resolvedModule: vmod.resolved.display,
hashKey: "displayHash",
},
],
},
{
subdirectory: "i18n",
hashGenerator: async (sources) => ({
i18nHash: await generateI18nHash(sources),
}),
modules: [
{
virtualModule: vmod.virtual.i18n,
resolvedModule: vmod.resolved.i18n,
hashKey: "i18nHash",
},
],
},
]