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:
committed by
GitHub
parent
253b642418
commit
1ba2fadf22
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
"|"
|
||||
)})$`
|
||||
),
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user