From f1176a067390fbb3c75b5796820ca8f90d6f070c Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 23 May 2024 14:02:19 +0200 Subject: [PATCH] feat(dashboard,admin-sdk,admin-shared,admin-vite-plugin): Add support for UI extensions (#7383) * intial work * update lock * add routes and fix HMR of configs * cleanup * rm imports * rm debug from plugin * address feedback * address feedback --- .eslintrc.js | 1 + packages/admin-next/admin-sdk/package.json | 3 +- packages/admin-next/admin-sdk/src/index.ts | 4 + .../admin-next/admin-sdk/src/lib/build.ts | 14 +- .../admin-next/admin-sdk/src/lib/config.ts | 37 +- .../admin-next/admin-sdk/src/lib/develop.ts | 13 +- .../admin-next/admin-sdk/src/lib/serve.ts | 5 +- packages/admin-next/admin-sdk/src/types.ts | 4 +- packages/admin-next/admin-sdk/tsconfig.json | 2 +- .../{tsup.config.ts => tsup.config.cjs} | 0 packages/admin-next/admin-shared/package.json | 5 +- .../src/extensions/config/index.ts | 2 + .../src/extensions/config/types.ts | 12 + .../src/extensions/config/utils.ts | 37 + .../src/extensions/routes/constants.ts | 1 + .../src/extensions/routes/index.ts | 2 + .../src/extensions/routes/types.ts | 3 + .../src/extensions/virtual/constants.ts | 40 + .../src/extensions/virtual/index.ts | 2 + .../src/extensions/virtual/utils.ts | 25 + .../src/{ => extensions/widgets}/constants.ts | 67 +- .../src/extensions/widgets/index.ts | 3 + .../src/extensions/widgets/types.ts | 3 + .../src/extensions/widgets/utils.ts | 9 + packages/admin-next/admin-shared/src/index.ts | 5 +- packages/admin-next/admin-shared/src/types.ts | 3 - .../tsup.config.cjs} | 1 + .../admin-next/admin-vite-plugin/package.json | 10 +- .../admin-next/admin-vite-plugin/src/babel.ts | 32 + .../admin-next/admin-vite-plugin/src/index.ts | 887 +----------------- .../admin-vite-plugin/src/plugin.ts | 794 ++++++++++++++++ .../admin-vite-plugin/tsup.config.cjs | 8 + packages/admin-next/dashboard/package.json | 1 + .../layout/main-layout/main-layout.tsx | 31 +- .../components/layout/nav-item/nav-item.tsx | 16 +- .../settings-layout/settings-layout.tsx | 76 +- .../dashboard/src/lib/extension-helpers.ts | 56 ++ packages/admin-next/dashboard/src/module.d.ts | 26 +- .../router-provider/route-extensions.tsx | 20 +- .../providers/router-provider/route-map.tsx | 4 +- .../router-provider/settings-extensions.tsx | 23 +- .../product-detail/product-detail.tsx | 9 +- .../admin-next/dashboard/tsconfig.node.json | 2 +- packages/admin-next/dashboard/tsup.config.cjs | 10 + packages/admin-next/dashboard/tsup.config.ts | 23 - packages/admin-next/dashboard/vite.config.mts | 10 +- packages/medusa/src/commands/build.ts | 24 +- packages/medusa/src/loaders/admin.ts | 29 +- packages/medusa/src/loaders/index.ts | 19 +- yarn.lock | 37 +- 50 files changed, 1359 insertions(+), 1091 deletions(-) rename packages/admin-next/admin-sdk/{tsup.config.ts => tsup.config.cjs} (100%) create mode 100644 packages/admin-next/admin-shared/src/extensions/config/index.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/config/types.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/config/utils.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/routes/constants.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/routes/index.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/routes/types.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/virtual/constants.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/virtual/index.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/virtual/utils.ts rename packages/admin-next/admin-shared/src/{ => extensions/widgets}/constants.ts (54%) create mode 100644 packages/admin-next/admin-shared/src/extensions/widgets/index.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/widgets/types.ts create mode 100644 packages/admin-next/admin-shared/src/extensions/widgets/utils.ts delete mode 100644 packages/admin-next/admin-shared/src/types.ts rename packages/admin-next/{admin-vite-plugin/tsup.config.ts => admin-shared/tsup.config.cjs} (90%) create mode 100644 packages/admin-next/admin-vite-plugin/src/babel.ts create mode 100644 packages/admin-next/admin-vite-plugin/src/plugin.ts create mode 100644 packages/admin-next/admin-vite-plugin/tsup.config.cjs create mode 100644 packages/admin-next/dashboard/src/lib/extension-helpers.ts create mode 100644 packages/admin-next/dashboard/tsup.config.cjs delete mode 100644 packages/admin-next/dashboard/tsup.config.ts diff --git a/.eslintrc.js b/.eslintrc.js index 5bbd25a6f093..7b0f9fe908f6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -84,6 +84,7 @@ module.exports = { "./packages/admin-next/dashboard/tsconfig.json", "./packages/admin-next/admin-sdk/tsconfig.json", + "./packages/admin-next/admin-shared/tsconfig.json", "./packages/admin-next/admin-vite-plugin/tsconfig.json", "./packages/inventory/tsconfig.spec.json", diff --git a/packages/admin-next/admin-sdk/package.json b/packages/admin-next/admin-sdk/package.json index 46b8a21eeccd..b2ccaa5bf53d 100644 --- a/packages/admin-next/admin-sdk/package.json +++ b/packages/admin-next/admin-sdk/package.json @@ -15,20 +15,19 @@ "devDependencies": { "@medusajs/types": "^1.11.16", "@types/compression": "^1.7.5", - "@types/connect-history-api-fallback": "^1.5.4", "copyfiles": "^2.4.1", "express": "^4.18.2", "tsup": "^8.0.1", "typescript": "^5.3.3" }, "dependencies": { + "@medusajs/admin-shared": "0.0.1", "@medusajs/admin-vite-plugin": "0.0.1", "@medusajs/dashboard": "0.0.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", "commander": "^11.1.0", "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", "deepmerge": "^4.3.1", "glob": "^7.1.6", "postcss": "^8.4.32", diff --git a/packages/admin-next/admin-sdk/src/index.ts b/packages/admin-next/admin-sdk/src/index.ts index 3d6f8b53b8cc..1aaade5c5eed 100644 --- a/packages/admin-next/admin-sdk/src/index.ts +++ b/packages/admin-next/admin-sdk/src/index.ts @@ -1,5 +1,9 @@ +import { defineRouteConfig, defineWidgetConfig } from "@medusajs/admin-shared" + export { build } from "./lib/build" export { develop } from "./lib/develop" export { serve } from "./lib/serve" +export { defineRouteConfig, defineWidgetConfig } + export * from "./types" diff --git a/packages/admin-next/admin-sdk/src/lib/build.ts b/packages/admin-next/admin-sdk/src/lib/build.ts index 79f9007a4030..86f902f74bb7 100644 --- a/packages/admin-next/admin-sdk/src/lib/build.ts +++ b/packages/admin-next/admin-sdk/src/lib/build.ts @@ -1,3 +1,4 @@ +import type { InlineConfig } from "vite" import { BundlerOptions } from "../types" import { getViteConfig } from "./config" @@ -5,13 +6,10 @@ export async function build(options: BundlerOptions) { const vite = await import("vite") const viteConfig = await getViteConfig(options) - - try { - await vite.build( - vite.mergeConfig(viteConfig, { mode: "production", logLevel: "silent" }) - ) - } catch (error) { - console.error(error) - throw new Error("Failed to build admin panel") + const buildConfig: InlineConfig = { + mode: "production", + logLevel: "error", } + + await vite.build(vite.mergeConfig(viteConfig, buildConfig)) } diff --git a/packages/admin-next/admin-sdk/src/lib/config.ts b/packages/admin-next/admin-sdk/src/lib/config.ts index 4a0f817285cf..1f2f2cd5443f 100644 --- a/packages/admin-next/admin-sdk/src/lib/config.ts +++ b/packages/admin-next/admin-sdk/src/lib/config.ts @@ -1,3 +1,4 @@ +import { VIRTUAL_MODULES } from "@medusajs/admin-shared" import path from "path" import { Config } from "tailwindcss" import type { InlineConfig } from "vite" @@ -10,7 +11,7 @@ export async function getViteConfig( ): Promise { const { searchForWorkspaceRoot } = await import("vite") const { default: react } = await import("@vitejs/plugin-react") - const { default: inject } = await import("@medusajs/admin-vite-plugin") + const { default: medusa } = await import("@medusajs/admin-vite-plugin") const getPort = await import("get-port") const hmrPort = await getPort.default() @@ -20,7 +21,7 @@ export async function getViteConfig( const backendUrl = options.backendUrl ?? "" return { - root: path.resolve(__dirname, "./"), + root, base: options.path, build: { emptyOutDir: true, @@ -28,19 +29,15 @@ export async function getViteConfig( }, optimizeDeps: { include: ["@medusajs/dashboard", "react-dom/client"], + exclude: VIRTUAL_MODULES, }, define: { __BASE__: JSON.stringify(options.path), __BACKEND_URL__: JSON.stringify(backendUrl), }, server: { - open: true, fs: { - allow: [ - searchForWorkspaceRoot(process.cwd()), - path.resolve(__dirname, "../../medusa"), - path.resolve(__dirname, "../../app"), - ], + allow: [searchForWorkspaceRoot(process.cwd())], }, hmr: { port: hmrPort, @@ -51,20 +48,22 @@ export async function getViteConfig( postcss: { plugins: [ require("tailwindcss")({ - config: createTailwindConfig(root), + config: createTailwindConfig(root, options.sources), }), ], }, }, - /** - * TODO: Remove polyfills, they are currently only required for the - * `axios` dependency in the dashboard. Once we have the new SDK, - * we should remove this, and leave it up to the user to include - * polyfills if they need them. - */ plugins: [ react(), - inject(), + medusa({ + sources: options.sources, + }), + /** + * TODO: Remove polyfills, they are currently only required for the + * `axios` dependency in the dashboard. Once we have the new SDK, + * we should remove this, and leave it up to the user to include + * polyfills if they need them. + */ nodePolyfills({ include: ["crypto", "util", "stream"], }), @@ -72,7 +71,7 @@ export async function getViteConfig( } } -function createTailwindConfig(entry: string) { +function createTailwindConfig(entry: string, sources: string[] = []) { const root = path.join(entry, "**/*.{js,ts,jsx,tsx}") const html = path.join(entry, "index.html") @@ -98,9 +97,11 @@ function createTailwindConfig(entry: string) { // ignore } + const extensions = sources.map((s) => path.join(s, "**/*.{js,ts,jsx,tsx}")) + const config: Config = { presets: [require("@medusajs/ui-preset")], - content: [html, root, dashboard, ui], + content: [html, root, dashboard, ui, ...extensions], darkMode: "class", } diff --git a/packages/admin-next/admin-sdk/src/lib/develop.ts b/packages/admin-next/admin-sdk/src/lib/develop.ts index cb6823faa129..ca575be30e75 100644 --- a/packages/admin-next/admin-sdk/src/lib/develop.ts +++ b/packages/admin-next/admin-sdk/src/lib/develop.ts @@ -1,4 +1,5 @@ import express from "express" +import type { InlineConfig } from "vite" import { BundlerOptions } from "../types" import { getViteConfig } from "./config" @@ -10,14 +11,22 @@ export async function develop(options: BundlerOptions) { try { const viteConfig = await getViteConfig(options) + + const developConfig: InlineConfig = { + mode: "development", + logLevel: "warn", + } + const server = await vite.createServer( - vite.mergeConfig(viteConfig, { logLevel: "info", mode: "development" }) + vite.mergeConfig(viteConfig, developConfig) ) router.use(server.middlewares) } catch (error) { console.error(error) - throw new Error("Could not start development server") + throw new Error( + "Failed to start admin development server. See error above." + ) } return router diff --git a/packages/admin-next/admin-sdk/src/lib/serve.ts b/packages/admin-next/admin-sdk/src/lib/serve.ts index 185414923808..c4d41c0bde2e 100644 --- a/packages/admin-next/admin-sdk/src/lib/serve.ts +++ b/packages/admin-next/admin-sdk/src/lib/serve.ts @@ -1,3 +1,4 @@ +import compression from "compression" import { Request, Response, Router, static as static_ } from "express" import fs from "fs" import { ServerResponse } from "http" @@ -24,7 +25,7 @@ export async function serve(options: ServeOptions) { if (!indexExists) { throw new Error( - `Could not find the admin UI build files. Please run \`npm run build\` or \`yarn build\` command and try again.` + `Could not find index.html in the admin build directory. Make sure to run 'medusa build' before starting the server.` ) } @@ -41,6 +42,8 @@ export async function serve(options: ServeOptions) { res.setHeader("Vary", "Origin, Cache-Control") } + router.use(compression()) + router.get("/", sendHtml) router.use( static_(options.outDir, { diff --git a/packages/admin-next/admin-sdk/src/types.ts b/packages/admin-next/admin-sdk/src/types.ts index 43836096ae1a..23e09ef1c74a 100644 --- a/packages/admin-next/admin-sdk/src/types.ts +++ b/packages/admin-next/admin-sdk/src/types.ts @@ -1,4 +1,6 @@ import { AdminOptions } from "@medusajs/types" export type BundlerOptions = Required> & - Pick + Pick & { + sources?: string[] + } diff --git a/packages/admin-next/admin-sdk/tsconfig.json b/packages/admin-next/admin-sdk/tsconfig.json index 1c1cf0601e17..8a1a6413b78e 100644 --- a/packages/admin-next/admin-sdk/tsconfig.json +++ b/packages/admin-next/admin-sdk/tsconfig.json @@ -17,5 +17,5 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], - "exclude": ["tsup.config.ts", "node_modules", "dist"] + "exclude": ["tsup.config.cjs", "node_modules", "dist"] } diff --git a/packages/admin-next/admin-sdk/tsup.config.ts b/packages/admin-next/admin-sdk/tsup.config.cjs similarity index 100% rename from packages/admin-next/admin-sdk/tsup.config.ts rename to packages/admin-next/admin-sdk/tsup.config.cjs diff --git a/packages/admin-next/admin-shared/package.json b/packages/admin-next/admin-shared/package.json index 2bfaf2c33c1d..e980aa2011c3 100644 --- a/packages/admin-next/admin-shared/package.json +++ b/packages/admin-next/admin-shared/package.json @@ -5,14 +5,17 @@ "author": "Kasper Kristensen ", "types": "dist/index.d.ts", "main": "dist/index.js", + "module": "dist/index.mjs", "files": [ "dist", "package.json" ], "scripts": { - "build": "tsc" + "build": "tsup" }, "devDependencies": { + "@types/react": "^18.3.2", + "tsup": "^8.0.2", "typescript": "^5.3.3" }, "packageManager": "yarn@3.2.1" diff --git a/packages/admin-next/admin-shared/src/extensions/config/index.ts b/packages/admin-next/admin-shared/src/extensions/config/index.ts new file mode 100644 index 000000000000..21aecd353d30 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/config/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./utils" diff --git a/packages/admin-next/admin-shared/src/extensions/config/types.ts b/packages/admin-next/admin-shared/src/extensions/config/types.ts new file mode 100644 index 000000000000..4b3f8ee4bd59 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/config/types.ts @@ -0,0 +1,12 @@ +import type { ComponentType } from "react" + +import { InjectionZone } from "../widgets" + +export type WidgetConfig = { + zone: InjectionZone | InjectionZone[] +} + +export type RouteConfig = { + label?: string + icon?: ComponentType +} diff --git a/packages/admin-next/admin-shared/src/extensions/config/utils.ts b/packages/admin-next/admin-shared/src/extensions/config/utils.ts new file mode 100644 index 000000000000..395cb820444b --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/config/utils.ts @@ -0,0 +1,37 @@ +import { RouteConfig, WidgetConfig } from "./types" + +function createConfigHelper>( + config: TConfig +): TConfig { + return { + ...config, + /** + * This property is required to allow the config to be exported, + * while still allowing HMR to work correctly. + * + * It tricks Fast Refresh into thinking that the config is a React component, + * which allows it to be updated without a full page reload. + */ + $$typeof: Symbol.for("react.memo"), + } +} + +/** + * Define a widget configuration. + * + * @param config The widget configuration. + * @returns The widget configuration. + */ +export function defineWidgetConfig(config: WidgetConfig) { + return createConfigHelper(config) +} + +/** + * Define a route configuration. + * + * @param config The route configuration. + * @returns The route configuration. + */ +export function defineRouteConfig(config: RouteConfig) { + return createConfigHelper(config) +} diff --git a/packages/admin-next/admin-shared/src/extensions/routes/constants.ts b/packages/admin-next/admin-shared/src/extensions/routes/constants.ts new file mode 100644 index 000000000000..a048d89c2068 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/routes/constants.ts @@ -0,0 +1 @@ +export const ROUTE_IMPORTS = ["routes/pages", "routes/links"] as const diff --git a/packages/admin-next/admin-shared/src/extensions/routes/index.ts b/packages/admin-next/admin-shared/src/extensions/routes/index.ts new file mode 100644 index 000000000000..6c07e3e75f6d --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/routes/index.ts @@ -0,0 +1,2 @@ +export * from "./constants" +export * from "./types" diff --git a/packages/admin-next/admin-shared/src/extensions/routes/types.ts b/packages/admin-next/admin-shared/src/extensions/routes/types.ts new file mode 100644 index 000000000000..62417f6e5bfc --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/routes/types.ts @@ -0,0 +1,3 @@ +import { ROUTE_IMPORTS } from "./constants" + +export type RouteImport = (typeof ROUTE_IMPORTS)[number] diff --git a/packages/admin-next/admin-shared/src/extensions/virtual/constants.ts b/packages/admin-next/admin-shared/src/extensions/virtual/constants.ts new file mode 100644 index 000000000000..6b8ee3eb7df7 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/virtual/constants.ts @@ -0,0 +1,40 @@ +import { ROUTE_IMPORTS } from "../routes" +import { INJECTION_ZONES } from "../widgets" +import { getVirtualId, getWidgetImport, resolveVirtualId } from "./utils" + +const VIRTUAL_WIDGET_MODULES = INJECTION_ZONES.map((zone) => { + return getVirtualId(getWidgetImport(zone)) +}) + +const VIRTUAL_ROUTE_MODULES = ROUTE_IMPORTS.map((route) => { + return getVirtualId(route) +}) + +/** + * All virtual modules that are used in the admin panel. Virtual modules are used + * to inject custom widgets, routes and settings. A virtual module is imported using + * a string that corresponds to the id of the virtual module. + * + * @example + * ```ts + * import ProductDetailsBefore from "virtual:medusa/widgets/product/details/before" + * ``` + */ +export const VIRTUAL_MODULES = [ + ...VIRTUAL_WIDGET_MODULES, + ...VIRTUAL_ROUTE_MODULES, +] + +/** + * Reolved paths to all virtual widget modules. + */ +export const RESOLVED_WIDGET_MODULES = VIRTUAL_WIDGET_MODULES.map((id) => { + return resolveVirtualId(id) +}) + +/** + * Reolved paths to all virtual route modules. + */ +export const RESOLVED_ROUTE_MODULES = VIRTUAL_ROUTE_MODULES.map((id) => { + return resolveVirtualId(id) +}) diff --git a/packages/admin-next/admin-shared/src/extensions/virtual/index.ts b/packages/admin-next/admin-shared/src/extensions/virtual/index.ts new file mode 100644 index 000000000000..b570da8e4238 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/virtual/index.ts @@ -0,0 +1,2 @@ +export * from "./constants" +export * from "./utils" diff --git a/packages/admin-next/admin-shared/src/extensions/virtual/utils.ts b/packages/admin-next/admin-shared/src/extensions/virtual/utils.ts new file mode 100644 index 000000000000..a3e523fb1f74 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/virtual/utils.ts @@ -0,0 +1,25 @@ +import { InjectionZone } from "../widgets" + +const PREFIX = "virtual:medusa/" + +export const getVirtualId = (name: string) => { + return `${PREFIX}${name}` +} + +export const resolveVirtualId = (id: string) => { + return `\0${id}` +} + +export const getWidgetImport = (zone: InjectionZone) => { + return `widgets/${zone.replace(/\./g, "/")}` +} + +export const getWidgetZone = (resolvedId: string): InjectionZone => { + const virtualPrefix = `\0${PREFIX}widgets/` + + const zone = resolvedId + .replace(virtualPrefix, "") + .replace(/\//g, ".") as InjectionZone + + return zone as InjectionZone +} diff --git a/packages/admin-next/admin-shared/src/constants.ts b/packages/admin-next/admin-shared/src/extensions/widgets/constants.ts similarity index 54% rename from packages/admin-next/admin-shared/src/constants.ts rename to packages/admin-next/admin-shared/src/extensions/widgets/constants.ts index 740f68551dce..d2fe06699bfd 100644 --- a/packages/admin-next/admin-shared/src/constants.ts +++ b/packages/admin-next/admin-shared/src/extensions/widgets/constants.ts @@ -1,64 +1,101 @@ -export const injectionZones = [ - // Order injection zones +const ORDER_INJECTION_ZONES = [ "order.details.before", "order.details.after", "order.list.before", "order.list.after", - // Draft order injection zones +] as const + +const DRAFT_ORDER_INJECTION_ZONES = [ "draft_order.list.before", "draft_order.list.after", "draft_order.details.before", "draft_order.details.after", - // Customer injection zones +] as const + +const CUSTOMER_INJECTION_ZONES = [ "customer.details.before", "customer.details.after", "customer.list.before", "customer.list.after", - // Customer group injection zones +] as const + +const CUSTOMER_GROUP_INJECTION_ZONES = [ "customer_group.details.before", "customer_group.details.after", "customer_group.list.before", "customer_group.list.after", - // Product injection zones +] as const + +const PRODUCT_INJECTION_ZONES = [ "product.details.before", "product.details.after", "product.list.before", "product.list.after", "product.details.side.before", "product.details.side.after", - // Product collection injection zones +] as const + +const PRODUCT_COLLECTION_INJECTION_ZONES = [ "product_collection.details.before", "product_collection.details.after", "product_collection.list.before", "product_collection.list.after", - // Product category injection zones +] as const + +const PRODUCT_CATEGORY_INJECTION_ZONES = [ "product_category.details.before", "product_category.details.after", "product_category.list.before", "product_category.list.after", - // Price list injection zones +] as const + +const PRICE_LIST_INJECTION_ZONES = [ "price_list.details.before", "price_list.details.after", "price_list.list.before", "price_list.list.after", - // Discount injection zones +] as const + +const DISCOUNT_INJECTION_ZONES = [ "discount.details.before", "discount.details.after", "discount.list.before", "discount.list.after", - // Promotion injection zones +] as const + +const PROMOTION_INJECTION_ZONES = [ "promotion.details.before", "promotion.details.after", "promotion.list.before", "promotion.list.after", - // Gift card injection zones +] as const + +const GIFT_CARD_INJECTION_ZONES = [ "gift_card.details.before", "gift_card.details.after", "gift_card.list.before", "gift_card.list.after", "custom_gift_card.before", "custom_gift_card.after", - // Login - "login.before", - "login.after", +] as const + +const LOGIN_INJECTION_ZONES = ["login.before", "login.after"] as const + +/** + * All valid injection zones in the admin panel. An injection zone is a specific place + * in the admin panel where a plugin can inject custom widgets. + */ +export const INJECTION_ZONES = [ + ...ORDER_INJECTION_ZONES, + ...DRAFT_ORDER_INJECTION_ZONES, + ...CUSTOMER_INJECTION_ZONES, + ...CUSTOMER_GROUP_INJECTION_ZONES, + ...PRODUCT_INJECTION_ZONES, + ...PRODUCT_COLLECTION_INJECTION_ZONES, + ...PRODUCT_CATEGORY_INJECTION_ZONES, + ...PRICE_LIST_INJECTION_ZONES, + ...DISCOUNT_INJECTION_ZONES, + ...PROMOTION_INJECTION_ZONES, + ...GIFT_CARD_INJECTION_ZONES, + ...LOGIN_INJECTION_ZONES, ] as const diff --git a/packages/admin-next/admin-shared/src/extensions/widgets/index.ts b/packages/admin-next/admin-shared/src/extensions/widgets/index.ts new file mode 100644 index 000000000000..43b4d0d4f7c8 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/widgets/index.ts @@ -0,0 +1,3 @@ +export * from "./constants" +export * from "./types" +export * from "./utils" diff --git a/packages/admin-next/admin-shared/src/extensions/widgets/types.ts b/packages/admin-next/admin-shared/src/extensions/widgets/types.ts new file mode 100644 index 000000000000..bd7fed40345f --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/widgets/types.ts @@ -0,0 +1,3 @@ +import { INJECTION_ZONES } from "./constants" + +export type InjectionZone = (typeof INJECTION_ZONES)[number] diff --git a/packages/admin-next/admin-shared/src/extensions/widgets/utils.ts b/packages/admin-next/admin-shared/src/extensions/widgets/utils.ts new file mode 100644 index 000000000000..ed4879d58d59 --- /dev/null +++ b/packages/admin-next/admin-shared/src/extensions/widgets/utils.ts @@ -0,0 +1,9 @@ +import { INJECTION_ZONES } from "./constants" +import { InjectionZone } from "./types" + +/** + * Validates that the provided zone is a valid injection zone for a widget. + */ +export function isValidInjectionZone(zone: any): zone is InjectionZone { + return INJECTION_ZONES.includes(zone) +} diff --git a/packages/admin-next/admin-shared/src/index.ts b/packages/admin-next/admin-shared/src/index.ts index 6c07e3e75f6d..505386410985 100644 --- a/packages/admin-next/admin-shared/src/index.ts +++ b/packages/admin-next/admin-shared/src/index.ts @@ -1,2 +1,3 @@ -export * from "./constants" -export * from "./types" +export * from "./extensions/config" +export * from "./extensions/virtual" +export * from "./extensions/widgets" diff --git a/packages/admin-next/admin-shared/src/types.ts b/packages/admin-next/admin-shared/src/types.ts deleted file mode 100644 index 64c703034f63..000000000000 --- a/packages/admin-next/admin-shared/src/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { injectionZones } from "./constants" - -export type InjectionZone = (typeof injectionZones)[number] diff --git a/packages/admin-next/admin-vite-plugin/tsup.config.ts b/packages/admin-next/admin-shared/tsup.config.cjs similarity index 90% rename from packages/admin-next/admin-vite-plugin/tsup.config.ts rename to packages/admin-next/admin-shared/tsup.config.cjs index 48fbeed022fa..f54ede878e0d 100644 --- a/packages/admin-next/admin-vite-plugin/tsup.config.ts +++ b/packages/admin-next/admin-shared/tsup.config.cjs @@ -4,4 +4,5 @@ export default defineConfig({ entry: ["./src/index.ts"], format: ["cjs", "esm"], dts: true, + clean: true, }) diff --git a/packages/admin-next/admin-vite-plugin/package.json b/packages/admin-next/admin-vite-plugin/package.json index cb4bda3b0a30..1ec023e3f921 100644 --- a/packages/admin-next/admin-vite-plugin/package.json +++ b/packages/admin-next/admin-vite-plugin/package.json @@ -2,12 +2,13 @@ "name": "@medusajs/admin-vite-plugin", "version": "0.0.1", "main": "dist/index.js", - "types": "dist/index.d.ts", "module": "dist/index.mjs", + "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.mjs", - "require": "./dist/index.js" + "require": "./dist/index.js", + "types": "./dist/index.d.ts" } }, "files": [ @@ -15,11 +16,11 @@ "package.json" ], "scripts": { - "build": "tsup" + "build": "tsup", + "watch": "tsup --watch" }, "devDependencies": { "@babel/types": "7.22.5", - "@medusajs/admin-shared": "0.0.1", "@types/babel__traverse": "7.20.5", "@types/node": "^20.10.4", "tsup": "8.0.1", @@ -32,6 +33,7 @@ "dependencies": { "@babel/parser": "7.23.5", "@babel/traverse": "7.23.5", + "@medusajs/admin-shared": "0.0.1", "chokidar": "3.5.3", "fdir": "6.1.1", "magic-string": "0.30.5" diff --git a/packages/admin-next/admin-vite-plugin/src/babel.ts b/packages/admin-next/admin-vite-plugin/src/babel.ts new file mode 100644 index 000000000000..bfef3673c84a --- /dev/null +++ b/packages/admin-next/admin-vite-plugin/src/babel.ts @@ -0,0 +1,32 @@ +import { parse, type ParseResult, type ParserOptions } from "@babel/parser" +import _traverse, { type NodePath } from "@babel/traverse" +import { + ExportDefaultDeclaration, + ExportNamedDeclaration, + File, + ObjectProperty, +} from "@babel/types" + +/** + * Depending on whether we are running the CJS or ESM build of the plugin, we + * need to import the default export of the `@babel/traverse` package in + * different ways. + */ +let traverse: typeof _traverse + +if (typeof _traverse === "function") { + traverse = _traverse +} else { + traverse = (_traverse as any).default +} + +export { parse, traverse } +export type { + ExportDefaultDeclaration, + ExportNamedDeclaration, + File, + NodePath, + ObjectProperty, + ParseResult, + ParserOptions, +} diff --git a/packages/admin-next/admin-vite-plugin/src/index.ts b/packages/admin-next/admin-vite-plugin/src/index.ts index 5aa7140866f0..fd5f6fdca748 100644 --- a/packages/admin-next/admin-vite-plugin/src/index.ts +++ b/packages/admin-next/admin-vite-plugin/src/index.ts @@ -1,885 +1,4 @@ -import { ParseResult, ParserOptions, parse } from "@babel/parser" -import _traverse, { NodePath } from "@babel/traverse" -import { - ExportDefaultDeclaration, - ExportNamedDeclaration, - File, - ObjectExpression, - ObjectProperty, -} from "@babel/types" -import chokidar from "chokidar" -import { fdir } from "fdir" -import fs from "fs/promises" -import MagicString from "magic-string" -import path from "path" -import { Logger, PluginOption, ViteDevServer } from "vite" +import { medusaVitePlugin, type MedusaVitePlugin } from "./plugin" -import { InjectionZone, injectionZones } from "@medusajs/admin-shared" - -const traverse = (_traverse as any).default as typeof _traverse - -const VIRTUAL_PREFIX = "/@virtual/medusajs-admin-vite-plugin/" -const IMPORT_PREFIX = "medusa-admin:" - -const WIDGET_MODULE = `${IMPORT_PREFIX}widgets/` -const WIDGET_MODULES = injectionZones.map((zone) => { - return `${WIDGET_MODULE}${zone.replace(/\./g, "/")}` -}) - -const ROUTE_PAGE_MODULE = `${IMPORT_PREFIX}routes/pages` -const ROUTE_LINK_MODULE = `${IMPORT_PREFIX}routes/links` - -const ROUTE_MODULES = [ROUTE_PAGE_MODULE, ROUTE_LINK_MODULE] - -const SETTING_PAGE_MODULE = `${IMPORT_PREFIX}settings/pages` -const SETTING_CARD_MODULE = `${IMPORT_PREFIX}settings/cards` - -const SETTING_MODULE = [SETTING_PAGE_MODULE, SETTING_CARD_MODULE] - -const MODULES = [...WIDGET_MODULES, ...ROUTE_MODULES, ...SETTING_MODULE] - -type InjectArgs = { - sources?: string[] -} - -type LoadModuleOptions = - | { type: "widget"; get: InjectionZone } - | { type: "route"; get: "page" | "link" } - | { type: "setting"; get: "page" | "card" } - -export default function inject(args?: InjectArgs): PluginOption { - const _extensionGraph = new Map>() - const _sources = new Set([...(args?.sources || [])]) - - let server: ViteDevServer - let watcher: chokidar.FSWatcher - let logger: Logger - - /** - * Traverses the directory and returns all files that ends with .tsx or .jsx, - * excluding files in subdirectories that starts with _. - * - * @param dir - The directory to traverse - * @param file - The file name to filter by without extension - * @param depth - The depth of the files to return - */ - async function traverseDirectory( - dir: string, - file?: string, - depth?: { min: number; max?: number } - ) { - const baseDepth = dir.split(path.sep).length - - const crawler = new fdir() - .withBasePath() - .exclude((dirName) => dirName.startsWith("_")) - .filter((path) => path.endsWith(".tsx") || path.endsWith(".jsx")) - - if (file) { - crawler.filter( - (path) => path.endsWith(`${file}.tsx`) || path.endsWith(`${file}.jsx`) - ) - } - - if (depth) { - crawler.filter((file) => { - const directoryDepth = file.split(path.sep).length - 1 - - if (depth.max && directoryDepth > baseDepth + depth.max) { - return false - } - - if (directoryDepth < baseDepth + depth.min) { - return false - } - - return true - }) - } - - return await crawler.crawl(dir).withPromise() - } - - /** - * Generates a module with a source map from a code string - */ - function generateModule(code: string) { - const magicString = new MagicString(code) - - return { - code: magicString.toString(), - map: magicString.generateMap({ hires: true }), - } - } - - /** - * Validates that the default export of a file is a JSX component - */ - function validateDefaultExport( - path: NodePath, - ast: ParseResult - ) { - let hasComponentExport = false - const declaration = path.node.declaration - - if ( - declaration && - (declaration.type === "Identifier" || - declaration.type === "FunctionDeclaration") - ) { - const exportName = - declaration.type === "Identifier" - ? declaration.name - : declaration.id && declaration.id.name - - if (exportName) { - try { - traverse(ast, { - VariableDeclarator({ node, scope }) { - let isDefaultExport = false - - if ( - node.id.type === "Identifier" && - node.id.name === exportName - ) { - isDefaultExport = true - } - - if (!isDefaultExport) { - return - } - - traverse( - node, - { - ReturnStatement(path) { - if ( - path.node.argument?.type === "JSXElement" || - path.node.argument?.type === "JSXFragment" - ) { - hasComponentExport = true - } - }, - }, - scope - ) - }, - }) - } catch (e) { - console.error( - `An error occured while validating the default export of '${path}'. The following error must be resolved before continuing:\n${e}` - ) - return false - } - } - } - - return hasComponentExport - } - - /** - * Gets the properties of the config object in an extension file - */ - function getProperties(path: NodePath) { - const declaration = path.node.declaration - - if (declaration && declaration.type === "VariableDeclaration") { - const configDeclaration = declaration.declarations.find( - (d) => - d.type === "VariableDeclarator" && - d.id.type === "Identifier" && - d.id.name === "config" - ) - - if ( - configDeclaration && - configDeclaration.init?.type === "ObjectExpression" - ) { - return configDeclaration.init.properties - } - } - - return null - } - - /** - * Validates that the provided zone is a valid injection zone for a widget - */ - function validateInjectionZone(zone: any): zone is InjectionZone { - return injectionZones.includes(zone) - } - - function validateWidgetConfig( - path: NodePath, - zone?: InjectionZone - ) { - const properties = getProperties(path) - - if (!properties) { - return { zoneIsValid: false, zoneValue: undefined } - } - - const zoneProperty = properties.find( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "zone" - ) as ObjectProperty | undefined - - if (!zoneProperty) { - return { zoneIsValid: false, zoneValue: undefined } - } - - let zoneIsValid = false - let zoneValue: string | string[] | undefined = undefined - - if (zoneProperty.value.type === "StringLiteral") { - zoneIsValid = !zone - ? validateInjectionZone(zoneProperty.value.value) - : zone === zoneProperty.value.value - zoneValue = zoneProperty.value.value - } else if (zoneProperty.value.type === "ArrayExpression") { - zoneIsValid = zoneProperty.value.elements.every((_zone) => { - if (!_zone || _zone.type !== "StringLiteral") { - return false - } - - const isZoneMatch = !zone ? true : zone === _zone.value - - return validateInjectionZone(_zone.value) && isZoneMatch - }) - - zoneValue = zoneProperty.value.elements - .map((e) => { - if (e && e.type === "StringLiteral") { - return e.value - } - }) - .filter(Boolean) as string[] - } - - return { zoneIsValid, zoneValue } - } - - async function validateWidget(file: string, zone?: InjectionZone) { - const content = await fs.readFile(file, "utf-8") - - const parserOptions: ParserOptions = { - sourceType: "module", - plugins: ["jsx"], - } - - if (file.endsWith(".tsx")) { - parserOptions.plugins?.push("typescript") - } - - let ast: ParseResult - - try { - ast = parse(content, parserOptions) - } catch (err) { - logger.error( - `An error occured while parsing the content of ${file}:\n${err}`, - { - error: err as Error, - timestamp: true, - } - ) - return { isValidWidget: false, zoneValue: undefined } - } - - let hasDefaultExport = false - let hasNamedExport = false - let zoneValue: string | string[] | undefined - - try { - traverse(ast, { - ExportDefaultDeclaration(path) { - hasDefaultExport = validateDefaultExport(path, ast) - }, - ExportNamedDeclaration(path) { - const { zoneIsValid, zoneValue: value } = validateWidgetConfig( - path, - zone - ) - hasNamedExport = zoneIsValid - zoneValue = value - }, - }) - } catch (err) { - logger.error(`An error occured while validating the content of ${file}`, { - error: err as Error, - timestamp: true, - }) - - return { isValidWidget: false, zoneValue: undefined } - } - - return { isValidWidget: hasDefaultExport && hasNamedExport, zoneValue } - } - - async function generateWidgetEntrypoint(zone: InjectionZone) { - const files = ( - await Promise.all( - Array.from(_sources).map(async (source) => - traverseDirectory(`${source}/widgets`) - ) - ) - ).flat() - - const validatedWidgets = ( - await Promise.all( - files.map(async (widget) => { - const { isValidWidget } = await validateWidget(widget, zone) - return isValidWidget ? widget : null - }) - ) - ).filter(Boolean) as string[] - - if (!validatedWidgets.length) { - const code = `export default { - widgets: [], - }` - - return { module: generateModule(code), paths: [] } - } - - const importString = validatedWidgets - .map((path, index) => `import WidgetExt${index} from "${path}";`) - .join("\n") - - const exportString = `export default { - widgets: [${validatedWidgets - .map((_, index) => `{ Component: WidgetExt${index} }`) - .join(", ")}], - }` - - const code = `${importString}\n${exportString}` - - return { module: generateModule(code), paths: validatedWidgets } - } - - function validateRouteConfig( - path: NodePath, - requireLink: boolean - ) { - const properties = getProperties(path) - - if (!properties) { - return false - } - - const linkProperty = properties.find( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "link" - ) as ObjectProperty | undefined - - /** - * Link is optional unless requireLink is true. - */ - if (!linkProperty && !requireLink) { - return true - } - - const linkValue = linkProperty?.value as ObjectExpression | undefined - - if (!linkValue) { - return false - } - - let labelIsValid = false - - if ( - linkValue.properties.some( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "label" && - p.value.type === "StringLiteral" - ) - ) { - labelIsValid = true - } - - return labelIsValid - } - - async function validateRoute(file: string, requireLink: boolean) { - const content = await fs.readFile(file, "utf-8") - - const parserOptions: ParserOptions = { - sourceType: "module", - plugins: ["jsx"], - } - - if (file.endsWith(".tsx")) { - parserOptions.plugins?.push("typescript") - } - - let ast: ParseResult - - try { - ast = parse(content, parserOptions) - } catch (err) { - logger.error("An error occured while validating a route.", { - error: err as Error, - timestamp: true, - }) - return false - } - - let hasDefaultExport = false - let hasNamedExport = false - - try { - traverse(ast, { - ExportDefaultDeclaration(path) { - hasDefaultExport = validateDefaultExport(path, ast) - }, - ExportNamedDeclaration(path) { - hasNamedExport = validateRouteConfig(path, requireLink) - }, - }) - } catch (err) { - logger.error("An error occured while validating a route.", { - error: err as Error, - timestamp: true, - }) - return false - } - - return hasDefaultExport && hasNamedExport - } - - function createPath(file: string) { - return file - .replace(/.*\/admin\/(routes|settings)/, "") - .replace(/\[([^\]]+)\]/g, ":$1") - .replace(/\/page\.(tsx|jsx)/, "") - } - - async function generateRouteEntrypoint(get: "page" | "link") { - const files = ( - await Promise.all( - Array.from(_sources).map(async (source) => - traverseDirectory(`${source}/routes`, "page", { min: 1 }) - ) - ) - ).flat() - - const validatedRoutes = ( - await Promise.all( - files.map(async (route) => { - const isValid = await validateRoute(route, get === "link") - return isValid ? route : null - }) - ) - ).filter(Boolean) as string[] - - if (!validatedRoutes.length) { - const code = `export default { - ${get}s: [], - }` - - return { module: generateModule(code), paths: [] } - } - - const importString = validatedRoutes - .map((path, index) => { - return get === "page" - ? `import RouteExt${index} from "${path}";` - : `import { config as routeConfig${index} } from "${path}";` - }) - .join("\n") - - const exportString = `export default { - ${get}s: [${validatedRoutes - .map((file, index) => { - return get === "page" - ? `{ path: "${createPath(file)}", file: "${file}" }` - : `{ path: "${createPath(file)}", ...routeConfig${index}.link }` - }) - .join(", ")}], - }` - - const code = `${importString}\n${exportString}` - - return { module: generateModule(code), paths: validatedRoutes } - } - - async function validateSetting(file: string) { - const content = await fs.readFile(file, "utf-8") - - const parserOptions: ParserOptions = { - sourceType: "module", - plugins: ["jsx"], - } - - if (file.endsWith(".tsx")) { - parserOptions.plugins?.push("typescript") - } - - let ast: ParseResult - - try { - ast = parse(content, parserOptions) - } catch (err) { - logger.error("An error occured while validating a setting.", { - error: err as Error, - timestamp: true, - }) - return false - } - - let hasDefaultExport = false - let hasNamedExport = false - - try { - traverse(ast, { - ExportDefaultDeclaration(path) { - hasDefaultExport = validateDefaultExport(path, ast) - }, - ExportNamedDeclaration(path) { - hasNamedExport = validateSettingConfig(path) - }, - }) - } catch (err) { - logger.error("An error occured while validating a setting.", { - error: err as Error, - timestamp: true, - }) - return false - } - - return hasDefaultExport && hasNamedExport - } - - function validateSettingConfig(path: NodePath) { - const properties = getProperties(path) - - if (!properties) { - return false - } - - const cardProperty = properties.find( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "card" - ) as ObjectProperty | undefined - - // Link property is required for settings - if (!cardProperty) { - return false - } - - const cardValue = cardProperty.value as ObjectExpression - - let hasLabel = false - let hasDescription = false - - if ( - cardValue.properties.some( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "label" && - p.value.type === "StringLiteral" - ) - ) { - hasLabel = true - } - - if ( - cardValue.properties.some( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "description" && - p.value.type === "StringLiteral" - ) - ) { - hasDescription = true - } - - return hasLabel && hasDescription - } - - async function generateSettingEntrypoint(get: "page" | "card") { - const files = ( - await Promise.all( - Array.from(_sources).map(async (source) => - traverseDirectory(`${source}/settings`, "page", { min: 1, max: 1 }) - ) - ) - ).flat() - - const validatedSettings = ( - await Promise.all( - files.map(async (setting) => { - const isValid = await validateSetting(setting) - return isValid ? setting : null - }) - ) - ).filter(Boolean) as string[] - - if (!validatedSettings.length) { - const code = `export default { - ${get}s: [], - }` - - return { module: generateModule(code), paths: [] } - } - - const importString = validatedSettings - .map((path, index) => { - return get === "page" - ? `import SettingExt${index} from "${path}";` - : `import { config as settingConfig${index} } from "${path}";` - }) - .join("\n") - - const exportString = `export default { - ${get}s: [${validatedSettings - .map((file, index) => { - return get === "page" - ? `{ path: "${createPath(file)}", file: "${file}" }` - : `{ path: "${createPath(file)}", ...settingConfig${index}.card }` - }) - .join(", ")}], - }` - - const code = `${importString}\n${exportString}` - - return { module: generateModule(code), paths: validatedSettings } - } - - async function loadModule(options: LoadModuleOptions) { - switch (options.type) { - case "widget": { - return await generateWidgetEntrypoint(options.get) - } - case "route": { - return await generateRouteEntrypoint(options.get) - } - case "setting": { - return await generateSettingEntrypoint(options.get) - } - } - } - - function getExtensionType(file: string) { - const normalizedPath = path.normalize(file) - - if (normalizedPath.includes(path.normalize("/admin/widgets/"))) { - return "widget" - } else if (normalizedPath.includes(path.normalize("/admin/routes/"))) { - return "route" - } else if (normalizedPath.includes(path.normalize("/admin/settings/"))) { - return "setting" - } else { - return "none" - } - } - - async function handleWidgetChange(file: string) { - const { isValidWidget, zoneValue } = await validateWidget(file) - - if (!isValidWidget || !zoneValue) { - _extensionGraph.delete(file) - return - } - - const zoneValues = Array.isArray(zoneValue) ? zoneValue : [zoneValue] - - for (const zone of zoneValues) { - const zonePath = zone.replace(/\./g, "/") - const moduleId = `${VIRTUAL_PREFIX}${WIDGET_MODULE}${zonePath}` - - const module = server.moduleGraph.getModuleById(moduleId) - - if (module) { - await server.reloadModule(module) - } - } - } - - async function handleRouteChange(file: string) { - const isValidRoute = await validateRoute(file, false) - - if (!isValidRoute) { - _extensionGraph.delete(file) - return - } - - for (const moduleId of ROUTE_MODULES) { - const fullModuleId = `${VIRTUAL_PREFIX}${moduleId}` - const module = server.moduleGraph.getModuleById(fullModuleId) - - if (module) { - await server.reloadModule(module) - } - } - } - - async function handleSettingChange(file: string) { - const isValidSetting = await validateSetting(file) - - if (!isValidSetting) { - _extensionGraph.delete(file) - return - } - - for (const moduleId of SETTING_MODULE) { - const fullModuleId = `${VIRTUAL_PREFIX}${moduleId}` - const module = server.moduleGraph.getModuleById(fullModuleId) - - if (module) { - await server.reloadModule(module) - } - } - } - - async function handleExtensionUnlink(file: string) { - const moduleIds = _extensionGraph.get(file) - - if (!moduleIds) { - return - } - - for (const moduleId of moduleIds) { - const module = server.moduleGraph.getModuleById(moduleId) - - if (module) { - _extensionGraph.delete(file) - await server.reloadModule(module) - } - } - } - - async function loadModuleAndUpdateGraph( - id: string, - options: LoadModuleOptions - ) { - const { module, paths } = await loadModule(options) - - for (const path of paths) { - const ids = _extensionGraph.get(path) || new Set() - ids.add(id) - _extensionGraph.set(path, ids) - } - - return module - } - - return { - name: "@medusajs/admin-vite-plugin", - configureServer(s) { - server = s - logger = s.config.logger - - watcher = chokidar.watch(Array.from(_sources), { - persistent: true, - ignoreInitial: true, - }) - - watcher.on("add", async (file) => { - const type = getExtensionType(file) - - if (type === "none") { - return - } - - if (type === "widget") { - await handleWidgetChange(file) - return - } - - if (type === "route") { - await handleRouteChange(file) - return - } - - if (type === "setting") { - await handleSettingChange(file) - return - } - - return - }) - - watcher.on("change", async (file) => { - const type = getExtensionType(file) - - if (type === "none") { - return - } - - if (type === "widget") { - await handleWidgetChange(file) - return - } - - if (type === "route") { - await handleRouteChange(file) - return - } - - if (type === "setting") { - await handleSettingChange(file) - return - } - - return - }) - - watcher.on("unlink", async (file) => { - await handleExtensionUnlink(file) - return - }) - }, - resolveId(id) { - if (MODULES.includes(id)) { - return VIRTUAL_PREFIX + id - } - - return null - }, - async load(id: string) { - if (!id.startsWith(VIRTUAL_PREFIX)) { - return null - } - - const idNoPrefix = id.slice(VIRTUAL_PREFIX.length) - - const moduleMap: Record = { - [ROUTE_PAGE_MODULE]: { type: "route", get: "page" }, - [ROUTE_LINK_MODULE]: { type: "route", get: "link" }, - [SETTING_PAGE_MODULE]: { type: "setting", get: "page" }, - [SETTING_CARD_MODULE]: { type: "setting", get: "card" }, - } - - if (WIDGET_MODULES.includes(idNoPrefix)) { - const zone = idNoPrefix - .replace(WIDGET_MODULE, "") - .replace(/\//g, ".") as InjectionZone - return loadModuleAndUpdateGraph(id, { type: "widget", get: zone }) - } - - const moduleOptions = moduleMap[idNoPrefix] - - if (moduleOptions) { - return loadModuleAndUpdateGraph(id, moduleOptions) - } - - return null - }, - async closeBundle() { - if (watcher) { - await watcher.close() - } - }, - } -} +export default medusaVitePlugin +export type { MedusaVitePlugin } diff --git a/packages/admin-next/admin-vite-plugin/src/plugin.ts b/packages/admin-next/admin-vite-plugin/src/plugin.ts new file mode 100644 index 000000000000..ba079037eb14 --- /dev/null +++ b/packages/admin-next/admin-vite-plugin/src/plugin.ts @@ -0,0 +1,794 @@ +import { + InjectionZone, + RESOLVED_ROUTE_MODULES, + RESOLVED_WIDGET_MODULES, + VIRTUAL_MODULES, + getVirtualId, + getWidgetImport, + getWidgetZone, + isValidInjectionZone, + resolveVirtualId, +} from "@medusajs/admin-shared" +import { fdir } from "fdir" +import fs from "fs/promises" +import MagicString from "magic-string" +import path from "path" +import type * as Vite from "vite" + +import { + ExportNamedDeclaration, + ObjectProperty, + parse, + traverse, + type ExportDefaultDeclaration, + type File, + type NodePath, + type ParseResult, + type ParserOptions, +} from "./babel" + +const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"] + +/** + * Returns the module type of a given file. + */ +function getModuleType(file: string) { + const normalizedPath = path.normalize(file) + + if (normalizedPath.includes(path.normalize("/admin/widgets/"))) { + return "widget" + } else if (normalizedPath.includes(path.normalize("/admin/routes/"))) { + return "route" + } else { + return "none" + } +} + +/** + * Returns the parser options for a given file. + */ +function getParserOptions(file: string): ParserOptions { + const options: ParserOptions = { + sourceType: "module", + plugins: ["jsx"], + } + + if (file.endsWith(".tsx")) { + options.plugins?.push("typescript") + } + + return options +} + +/** + * Generates a module with a source map from a code string + */ +function generateModule(code: string) { + const magicString = new MagicString(code) + + return { + code: magicString.toString(), + map: magicString.generateMap({ hires: true }), + } +} + +/** + * Crawls a directory and returns all files that match the criteria. + */ +async function crawl( + dir: string, + file?: string, + depth?: { min: number; max?: number } +) { + const dirDepth = dir.split(path.sep).length + + const crawler = new fdir() + .withBasePath() + .exclude((dirName) => dirName.startsWith("_")) + .filter((path) => { + return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(ext)) + }) + + if (file) { + crawler.filter((path) => { + return VALID_FILE_EXTENSIONS.some((ext) => path.endsWith(file + ext)) + }) + } + + if (depth) { + crawler.filter((file) => { + const pathDepth = file.split(path.sep).length - 1 + + if (depth.max && pathDepth > dirDepth + depth.max) { + return false + } + + if (pathDepth < dirDepth + depth.min) { + return false + } + + return true + }) + } + + return crawler.crawl(dir).withPromise() +} + +/** + * Extracts and returns the properties of a `config` object from a named export declaration. + */ +function getConfigObjectProperties(path: NodePath) { + const declaration = path.node.declaration + + if (declaration && declaration.type === "VariableDeclaration") { + const configDeclaration = declaration.declarations.find( + (d) => + d.type === "VariableDeclarator" && + d.id.type === "Identifier" && + d.id.name === "config" + ) + + if ( + configDeclaration && + configDeclaration.init?.type === "CallExpression" && + configDeclaration.init.arguments.length > 0 && + configDeclaration.init.arguments[0].type === "ObjectExpression" + ) { + return configDeclaration.init.arguments[0].properties + } + } + + return null +} + +/** + * Validates if the default export in a given AST is a component (JSX element or fragment). + */ +function isDefaultExportComponent( + path: NodePath, + ast: File +): boolean { + let hasComponentExport = false + const declaration = path.node.declaration + + if ( + declaration && + (declaration.type === "Identifier" || + declaration.type === "FunctionDeclaration") + ) { + const exportName = + declaration.type === "Identifier" + ? declaration.name + : declaration.id && declaration.id.name + + if (exportName) { + try { + traverse(ast, { + VariableDeclarator({ node, scope }) { + let isDefaultExport = false + + if (node.id.type === "Identifier" && node.id.name === exportName) { + isDefaultExport = true + } + + if (!isDefaultExport) { + return + } + + traverse( + node, + { + ReturnStatement(path) { + if ( + path.node.argument?.type === "JSXElement" || + path.node.argument?.type === "JSXFragment" + ) { + hasComponentExport = true + } + }, + }, + scope + ) + }, + }) + } catch (e) { + return false + } + } + } + + return hasComponentExport +} + +/** Widget utilities */ + +/** + * Validates the widget configuration. + */ +function validateWidgetConfig( + path: NodePath, + zone?: InjectionZone +): { zoneIsValid: boolean; zoneValue: string | string[] | null } { + let zoneIsValid = false + let zoneValue: string | string[] | null = null + + const properties = getConfigObjectProperties(path) + + if (!properties) { + return { zoneIsValid, zoneValue } + } + + const zoneProperty = properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "zone" + ) as ObjectProperty | undefined + + if (!zoneProperty) { + return { zoneIsValid, zoneValue } + } + + if (zoneProperty.value.type === "StringLiteral") { + zoneIsValid = !zone + ? isValidInjectionZone(zoneProperty.value.value) + : zone === zoneProperty.value.value + zoneValue = zoneProperty.value.value + } else if (zoneProperty.value.type === "ArrayExpression") { + zoneIsValid = zoneProperty.value.elements.every((e) => { + if (!e || e.type !== "StringLiteral") { + return false + } + + const isZoneMatch = !zone ? true : zone === e.value + + return isValidInjectionZone(e.value) && isZoneMatch + }) + + const values: string[] = [] + + for (const element of zoneProperty.value.elements) { + if (element && element.type === "StringLiteral") { + values.push(element.value) + } + } + + zoneValue = values + } + + return { zoneIsValid, zoneValue } +} + +/** + * Validates a widget file. + */ +async function validateWidget( + file: string, + zone?: InjectionZone +): Promise< + { valid: true; zone: InjectionZone } | { valid: false; zone: null } +> { + let _zoneValue: string | string[] | null = null + + const content = await fs.readFile(file, "utf-8") + const parserOptions = getParserOptions(file) + + let ast: ParseResult + + try { + ast = parse(content, parserOptions) + } catch (e) { + return { valid: false, zone: _zoneValue } + } + + let hasDefaultExport = false + let hasNamedExport = false + + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + hasDefaultExport = isDefaultExportComponent(path, ast) + }, + ExportNamedDeclaration(path) { + const { zoneIsValid, zoneValue } = validateWidgetConfig(path, zone) + + hasNamedExport = zoneIsValid + _zoneValue = zoneValue + }, + }) + } catch (err) { + return { valid: false, zone: _zoneValue } + } + + return { valid: hasNamedExport && hasDefaultExport, zone: _zoneValue as any } +} + +async function generateWidgetEntrypoint( + sources: Set, + zone: InjectionZone +) { + const files = ( + await Promise.all( + Array.from(sources).map(async (source) => crawl(`${source}/widgets`)) + ) + ).flat() + + const validatedWidgets = ( + await Promise.all( + files.map(async (widget) => { + const { valid } = await validateWidget(widget, zone) + return valid ? widget : null + }) + ) + ).filter(Boolean) as string[] + + if (!validatedWidgets.length) { + const code = `export default { + widgets: [], + }` + + return { module: generateModule(code), paths: [] } + } + + const importString = validatedWidgets + .map((path, index) => `import WidgetExt${index} from "${path}";`) + .join("\n") + + const exportString = `export default { + widgets: [${validatedWidgets + .map((_, index) => `{ Component: WidgetExt${index} }`) + .join(", ")}], + }` + + const code = `${importString}\n${exportString}` + + return { module: generateModule(code), paths: validatedWidgets } +} + +/** Route utilities */ + +function validateRouteConfig( + path: NodePath, + resolveMenuItem: boolean +) { + const properties = getConfigObjectProperties(path) + + /** + * When resolving links for the sidebar, we a config to get the props needed to + * render the link correctly. + * + * If the user has not provided any config, then the route can never be a valid + * menu item, so we can skip the validation, and return false. + */ + if (!properties && resolveMenuItem) { + return false + } + + /** + * A config is not required for a component to be a valid route. + */ + if (!properties) { + return true + } + + const labelProperty = properties.find( + (p) => + p.type === "ObjectProperty" && + p.key.type === "Identifier" && + p.key.name === "label" + ) as ObjectProperty | undefined + + const labelIsValid = + !labelProperty || labelProperty.value.type === "StringLiteral" + + return labelIsValid +} + +async function validateRoute(file: string, resolveMenuItem = false) { + const content = await fs.readFile(file, "utf-8") + const parserOptions = getParserOptions(file) + + let ast: ParseResult + + try { + ast = parse(content, parserOptions) + } catch (_e) { + return false + } + + let hasDefaultExport = false + let hasNamedExport = resolveMenuItem ? false : true + + try { + traverse(ast, { + ExportDefaultDeclaration(path) { + hasDefaultExport = isDefaultExportComponent(path, ast) + }, + ExportNamedDeclaration(path) { + hasNamedExport = validateRouteConfig(path, resolveMenuItem) + }, + }) + } catch (_e) { + return false + } + + return hasNamedExport && hasDefaultExport +} + +function createRoutePath(file: string) { + return file + .replace(/.*\/admin\/(routes|settings)/, "") + .replace(/\[([^\]]+)\]/g, ":$1") + .replace(/\/page\.(tsx|jsx)/, "") +} + +async function generateRouteEntrypoint( + sources: Set, + type: "page" | "link", + base = "" +) { + const files = ( + await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/routes`, "page", { min: 1 }) + ) + ) + ).flat() + + const validatedRoutes = ( + await Promise.all( + files.map(async (route) => { + const valid = await validateRoute(route, type === "link") + return valid ? route : null + }) + ) + ).filter(Boolean) as string[] + + if (!validatedRoutes.length) { + const code = `export default { + ${type}s: [], + }` + + return { module: generateModule(code), paths: [] } + } + + const importString = validatedRoutes + .map((path, index) => { + return type === "page" + ? `import RouteExt${index} from "${path}";` + : `import { config as routeConfig${index} } from "${path}";` + }) + .join("\n") + + const exportString = `export default { + ${type}s: [${validatedRoutes + .map((file, index) => { + return type === "page" + ? `{ path: "${createRoutePath(file)}", file: "${base + file}" }` + : `{ path: "${createRoutePath(file)}", ...routeConfig${index} }` + }) + .join(", ")}], + }` + + const code = `${importString}\n${exportString}` + + return { module: generateModule(code), paths: validatedRoutes } +} + +type LoadModuleOptions = + | { + type: "widget" + get: InjectionZone + } + | { + type: "route" + get: "page" | "link" + } + +export type MedusaVitePluginOptions = { + /** + * A list of directories to source extensions from. + */ + sources?: string[] +} + +export type MedusaVitePlugin = (config?: MedusaVitePluginOptions) => Vite.Plugin +export const medusaVitePlugin: MedusaVitePlugin = (options) => { + const _extensionGraph = new Map>() + const _sources = new Set(options?.sources ?? []) + let _base = "" + + let server: Vite.ViteDevServer | undefined + let watcher: Vite.FSWatcher | undefined + + async function loadModule(options: LoadModuleOptions) { + switch (options.type) { + case "widget": { + return await generateWidgetEntrypoint(_sources, options.get) + } + case "route": + return await generateRouteEntrypoint(_sources, options.get, _base) + default: + return null + } + } + + async function register(id: string, options: LoadModuleOptions) { + const result = await loadModule(options) + + if (!result) { + return + } + + const { module, paths } = result + + for (const path of paths) { + const ids = _extensionGraph.get(path) || new Set() + ids.add(id) + _extensionGraph.set(path, ids) + } + + return module + } + + async function handleWidgetChange(file: string, event: "add" | "change") { + const { valid, zone } = await validateWidget(file) + const zoneValues = Array.isArray(zone) ? zone : [zone] + + if (event === "change") { + /** + * If the file is in the extension graph, and it has become + * invalid, we need to remove it from the graph and reload all modules + * that import the widget. + */ + if (!valid) { + const extensionIds = _extensionGraph.get(file) + _extensionGraph.delete(file) + + if (!extensionIds) { + return + } + + for (const moduleId of extensionIds) { + const module = server?.moduleGraph.getModuleById(moduleId) + + if (module) { + await server?.reloadModule(module) + } + } + + return + } + + /** + * If the file is not in the extension graph, we need to add it. + * We also need to reload all modules that import the widget. + */ + if (!_extensionGraph.has(file)) { + const imports = new Set() + + for (const zoneValue of zoneValues) { + const zonePath = getWidgetImport(zoneValue) + const moduleId = getVirtualId(zonePath) + const resolvedModuleId = resolveVirtualId(moduleId) + const module = server?.moduleGraph.getModuleById(resolvedModuleId) + if (module) { + imports.add(resolvedModuleId) + await server?.reloadModule(module) + } + } + + _extensionGraph.set(file, imports) + } + } + + if (event === "add") { + /** + * If a new file is added in /admin/widgets, but it is not valid, + * we don't need to do anything. + */ + if (!valid) { + return + } + + /** + * If a new file is added in /admin/widgets, and it is valid, we need to + * add it to the extension graph and reload all modules that need to import + * the widget so that they can be updated with the new widget. + */ + const imports = new Set() + + for (const zoneValue of zoneValues) { + const zonePath = getWidgetImport(zoneValue) + const moduleId = getVirtualId(zonePath) + const resolvedModuleId = resolveVirtualId(moduleId) + + const module = server?.moduleGraph.getModuleById(resolvedModuleId) + + if (module) { + imports.add(resolvedModuleId) + await server?.reloadModule(module) + } + } + + _extensionGraph.set(file, imports) + } + } + + async function handleRouteChange(file: string, event: "add" | "change") { + const valid = await validateRoute(file) + + if (event === "change") { + /** + * If the file is in the extension graph, and it has become + * invalid, we need to remove it from the graph and reload all modules + * that import the route. + */ + if (!valid) { + const extensionIds = _extensionGraph.get(file) + _extensionGraph.delete(file) + + if (!extensionIds) { + return + } + + for (const moduleId of extensionIds) { + const module = server?.moduleGraph.getModuleById(moduleId) + + if (module) { + await server?.reloadModule(module) + } + } + + return + } + + /** + * If the file is not in the extension graph, we need to add it. + * We also need to reload all modules that import the route. + */ + if (!_extensionGraph.has(file)) { + const moduleId = getVirtualId(file) + const resolvedModuleId = resolveVirtualId(moduleId) + const module = server?.moduleGraph.getModuleById(resolvedModuleId) + if (module) { + await server?.reloadModule(module) + } + } + + if (_extensionGraph.has(file)) { + const modules = _extensionGraph.get(file) + + if (!modules) { + return + } + + for (const moduleId of modules) { + const module = server?.moduleGraph.getModuleById(moduleId) + + if (module) { + await server?.reloadModule(module) + } + } + } + } + + if (event === "add") { + /** + * If a new file is added in /admin/routes, but it is not valid, + * we don't need to do anything. + */ + if (!valid) { + return + } + + const imports = new Set() + + for (const resolvedModuleId of RESOLVED_ROUTE_MODULES) { + const module = server?.moduleGraph.getModuleById(resolvedModuleId) + if (module) { + imports.add(resolvedModuleId) + await server?.reloadModule(module) + } + } + + _extensionGraph.set(file, imports) + } + } + + async function handleAddOrChange(path: string, event: "add" | "change") { + const type = getModuleType(path) + + switch (type) { + case "widget": + await handleWidgetChange(path, event) + break + case "route": + await handleRouteChange(path, event) + break + default: + // In all other cases we don't need to do anything. + break + } + } + + async function handleUnlink(path: string) { + const moduleIds = _extensionGraph.get(path) + _extensionGraph.delete(path) + + if (!moduleIds) { + return + } + + for (const moduleId of moduleIds) { + const module = server?.moduleGraph.getModuleById(moduleId) + + if (module) { + await server?.reloadModule(module) + } + } + } + + return { + name: "@medusajs/admin-vite-plugin", + enforce: "pre", + configResolved(config) { + if (config.server?.middlewareMode) { + /** + * If we are in middleware mode, we need to set the base to the + "@fs". + * + * This ensures that the page components are lazy-loaded correctly. + */ + _base = `${config.base}@fs` + } + }, + configureServer(_server) { + server = _server + watcher = _server.watcher + + _sources.forEach((source) => { + watcher?.add(source) + }) + + watcher.on("all", async (event, path) => { + switch (event) { + case "add": + case "change": { + await handleAddOrChange(path, event) + break + } + case "unlinkDir": + case "unlink": + await handleUnlink(path) + break + default: + break + } + }) + }, + resolveId(id) { + if (VIRTUAL_MODULES.includes(id)) { + return resolveVirtualId(id) + } + + return null + }, + async load(id) { + if (RESOLVED_WIDGET_MODULES.includes(id)) { + const zone = getWidgetZone(id) + + return register(id, { type: "widget", get: zone }) + } + + if (RESOLVED_ROUTE_MODULES.includes(id)) { + const type = id.includes("link") ? "link" : "page" + return register(id, { type: "route", get: type }) + } + }, + async closeBundle() { + if (watcher) { + await watcher.close() + } + }, + } +} diff --git a/packages/admin-next/admin-vite-plugin/tsup.config.cjs b/packages/admin-next/admin-vite-plugin/tsup.config.cjs new file mode 100644 index 000000000000..f54ede878e0d --- /dev/null +++ b/packages/admin-next/admin-vite-plugin/tsup.config.cjs @@ -0,0 +1,8 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["./src/index.ts"], + format: ["cjs", "esm"], + dts: true, + clean: true, +}) diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 66bd08c780da..551144f119d0 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -61,6 +61,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@medusajs/admin-shared": "^0.0.1", "@medusajs/admin-vite-plugin": "0.0.1", "@medusajs/types": "1.11.16", "@medusajs/ui-preset": "1.1.3", diff --git a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx index 109f7cdb7946..ece52d31902a 100644 --- a/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx +++ b/packages/admin-next/dashboard/src/components/layout/main-layout/main-layout.tsx @@ -13,12 +13,15 @@ import { Avatar, Text } from "@medusajs/ui" import * as Collapsible from "@radix-ui/react-collapsible" import { useTranslation } from "react-i18next" -import { ComponentType } from "react" import { useStore } from "../../../hooks/api/store" import { Skeleton } from "../../common/skeleton" import { NavItem, NavItemProps } from "../../layout/nav-item" import { Shell } from "../../layout/shell" +import routes from "virtual:medusa/routes/links" +import { settingsRouteRegex } from "../../../lib/extension-helpers" +import { Divider } from "../../common/divider" + export const MainLayout = () => { return ( @@ -34,7 +37,7 @@ const MainSidebar = () => {
-
+
@@ -157,7 +160,7 @@ const CoreRouteSection = () => { const coreRoutes = useCoreRoutes() return ( -