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
This commit is contained in:
Kasper Fabricius Kristensen
2025-01-13 11:45:33 +01:00
committed by GitHub
parent 253b642418
commit 1ba2fadf22
13 changed files with 316 additions and 92 deletions

View File

@@ -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"

View File

@@ -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(

View File

@@ -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<string, string>)
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)
}

View File

@@ -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,
}

View File

@@ -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<RouteConfig | null> {
}
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<RouteConfig | null> {
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}`
}

View File

@@ -23,7 +23,6 @@ type RouteResult = {
export async function generateRoutes(sources: Set<string>) {
const files = await getFilesFromSources(sources)
const results = await getRouteResults(files)
const imports = results.map((result) => result.imports).flat()
const code = generateCode(results)

View File

@@ -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(
"|"
)})$`
),
""
)
}

View File

@@ -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<ExportNamedDeclaration>
path: NodePath<ExportNamedDeclaration | VariableDeclarator>
) {
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
}

View File

@@ -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<InjectionZone[] | null> {
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 (isTemplateLiteral(zoneProperty.value)) {
logger.warn(
`'zone' property cannot be a template literal (e.g. \`product.details.after\`).`,
{ file }
)
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)
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
}
}
}
},
/**
* For unbundled files, the `config` will always be a named export.
*/
ExportNamedDeclaration(path) {
if (zoneFound) {
return
}
zones.push(...values)
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
}
}
}
},
})
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
}
}