From 1ba2fadf22a30de9f94aee4f195163ef5e9e84d2 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Mon, 13 Jan 2025 11:45:33 +0100 Subject: [PATCH] feat(admin-bundler,admin-vite-plugin,medusa): Add support for loading Admin Extensions from plugins (#10869) Should not be merged before https://github.com/medusajs/medusa/pull/10895 **What** - Introduces a new `plugin` command to `admin-bundler`, currently not used anywhere but will be called from `medusa build:plugin` - Discovers plugins with extensions and add passes the to `admin-vite-plugin`. - Updates `admin-vite-plugin` so its able to read built admin extensions. Resolves CMRC-830, CMRC-839 --- .changeset/stupid-plums-buy.md | 7 + packages/admin/admin-bundler/package.json | 1 + .../admin/admin-bundler/src/lib/config.ts | 1 - .../admin/admin-bundler/src/lib/plugin.ts | 57 ++++++++ packages/admin/admin-vite-plugin/src/babel.ts | 4 + .../src/routes/generate-menu-items.ts | 106 +++++++++++---- .../src/routes/generate-routes.ts | 1 - .../admin-vite-plugin/src/routes/helpers.ts | 11 +- packages/admin/admin-vite-plugin/src/utils.ts | 46 ++++++- .../src/widgets/generate-widgets.ts | 128 ++++++++++++------ packages/medusa/src/loaders/admin.ts | 17 ++- packages/medusa/src/loaders/index.ts | 22 +-- yarn.lock | 1 + 13 files changed, 313 insertions(+), 89 deletions(-) create mode 100644 .changeset/stupid-plums-buy.md create mode 100644 packages/admin/admin-bundler/src/lib/plugin.ts diff --git a/.changeset/stupid-plums-buy.md b/.changeset/stupid-plums-buy.md new file mode 100644 index 0000000000000..58c2f1d4542d8 --- /dev/null +++ b/.changeset/stupid-plums-buy.md @@ -0,0 +1,7 @@ +--- +"@medusajs/admin-vite-plugin": patch +"@medusajs/admin-bundler": patch +"@medusajs/medusa": patch +--- + +feat(admin-bundler,admin-vite-plugin): Support loading loading admin extensions from plugins. diff --git a/packages/admin/admin-bundler/package.json b/packages/admin/admin-bundler/package.json index ec481411aa079..e79b66ff6b86f 100644 --- a/packages/admin/admin-bundler/package.json +++ b/packages/admin/admin-bundler/package.json @@ -32,6 +32,7 @@ "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.16", "compression": "^1.7.4", + "glob": "^11.0.0", "postcss": "^8.4.32", "tailwindcss": "^3.3.6", "vite": "^5.2.11" diff --git a/packages/admin/admin-bundler/src/lib/config.ts b/packages/admin/admin-bundler/src/lib/config.ts index 26cea424fad74..442304294de05 100644 --- a/packages/admin/admin-bundler/src/lib/config.ts +++ b/packages/admin/admin-bundler/src/lib/config.ts @@ -2,7 +2,6 @@ import { VIRTUAL_MODULES } from "@medusajs/admin-shared" import path from "path" import { Config } from "tailwindcss" import type { InlineConfig } from "vite" - import { BundlerOptions } from "../types" export async function getViteConfig( diff --git a/packages/admin/admin-bundler/src/lib/plugin.ts b/packages/admin/admin-bundler/src/lib/plugin.ts new file mode 100644 index 0000000000000..44c0261faf750 --- /dev/null +++ b/packages/admin/admin-bundler/src/lib/plugin.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "fs" +import { glob } from "glob" +import path from "path" +import { UserConfig } from "vite" + +export async function plugin() { + const vite = await import("vite") + const entries = await glob("src/admin/**/*.{ts,tsx,js,jsx}") + + const entryPoints = entries.reduce((acc, entry) => { + // Convert src/admin/routes/brands/page.tsx -> admin/routes/brands/page + const outPath = entry + .replace(/^src\//, "") + .replace(/\.(ts|tsx|js|jsx)$/, "") + + acc[outPath] = path.resolve(process.cwd(), entry) + return acc + }, {} as Record) + + const pkg = JSON.parse( + readFileSync(path.resolve(process.cwd(), "package.json"), "utf-8") + ) + const external = new Set([ + ...Object.keys(pkg.dependencies || {}), + ...Object.keys(pkg.peerDependencies || {}), + "react", + "react-dom", + "react/jsx-runtime", + "react-router-dom", + "@medusajs/admin-sdk", + ]) + + const pluginConfig: UserConfig = { + build: { + lib: { + entry: entryPoints, + formats: ["es"], + }, + minify: false, + outDir: path.resolve(process.cwd(), "dist"), + rollupOptions: { + external: [...external], + output: { + globals: { + react: "React", + "react-dom": "React-dom", + "react/jsx-runtime": "react/jsx-runtime", + }, + preserveModules: true, + entryFileNames: `[name].js`, + }, + }, + }, + } + + await vite.build(pluginConfig) +} diff --git a/packages/admin/admin-vite-plugin/src/babel.ts b/packages/admin/admin-vite-plugin/src/babel.ts index 0f166887075b2..697278bc70afd 100644 --- a/packages/admin/admin-vite-plugin/src/babel.ts +++ b/packages/admin/admin-vite-plugin/src/babel.ts @@ -17,11 +17,13 @@ import { isTemplateLiteral, isVariableDeclaration, isVariableDeclarator, + Node, ObjectExpression, ObjectMethod, ObjectProperty, SpreadElement, StringLiteral, + VariableDeclarator, } from "@babel/types" /** @@ -58,6 +60,7 @@ export type { ExportDefaultDeclaration, ExportNamedDeclaration, File, + Node, NodePath, ObjectExpression, ObjectMethod, @@ -66,4 +69,5 @@ export type { ParserOptions, SpreadElement, StringLiteral, + VariableDeclarator, } diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts b/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts index 9210050964b2c..f6195b6dd5140 100644 --- a/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts +++ b/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts @@ -1,9 +1,16 @@ +import { + NESTED_ROUTE_POSITIONS, + NestedRoutePosition, +} from "@medusajs/admin-shared" import fs from "fs/promises" import { outdent } from "outdent" import { File, isIdentifier, isObjectProperty, + isStringLiteral, + Node, + ObjectProperty, parse, ParseResult, traverse, @@ -16,7 +23,6 @@ import { normalizePath, } from "../utils" import { getRoute } from "./helpers" -import { NESTED_ROUTE_POSITIONS } from "@medusajs/admin-shared" type RouteConfig = { label: boolean @@ -142,48 +148,47 @@ async function getRouteConfig(file: string): Promise { } let config: RouteConfig | null = null + let configFound = false try { traverse(ast, { - ExportNamedDeclaration(path) { + /** + * For bundled files, the config will not be a named export, + * but instead a variable declaration. + */ + VariableDeclarator(path) { + if (configFound) { + return + } + const properties = getConfigObjectProperties(path) if (!properties) { return } - const hasProperty = (name: string) => - properties.some( - (prop) => isObjectProperty(prop) && isIdentifier(prop.key, { name }) - ) + config = processConfigProperties(properties, file) - const hasLabel = hasProperty("label") - if (!hasLabel) { + if (config) { + configFound = true + } + }, + /** + * For unbundled files, the `config` will always be a named export. + */ + ExportNamedDeclaration(path) { + if (configFound) { return } - const nested = properties.find( - (prop) => - isObjectProperty(prop) && isIdentifier(prop.key, { name: "nested" }) - ) - - const nestedValue = nested ? (nested as any).value.value : undefined - - if (nestedValue && !NESTED_ROUTE_POSITIONS.includes(nestedValue)) { - logger.error( - `Invalid nested route position: "${nestedValue}". Allowed values are: ${NESTED_ROUTE_POSITIONS.join( - ", " - )}`, - { - file, - } - ) + const properties = getConfigObjectProperties(path) + if (!properties) { return } - config = { - label: hasLabel, - icon: hasProperty("icon"), - nested: nestedValue, + config = processConfigProperties(properties, file) + + if (config) { + configFound = true } }, }) @@ -197,6 +202,51 @@ async function getRouteConfig(file: string): Promise { return config } +function processConfigProperties( + properties: Node[], + file: string +): RouteConfig | null { + const hasProperty = (name: string) => + properties.some( + (prop) => isObjectProperty(prop) && isIdentifier(prop.key, { name }) + ) + + const hasLabel = hasProperty("label") + if (!hasLabel) { + return null + } + + const nested = properties.find( + (prop) => + isObjectProperty(prop) && isIdentifier(prop.key, { name: "nested" }) + ) as ObjectProperty | undefined + + let nestedValue: string | undefined = undefined + + if (isStringLiteral(nested?.value)) { + nestedValue = nested.value.value + } + + if ( + nestedValue && + !NESTED_ROUTE_POSITIONS.includes(nestedValue as NestedRoutePosition) + ) { + logger.error( + `Invalid nested route position: "${nestedValue}". Allowed values are: ${NESTED_ROUTE_POSITIONS.join( + ", " + )}`, + { file } + ) + return null + } + + return { + label: hasLabel, + icon: hasProperty("icon"), + nested: nestedValue, + } +} + function generateRouteConfigName(index: number): string { return `RouteConfig${index}` } diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts b/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts index 51c71ca54a7ff..4e9575e2703c4 100644 --- a/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts +++ b/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts @@ -23,7 +23,6 @@ type RouteResult = { export async function generateRoutes(sources: Set) { const files = await getFilesFromSources(sources) const results = await getRouteResults(files) - const imports = results.map((result) => result.imports).flat() const code = generateCode(results) diff --git a/packages/admin/admin-vite-plugin/src/routes/helpers.ts b/packages/admin/admin-vite-plugin/src/routes/helpers.ts index cecf2fd4b54b8..22e97c641a6d4 100644 --- a/packages/admin/admin-vite-plugin/src/routes/helpers.ts +++ b/packages/admin/admin-vite-plugin/src/routes/helpers.ts @@ -1,9 +1,16 @@ -import { normalizePath } from "../utils" +import { normalizePath, VALID_FILE_EXTENSIONS } from "../utils" export function getRoute(file: string): string { const importPath = normalizePath(file) return importPath .replace(/.*\/admin\/(routes)/, "") .replace(/\[([^\]]+)\]/g, ":$1") - .replace(/\/page\.(tsx|jsx)/, "") + .replace( + new RegExp( + `/page\\.(${VALID_FILE_EXTENSIONS.map((ext) => ext.slice(1)).join( + "|" + )})$` + ), + "" + ) } diff --git a/packages/admin/admin-vite-plugin/src/utils.ts b/packages/admin/admin-vite-plugin/src/utils.ts index 1b094db0fb0e2..6758cd6871f92 100644 --- a/packages/admin/admin-vite-plugin/src/utils.ts +++ b/packages/admin/admin-vite-plugin/src/utils.ts @@ -14,6 +14,7 @@ import { type ExportNamedDeclaration, type NodePath, type ParserOptions, + type VariableDeclarator, } from "./babel" export function normalizePath(file: string) { @@ -48,7 +49,7 @@ export function generateModule(code: string) { } } -const VALID_FILE_EXTENSIONS = [".tsx", ".jsx"] +export const VALID_FILE_EXTENSIONS = [".tsx", ".jsx", ".js"] /** * Crawls a directory and returns all files that match the criteria. @@ -96,8 +97,25 @@ export async function crawl( * Extracts and returns the properties of a `config` object from a named export declaration. */ export function getConfigObjectProperties( - path: NodePath + path: NodePath ) { + if (isVariableDeclarator(path.node)) { + const configDeclaration = isIdentifier(path.node.id, { name: "config" }) + ? path.node + : null + + if ( + configDeclaration && + isCallExpression(configDeclaration.init) && + configDeclaration.init.arguments.length > 0 && + isObjectExpression(configDeclaration.init.arguments[0]) + ) { + return configDeclaration.init.arguments[0].properties + } + + return null + } + const declaration = path.node.declaration if (isVariableDeclaration(declaration)) { @@ -126,6 +144,30 @@ export async function hasDefaultExport( ExportDefaultDeclaration() { hasDefaultExport = true }, + AssignmentExpression(path) { + if ( + path.node.left.type === "MemberExpression" && + path.node.left.object.type === "Identifier" && + path.node.left.object.name === "exports" && + path.node.left.property.type === "Identifier" && + path.node.left.property.name === "default" + ) { + hasDefaultExport = true + } + }, + ExportNamedDeclaration(path) { + const specifiers = path.node.specifiers + if ( + specifiers?.some( + (s) => + s.type === "ExportSpecifier" && + s.exported.type === "Identifier" && + s.exported.name === "default" + ) + ) { + hasDefaultExport = true + } + }, }) return hasDefaultExport } diff --git a/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts b/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts index af5960458316f..c5e765ff5e36f 100644 --- a/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts +++ b/packages/admin/admin-vite-plugin/src/widgets/generate-widgets.ts @@ -6,18 +6,13 @@ import { isArrayExpression, isStringLiteral, isTemplateLiteral, - ObjectProperty, + Node, parse, ParseResult, traverse, } from "../babel" import { logger } from "../logger" -import { - getConfigObjectProperties, - getParserOptions, - hasDefaultExport, - normalizePath, -} from "../utils" +import { getParserOptions, hasDefaultExport, normalizePath } from "../utils" import { getWidgetFilesFromSources } from "./helpers" type WidgetConfig = { @@ -155,51 +150,106 @@ async function getWidgetZone( ): Promise { const zones: string[] = [] + /** + * We need to keep track of whether we have found a zone in the file. + * This is to avoid processing the same config both using the `ExportNamedDeclaration` + * and `VariableDeclarator` paths, which would be the case for the unbundled files. + */ + let zoneFound = false + traverse(ast, { - ExportNamedDeclaration(path) { - const properties = getConfigObjectProperties(path) - if (!properties) { + /** + * In case we are processing a bundled file, the `config` will most likely + * not be a named export. Instead we look for a `VariableDeclaration` named + * `config` and extract the `zone` property from it. + */ + VariableDeclarator(path) { + if (zoneFound) { return } - const zoneProperty = properties.find( - (p) => - p.type === "ObjectProperty" && - p.key.type === "Identifier" && - p.key.name === "zone" - ) as ObjectProperty | undefined - - if (!zoneProperty) { - logger.warn(`'zone' property is missing from the widget config.`, { - file, - }) - return + if ( + path.node.id.type === "Identifier" && + path.node.id.name === "config" && + path.node.init?.type === "CallExpression" + ) { + const arg = path.node.init.arguments[0] + if (arg?.type === "ObjectExpression") { + const zoneProperty = arg.properties.find( + (p: any) => p.type === "ObjectProperty" && p.key.name === "zone" + ) + if (zoneProperty?.type === "ObjectProperty") { + extractZoneValues(zoneProperty.value, zones, file) + zoneFound = true + } + } } - - if (isTemplateLiteral(zoneProperty.value)) { - logger.warn( - `'zone' property cannot be a template literal (e.g. \`product.details.after\`).`, - { file } - ) + }, + /** + * For unbundled files, the `config` will always be a named export. + */ + ExportNamedDeclaration(path) { + if (zoneFound) { return } - if (isStringLiteral(zoneProperty.value)) { - zones.push(zoneProperty.value.value) - } else if (isArrayExpression(zoneProperty.value)) { - const values: string[] = [] - - for (const element of zoneProperty.value.elements) { - if (isStringLiteral(element)) { - values.push(element.value) + const declaration = path.node.declaration + if ( + declaration?.type === "VariableDeclaration" && + declaration.declarations[0]?.type === "VariableDeclarator" && + declaration.declarations[0].id.type === "Identifier" && + declaration.declarations[0].id.name === "config" && + declaration.declarations[0].init?.type === "CallExpression" + ) { + const arg = declaration.declarations[0].init.arguments[0] + if (arg?.type === "ObjectExpression") { + const zoneProperty = arg.properties.find( + (p: any) => p.type === "ObjectProperty" && p.key.name === "zone" + ) + if (zoneProperty?.type === "ObjectProperty") { + extractZoneValues(zoneProperty.value, zones, file) + zoneFound = true } } - - zones.push(...values) } }, }) + if (!zoneFound) { + logger.warn(`'zone' property is missing from the widget config.`, { file }) + return null + } + const validatedZones = zones.filter(isValidInjectionZone) - return validatedZones.length > 0 ? validatedZones : null + + if (validatedZones.length === 0) { + logger.warn(`'zone' property is not a valid injection zone.`, { + file, + }) + return null + } + + return validatedZones +} + +function extractZoneValues(value: Node, zones: string[], file: string) { + if (isTemplateLiteral(value)) { + logger.warn( + `'zone' property cannot be a template literal (e.g. \`product.details.after\`).`, + { file } + ) + return + } + + if (isStringLiteral(value)) { + zones.push(value.value) + } else if (isArrayExpression(value)) { + const values = value.elements + .filter((e) => isStringLiteral(e)) + .map((e) => e.value) + zones.push(...values) + } else { + logger.warn(`'zone' property is not a string or array.`, { file }) + return + } } diff --git a/packages/medusa/src/loaders/admin.ts b/packages/medusa/src/loaders/admin.ts index fa126ae03bf83..8cb2dda878372 100644 --- a/packages/medusa/src/loaders/admin.ts +++ b/packages/medusa/src/loaders/admin.ts @@ -1,5 +1,9 @@ import { logger } from "@medusajs/framework/logger" -import { AdminOptions, ConfigModule } from "@medusajs/framework/types" +import { + AdminOptions, + ConfigModule, + PluginDetails, +} from "@medusajs/framework/types" import { Express } from "express" import fs from "fs" import path from "path" @@ -9,6 +13,7 @@ type Options = { app: Express configModule: ConfigModule rootDirectory: string + plugins: PluginDetails[] } type IntializedOptions = Required> & @@ -23,16 +28,18 @@ export default async function adminLoader({ app, configModule, rootDirectory, + plugins, }: Options) { const { admin } = configModule const sources: string[] = [] - const projectSource = path.join(rootDirectory, "src", "admin") + for (const plugin of plugins) { + const pluginSource = path.join(plugin.resolve, "admin") - // check if the projectSource exists - if (fs.existsSync(projectSource)) { - sources.push(projectSource) + if (fs.existsSync(pluginSource)) { + sources.push(pluginSource) + } } const adminOptions: IntializedOptions = { diff --git a/packages/medusa/src/loaders/index.ts b/packages/medusa/src/loaders/index.ts index 4ea3deb44410f..97a7cae2b5b05 100644 --- a/packages/medusa/src/loaders/index.ts +++ b/packages/medusa/src/loaders/index.ts @@ -1,3 +1,12 @@ +import { container, MedusaAppLoader } from "@medusajs/framework" +import { configLoader } from "@medusajs/framework/config" +import { pgConnectionLoader } from "@medusajs/framework/database" +import { featureFlagsLoader } from "@medusajs/framework/feature-flags" +import { expressLoader } from "@medusajs/framework/http" +import { JobLoader } from "@medusajs/framework/jobs" +import { LinkLoader } from "@medusajs/framework/links" +import { logger } from "@medusajs/framework/logger" +import { SubscriberLoader } from "@medusajs/framework/subscribers" import { ConfigModule, LoadedModule, @@ -10,6 +19,7 @@ import { mergePluginModules, promiseAll, } from "@medusajs/framework/utils" +import { WorkflowLoader } from "@medusajs/framework/workflows" import { asValue } from "awilix" import { Express, NextFunction, Request, Response } from "express" import { join } from "path" @@ -17,16 +27,6 @@ import requestIp from "request-ip" import { v4 } from "uuid" import adminLoader from "./admin" import apiLoader from "./api" -import { configLoader } from "@medusajs/framework/config" -import { expressLoader } from "@medusajs/framework/http" -import { JobLoader } from "@medusajs/framework/jobs" -import { LinkLoader } from "@medusajs/framework/links" -import { logger } from "@medusajs/framework/logger" -import { container, MedusaAppLoader } from "@medusajs/framework" -import { pgConnectionLoader } from "@medusajs/framework/database" -import { SubscriberLoader } from "@medusajs/framework/subscribers" -import { WorkflowLoader } from "@medusajs/framework/workflows" -import { featureFlagsLoader } from "@medusajs/framework/feature-flags" import { getResolvedPlugins } from "./helpers/resolve-plugins" type Options = { @@ -107,7 +107,7 @@ async function loadEntrypoints( next() }) - await adminLoader({ app: expressApp, configModule, rootDirectory }) + await adminLoader({ app: expressApp, configModule, rootDirectory, plugins }) await apiLoader({ container, plugins, diff --git a/yarn.lock b/yarn.lock index 834648f0758e2..6ce8ed89ad825 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5252,6 +5252,7 @@ __metadata: compression: ^1.7.4 copyfiles: ^2.4.1 express: ^4.21.0 + glob: ^11.0.0 postcss: ^8.4.32 tailwindcss: ^3.3.6 tsup: ^8.0.1